fix(acp-snapshot): retain unchanged message ids
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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 packages/support/acp-snapshot/README.md
|
||||
README.md: 948c33a91977f078d16842c285011bf8f83623bd
|
||||
README.zh.md: fb86bd4e236be1c79f66dc46fbaac4d7dfbf9977
|
||||
README.md: e3752dfb522cd55776f3ef796acdc15037e5a761
|
||||
README.zh.md: 6ce531640b5311662e4b958177e4417c8878617f
|
||||
|
||||
@@ -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 captured surfaces into stable text or portable fixtures: `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), `tokenizeSessionFixtureCwd` (the generated workspace and its filesystem aliases → `{{cwd}}`, authored temp paths unchanged), `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, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. 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.
|
||||
- **`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, a tokenized pin per header class composed with independently shared `system-prompt.expected.md` and `tool-schemas.expected.json` sidecars, and a live uniformity guard. Its fixture guards reject orphan scenario dirs, missing files, multiple pins for one class, duplicate sidecar content, unscrubbed JSONL headers, and malformed pinning headers. Before record or refresh writes fixtures, an unchanged complete message retains its committed UUID when its identity-free value resolves to exactly one fresh ID and one existing ID across the scenario's parent/child logs; new, changed, and ambiguous messages keep fresh UUIDs. 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。
|
||||
- **规范化器**:将已捕获接口转换为稳定文本或可移植 fixture 的纯函数:`normalizeStdout`(JSON-RPC id → 首次出现序列;UUID 以及生成 cwd 的每个原生/JavaScript 文件系统写法 → token,按最长优先;根据 cwd 的分隔符选择规范 `/` 或宿主原生形式;同时作为 stdout 纯度检查)、`normalizeSessionLog`(时间归零、保留 `seq`、使用同一 cwd 路径策略)、`tokenizeSessionFixtureCwd`(生成的 workspace 及其文件系统别名 → `{{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 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。刷新会使用收集所得本次运行的 id、cwd 及全部 cwd 别名评估本次生成的叶值;只有完整逻辑记录布局对齐且易变字符串替换形成双射时,才会复用规范化后等价的叶值;有歧义的日志保留本次生成的字符串,而本次生成的语义值仍为权威数据。它还会在对齐事件时间前展开打包时序 envelope,因此切换打包/非打包布局无法移动后续记录。新插入的 `session/title` 使用前一个事件的时间,因此功能驱动的插入不会扰动 fixture 余下部分。每个场景目录的 `session.jsonl` 和连续 `session.<n>.jsonl` 同级文件是有序主级/子级清单;场景表不重复其数量。必须在 vitest 收集时调用。
|
||||
- **`defineAcpSnapshotSuite`(工厂)**:为场景表注册完整 describe/it 树:每场景预期输出与重新持久化日志比较、录制/刷新 fixture 回写、拒绝结构化 `UNKNOWN_TOOL` 结果、每个 header 类别一个 token 化 pin(由可独立共享的 `system-prompt.expected.md` 和 `tool-schemas.expected.json` sidecar 组合而成),以及实时一致性保护。其 fixture 保护会拒绝遗留场景目录、缺失文件、一个类别包含多个 pin、重复的 sidecar 内容、未擦除的 JSONL header,以及格式错误的 pin header。在录制或刷新写入 fixture 前,如果一条未变化的完整消息去除身份后的值在场景的父级/子级日志中恰好对应一个本次生成的 ID 和一个现有 ID,它就会保留已提交的 UUID;新增、发生变化和有歧义的消息则保留本次生成的 UUID。刷新会使用收集所得本次运行的 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)负责删除该迁移器。
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ const TOOLS_TOKEN = '{{tools}}'
|
||||
|
||||
const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
|
||||
|
||||
/** Canonical UUID spelling minted for ordinary message identities. */
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/** A snapshot scenario and how its fixtures are produced. */
|
||||
export interface Scenario {
|
||||
name: string
|
||||
@@ -480,9 +483,9 @@ export function headerChangeCount(rawLog: string): number {
|
||||
.length
|
||||
}
|
||||
|
||||
/** A literal string replacement used to carry an existing fixture's volatile value into a refreshed log. */
|
||||
/** A literal string replacement used to carry an existing fixture value into fresh write-back. */
|
||||
export interface FixtureReplacement {
|
||||
/** The fresh replay-run value to replace. */
|
||||
/** The fresh run's value to replace. */
|
||||
from: string
|
||||
/** The existing fixture value to keep. */
|
||||
to: string
|
||||
@@ -494,6 +497,82 @@ function parseJsonlRecords(text: string): Record<string, unknown>[] {
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** Return the complete identified message carried by one surface event. */
|
||||
function eventMessage(record: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
const data = record.data
|
||||
if (!isRecord(data)) return undefined
|
||||
const message = record.type === 'user/message'
|
||||
? data
|
||||
: record.type === 'assistant/message' || record.type === 'tool/result' || record.type === 'steering/message'
|
||||
? data.message
|
||||
: undefined
|
||||
if (
|
||||
!isRecord(message)
|
||||
|| typeof message.id !== 'string'
|
||||
|| !UUID_RE.test(message.id)
|
||||
|| typeof message.role !== 'string'
|
||||
|| !Array.isArray(message.content)
|
||||
|| !isRecord(message.source)
|
||||
) return undefined
|
||||
return message
|
||||
}
|
||||
|
||||
/** Serialize parsed JSON by value rather than insertion order. */
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
||||
if (isRecord(value)) {
|
||||
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/** Index each unambiguous identity-free message value by its sole message id. */
|
||||
function uniqueMessageIds(logs: readonly string[]): Map<string, string | undefined> {
|
||||
const fingerprintsById = new Map<string, string | undefined>()
|
||||
for (const log of logs) {
|
||||
for (const record of parseJsonlRecords(log)) {
|
||||
const message = eventMessage(record)
|
||||
if (message === undefined) continue
|
||||
const { id, ...withoutId } = message
|
||||
const messageId = id as string
|
||||
const fingerprint = canonicalJson(withoutId)
|
||||
if (!fingerprintsById.has(messageId)) fingerprintsById.set(messageId, fingerprint)
|
||||
else if (fingerprintsById.get(messageId) !== fingerprint) fingerprintsById.set(messageId, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const idsByFingerprint = new Map<string, string | undefined>()
|
||||
for (const [id, fingerprint] of fingerprintsById) {
|
||||
if (fingerprint === undefined) continue
|
||||
if (!idsByFingerprint.has(fingerprint)) idsByFingerprint.set(fingerprint, id)
|
||||
else idsByFingerprint.set(fingerprint, undefined)
|
||||
}
|
||||
return idsByFingerprint
|
||||
}
|
||||
|
||||
/**
|
||||
* Match unchanged complete messages across a scenario's fresh and existing logs.
|
||||
* New, changed, repeated, or otherwise ambiguous messages keep their fresh ids.
|
||||
*/
|
||||
function fixtureMessageIdReplacements(logs: HarvestedLog[], fixtures: string[]): FixtureReplacement[] {
|
||||
const freshIds = uniqueMessageIds(logs.map(log => log.content))
|
||||
const existingIds = uniqueMessageIds(fixtures)
|
||||
const replacements: FixtureReplacement[] = []
|
||||
for (const [fingerprint, fresh] of freshIds) {
|
||||
const existing = existingIds.get(fingerprint)
|
||||
if (fresh === undefined || existing === undefined || fresh === existing) continue
|
||||
replacements.push({ from: fresh, to: existing })
|
||||
}
|
||||
return replacements
|
||||
}
|
||||
|
||||
/** Apply literal fixture replacements without changing any other fresh value. */
|
||||
function applyFixtureReplacements(content: string, replacements: readonly FixtureReplacement[]): string {
|
||||
let stable = content
|
||||
for (const { from, to } of replacements) stable = stable.split(from).join(to)
|
||||
return stable
|
||||
}
|
||||
|
||||
/** One packed row's member times, or `undefined` for an ordinary record. */
|
||||
function packedTimes(record: Record<string, unknown>): number[] | undefined {
|
||||
if (!PACKED_CHUNK_ROW_TYPES.has(record.type as string)) return undefined
|
||||
@@ -539,14 +618,15 @@ export function unknownToolCallIds(rawLog: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cross-log id/cwd/spill-path replacements used by refresh write-back.
|
||||
* Build refresh write-back replacements: scenario-wide unchanged message ids,
|
||||
* plus per-log session ids, cwd values, and spill paths.
|
||||
*
|
||||
* @param logs The freshly harvested logs, in fixture order.
|
||||
* @param fixtures The existing fixture contents, in matching order.
|
||||
* @returns Literal replacements from fresh volatile values to the fixture's old values.
|
||||
* @returns Literal replacements from fresh values to the fixture's existing values.
|
||||
*/
|
||||
export function refreshFixtureReplacements(logs: HarvestedLog[], fixtures: string[]): FixtureReplacement[] {
|
||||
const replacements: FixtureReplacement[] = []
|
||||
const replacements = fixtureMessageIdReplacements(logs, fixtures)
|
||||
for (let i = 0; i < logs.length; i++) {
|
||||
const fresh = parseJsonlRecords((logs[i] as HarvestedLog).content)[0]
|
||||
const existing = parseJsonlRecords(fixtures[i] ?? '')[0]
|
||||
@@ -823,8 +903,7 @@ export function stabilizeRefreshLog(
|
||||
freshContext: NormalizeContext,
|
||||
): string {
|
||||
const freshRecords = parseJsonlRecords(fresh)
|
||||
let stable = fresh
|
||||
for (const { from, to } of replacements) stable = stable.split(from).join(to)
|
||||
const stable = applyFixtureReplacements(fresh, replacements)
|
||||
const existingRecords = logicalRecords(parseJsonlRecords(existing))
|
||||
const records = parseJsonlRecords(stable)
|
||||
const existingContext = fixtureContext(existing)
|
||||
@@ -1016,10 +1095,6 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
const portableFixture = scenario.workspaceParent === undefined
|
||||
? tokenizeSessionFixtureCwd
|
||||
: (log: string): string => log
|
||||
const existingFixtures = REFRESHING
|
||||
? await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8')))
|
||||
: []
|
||||
const replacements = REFRESHING ? refreshFixtureReplacements(result.sessionLogs, existingFixtures) : []
|
||||
const writesSessionFixtures = (RECORDING && scenario.recorded && scenario.hasModelTurn)
|
||||
|| (REFRESHING && comparesLog)
|
||||
if (writesSessionFixtures) {
|
||||
@@ -1032,14 +1107,25 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
|
||||
'session.jsonl',
|
||||
...Array.from({ length: result.sessionLogs.length - 1 }, (_, i) => `session.${i + 1}.jsonl`),
|
||||
]
|
||||
const existingFixtures = await Promise.all(outputFixtureFiles.map(async (file) => {
|
||||
const path = join(dir, file)
|
||||
return existsSync(path) ? readFile(path, 'utf8') : ''
|
||||
}))
|
||||
const replacements = REFRESHING
|
||||
? refreshFixtureReplacements(result.sessionLogs, existingFixtures)
|
||||
: fixtureMessageIdReplacements(result.sessionLogs, existingFixtures)
|
||||
const primary = (result.sessionLogs[0] as HarvestedLog).content
|
||||
await writeFile(join(dir, outputFixtureFiles[0] as string), scrub(portableFixture(
|
||||
REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements, ctx) : primary,
|
||||
REFRESHING
|
||||
? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements, ctx)
|
||||
: applyFixtureReplacements(primary, replacements),
|
||||
)))
|
||||
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(portableFixture(
|
||||
REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements, ctx) : child,
|
||||
REFRESHING
|
||||
? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements, ctx)
|
||||
: applyFixtureReplacements(child, replacements),
|
||||
)))
|
||||
}
|
||||
if (RECORDING) {
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
"logs": [
|
||||
{ "file": "b/parent/session.jsonl", "lines": [
|
||||
{ "type": "session", "id": "{{SID}}", "createdAt": 700, "cwd": "{{CWD}}", "delegationDepth": 0 },
|
||||
{ "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
|
||||
{ "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } },
|
||||
{ "type": "user/message", "seq": 1, "time": 5, "data": { "role": "user", "content": [{ "type": "text", "text": "same inherited message" }], "source": { "kind": "user" }, "id": "11111111-1111-4111-8111-111111111111" }, "surfaceOp": "append" }
|
||||
]},
|
||||
{ "file": "b/child/session.jsonl", "lines": [
|
||||
{ "type": "session", "id": "abababab-cdcd-4efe-8ada-badabadabada", "createdAt": 800, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 },
|
||||
{ "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
|
||||
{ "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } },
|
||||
{ "type": "user/message", "seq": 1, "time": 5, "data": { "role": "user", "content": [{ "type": "text", "text": "same inherited message" }], "source": { "kind": "user" }, "id": "11111111-1111-4111-8111-111111111111" }, "surfaceOp": "append" }
|
||||
]}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
{"type":"session","id":"abababab-cdcd-4efe-8ada-badabadabada","createdAt":800,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","parentSession":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88","delegationDepth":1}
|
||||
{"type":"request/header","seq":0,"time":2,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"user/message","seq":1,"time":5,"data":{"role":"user","content":[{"type":"text","text":"same inherited message"}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
{"type":"session","id":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88","createdAt":700,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","delegationDepth":0}
|
||||
{"type":"request/header","seq":0,"time":3,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"user/message","seq":1,"time":5,"data":{"role":"user","content":[{"type":"text","text":"same inherited message"}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"}
|
||||
|
||||
@@ -193,6 +193,18 @@ describe('defineAcpSnapshotSuite: record inventory write-back', () => {
|
||||
expect(fixture).toContain('"cwd":"{{cwd}}"')
|
||||
expect(() => readFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'utf8')).toThrow()
|
||||
})
|
||||
|
||||
it('retains an unchanged message id across the recorded parent and child fixtures', () => {
|
||||
const existingMessageId = '22222222-2222-4222-8222-222222222222'
|
||||
const freshMessageId = '11111111-1111-4111-8111-111111111111'
|
||||
const fixtures = ['session.jsonl', 'session.1.jsonl']
|
||||
.map(file => readFileSync(join(recordDir, 'rec-child', file), 'utf8'))
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
expect(fixture).toContain(`"id":"${existingMessageId}"`)
|
||||
expect(fixture).not.toContain(freshMessageId)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineAcpSnapshotSuite: registration contract', () => {
|
||||
@@ -663,6 +675,78 @@ describe('refreshFixtureReplacements', () => {
|
||||
{ from: freshBash, to: oldBash },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps one inherited message id across parent and child logs', () => {
|
||||
const freshMessageId = '11111111-1111-4111-8111-111111111111'
|
||||
const existingMessageId = '22222222-2222-4222-8222-222222222222'
|
||||
const content = [{ type: 'text', text: 'inherited' }]
|
||||
const log = (sessionId: string, messageId: string): string => [
|
||||
JSON.stringify({ type: 'session', id: sessionId, cwd: '/same' }),
|
||||
JSON.stringify({
|
||||
type: 'user/message',
|
||||
data: { role: 'user', content, source: { kind: 'user' }, id: messageId },
|
||||
}),
|
||||
'',
|
||||
].join('\n')
|
||||
const harvested = (content: string): HarvestedLog => ({ id: 'diagnostic', createdAt: 1, content })
|
||||
|
||||
const replacements = refreshFixtureReplacements(
|
||||
[harvested(log('fresh-parent', freshMessageId)), harvested(log('fresh-child', freshMessageId))],
|
||||
[log('old-parent', existingMessageId), log('old-child', existingMessageId)],
|
||||
)
|
||||
|
||||
expect(replacements.filter(replacement => replacement.from === freshMessageId)).toEqual([
|
||||
{ from: freshMessageId, to: existingMessageId },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps fresh ids for new, changed, and ambiguous messages', () => {
|
||||
const ids = {
|
||||
new: '11111111-1111-4111-8111-111111111111',
|
||||
changed: '22222222-2222-4222-8222-222222222222',
|
||||
ambiguousA: '33333333-3333-4333-8333-333333333333',
|
||||
ambiguousB: '44444444-4444-4444-8444-444444444444',
|
||||
oldChanged: '55555555-5555-4555-8555-555555555555',
|
||||
oldAmbiguous: '66666666-6666-4666-8666-666666666666',
|
||||
stable: '77777777-7777-4777-8777-777777777777',
|
||||
} as const
|
||||
const message = (id: string, text: string): Record<string, unknown> => ({
|
||||
type: 'user/message',
|
||||
data: { role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' }, id },
|
||||
})
|
||||
const log = (messages: Record<string, unknown>[]): string => [
|
||||
JSON.stringify({ type: 'session', id: 'same', cwd: '/same' }),
|
||||
...messages.map(record => JSON.stringify(record)),
|
||||
'',
|
||||
].join('\n')
|
||||
const fresh = log([
|
||||
message(ids.new, 'new'),
|
||||
message(ids.changed, 'changed'),
|
||||
message(ids.changed, 'changed again'),
|
||||
message(ids.ambiguousA, 'duplicate'),
|
||||
message(ids.ambiguousB, 'duplicate'),
|
||||
message(ids.stable, 'stable'),
|
||||
])
|
||||
const existing = log([
|
||||
message(ids.oldChanged, 'before'),
|
||||
message(ids.oldAmbiguous, 'duplicate'),
|
||||
message(ids.stable, 'stable'),
|
||||
])
|
||||
|
||||
const replacements = refreshFixtureReplacements(
|
||||
[{ id: 'diagnostic', createdAt: 1, content: fresh }],
|
||||
[existing],
|
||||
)
|
||||
|
||||
const replacedIds = replacements.map(replacement => replacement.from)
|
||||
for (const id of [
|
||||
ids.new,
|
||||
ids.changed,
|
||||
ids.ambiguousA,
|
||||
ids.ambiguousB,
|
||||
ids.stable,
|
||||
]) expect(replacedIds).not.toContain(id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stabilizeRefreshLog', () => {
|
||||
@@ -765,6 +849,50 @@ describe('stabilizeRefreshLog', () => {
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('retains unchanged message ids across an unrelated inserted event', () => {
|
||||
const freshUserId = '11111111-1111-4111-8111-111111111111'
|
||||
const existingUserId = '22222222-2222-4222-8222-222222222222'
|
||||
const freshAssistantId = '33333333-3333-4333-8333-333333333333'
|
||||
const existingAssistantId = '44444444-4444-4444-8444-444444444444'
|
||||
const user = (id: string): Record<string, unknown> => ({
|
||||
type: 'user/message',
|
||||
data: { role: 'user', content: [{ type: 'text', text: 'same user' }], source: { kind: 'user' }, id },
|
||||
})
|
||||
const assistant = (id: string): Record<string, unknown> => ({
|
||||
type: 'assistant/message',
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'same assistant' }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
id,
|
||||
},
|
||||
},
|
||||
})
|
||||
const lines = (records: Record<string, unknown>[]): string => [
|
||||
JSON.stringify({ type: 'session', id: 'same', createdAt: 1, cwd: '/same' }),
|
||||
...records.map(record => JSON.stringify(record)),
|
||||
'',
|
||||
].join('\n')
|
||||
const fresh = lines([
|
||||
user(freshUserId),
|
||||
{ type: 'session/inherited', data: {} },
|
||||
assistant(freshAssistantId),
|
||||
])
|
||||
const existing = lines([user(existingUserId), assistant(existingAssistantId)])
|
||||
const replacements = refreshFixtureReplacements(
|
||||
[{ id: 'diagnostic', createdAt: 1, content: fresh }],
|
||||
[existing],
|
||||
)
|
||||
const output = stabilize(fresh, existing, replacements).trim().split('\n')
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
|
||||
expect((output[1]?.data as { id: string }).id).toBe(existingUserId)
|
||||
expect(((output[3]?.data as { message: { id: string } }).message).id).toBe(existingAssistantId)
|
||||
})
|
||||
|
||||
it('keeps volatile fixture fields while preserving fresh meaningful payloads', () => {
|
||||
const fresh = [
|
||||
'{"type":"session","id":"new-child","createdAt":200,"cwd":"/new","parentSession":"new-parent","seedLength":1}',
|
||||
|
||||
Reference in New Issue
Block a user