test(snapshot): declare recorded session ownership
This commit is contained in:
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)
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
version: 1
|
||||
profile: acp
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
version: 1
|
||||
profile: acp
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
version: 1
|
||||
profile: acp
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
version: 1
|
||||
profile: acp
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
version: 1
|
||||
profile: acp
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
version: 1
|
||||
profile: acp
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
version: 1
|
||||
profile: acp
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
version: 1
|
||||
profile: acp
|
||||
+2
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
version: 1
|
||||
profile: acp
|
||||
Reference in new issue
Block a user