test(snapshot): declare recorded session ownership

This commit is contained in:
Tianyi Cui committed 2026-08-24 21:36:31 +08:00
1 parent 5c67cf898c
commit 84d6482a95
100 files changed
+324 -2

No files matched your search

@@ -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/test-support/session-snapshot/README.md
README.md: 785d88841b24bdaf828ba48678751c22555010ac
README.zh.md: ee0c5274ed4c5b42c0035104a0dfddbe4d34d37f
README.md: dfd298448d3373d0966d87963be2f1d8425f5961
README.zh.md: fa6c960feee202a893b263b913e7ec178da46ac2
@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
Session-log snapshot support for the keyless snapshot tier (`pnpm run test:snapshot`, [testing policy](../../../docs/testing.md)). Transport-neutral normalization and fixture invariants live beside the existing ACP protocol adapter so additional `dsh` profile adapters can reuse the recorded-session format without copying its guards.
Every recorded-session directory carries a closed `snapshot.yml` manifest. `profile` names the shipped `dsh` controller; a directory owns its local `session.jsonl` unless `session.source` names another scenario's read-only canonical recording. Unknown fields, JavaScript YAML tags, absolute paths, and platform-specific separators fail during collection.
The current ACP adapter has four importable layers:
- **`launchAcpTestAgent` (launcher)** — boots a source entry under tsx or a built `lib` entry 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. Product suites name a `dsh` profile: the launcher passes the base and selected scenario patches through `--patch`, selects the scenario's sibling `*cordis.snapshot.yml` in replay, and materializes temporary copies whose relative plugin modules become absolute file URLs. Test-only fake bins may omit the profile and retain their own config grammar. 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.
@@ -4,6 +4,8 @@
无密钥快照层(`pnpm run test:snapshot`,见[测试策略](../../../docs/testing.zh.md))的会话日志快照支持。与传输无关的规范化和 fixture(测试前置数据)不变量与现有 ACP(Agent Client Protocol)协议适配器位于同一包中,使其他 `dsh` profile 适配器可以复用录制会话格式,而不复制其保护机制。
每个录制会话目录都包含一个封闭的 `snapshot.yml` manifest。`profile` 指名随附的 `dsh` 控制器;除非 `session.source` 指向另一个场景的只读规范录制,否则目录拥有本地 `session.jsonl`。收集期间会拒绝未知字段、JavaScript YAML tag、绝对路径和平台专用分隔符。
当前 ACP 适配器包含四个可单独导入的层:
- **`launchAcpTestAgent`(启动器)**:从指定 cwd 在 tsx 下启动源码入口,或在普通 Node 下启动已构建 `lib` 入口;通过原始字节 stdout tee 连接 SDK 客户端,收集会话更新和 stderr,在启动阶段报告异步 spawn 失败,默认拒绝未处理的权限请求,并负责优雅或带信号关闭。产品套件指定一个 `dsh` profile:启动器通过 `--patch` 传入基础 patch 与所选场景 patch,在 replay 时选择场景同级的 `*cordis.snapshot.yml`,并把相对插件模块改写成绝对 file URL 后物化为临时副本。测试专用 fake bin 可以省略 profile 并保留自己的配置语法。关闭会等待进程退出、继承 stdio 关闭和 ACP parser 耗尽,然后才完成关闭或传播子级错误,使捕获内容完整,且调用方可在任一结果后移除自有路径。
@@ -46,6 +46,12 @@ export {
type NormalizeContext,
type NormalizeOptions,
} from './normalize.ts'
export {
parseSnapshotManifest,
type SnapshotManifest,
type SnapshotProfile,
type SnapshotSessionReference,
} from './manifest.ts'
export {
defineAcpSnapshotSuite,
refreshFixtureReplacements,
@@ -0,0 +1,82 @@
/** Parse and validate one recorded-session snapshot manifest. */
import { isAbsolute } from 'node:path'
import * as yaml from 'js-yaml'
/** Public `dsh` profile used to control a recorded-session scenario. */
export type SnapshotProfile = 'headless' | 'sdk' | 'acp' | 'web'
/** Optional reference to another scenario's canonical session. */
export interface SnapshotSessionReference {
/** Repository-relative POSIX path from this scenario directory to the owning `session.jsonl`. */
source: string
}
/** Declarative ownership metadata stored beside a recorded session. */
export interface SnapshotManifest {
/** Manifest format version. */
version: 1
/** Shipped profile whose public interface controls the scenario. */
profile: SnapshotProfile
/** Absent when this directory owns `session.jsonl`; present for a read-only borrower. */
session?: SnapshotSessionReference
}
const PROFILES = new Set<SnapshotProfile>(['headless', 'sdk', 'acp', 'web'])
function record(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} must be a mapping`)
}
return value as Record<string, unknown>
}
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], label: string): void {
const unknown = Object.keys(value).filter(key => !allowed.includes(key)).sort()
if (unknown.length > 0) throw new Error(`${label} has unknown field(s): ${unknown.join(', ')}`)
}
/**
* Parse one `snapshot.yml` without admitting JavaScript YAML tags or unknown fields.
* @param source - complete manifest text.
* @param path - diagnostic path.
* @returns validated manifest metadata.
*/
export function parseSnapshotManifest(source: string, path = 'snapshot.yml'): SnapshotManifest {
let parsed: unknown
try {
parsed = yaml.load(source, { schema: yaml.JSON_SCHEMA })
} catch (error) {
throw new Error(`session-snapshot: ${path}: invalid YAML: ${String(error)}`)
}
try {
const root = record(parsed, 'manifest')
exactKeys(root, ['version', 'profile', 'session'], 'manifest')
if (root.version !== 1) throw new Error('manifest.version must equal 1')
if (typeof root.profile !== 'string' || !PROFILES.has(root.profile as SnapshotProfile)) {
throw new Error('manifest.profile must be headless, sdk, acp, or web')
}
let session: SnapshotSessionReference | undefined
if (root.session !== undefined) {
const value = record(root.session, 'manifest.session')
exactKeys(value, ['source'], 'manifest.session')
if (typeof value.source !== 'string' || value.source.trim() === '') {
throw new Error('manifest.session.source must be a non-empty string')
}
if (isAbsolute(value.source) || value.source.includes('\\') || value.source.includes('\0')) {
throw new Error('manifest.session.source must be a relative POSIX path')
}
session = { source: value.source }
}
return {
version: 1,
profile: root.profile as SnapshotProfile,
...(session === undefined ? {} : { session }),
}
} catch (error) {
throw new Error(`session-snapshot: ${path}: ${error instanceof Error ? error.message : String(error)}`)
}
}
@@ -23,6 +23,7 @@ import { join } from 'node:path'
import { isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
import { describe, expect, it } from 'vitest'
import { type AgentUnderTest, type HarvestedLog, type InputScript, runScenario } from './harness.ts'
import { parseSnapshotManifest } from './manifest.ts'
import {
type CwdPathMode,
type NormalizeContext,
@@ -1452,6 +1453,11 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
const files = (await readdir(dir, { withFileTypes: true }))
.filter(entry => entry.isFile())
.map(entry => entry.name)
const manifestPath = join(dir, 'snapshot.yml')
expect(existsSync(manifestPath), `${name}/snapshot.yml`).toBe(true)
const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath)
expect(manifest.profile, `${name}: manifest profile`).toBe(agent.profile ?? 'acp')
expect(manifest.session, `${name}: ACP scenarios own their session`).toBeUndefined()
const childIndices = (pattern: RegExp): Set<number> => new Set(files
.map(file => pattern.exec(file))
.filter((match): match is RegExpExecArray => match !== null)
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { parseSnapshotManifest } from '../src/manifest.ts'
describe('snapshot manifest', () => {
it('parses an owning scenario', () => {
expect(parseSnapshotManifest('version: 1\nprofile: headless\n')).toEqual({
version: 1,
profile: 'headless',
})
})
it('parses a read-only session reference', () => {
expect(parseSnapshotManifest([
'version: 1',
'profile: web',
'session:',
' source: ../../session/tool-call-turn/session.jsonl',
'',
].join('\n'))).toEqual({
version: 1,
profile: 'web',
session: { source: '../../session/tool-call-turn/session.jsonl' },
})
})
it.each([
['', 'manifest must be a mapping'],
['version: 2\nprofile: acp\n', 'manifest.version must equal 1'],
['version: 1\nprofile: private\n', 'manifest.profile must be headless, sdk, acp, or web'],
['version: 1\nprofile: acp\nextra: true\n', 'manifest has unknown field(s): extra'],
['version: 1\nprofile: acp\nsession: {}\n', 'manifest.session.source must be a non-empty string'],
['version: 1\nprofile: acp\nsession:\n source: /tmp/session.jsonl\n', 'manifest.session.source must be a relative POSIX path'],
['version: 1\nprofile: acp\nsession:\n source: ..\\session.jsonl\n', 'manifest.session.source must be a relative POSIX path'],
['version: 1\nprofile: !!js acp\n', 'invalid YAML'],
])('rejects invalid metadata', (source, message) => {
expect(() => parseSnapshotManifest(source, 'case/snapshot.yml')).toThrow(message)
})
})
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: acp
@@ -0,0 +1,2 @@
version: 1
profile: acp