mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge current master with codex/fix-acp-snapshot-refresh-volatiles
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-27-stable-snapshot-refresh-volatiles.md
|
||||
2026-07-27-stable-snapshot-refresh-volatiles.md: e2e951cd9f78b319a701a3e60afba48786633f03
|
||||
2026-07-27-stable-snapshot-refresh-volatiles.zh.md: 55302b509e28520f90f6cd820e4962be014318cc
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Stable snapshot refresh volatiles
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-27-stable-snapshot-refresh-volatiles.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
ACP snapshot comparison normalizes generated UUIDs, cwd aliases, spill locators, embedded event times, and omitted-byte counts, but refresh write-back persisted the fresh raw values. A behaviorally unchanged refresh therefore rewrote fixtures with new randomness or host-specific path spellings even though the comparison contract considered both logs equal.
|
||||
|
||||
## Decision
|
||||
|
||||
Refresh write-back uses `normalizeSessionLog` as its sole volatile-value authority. It normalizes the original harvested records with the fresh run's ids, cwd, and every cwd alias, while normalizing fixture records with the fixture header context; literal replacements affect only the raw values being written. After existing record alignment, it recursively compares fresh and existing leaves through those normalized records: normalized-equivalent leaves retain the existing raw value, while normalized-distinct leaves retain the fresh semantic value.
|
||||
|
||||
Before reuse, the complete logical-record layout must align, apart from the existing packed-chunk and inserted-title equivalences. Normalized-equivalent changed strings form a log-wide bijection: one fresh string maps to exactly one existing string and vice versa, so repeated IDs remain correlated across records. An unexplained record mismatch or conflicting mapping disables normalized string reuse for that log.
|
||||
|
||||
Object fields align by key. Array elements align only when all corresponding arrays have the same length; otherwise the fresh array wins. Strings remain atomic leaves. Existing packed-chunk timing alignment and inserted-title handling remain separate because they align logical events rather than values inside one record.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use deterministic UUIDs and spill filenames in snapshot deployments.** Replacing production randomness would weaken the security shape under test or require test-only behavior in storage and approval implementations.
|
||||
|
||||
**Commit normalized fixtures.** Tokenized session logs would stop being raw replay inputs and would cause a broad fixture migration unrelated to the write-back defect.
|
||||
|
||||
**Preserve a whole record when its normalized form is unchanged.** This is simpler but churns a random field whenever another field in the same record changes semantically. Leaf-level preservation keeps those decisions independent.
|
||||
|
||||
## Consequences
|
||||
|
||||
Repeated refreshes no longer rewrite aligned fixture values solely because the normalizer classifies them as volatile, and new volatile categories added to the normalizer automatically inherit the write-back behavior. Structural ambiguity remains conservative: unmatched records, conflicting string mappings, resized arrays, and strings containing both semantic and volatile changes use fresh values rather than risk reusing misaligned data.
|
||||
|
||||
Focused unit coverage pins recursive object/array behavior, correlated IDs, ambiguous-layout fallback, conflicting mappings, fresh cwd aliases, volatile strings, and fresh semantic fields. Keyless refresh coverage proves approval UUIDs, cwd aliases, spill paths, and event-read volatility leave their committed fixtures byte-identical.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 稳定快照刷新中的易变值
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-27-stable-snapshot-refresh-volatiles.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
ACP(Agent Client Protocol)快照比较会归一化生成的 UUID、cwd 别名、spill locator、嵌入的事件时间和省略字节数,但刷新写回会持久化本次生成的原始值。因此,即使比较契约将两份日志视为相等,一次行为未发生变化的刷新仍会用新的随机值或宿主特有的路径写法改写 fixture(测试前置数据)。
|
||||
|
||||
## 决策
|
||||
|
||||
刷新写回以 `normalizeSessionLog` 作为易变值的唯一判定依据。系统使用本次运行的 id、cwd 及全部 cwd 别名归一化原始收集记录,并使用 fixture header 上下文归一化 fixture 记录;字面量替换只影响要写入的原始值。现有记录完成对齐后,系统基于这些归一化记录,递归比较本次生成记录与现有记录的叶节点:归一化后等价的叶节点保留现有原始值,归一化后不同的叶节点则保留本次生成的语义值。
|
||||
|
||||
复用前必须确保完整逻辑记录布局对齐,现有的打包分片与插入标题等价情形除外。归一化后等价但发生变化的字符串在整份日志范围内形成双射:一个本次生成的字符串只映射到一个现有字符串,反向亦然,因此跨记录重复出现的 ID 仍保持关联。出现无法解释的记录不匹配或映射冲突时,该日志会停用规范化字符串复用。
|
||||
|
||||
对象字段按键对齐。只有所有对应数组长度相同时,才对齐其元素;否则以本次生成的数组为准。字符串始终作为不可拆分的叶节点。现有的打包分片计时对齐与插入标题处理仍保持独立,因为它们对齐的是逻辑事件,而非单条记录内的值。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在快照部署中使用确定性的 UUID 和 spill 文件名。** 替换生产环境使用的随机性会削弱测试所要验证的安全属性,或者要求存储与审批实现引入仅用于测试的行为。
|
||||
|
||||
**提交归一化后的 fixture。** token 化的会话日志将不再是原始回放输入,并会引发与写回缺陷无关的大范围 fixture 迁移。
|
||||
|
||||
**当整条记录的归一化形式未变时保留整条记录。** 这种做法更简单,但同一记录中的另一个字段发生语义变化时,也会改写其中的随机字段。按叶节点保留可使这些决策彼此独立。
|
||||
|
||||
## 后果
|
||||
|
||||
重复刷新不再仅仅因为规范化器将已对齐的 fixture 值归类为易变值,就改写这些值;以后加入规范化器的新易变值类别也会自动继承该写回行为。结构有歧义时仍采取保守策略:记录无法匹配、字符串映射冲突、数组尺寸发生变化,或字符串同时包含语义变化与易变变化时,均使用本次生成的值,避免冒险复用未对齐的数据。
|
||||
|
||||
聚焦的单元测试固定了递归处理对象与数组的行为、关联 ID、有歧义布局时的回退、映射冲突、本次运行的 cwd 别名、易变字符串以及本次生成的语义字段。无密钥刷新测试证明,审批 UUID、cwd 别名、spill 路径和事件读取中的易变值不会改变已提交 fixture 的任何字节。
|
||||
@@ -243,6 +243,7 @@ describe('headless stream-json snapshots', () => {
|
||||
const children = logs.filter(log => typeof log.header.parentSession === 'string')
|
||||
.sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt))
|
||||
const actualSessions = [parent, ...children]
|
||||
const actualContext = contextFromLogs(actualSessions.map(log => log.content))
|
||||
if (refreshing) {
|
||||
const harvested = actualSessions.map((log): HarvestedLog => ({
|
||||
id: String(log.header.id),
|
||||
@@ -259,12 +260,11 @@ describe('headless stream-json snapshots', () => {
|
||||
if (existing === undefined || file === undefined) {
|
||||
throw new Error(`headless snapshot has no fixture for persisted log ${index}`)
|
||||
}
|
||||
const stable = stabilizeRefreshLog(actual.content, existing, replacements)
|
||||
const stable = stabilizeRefreshLog(actual.content, existing, replacements, actualContext)
|
||||
await writeFile(file, stable)
|
||||
return stable
|
||||
}))
|
||||
}
|
||||
const actualContext = contextFromLogs(actualSessions.map(log => log.content))
|
||||
const expectedContext = contextFromLogs(expectedSessions)
|
||||
for (const [index, actual] of actualSessions.entries()) {
|
||||
const expected = expectedSessions[index]
|
||||
@@ -439,6 +439,7 @@ describe('headless stream-json snapshots', () => {
|
||||
expect(logs).toHaveLength(1)
|
||||
const actual = logs[0]
|
||||
if (actual === undefined) throw new Error('headless PTY snapshot did not persist its session')
|
||||
const actualContext = contextFromLogs([actual.content])
|
||||
if (refreshing) {
|
||||
const harvested: HarvestedLog = {
|
||||
id: String(actual.header.id),
|
||||
@@ -446,10 +447,9 @@ describe('headless stream-json snapshots', () => {
|
||||
content: actual.content,
|
||||
}
|
||||
const replacements = refreshFixtureReplacements([harvested], [expectedSession])
|
||||
expectedSession = stabilizeRefreshLog(actual.content, expectedSession, replacements)
|
||||
expectedSession = stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext)
|
||||
await writeFile(ptySessionFixture, expectedSession)
|
||||
}
|
||||
const actualContext = contextFromLogs([actual.content])
|
||||
const expectedContext = contextFromLogs([expectedSession])
|
||||
expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
|
||||
.toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext)))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: d22e6e2d95a1ed930a7f4876daf4b06e2f761f7a
|
||||
README.zh.md: 514b7ebfe02cb34ed559633a0fd82cf6194fa4b3
|
||||
# pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md
|
||||
README.md: afbb23e2251932d41ac5d5d5b7d966f2750857fe
|
||||
README.zh.md: 67ebbff395881c811819bb9e3dcfa4faa4d39914
|
||||
|
||||
@@ -9,7 +9,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 and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; 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/archived/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.
|
||||
- **`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 evaluates fresh leaves with the harvested run's ids, cwd, and every cwd alias, then reuses normalized-equivalent leaves only when the complete logical-record layout aligns and volatile string replacements form a bijection; ambiguous logs keep fresh strings, and fresh semantic values remain authoritative. It also expands packed timing envelopes before aligning event times, so switching between packed and unpacked layouts cannot shift later records. 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.
|
||||
|
||||
Committed session fixtures use canonical packed rows. An in-flight branch that merges this contract runs the [temporary repository migrator](../../../scripts/migrate-packed-session-fixtures.ts) with `pnpm run migrate:packed-session-fixtures`; its [removal proposal](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md) owns deletion after affected branches converge.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ ACP 快照套件工具包:无密钥快照层(`pnpm run test:snapshot`,见[
|
||||
- **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源 agent,或在普通 Node 下启动已构建 `lib` agent;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动过程中公开异步 spawn 失败,对未处理权限请求快速失败,并负责优雅或带信号关闭。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才解析或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。当 Windows 接受强制终止但异步发布退出标记时,关闭会给该标记有界宽限,然后才将回退拒绝视为第二次失败。快照和普通 e2e 套件共享该进程边界;测试只需提供 agent 路径、cwd、环境覆盖和任何权限策略。
|
||||
- **`runScenario`(harness)**:通过启动器从确定性 `input.json` 脚本驱动 ACP JSON-RPC stdio,将原始 stdout tee 给预期输出和纯度检查,并在优雅 stdin EOF 后收集每个持久化原始 JSONL 会话日志(父级和 subagent 子级,主级优先)。`AgentUnderTest` 提供绝对 `binScript`、可选 `libBinScript`、`configPath` 和 `tsconfigPath` 路径,因为子进程 cwd 位于仓库外。当生成子级 cwd 自身位于待测授权中时,`workspaceParent` 可以将它从平台临时目录移出。启动失败会在拒绝诊断中保留已捕获 agent stderr。
|
||||
- **规范化器**:将两个已捕获接口转换为稳定文本的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`scrubSystemPrompts`(提示词文本 → `{{system}}`)、`scrubToolSchemas`(schema bulk → `{{tools}}`)和 `scrubRequestHeaders`(每个 pin 之外的所有 header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}`,保留结构;见[header 固定 Agent Note](../../../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。
|
||||
- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin(`system-prompt.expected.md` 加 `tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。刷新会在对齐现有可变事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录;新分片碎片数组仍为权威数据。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session.<n>.jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。
|
||||
- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每 header 类别 pin(`system-prompt.expected.md` 加 `tool-schemas.expected.json`)及其实时一致性保护,以及 fixture 保护块(无遗留场景目录、必需文件存在、每类别恰好一个 pin、每个 JSONL 的提示词/schema 已擦除、非 pin fixture 的 header 已完全擦除)。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session.<n>.jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。
|
||||
|
||||
签入仓库的会话 fixture 使用规范打包行。合并此契约的在途分支通过 `pnpm run migrate:packed-session-fixtures` 运行[临时仓库迁移器](../../../scripts/migrate-packed-session-fixtures.ts);待受影响分支收敛后,由其[移除提案](../../../.agents/notes/proposed/process/2026-07-26-remove-packed-session-fixture-migrator.md)负责删除该迁移器。
|
||||
|
||||
|
||||
@@ -519,28 +519,234 @@ function preservePackedMemberTimes(
|
||||
row.data.dt = gaps
|
||||
}
|
||||
|
||||
/** Whether a parsed JSON value is a non-array object. */
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse existing leaves whose normalized values equal the fresh values.
|
||||
* Objects merge by key; arrays merge only when their positions still align.
|
||||
*/
|
||||
function preserveNormalizedVolatiles(
|
||||
fresh: unknown,
|
||||
existing: unknown,
|
||||
normalizedFresh: unknown,
|
||||
normalizedExisting: unknown,
|
||||
stringMappings: ReadonlyMap<string, string>,
|
||||
): unknown {
|
||||
if (
|
||||
Array.isArray(fresh)
|
||||
&& Array.isArray(existing)
|
||||
&& Array.isArray(normalizedFresh)
|
||||
&& Array.isArray(normalizedExisting)
|
||||
) {
|
||||
if (
|
||||
fresh.length !== existing.length
|
||||
|| fresh.length !== normalizedFresh.length
|
||||
|| fresh.length !== normalizedExisting.length
|
||||
) return fresh
|
||||
return fresh.map((value, index) => preserveNormalizedVolatiles(
|
||||
value,
|
||||
existing[index],
|
||||
normalizedFresh[index],
|
||||
normalizedExisting[index],
|
||||
stringMappings,
|
||||
))
|
||||
}
|
||||
if (
|
||||
isRecord(fresh)
|
||||
&& isRecord(existing)
|
||||
&& isRecord(normalizedFresh)
|
||||
&& isRecord(normalizedExisting)
|
||||
) {
|
||||
return Object.fromEntries(Object.entries(fresh).map(([key, value]) => [
|
||||
key,
|
||||
Object.hasOwn(existing, key)
|
||||
&& Object.hasOwn(normalizedFresh, key)
|
||||
&& Object.hasOwn(normalizedExisting, key)
|
||||
? preserveNormalizedVolatiles(
|
||||
value,
|
||||
existing[key],
|
||||
normalizedFresh[key],
|
||||
normalizedExisting[key],
|
||||
stringMappings,
|
||||
)
|
||||
: value,
|
||||
]))
|
||||
}
|
||||
if (
|
||||
typeof fresh === 'string'
|
||||
&& typeof existing === 'string'
|
||||
&& typeof normalizedFresh === 'string'
|
||||
&& normalizedFresh === normalizedExisting
|
||||
) {
|
||||
return stringMappings.get(JSON.stringify([normalizedFresh, fresh])) === existing
|
||||
? existing
|
||||
: fresh
|
||||
}
|
||||
return Object.is(normalizedFresh, normalizedExisting) ? existing : fresh
|
||||
}
|
||||
|
||||
/** Normalize one aligned record with the same contract used by fixture comparison. */
|
||||
function normalizedRefreshRecord(
|
||||
record: Record<string, unknown>,
|
||||
context: NormalizeContext,
|
||||
): Record<string, unknown> {
|
||||
return JSON.parse(normalizeSessionLog(`${JSON.stringify(record)}\n`, context)) as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Add normalized-equivalent string replacements to a bijection.
|
||||
* Structural differences are fresh-owned and therefore contribute no mapping.
|
||||
*/
|
||||
function collectNormalizedStringMappings(
|
||||
fresh: unknown,
|
||||
existing: unknown,
|
||||
normalizedFresh: unknown,
|
||||
normalizedExisting: unknown,
|
||||
forward: Map<string, string>,
|
||||
reverse: Map<string, string>,
|
||||
): boolean {
|
||||
if (
|
||||
Array.isArray(fresh)
|
||||
&& Array.isArray(existing)
|
||||
&& Array.isArray(normalizedFresh)
|
||||
&& Array.isArray(normalizedExisting)
|
||||
) {
|
||||
if (
|
||||
fresh.length !== existing.length
|
||||
|| fresh.length !== normalizedFresh.length
|
||||
|| fresh.length !== normalizedExisting.length
|
||||
) return true
|
||||
return fresh.every((value, index) => collectNormalizedStringMappings(
|
||||
value,
|
||||
existing[index],
|
||||
normalizedFresh[index],
|
||||
normalizedExisting[index],
|
||||
forward,
|
||||
reverse,
|
||||
))
|
||||
}
|
||||
if (
|
||||
isRecord(fresh)
|
||||
&& isRecord(existing)
|
||||
&& isRecord(normalizedFresh)
|
||||
&& isRecord(normalizedExisting)
|
||||
) {
|
||||
return Object.entries(fresh).every(([key, value]) =>
|
||||
!Object.hasOwn(existing, key)
|
||||
|| !Object.hasOwn(normalizedFresh, key)
|
||||
|| !Object.hasOwn(normalizedExisting, key)
|
||||
|| collectNormalizedStringMappings(
|
||||
value,
|
||||
existing[key],
|
||||
normalizedFresh[key],
|
||||
normalizedExisting[key],
|
||||
forward,
|
||||
reverse,
|
||||
))
|
||||
}
|
||||
if (
|
||||
typeof fresh !== 'string'
|
||||
|| typeof existing !== 'string'
|
||||
|| typeof normalizedFresh !== 'string'
|
||||
|| normalizedFresh !== normalizedExisting
|
||||
|| fresh === existing
|
||||
) return true
|
||||
const freshKey = JSON.stringify([normalizedFresh, fresh])
|
||||
const existingKey = JSON.stringify([normalizedFresh, existing])
|
||||
const mappedExisting = forward.get(freshKey)
|
||||
const mappedFresh = reverse.get(existingKey)
|
||||
if (
|
||||
mappedExisting !== undefined && mappedExisting !== existing
|
||||
|| mappedFresh !== undefined && mappedFresh !== fresh
|
||||
) return false
|
||||
forward.set(freshKey, existing)
|
||||
reverse.set(existingKey, fresh)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a log-wide bijection for normalized-equivalent strings.
|
||||
* Any unexplained record mismatch or conflicting replacement disables reuse.
|
||||
*/
|
||||
function normalizedStringMappings(
|
||||
records: Record<string, unknown>[],
|
||||
freshRecords: Record<string, unknown>[],
|
||||
existingRecords: Record<string, unknown>[],
|
||||
freshContext: NormalizeContext,
|
||||
existingContext: NormalizeContext,
|
||||
): Map<string, string> | undefined {
|
||||
const forward = new Map<string, string>()
|
||||
const reverse = new Map<string, string>()
|
||||
let existingIndex = 0
|
||||
for (let recordIndex = 0; recordIndex < records.length; recordIndex++) {
|
||||
const record = records[recordIndex] as Record<string, unknown>
|
||||
const existingRecord = existingRecords[existingIndex]
|
||||
const memberCount = packedTimes(record)?.length ?? 1
|
||||
if (record.type === 'session/title' && existingRecord?.type !== 'session/title') continue
|
||||
if (memberCount > 1) {
|
||||
const existingMembers = existingRecords.slice(existingIndex, existingIndex + memberCount)
|
||||
if (
|
||||
existingMembers.length !== memberCount
|
||||
|| existingMembers.some(member => member.type !== 'assistant/chunk')
|
||||
) return undefined
|
||||
} else {
|
||||
if (existingRecord === undefined || existingRecord.type !== record.type) return undefined
|
||||
if (!collectNormalizedStringMappings(
|
||||
record,
|
||||
existingRecord,
|
||||
normalizedRefreshRecord(freshRecords[recordIndex] as Record<string, unknown>, freshContext),
|
||||
normalizedRefreshRecord(existingRecord, existingContext),
|
||||
forward,
|
||||
reverse,
|
||||
)) return undefined
|
||||
}
|
||||
existingIndex += memberCount
|
||||
}
|
||||
return existingIndex === existingRecords.length ? forward : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a fresh replay-produced log so repeated refreshes do not churn
|
||||
* volatile fixture fields. Meaningful event payloads come from `fresh`; the
|
||||
* existing fixture lends session ids, cwd, creation times, logical event
|
||||
* times, and hook durations where the record shape still matches. Packed
|
||||
* timing envelopes expand for alignment, so packing does not shift later
|
||||
* records; fresh fragment arrays remain authoritative.
|
||||
* existing fixture lends normalized-equivalent values, including ids, paths,
|
||||
* creation/event times, spill locators, and hook durations, only when the
|
||||
* complete record layout aligns and volatile strings form a consistent
|
||||
* bijection. Ambiguous layouts or mappings keep fresh strings. Packed timing
|
||||
* envelopes expand for alignment, so packing does not shift later records;
|
||||
* fresh semantic values and fragment arrays remain authoritative.
|
||||
*
|
||||
* @param fresh The newly harvested session JSONL.
|
||||
* @param existing The committed fixture JSONL being refreshed.
|
||||
* @param replacements Cross-log literal replacements from {@link refreshFixtureReplacements}.
|
||||
* @param freshContext The harvested run's ids, cwd, and every cwd alias.
|
||||
* @returns The stabilized JSONL content to write back.
|
||||
*/
|
||||
export function stabilizeRefreshLog(fresh: string, existing: string, replacements: FixtureReplacement[]): string {
|
||||
export function stabilizeRefreshLog(
|
||||
fresh: string,
|
||||
existing: string,
|
||||
replacements: FixtureReplacement[],
|
||||
freshContext: NormalizeContext,
|
||||
): string {
|
||||
const freshRecords = parseJsonlRecords(fresh)
|
||||
let stable = fresh
|
||||
for (const { from, to } of replacements) stable = stable.split(from).join(to)
|
||||
const existingRecords = logicalRecords(parseJsonlRecords(existing))
|
||||
const records = parseJsonlRecords(stable)
|
||||
const existingContext = fixtureContext(existing)
|
||||
const stringMappings = normalizedStringMappings(
|
||||
records,
|
||||
freshRecords,
|
||||
existingRecords,
|
||||
freshContext,
|
||||
existingContext,
|
||||
)
|
||||
let existingIndex = 0
|
||||
let previousEventTime: unknown
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i] as Record<string, unknown>
|
||||
let record = records[i] as Record<string, unknown>
|
||||
const existingRecord = existingRecords[existingIndex]
|
||||
const memberCount = packedTimes(record)?.length ?? 1
|
||||
const insertedTitle = record.type === 'session/title' && existingRecord?.type !== 'session/title'
|
||||
@@ -549,6 +755,21 @@ export function stabilizeRefreshLog(fresh: string, existing: string, replacement
|
||||
if (typeof previousEventTime !== 'number') throw new Error('acp-snapshot: inserted title has no preceding event time')
|
||||
record.time = previousEventTime
|
||||
} else {
|
||||
if (
|
||||
stringMappings !== undefined
|
||||
&& memberCount === 1
|
||||
&& existingRecord !== undefined
|
||||
&& existingRecord.type === record.type
|
||||
) {
|
||||
record = preserveNormalizedVolatiles(
|
||||
record,
|
||||
existingRecord,
|
||||
normalizedRefreshRecord(freshRecords[i] as Record<string, unknown>, freshContext),
|
||||
normalizedRefreshRecord(existingRecord, existingContext),
|
||||
stringMappings,
|
||||
) as Record<string, unknown>
|
||||
records[i] = record
|
||||
}
|
||||
preservePackedMemberTimes(record, existingRecords.slice(existingIndex, existingIndex + memberCount))
|
||||
preserveFixtureVolatiles(record, existingRecord)
|
||||
existingIndex += memberCount
|
||||
@@ -668,12 +889,12 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
]
|
||||
const primary = (result.sessionLogs[0] as HarvestedLog).content
|
||||
await writeFile(join(dir, outputFixtureFiles[0] as string), scrub(
|
||||
REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements) : primary,
|
||||
REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements, ctx) : primary,
|
||||
))
|
||||
for (let i = 1; i < result.sessionLogs.length; i++) {
|
||||
const child = (result.sessionLogs[i] as HarvestedLog).content
|
||||
await writeFile(join(dir, outputFixtureFiles[i] as string), scrub(
|
||||
REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements) : child,
|
||||
REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements, ctx) : child,
|
||||
))
|
||||
}
|
||||
if (RECORDING) {
|
||||
|
||||
@@ -43,6 +43,15 @@ const AGENT = {
|
||||
}
|
||||
|
||||
const REPLAY_DIR = fileURLToPath(new URL('./fixtures/suite', import.meta.url))
|
||||
|
||||
function stabilize(
|
||||
fresh: string,
|
||||
existing: string,
|
||||
replacements: Parameters<typeof stabilizeRefreshLog>[2] = [],
|
||||
freshContext: Parameters<typeof stabilizeRefreshLog>[3] = fixtureContext(fresh),
|
||||
): string {
|
||||
return stabilizeRefreshLog(fresh, existing, replacements, freshContext)
|
||||
}
|
||||
const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta.url))
|
||||
|
||||
// Replay pins explicit header classes; recording covers the default fallback.
|
||||
@@ -500,7 +509,7 @@ describe('stabilizeRefreshLog', () => {
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
expect(stabilizeRefreshLog(fresh, existing, [])).toBe([
|
||||
expect(stabilize(fresh, existing)).toBe([
|
||||
'{"type":"session","id":"same","createdAt":100}',
|
||||
'{"type":"reasoning-chunks","seq0":2,"time0":100,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["new",""," split"]}}',
|
||||
'{"type":"assistant/message","seq":5,"time":104,"data":{}}',
|
||||
@@ -520,7 +529,7 @@ describe('stabilizeRefreshLog', () => {
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
expect(stabilizeRefreshLog(fresh, existing, [])).toBe([
|
||||
expect(stabilize(fresh, existing)).toBe([
|
||||
'{"type":"session","id":"same","createdAt":100}',
|
||||
'{"type":"text-chunks","seq0":2,"time0":100,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["new",""," split"]}}',
|
||||
'',
|
||||
@@ -545,10 +554,9 @@ describe('stabilizeRefreshLog', () => {
|
||||
time,
|
||||
data: {},
|
||||
}))
|
||||
const output = stabilizeRefreshLog(
|
||||
const output = stabilize(
|
||||
`${JSON.stringify({ type: 'session', id: 'same', createdAt: 200 })}\n${JSON.stringify(freshRow)}\n`,
|
||||
`${JSON.stringify({ type: 'session', id: 'same', createdAt: 100 })}\n${existingRows.map(row => JSON.stringify(row)).join('\n')}\n`,
|
||||
[],
|
||||
).trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
|
||||
expect(output[1]).toStrictEqual({ ...freshRow, time0: expectedTime0 })
|
||||
@@ -573,7 +581,7 @@ describe('stabilizeRefreshLog', () => {
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
expect(stabilizeRefreshLog(fresh, existing, [])).toBe([
|
||||
expect(stabilize(fresh, existing)).toBe([
|
||||
'{"type":"session","id":"same","createdAt":100}',
|
||||
'{"type":"turn/start","seq":0,"time":11}',
|
||||
'{"type":"user/message","seq":1,"time":12}',
|
||||
@@ -602,7 +610,7 @@ describe('stabilizeRefreshLog', () => {
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
expect(stabilizeRefreshLog(fresh, existing, [
|
||||
expect(stabilize(fresh, existing, [
|
||||
{ from: 'new-parent', to: 'old-parent' },
|
||||
{ from: 'new-child', to: 'old-child' },
|
||||
{ from: '/new', to: '/old' },
|
||||
@@ -615,4 +623,233 @@ describe('stabilizeRefreshLog', () => {
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('preserves normalized volatile fields while accepting fresh semantic fields', () => {
|
||||
const freshApprovalId = '11111111-1111-4111-8111-111111111111'
|
||||
const existingApprovalId = '22222222-2222-4222-8222-222222222222'
|
||||
const freshSpill = '/tmp/dsh-acp-snap-012345678/session-111111111111/222222222222-bash.txt'
|
||||
const existingSpill = '/tmp/dsh-acp-snap-012345678/session-aaaaaaaaaaaa/bbbbbbbbbbbb-bash.txt'
|
||||
const freshEventRead = [
|
||||
'Session main — title',
|
||||
'Target event seq 4:',
|
||||
'```json',
|
||||
'{',
|
||||
' "time": 1785000000000,',
|
||||
' "data": {}',
|
||||
'}',
|
||||
'```',
|
||||
'',
|
||||
`(Omitted 40000 bytes. Full formatted result stored at: ${freshSpill}. Use read with offset/limit, or grep this path to search within it.)`,
|
||||
].join('\n')
|
||||
const existingEventRead = freshEventRead
|
||||
.replace('1785000000000', '1784000000000')
|
||||
.replace('40000 bytes', '30000 bytes')
|
||||
.replace(freshSpill, existingSpill)
|
||||
const fresh = [
|
||||
'{"type":"session","id":"same","createdAt":200,"cwd":"/old"}',
|
||||
JSON.stringify({
|
||||
type: 'approval/asked',
|
||||
seq: 1,
|
||||
time: 22,
|
||||
data: {
|
||||
id: freshApprovalId,
|
||||
outcome: 'fresh',
|
||||
aliases: [freshApprovalId, 'fresh'],
|
||||
resized: [freshApprovalId, 'new'],
|
||||
shape: { shared: freshApprovalId, added: true },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'tool/result',
|
||||
seq: 2,
|
||||
time: 23,
|
||||
data: {
|
||||
spill: `Full formatted result stored at: ${freshSpill}. Use read with offset/limit, or grep this path to search within it.`,
|
||||
path: '/private/old/result.txt',
|
||||
eventRead: freshEventRead,
|
||||
},
|
||||
}),
|
||||
'',
|
||||
].join('\n')
|
||||
const existing = [
|
||||
'{"type":"session","id":"same","createdAt":100,"cwd":"/old"}',
|
||||
JSON.stringify({
|
||||
type: 'approval/asked',
|
||||
seq: 1,
|
||||
time: 11,
|
||||
data: {
|
||||
id: existingApprovalId,
|
||||
outcome: 'stale',
|
||||
aliases: [existingApprovalId, 'stale'],
|
||||
resized: [existingApprovalId],
|
||||
shape: { shared: existingApprovalId },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'tool/result',
|
||||
seq: 2,
|
||||
time: 12,
|
||||
data: {
|
||||
spill: `Full formatted result stored at: ${existingSpill}. Use read with offset/limit, or grep this path to search within it.`,
|
||||
path: '/old/result.txt',
|
||||
eventRead: existingEventRead,
|
||||
},
|
||||
}),
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const output = stabilize(fresh, existing).trim().split('\n')
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
expect(output).toEqual([
|
||||
{ type: 'session', id: 'same', createdAt: 100, cwd: '/old' },
|
||||
{
|
||||
type: 'approval/asked',
|
||||
seq: 1,
|
||||
time: 11,
|
||||
data: {
|
||||
id: existingApprovalId,
|
||||
outcome: 'fresh',
|
||||
aliases: [existingApprovalId, 'fresh'],
|
||||
resized: [freshApprovalId, 'new'],
|
||||
shape: { shared: existingApprovalId, added: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'tool/result',
|
||||
seq: 2,
|
||||
time: 12,
|
||||
data: {
|
||||
spill: `Full formatted result stored at: ${existingSpill}. Use read with offset/limit, or grep this path to search within it.`,
|
||||
path: '/old/result.txt',
|
||||
eventRead: existingEventRead,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes fresh cwd aliases before reusing existing paths', () => {
|
||||
const freshCwd = String.raw`C:\Users\RUNNER~1\AppData\Local\Temp\acp-snap-cwd-new`
|
||||
const freshAlias = String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp-snap-cwd-new`
|
||||
const existingCwd = String.raw`C:\Users\RUNNER~1\AppData\Local\Temp\acp-snap-cwd-old`
|
||||
const fresh = [
|
||||
JSON.stringify({ type: 'session', id: 'same', createdAt: 200, cwd: freshCwd }),
|
||||
JSON.stringify({ type: 'tool/result', data: { path: `${freshAlias}\\result.txt` } }),
|
||||
'',
|
||||
].join('\n')
|
||||
const existing = [
|
||||
JSON.stringify({ type: 'session', id: 'same', createdAt: 100, cwd: existingCwd }),
|
||||
JSON.stringify({ type: 'tool/result', data: { path: `${existingCwd}\\result.txt` } }),
|
||||
'',
|
||||
].join('\n')
|
||||
const freshContext = { ...fixtureContext(fresh), cwdAliases: [freshAlias] }
|
||||
|
||||
expect(stabilize(
|
||||
fresh,
|
||||
existing,
|
||||
[{ from: freshCwd, to: existingCwd }],
|
||||
freshContext,
|
||||
)).toBe([
|
||||
JSON.stringify({ type: 'session', id: 'same', createdAt: 100, cwd: existingCwd }),
|
||||
JSON.stringify({ type: 'tool/result', data: { path: `${existingCwd}\\result.txt` } }),
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('preserves one correlated volatile id through a consistent log-wide mapping', () => {
|
||||
const freshId = '11111111-1111-4111-8111-111111111111'
|
||||
const existingId = '22222222-2222-4222-8222-222222222222'
|
||||
const fresh = [
|
||||
'{"type":"session","id":"same","createdAt":200,"cwd":"/old"}',
|
||||
JSON.stringify({ type: 'approval/asked', data: { id: freshId } }),
|
||||
JSON.stringify({ type: 'approval/decided', data: { id: freshId, outcome: 'allowed-once' } }),
|
||||
'',
|
||||
].join('\n')
|
||||
const existing = [
|
||||
'{"type":"session","id":"same","createdAt":100,"cwd":"/old"}',
|
||||
JSON.stringify({ type: 'approval/asked', data: { id: existingId } }),
|
||||
JSON.stringify({ type: 'approval/decided', data: { id: existingId, outcome: 'rejected' } }),
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
expect(stabilize(fresh, existing)).toBe([
|
||||
'{"type":"session","id":"same","createdAt":100,"cwd":"/old"}',
|
||||
JSON.stringify({ type: 'approval/asked', data: { id: existingId } }),
|
||||
JSON.stringify({ type: 'approval/decided', data: { id: existingId, outcome: 'allowed-once' } }),
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('keeps fresh correlated ids when record alignment is structurally ambiguous', () => {
|
||||
const firstFreshId = '11111111-1111-4111-8111-111111111111'
|
||||
const secondFreshId = '22222222-2222-4222-8222-222222222222'
|
||||
const existingId = '33333333-3333-4333-8333-333333333333'
|
||||
const fresh = [
|
||||
'{"type":"session","id":"same","createdAt":200,"cwd":"/old"}',
|
||||
JSON.stringify({ type: 'approval/asked', data: { id: firstFreshId } }),
|
||||
JSON.stringify({ type: 'approval/asked', data: { id: secondFreshId } }),
|
||||
JSON.stringify({ type: 'approval/decided', data: { id: firstFreshId } }),
|
||||
JSON.stringify({ type: 'approval/decided', data: { id: secondFreshId } }),
|
||||
'',
|
||||
].join('\n')
|
||||
const existing = [
|
||||
'{"type":"session","id":"same","createdAt":100,"cwd":"/old"}',
|
||||
JSON.stringify({ type: 'approval/asked', data: { id: existingId } }),
|
||||
JSON.stringify({ type: 'approval/decided', data: { id: existingId } }),
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const ids = stabilize(fresh, existing).trim().split('\n').slice(1)
|
||||
.map(line => (JSON.parse(line) as { data: { id: string } }).data.id)
|
||||
expect(ids).toEqual([firstFreshId, secondFreshId, firstFreshId, secondFreshId])
|
||||
})
|
||||
|
||||
it('keeps fresh ids when existing records remain unmatched', () => {
|
||||
const freshId = '11111111-1111-4111-8111-111111111111'
|
||||
const existingId = '22222222-2222-4222-8222-222222222222'
|
||||
const fresh = [
|
||||
'{"type":"session","id":"same","createdAt":200,"cwd":"/old"}',
|
||||
JSON.stringify({ type: 'approval/asked', data: { id: freshId } }),
|
||||
'',
|
||||
].join('\n')
|
||||
const existing = [
|
||||
'{"type":"session","id":"same","createdAt":100,"cwd":"/old"}',
|
||||
JSON.stringify({ type: 'approval/asked', data: { id: existingId } }),
|
||||
JSON.stringify({ type: 'approval/decided', data: { id: existingId } }),
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const output = stabilize(fresh, existing).trim().split('\n')
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
expect(output[1]).toEqual({ type: 'approval/asked', data: { id: freshId } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'one fresh id would map to two existing ids',
|
||||
fresh: ['a', 'b', 'b', 'a'],
|
||||
existing: ['x', 'y', 'x', 'y'],
|
||||
},
|
||||
{
|
||||
name: 'two fresh ids would map to one existing id',
|
||||
fresh: ['a', 'b'],
|
||||
existing: ['x', 'x'],
|
||||
},
|
||||
])('keeps fresh ids when $name', ({ fresh: freshNames, existing: existingNames }) => {
|
||||
const ids = {
|
||||
a: '11111111-1111-4111-8111-111111111111',
|
||||
b: '22222222-2222-4222-8222-222222222222',
|
||||
x: '33333333-3333-4333-8333-333333333333',
|
||||
y: '44444444-4444-4444-8444-444444444444',
|
||||
} as const
|
||||
const types = ['approval/asked', 'approval/asked', 'approval/decided', 'approval/decided']
|
||||
const log = (names: string[]): string => [
|
||||
'{"type":"session","id":"same","createdAt":100,"cwd":"/old"}',
|
||||
...names.map((name, index) => JSON.stringify({ type: types[index], data: { id: ids[name as keyof typeof ids] } })),
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const outputIds = stabilize(log(freshNames), log(existingNames)).trim().split('\n').slice(1)
|
||||
.map(line => (JSON.parse(line) as { data: { id: string } }).data.id)
|
||||
expect(outputIds).toEqual(freshNames.map(name => ids[name as keyof typeof ids]))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user