mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(test): keep session fixture projection local
This commit is contained in:
+2
-2
@@ -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 .agents/notes/implemented/testing/2026-08-18-session-snapshot-envelope-projection.md
|
||||
2026-08-18-session-snapshot-envelope-projection.md: 941489265f492f85d32a2a5454273cb29073ac19
|
||||
2026-08-18-session-snapshot-envelope-projection.zh.md: c4574595b082328fb7ba912f4f5bff476afc0600
|
||||
2026-08-18-session-snapshot-envelope-projection.md: d4e0b3e7038642f262ecb38a9e1593de5f80b672
|
||||
2026-08-18-session-snapshot-envelope-projection.zh.md: 6c4601545c6ac7a0939bd3d1d755ac3d4c91916c
|
||||
|
||||
+4
-2
@@ -14,7 +14,7 @@ A committed session snapshot is a projection of the persisted JSONL. The envelop
|
||||
|
||||
Snapshot serialization omits those keys from the parsed body object before writing the line. A persisted-JSONL input is parsed once at the snapshot boundary; the dedicated snapshot normalizer combines value normalization, request-header scrubbing, and projection in that object pass rather than serializing and parsing again. Generic log and stream normalization retains sequence envelopes. Runtime persistence is unchanged.
|
||||
|
||||
Replay parses the projected fixture and assigns contiguous synthetic sequence numbers in memory. Synthetic event times start at zero; packed `data.dt` values retain the relative gaps already stored in the fixture. One file must use projected body rows or complete persisted body rows throughout. The repository fixture-layout gate decodes this projected form, retains canonical packed rows, and rejects half-present or mixed persistence envelopes.
|
||||
Replay's existing `parseSessionLog` entry point accepts the projected fixture and assigns contiguous synthetic sequence numbers in memory. Synthetic event times start at zero; packed `data.dt` values retain the relative gaps already stored in the fixture. One file must use projected body rows or complete persisted body rows throughout. Projection stays private to each snapshot writer, and replay materialization stays private to the replay parser; the repository fixture-layout gate uses that parser, retains canonical packed rows, and rejects half-present or mixed persistence envelopes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -26,8 +26,10 @@ Replay parses the projected fixture and assigns contiguous synthetic sequence nu
|
||||
|
||||
**Introduce a second replay file.** Rejected because the projected session snapshot already retains every payload needed to derive model streams and compare re-persisted behavior. A second fixture would duplicate the transcript and create synchronization work.
|
||||
|
||||
**Publish a shared session-snapshot codec package.** Rejected because this change needs no new product or support-tier capability. Writers only delete four top-level fields while serializing, and replay only needs its existing session-log parser to accept the projected representation. A new package and record-level API would enlarge the change and create an ownership commitment without removing meaningful implementation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Adding, removing, or moving an event no longer rewrites the envelope of every later snapshot row. Snapshot diffs continue to show header changes and all payload changes, including numeric sequence references inside `data`.
|
||||
|
||||
The checked-in file is no longer byte-for-byte valid persistence JSONL. Consumers must use the shared snapshot decoder instead of passing body rows directly to the storage decoder. The synthetic time anchor is not historical wall-clock data; only retained packed gaps carry relative timing. Repository migration and write-back paths enforce the projection so a later recording cannot reintroduce the omitted fields.
|
||||
The checked-in file is no longer byte-for-byte valid persistence JSONL. Replay consumers must use `parseSessionLog` instead of passing body rows directly to the storage decoder; snapshot writers apply the projection only at their own file boundary. The synthetic time anchor is not historical wall-clock data; only retained packed gaps carry relative timing. Repository migration and write-back paths enforce the projection so a later recording cannot reintroduce the omitted fields.
|
||||
|
||||
+4
-2
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
快照序列化会在写入每行前从已解析的正文对象上省略这些键。以持久化 JSONL 为输入的入口只在快照边界解析一次;专用快照 normalizer 在同一轮对象操作中完成值规范化、request header 清理和投影,而不会先序列化再重新解析。通用日志和 stream 规范化会保留序号 envelope。运行时持久化不变。
|
||||
|
||||
回放解析投影后的 fixture,并在内存中分配连续的 synthetic 序号。synthetic 事件时间从零开始;打包行的 `data.dt` 保留 fixture 中已有的相对间隔。同一文件必须始终采用投影正文行或完整持久化正文行。仓库 fixture 布局门禁会解码这种投影格式,继续强制规范打包行,并拒绝不完整或混用的持久化 envelope。
|
||||
回放的现有 `parseSessionLog` 入口接受投影后的 fixture,并在内存中分配连续的 synthetic 序号。synthetic 事件时间从零开始;打包行的 `data.dt` 保留 fixture 中已有的相对间隔。同一文件必须始终采用投影正文行或完整持久化正文行。投影实现仅位于各快照写入方内部,回放物化实现仅位于回放 parser 内部;仓库 fixture 布局门禁使用该 parser,继续强制规范打包行,并拒绝不完整或混用的持久化 envelope。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -26,8 +26,10 @@ Status: implemented
|
||||
|
||||
**新增第二份 replay 文件。** 拒绝,因为投影后的会话快照仍保留派生模型流和比较重新持久化行为所需的全部 payload。第二份 fixture 会重复 transcript,并引入同步成本。
|
||||
|
||||
**发布共享 session-snapshot codec 包。** 拒绝,因为本次变更不需要新的产品或支持层 capability。写入方只需在序列化时删除四个顶层字段,回放只需让现有 session-log parser 接受投影表示。新包和 record-level API 会扩大变更并建立长期归属承诺,却不会删除有意义的实现。
|
||||
|
||||
## 后果
|
||||
|
||||
新增、删除或移动事件时,不再重写后续每一条快照记录的 envelope。快照 diff 仍会展示 header 变化和全部 payload 变化,包括 `data` 内的数字序号引用。
|
||||
|
||||
签入仓库的文件不再是逐字节有效的持久化 JSONL。消费方必须使用共享快照 decoder,而不能把正文行直接交给 storage decoder。synthetic 时间锚点不是历史墙钟数据;只有保留的打包间隔表示相对时间。仓库迁移与回写路径强制执行该投影,因此后续录制不会重新引入被省略的字段。
|
||||
签入仓库的文件不再是逐字节有效的持久化 JSONL。回放消费方必须使用 `parseSessionLog`,而不能把正文行直接交给 storage decoder;快照写入方仅在自身文件边界应用投影。synthetic 时间锚点不是历史墙钟数据;只有保留的打包间隔表示相对时间。仓库迁移与回写路径强制执行该投影,因此后续录制不会重新引入被省略的字段。
|
||||
|
||||
@@ -8,11 +8,10 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { decodeSessionSnapshot, projectSessionSnapshot } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
launchWebScaffold, parseSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
@@ -37,7 +36,7 @@ const SECOND_PROMPT = 'Now give the final answer.'
|
||||
* @returns A contiguous, closed two-turn fixture.
|
||||
*/
|
||||
function completedTailFixture(raw: string): string {
|
||||
const decoded = decodeSessionSnapshot(raw)
|
||||
const decoded = parseSeedFixture(raw)
|
||||
const kept = decoded.events.filter(event => event.seq < 101).map((event) => {
|
||||
if (event.type === 'assistant/message' && event.seq === 64) {
|
||||
const data = event.data as unknown as { content?: unknown[] }
|
||||
@@ -52,7 +51,7 @@ function completedTailFixture(raw: string): string {
|
||||
})
|
||||
let seq = kept.length
|
||||
let time = (kept.at(-1)?.time ?? -1) + 1
|
||||
const at = (event: Record<string, unknown>): Record<string, unknown> => ({
|
||||
const at = (event: Record<string, unknown>): { seq: number; time: number } & Record<string, unknown> => ({
|
||||
...event,
|
||||
seq: seq++,
|
||||
time: time++,
|
||||
@@ -67,11 +66,7 @@ function completedTailFixture(raw: string): string {
|
||||
at({ type: 'step/end', data: { turn: 2, step: 1 } }),
|
||||
at({ type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } }),
|
||||
]
|
||||
return projectSessionSnapshot([
|
||||
decoded.headerLine,
|
||||
...[...kept, ...tail].map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n'))
|
||||
return renderSeedFixture(decoded.headerLine, [...kept, ...tail])
|
||||
}
|
||||
|
||||
describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
|
||||
@@ -51,7 +51,7 @@ import type {
|
||||
LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { decodeSessionSnapshot, installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import SessionStore, {
|
||||
packChunkRuns,
|
||||
SESSION_FORMAT_VERSION,
|
||||
@@ -765,13 +765,47 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i
|
||||
: realized.split(fixtureCwd).join(scaffold.workspaceCwd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a committed web seed fixture through the replay reader.
|
||||
* @param fixtureText - session JSONL fixture contents.
|
||||
* @returns the original header line, parsed header, and logical events.
|
||||
*/
|
||||
export function parseSeedFixture(fixtureText: string): {
|
||||
headerLine: string
|
||||
header: Record<string, unknown>
|
||||
events: SessionEvent[]
|
||||
} {
|
||||
const headerLine = fixtureText.split(/\r?\n/).find(line => line.trim().length > 0)
|
||||
if (headerLine === undefined) throw new Error('seed fixture has no session header')
|
||||
const header = JSON.parse(headerLine) as Record<string, unknown>
|
||||
if (header.type !== 'session') throw new Error('seed fixture must start with a session header')
|
||||
return { headerLine, header, events: parseSessionLog(fixtureText) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render logical events as an envelope-free web seed fixture.
|
||||
* @param headerLine - original session header line.
|
||||
* @param events - logical session events in order.
|
||||
* @returns projected session JSONL.
|
||||
*/
|
||||
export function renderSeedFixture(
|
||||
headerLine: string,
|
||||
events: readonly ({ readonly seq: number; readonly time: number } & object)[],
|
||||
): string {
|
||||
return [
|
||||
headerLine,
|
||||
...events.map(({ seq: _seq, time: _time, ...event }) => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function seedSession(
|
||||
scaffold: WebScaffold,
|
||||
fixtureText: string,
|
||||
id: string,
|
||||
agentPreset?: string,
|
||||
): Promise<SessionId> {
|
||||
const decoded = decodeSessionSnapshot(realizeSeedFixture(scaffold, fixtureText, id))
|
||||
const decoded = parseSeedFixture(realizeSeedFixture(scaffold, fixtureText, id))
|
||||
const events = decoded.events
|
||||
if (events.length === 0) throw new Error('seed fixture has no events')
|
||||
const last = events[events.length - 1]!
|
||||
|
||||
@@ -17,14 +17,14 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { decodeSessionSnapshot, projectSessionSnapshot } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { TokenMeter } from '@deepseek-ai/dsh-token-meter'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, realizeSeedFixture, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
launchWebScaffold, parseSeedFixture, realizeSeedFixture, recordFixture, renderSeedFixture, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
@@ -54,7 +54,7 @@ const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and
|
||||
* @returns the fixture with a manual compaction lifecycle appended.
|
||||
*/
|
||||
function withCompaction(raw: string, meter: TokenMeter): string {
|
||||
const decoded = decodeSessionSnapshot(raw)
|
||||
const decoded = parseSeedFixture(raw)
|
||||
const events = decoded.events as unknown as Array<{
|
||||
type: string
|
||||
seq: number
|
||||
@@ -173,11 +173,7 @@ function withCompaction(raw: string, meter: TokenMeter): string {
|
||||
const closureTurn = lastTurn + 1
|
||||
at({ type: 'turn/start', data: { turn: closureTurn } })
|
||||
at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } })
|
||||
return projectSessionSnapshot([
|
||||
decoded.headerLine,
|
||||
...events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n'))
|
||||
return renderSeedFixture(decoded.headerLine, events)
|
||||
}
|
||||
|
||||
describe('web e2e: seeded history renders through cold resume', () => {
|
||||
|
||||
@@ -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 docs/config-catalog.md
|
||||
config-catalog.md: df6f38cb6df61651e104f535b0bac0b36922aefa
|
||||
config-catalog.zh.md: 310e34dab64ab783c76255e711fc9932e017cbbc
|
||||
config-catalog.md: 65cf7eebac62d0ab24ae9e4918bab37c7c75c08a
|
||||
config-catalog.zh.md: a4522cf1f1eccd290fffae775f9db542ff3d2851
|
||||
|
||||
@@ -1156,7 +1156,7 @@ export interface ReplayModelConfig {
|
||||
|
||||
Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
|
||||
|
||||
Source: [`packages/test-support/llm-replay/src/index.ts:779`](../packages/test-support/llm-replay/src/index.ts)
|
||||
Source: [`packages/test-support/llm-replay/src/index.ts:770`](../packages/test-support/llm-replay/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-llm-retry"></a>
|
||||
|
||||
|
||||
@@ -1158,7 +1158,7 @@ export interface ReplayModelConfig {
|
||||
|
||||
依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
|
||||
|
||||
来源:[`packages/test-support/llm-replay/src/index.ts:779`](../packages/test-support/llm-replay/src/index.ts)
|
||||
来源:[`packages/test-support/llm-replay/src/index.ts:770`](../packages/test-support/llm-replay/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-llm-retry"></a>
|
||||
|
||||
|
||||
@@ -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 docs/module-graph.md
|
||||
module-graph.md: 1643ee9ae9cb7f7994fc010075fa242a14e0133f
|
||||
module-graph.zh.md: b7161c862f37e35f921a861b75af07729897dc19
|
||||
module-graph.md: 0bd5f80534ba65e0d483bd04228cdac321e082bc
|
||||
module-graph.zh.md: 6239520b7d819eb14c2b859afa44a0c987545200
|
||||
|
||||
@@ -476,6 +476,8 @@ flowchart TD
|
||||
pkg_session_persistence --> pkg_timeout
|
||||
pkg_session_projection --> pkg_invariants
|
||||
pkg_session_projection --> pkg_session
|
||||
pkg_acp_snapshot --> pkg_invariants
|
||||
pkg_acp_snapshot --> pkg_session
|
||||
pkg_llm_retry --> pkg_agent
|
||||
pkg_llm_retry --> pkg_brand
|
||||
pkg_llm_retry --> pkg_invariants
|
||||
@@ -1053,9 +1055,6 @@ flowchart TD
|
||||
pkg_tool_pwsh --> pkg_system_prompt
|
||||
pkg_tool_pwsh --> pkg_tools
|
||||
pkg_tool_pwsh --> pkg_user_approval
|
||||
pkg_acp_snapshot --> pkg_invariants
|
||||
pkg_acp_snapshot --> pkg_llm_replay
|
||||
pkg_acp_snapshot --> pkg_session
|
||||
pkg_tool_ralph --> pkg_agent
|
||||
pkg_tool_ralph --> pkg_invariants
|
||||
pkg_tool_ralph --> pkg_llm
|
||||
@@ -1483,6 +1482,7 @@ flowchart TD
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
|
||||
| [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`acp-snapshot`](../packages/test-support/acp-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
|
||||
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) |
|
||||
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
|
||||
@@ -1586,7 +1586,6 @@ flowchart TD
|
||||
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`acp-snapshot`](../packages/test-support/acp-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-replay`](../packages/test-support/llm-replay), [`session`](../packages/core/session) |
|
||||
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
|
||||
|
||||
@@ -478,6 +478,8 @@ flowchart TD
|
||||
pkg_session_persistence --> pkg_timeout
|
||||
pkg_session_projection --> pkg_invariants
|
||||
pkg_session_projection --> pkg_session
|
||||
pkg_acp_snapshot --> pkg_invariants
|
||||
pkg_acp_snapshot --> pkg_session
|
||||
pkg_llm_retry --> pkg_agent
|
||||
pkg_llm_retry --> pkg_brand
|
||||
pkg_llm_retry --> pkg_invariants
|
||||
@@ -1055,9 +1057,6 @@ flowchart TD
|
||||
pkg_tool_pwsh --> pkg_system_prompt
|
||||
pkg_tool_pwsh --> pkg_tools
|
||||
pkg_tool_pwsh --> pkg_user_approval
|
||||
pkg_acp_snapshot --> pkg_invariants
|
||||
pkg_acp_snapshot --> pkg_llm_replay
|
||||
pkg_acp_snapshot --> pkg_session
|
||||
pkg_tool_ralph --> pkg_agent
|
||||
pkg_tool_ralph --> pkg_invariants
|
||||
pkg_tool_ralph --> pkg_llm
|
||||
@@ -1485,6 +1484,7 @@ flowchart TD
|
||||
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
|
||||
| [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`acp-snapshot`](../packages/test-support/acp-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
|
||||
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) |
|
||||
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
|
||||
@@ -1588,7 +1588,6 @@ flowchart TD
|
||||
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| [`acp-snapshot`](../packages/test-support/acp-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-replay`](../packages/test-support/llm-replay), [`session`](../packages/core/session) |
|
||||
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type SnapshotSuiteOptions,
|
||||
} from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import { decodeSessionSnapshot } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
|
||||
/**
|
||||
* The acp-agent example's snapshot suite: the scenario table for
|
||||
@@ -813,10 +813,11 @@ it('packed ACP fixture retains every chunk row kind without changing the logical
|
||||
return cloned
|
||||
}
|
||||
const logicalRecords = (fixture: string): unknown[] => {
|
||||
const decoded = decodeSessionSnapshot(fixture)
|
||||
const headerLine = fixture.split(/\r?\n/).find(line => line.trim().length > 0)
|
||||
if (headerLine === undefined) throw new Error('ACP fixture has no session header')
|
||||
return [
|
||||
decoded.header,
|
||||
...decoded.events.map(withoutMessageId),
|
||||
JSON.parse(headerLine) as unknown,
|
||||
...parseSessionLog(fixture).map(withoutMessageId),
|
||||
]
|
||||
}
|
||||
expect(logicalRecords(packedText)).toStrictEqual(logicalRecords(source))
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
refreshFixtureReplacements,
|
||||
scrubRequestHeaders,
|
||||
stabilizeRefreshLog,
|
||||
projectSessionSnapshot,
|
||||
tokenizeSessionFixtureCwd,
|
||||
type HarvestedLog,
|
||||
type NormalizeContext,
|
||||
@@ -72,6 +71,28 @@ interface PersistedLog {
|
||||
readonly header: JsonObject
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove persistence envelopes before committing a refreshed replay fixture.
|
||||
* @param rawLog - persisted or already-projected session JSONL.
|
||||
* @returns projected session JSONL with its header line unchanged.
|
||||
*/
|
||||
function projectSessionFixture(rawLog: string): string {
|
||||
let recordIndex = 0
|
||||
return rawLog.split(/\r?\n/).map((line) => {
|
||||
if (line.trim().length === 0) return line
|
||||
const record = JSON.parse(line) as Record<string, unknown>
|
||||
if (recordIndex++ === 0) {
|
||||
if (record.type !== 'session') throw new Error('session fixture must start with a session header')
|
||||
return line
|
||||
}
|
||||
delete record.seq
|
||||
delete record.time
|
||||
delete record.seq0
|
||||
delete record.time0
|
||||
return JSON.stringify(record)
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
interface DeepSeekDefaultsServer {
|
||||
readonly url: string
|
||||
readonly requests: JsonObject[]
|
||||
@@ -245,7 +266,7 @@ describe('headless stream-json snapshots', () => {
|
||||
const context = contextFromLogs([actual.content])
|
||||
const session = normalizeSessionSnapshot(actual.content, context)
|
||||
if (refreshing) await writeFile(headlessSessionExpected, session)
|
||||
expect(session).toBe(await readFile(headlessSessionExpected, 'utf8'))
|
||||
await expect(session).toMatchFileSnapshot(headlessSessionExpected)
|
||||
expect(session).toContain(task)
|
||||
expect(session).toContain('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP')
|
||||
},
|
||||
@@ -385,7 +406,7 @@ describe('headless stream-json snapshots', () => {
|
||||
content: actual.content,
|
||||
}
|
||||
const replacements = refreshFixtureReplacements([harvested], [expectedSession])
|
||||
expectedSession = projectSessionSnapshot(tokenizeSessionFixtureCwd(
|
||||
expectedSession = projectSessionFixture(tokenizeSessionFixtureCwd(
|
||||
stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext),
|
||||
))
|
||||
await writeFile(compactionSessionFixture, expectedSession)
|
||||
@@ -625,7 +646,7 @@ describe('headless stream-json snapshots', () => {
|
||||
if (existing === undefined || file === undefined) {
|
||||
throw new Error(`headless snapshot has no fixture for persisted log ${index}`)
|
||||
}
|
||||
const stable = projectSessionSnapshot(tokenizeSessionFixtureCwd(
|
||||
const stable = projectSessionFixture(tokenizeSessionFixtureCwd(
|
||||
stabilizeRefreshLog(actual.content, existing, replacements, actualContext),
|
||||
))
|
||||
await writeFile(file, stable)
|
||||
@@ -916,7 +937,7 @@ describe('headless stream-json snapshots', () => {
|
||||
const context = contextFromLogs([parent.content, child.content])
|
||||
const normalizedChild = normalizeSessionSnapshot(child.content, context)
|
||||
if (refreshing) await writeFile(childExpected, normalizedChild)
|
||||
expect(normalizedChild).toBe(await readFile(childExpected, 'utf8'))
|
||||
await expect(normalizedChild).toMatchFileSnapshot(childExpected)
|
||||
expect(normalizedChild).toContain('CHILD_RESULT')
|
||||
expect(normalizedChild).not.toContain('"name":"report"')
|
||||
},
|
||||
@@ -968,7 +989,7 @@ describe('headless stream-json snapshots', () => {
|
||||
content: actual.content,
|
||||
}
|
||||
const replacements = refreshFixtureReplacements([harvested], [expectedSession])
|
||||
expectedSession = projectSessionSnapshot(tokenizeSessionFixtureCwd(
|
||||
expectedSession = projectSessionFixture(tokenizeSessionFixtureCwd(
|
||||
stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext),
|
||||
))
|
||||
await writeFile(ptySessionFixture, expectedSession)
|
||||
|
||||
@@ -38,13 +38,11 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-replay": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-replay": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ export {
|
||||
type NormalizeContext,
|
||||
type NormalizeOptions,
|
||||
} from './normalize.ts'
|
||||
export { projectSessionSnapshot } from '@deepseek-ai/dsh-llm-replay'
|
||||
export {
|
||||
defineAcpSnapshotSuite,
|
||||
refreshFixtureReplacements,
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
* @module @deepseek-ai/dsh-acp-snapshot/normalize
|
||||
*/
|
||||
|
||||
import {
|
||||
decodeSessionSnapshotBody,
|
||||
isPackedSessionChunkRow,
|
||||
omitSessionEventEnvelope,
|
||||
} from '@deepseek-ai/dsh-llm-replay'
|
||||
import { decodeStorageRecord, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const SESSION_ID = '{{sessionId}}'
|
||||
const CWD = '{{cwd}}'
|
||||
@@ -18,6 +14,70 @@ const SYSTEM = '{{system}}'
|
||||
const TOOLS = '{{tools}}'
|
||||
const EVENT_TIME = '{{eventTime}}'
|
||||
const EVENT_OMITTED_BYTES = '{{eventOmittedBytes}}'
|
||||
const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
|
||||
|
||||
function isPackedFixtureRow(record: Record<string, unknown>): boolean {
|
||||
return typeof record.type === 'string' && PACKED_CHUNK_ROW_TYPES.has(record.type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize projected body envelopes in caller-owned parsed records.
|
||||
* Package-private support for normalization and refresh alignment.
|
||||
*
|
||||
* @param records - session body records in storage order.
|
||||
* @returns logical events expanded from the materialized records.
|
||||
*/
|
||||
function materializeFixtureBody(records: readonly Record<string, unknown>[]): SessionEvent[] {
|
||||
let nextSeq = 0
|
||||
let layout: 'unknown' | 'projected' | 'persisted' = 'unknown'
|
||||
return records.flatMap((record, index) => {
|
||||
const packed = isPackedFixtureRow(record)
|
||||
const seqKey = packed ? 'seq0' : 'seq'
|
||||
const timeKey = packed ? 'time0' : 'time'
|
||||
const presentMembers = Number(Object.hasOwn(record, seqKey)) + Number(Object.hasOwn(record, timeKey))
|
||||
if (presentMembers === 1) {
|
||||
throw new Error(`session snapshot line ${index + 2} must carry both ${seqKey}/${timeKey} or neither`)
|
||||
}
|
||||
const recordLayout = presentMembers === 0 ? 'projected' : 'persisted'
|
||||
if (layout === 'unknown') layout = recordLayout
|
||||
else if (layout !== recordLayout) {
|
||||
throw new Error(`session snapshot line ${index + 2} cannot mix projected and persisted body records`)
|
||||
}
|
||||
if (recordLayout === 'projected') {
|
||||
const envelope = { [seqKey]: nextSeq, [timeKey]: 0 }
|
||||
const materialized = Object.hasOwn(record, 'type')
|
||||
? { type: record.type, ...envelope, ...record }
|
||||
: { ...record, ...envelope }
|
||||
for (const key of Object.keys(record)) Reflect.deleteProperty(record, key)
|
||||
Object.assign(record, materialized)
|
||||
}
|
||||
const decoded = decodeStorageRecord(record)
|
||||
nextSeq += decoded.length
|
||||
return decoded
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse persisted or projected session JSONL for package-internal comparison.
|
||||
* Projected body envelopes are materialized on the returned records.
|
||||
*
|
||||
* @param rawLog - session JSONL contents.
|
||||
* @returns parsed records in file order.
|
||||
*/
|
||||
export function parseComparableSessionLog(rawLog: string): Record<string, unknown>[] {
|
||||
const records = rawLog.split('\n')
|
||||
.filter(line => line.trim().length > 0)
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
if (records[0]?.type === 'session') materializeFixtureBody(records.slice(1))
|
||||
return records
|
||||
}
|
||||
|
||||
function omitFixtureEnvelope(record: Record<string, unknown>): void {
|
||||
delete record.seq
|
||||
delete record.time
|
||||
delete record.seq0
|
||||
delete record.time0
|
||||
}
|
||||
|
||||
/** A cwd-rooted path after volatile cwd replacement, through its last separator-delimited segment. */
|
||||
const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g
|
||||
@@ -308,9 +368,7 @@ export function normalizeSessionLog(
|
||||
options: NormalizeOptions = {},
|
||||
): string {
|
||||
const cwdPathMode = options.cwdPathMode ?? 'canonical'
|
||||
const lines = rawLog.split('\n').filter(line => line.trim().length > 0)
|
||||
const records = lines.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
if (records[0]?.type === 'session') decodeSessionSnapshotBody(records.slice(1))
|
||||
const records = parseComparableSessionLog(rawLog)
|
||||
const normalized = records.map(record => normalizeSessionRecord(record, ctx, cwdPathMode))
|
||||
return normalized.map(r => JSON.stringify(r)).join('\n') + '\n'
|
||||
}
|
||||
@@ -324,7 +382,7 @@ function normalizeSessionRecord(
|
||||
// Header line: { type: 'session', createdAt, id, cwd, … }.
|
||||
if (record.type === 'session') {
|
||||
if ('createdAt' in record) record.createdAt = 0
|
||||
} else if (isPackedSessionChunkRow(record)) {
|
||||
} else if (isPackedFixtureRow(record)) {
|
||||
if ('time0' in record) record.time0 = 0
|
||||
const data = record.data
|
||||
if (data !== null && typeof data === 'object' && Array.isArray((data as { dt?: unknown }).dt)) {
|
||||
@@ -443,7 +501,7 @@ export function scrubSessionSnapshot(rawLog: string): string {
|
||||
|
||||
function scrubSessionSnapshotBodyRecord(record: Record<string, unknown>): void {
|
||||
scrubRequestHeaderRecord(record, { system: true, tools: true })
|
||||
omitSessionEventEnvelope(record)
|
||||
omitFixtureEnvelope(record)
|
||||
}
|
||||
|
||||
/** Which independent request-header payloads a scrubber replaces. */
|
||||
|
||||
@@ -21,7 +21,6 @@ import { readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
|
||||
import { decodeSessionSnapshot } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type AgentUnderTest, type HarvestedLog, type InputScript, runScenario } from './harness.ts'
|
||||
import {
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
extractSnapshotSpillPaths,
|
||||
normalizeSessionLog,
|
||||
normalizeStdout,
|
||||
parseComparableSessionLog,
|
||||
scrubRequestHeaders,
|
||||
scrubSessionSnapshot,
|
||||
scrubSystemPrompts,
|
||||
@@ -1033,8 +1033,8 @@ export function stabilizeRefreshLog(
|
||||
): string {
|
||||
const freshRecords = parseJsonlRecords(fresh)
|
||||
const stable = applyFixtureReplacements(fresh, replacements)
|
||||
const decodedExisting = decodeSessionSnapshot(existing)
|
||||
const existingRecords = logicalRecords([decodedExisting.header, ...decodedExisting.bodyRecords])
|
||||
const parsedExisting = parseComparableSessionLog(existing)
|
||||
const existingRecords = logicalRecords(parsedExisting)
|
||||
const records = parseJsonlRecords(stable)
|
||||
const existingContext = fixtureContext(existing)
|
||||
const stringMappings = normalizedStringMappings(
|
||||
|
||||
@@ -234,6 +234,22 @@ describe('normalizeSessionLog', () => {
|
||||
expect(out).toContain('"time":0')
|
||||
})
|
||||
|
||||
it('rejects incomplete or mixed projected envelopes', () => {
|
||||
expect(() => normalizeSessionLog(`${header({})}\n{"type":"turn/start","seq":0}\n`, ctx))
|
||||
.toThrow(/both seq\/time or neither/)
|
||||
expect(() => normalizeSessionLog([
|
||||
header({}),
|
||||
'{"type":"turn/start","data":{"turn":1}}',
|
||||
'{"type":"turn/end","seq":1,"time":0,"data":{"turn":1}}',
|
||||
'',
|
||||
].join('\n'), ctx)).toThrow(/cannot mix projected and persisted body records/)
|
||||
})
|
||||
|
||||
it('materializes a typeless projected record without adding a type field', () => {
|
||||
const out = normalizeSessionLog(`${header({})}\n{}\n`, ctx)
|
||||
expect(JSON.parse(out.trimEnd().split('\n')[1] ?? '{}')).toStrictEqual({ seq: 0, time: 0 })
|
||||
})
|
||||
|
||||
it('scrubs cwd and session id deep inside event data', () => {
|
||||
const ev = JSON.stringify({
|
||||
type: 'tool/result', seq: 2, time: 5,
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../llm-replay"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
|
||||
@@ -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/llm-replay/README.md
|
||||
README.md: d64f700d97e445230893fc383025746850e86306
|
||||
README.zh.md: 8bcd71cd28a8f80b2655cdc7fbfa38a1d608abd4
|
||||
README.md: 0eb36841f2ec18280de0803a0d7da5324c3b331b
|
||||
README.zh.md: 9c17c284421b221bcd1667e28ce8c5154654f4fd
|
||||
|
||||
@@ -59,8 +59,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
|
||||
- `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars.
|
||||
- `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order.
|
||||
- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing).
|
||||
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn ordinary loop chunks and explicitly marked local compaction outputs in a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived assistant group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
|
||||
- `decodeSessionSnapshot(text)` / `decodeSessionSnapshotBody(records)` / `projectSessionSnapshot(text)` / `omitSessionEventEnvelope(record)` — the fixture codec: decode projected or persisted JSONL, materialize envelopes on already-parsed body records, project a persisted JSONL input in one parse pass, or omit the persistence envelope from an already-parsed body record.
|
||||
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn ordinary loop chunks and explicitly marked local compaction outputs in a persisted or projected session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived assistant group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
|
||||
- Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`.
|
||||
|
||||
## Plugin export shape
|
||||
|
||||
@@ -59,8 +59,7 @@ fixture 是持久化会话日志(`<scenario>/session.jsonl`)的投影:它
|
||||
- `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR(热模块替换)安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。
|
||||
- `loadSessionScripts(config)`:解析场景中有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。
|
||||
- `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生;fixture 缺失时明确报错)。
|
||||
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志中的普通 loop 分片和显式标记的本地压缩输出转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生的 assistant 分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。
|
||||
- `decodeSessionSnapshot(text)` / `decodeSessionSnapshotBody(records)` / `projectSessionSnapshot(text)` / `omitSessionEventEnvelope(record)`:fixture 编解码器;可解码投影后或完整持久化 JSONL、在已解析正文记录上补齐 envelope、通过一次解析投影持久化 JSONL 输入,或从已解析正文记录中省略持久化 envelope。
|
||||
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将持久化或投影后会话日志中的普通 loop 分片和显式标记的本地压缩输出转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生的 assistant 分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override 伴随文件表达。
|
||||
- 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。
|
||||
|
||||
## 插件导出形态
|
||||
|
||||
@@ -25,16 +25,7 @@ import type {
|
||||
TokenUsage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter, LlmError, ReasoningEffortId, assertNever, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import { decodeSessionSnapshot } from './session-snapshot.ts'
|
||||
|
||||
export {
|
||||
decodeSessionSnapshot,
|
||||
decodeSessionSnapshotBody,
|
||||
isPackedSessionChunkRow,
|
||||
omitSessionEventEnvelope,
|
||||
projectSessionSnapshot,
|
||||
type DecodedSessionSnapshot,
|
||||
} from './session-snapshot.ts'
|
||||
import { parseReplaySessionLog } from './session-snapshot.ts'
|
||||
|
||||
/**
|
||||
* One recorded model call. `throw` may replay prefix chunks before failing;
|
||||
@@ -174,7 +165,7 @@ export interface SessionScript {
|
||||
* @returns every event after the header, in log order.
|
||||
*/
|
||||
export function parseSessionLog(text: string): SessionEvent[] {
|
||||
return decodeSessionSnapshot(text).events
|
||||
return parseReplaySessionLog(text)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Session snapshot projection and decoding for replay fixtures.
|
||||
* Private parser support for replay session fixtures.
|
||||
* @module @deepseek-ai/dsh-llm-replay/session-snapshot
|
||||
*/
|
||||
|
||||
@@ -7,24 +7,7 @@ import { decodeStorageRecord, type SessionEvent } from '@deepseek-ai/dsh-session
|
||||
|
||||
const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
|
||||
|
||||
/** A decoded replay fixture with storage envelopes materialized in memory. */
|
||||
export interface DecodedSessionSnapshot {
|
||||
/** Parsed session header. */
|
||||
header: Record<string, unknown>
|
||||
/** Original header line, retained byte-for-byte by projection. */
|
||||
headerLine: string
|
||||
/** Parsed body rows with sequence/time envelopes present. */
|
||||
bodyRecords: Record<string, unknown>[]
|
||||
/** Logical events expanded from ordinary and packed body rows. */
|
||||
events: SessionEvent[]
|
||||
}
|
||||
|
||||
interface JsonlRecord {
|
||||
text: string
|
||||
value: Record<string, unknown>
|
||||
}
|
||||
|
||||
function parseJsonl(text: string): JsonlRecord[] {
|
||||
function parseJsonl(text: string): Record<string, unknown>[] {
|
||||
return text.split(/\r?\n/).flatMap((line, index) => {
|
||||
if (line.trim().length === 0) return []
|
||||
let value: unknown
|
||||
@@ -36,22 +19,12 @@ function parseJsonl(text: string): JsonlRecord[] {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`session snapshot line ${index + 1} must be a JSON object`)
|
||||
}
|
||||
return [{ text: line, value: value as Record<string, unknown> }]
|
||||
return [{ ...value }]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a parsed storage or snapshot row is a packed chunk run.
|
||||
*
|
||||
* @param record - parsed session body record.
|
||||
* @returns whether the row uses the packed chunk representation.
|
||||
*/
|
||||
export function isPackedSessionChunkRow(record: Record<string, unknown>): boolean {
|
||||
return typeof record.type === 'string' && PACKED_CHUNK_ROW_TYPES.has(record.type)
|
||||
}
|
||||
|
||||
function envelopeKeys(record: Record<string, unknown>): readonly [string, string] {
|
||||
return isPackedSessionChunkRow(record)
|
||||
return typeof record.type === 'string' && PACKED_CHUNK_ROW_TYPES.has(record.type)
|
||||
? ['seq0', 'time0']
|
||||
: ['seq', 'time']
|
||||
}
|
||||
@@ -81,31 +54,21 @@ function insertSyntheticEnvelope(
|
||||
}
|
||||
|
||||
/**
|
||||
* Omit a body record's persistence-only sequence/time envelope in place.
|
||||
* Deleting all four possible keys keeps snapshot serialization independent of
|
||||
* the record discriminant while leaving nested payload fields untouched.
|
||||
* Parse a persisted or uniformly projected session fixture for replay.
|
||||
* Missing body envelopes are synthesized only for the returned event list;
|
||||
* projected and persisted rows cannot coexist in one fixture.
|
||||
*
|
||||
* @param record - parsed session body record owned by the caller.
|
||||
*/
|
||||
export function omitSessionEventEnvelope(record: Record<string, unknown>): void {
|
||||
delete record.seq
|
||||
delete record.time
|
||||
delete record.seq0
|
||||
delete record.time0
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize missing sequence/time envelopes on parsed snapshot body records.
|
||||
* The records are mutated in place and decoded in storage order.
|
||||
*
|
||||
* @param records - parsed session body records owned by the caller.
|
||||
* @param text - session JSONL contents.
|
||||
* @returns logical events expanded from ordinary and packed body rows.
|
||||
*/
|
||||
export function decodeSessionSnapshotBody(records: readonly Record<string, unknown>[]): SessionEvent[] {
|
||||
export function parseReplaySessionLog(text: string): SessionEvent[] {
|
||||
const records = parseJsonl(text)
|
||||
if (records[0]?.type !== 'session') throw new Error('session snapshot must start with a session header')
|
||||
|
||||
const events: SessionEvent[] = []
|
||||
let nextSeq = 0
|
||||
let projected: boolean | undefined
|
||||
for (const [index, record] of records.entries()) {
|
||||
for (const [index, record] of records.slice(1).entries()) {
|
||||
const [seqKey, timeKey] = envelopeKeys(record)
|
||||
const hasSeq = Object.hasOwn(record, seqKey)
|
||||
const hasTime = Object.hasOwn(record, timeKey)
|
||||
@@ -125,56 +88,8 @@ export function decodeSessionSnapshotBody(records: readonly Record<string, unkno
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`session snapshot line ${index + 2}: ${detail}`, { cause: error })
|
||||
}
|
||||
for (const event of decoded) events.push(event)
|
||||
events.push(...decoded)
|
||||
nextSeq += decoded.length
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a committed session snapshot or a complete persisted session log.
|
||||
* Snapshot body rows may all omit both members of their sequence/time
|
||||
* envelope; decoding restores a contiguous synthetic envelope without
|
||||
* changing payloads. Projected and persisted rows cannot coexist in one file.
|
||||
*
|
||||
* @param text - session JSONL contents.
|
||||
* @returns the header, materialized body rows, and logical events.
|
||||
*/
|
||||
export function decodeSessionSnapshot(text: string): DecodedSessionSnapshot {
|
||||
const records = parseJsonl(text)
|
||||
const header = records[0]
|
||||
if (header === undefined || header.value.type !== 'session') {
|
||||
throw new Error('session snapshot must start with a session header')
|
||||
}
|
||||
|
||||
const bodyRecords = records.slice(1).map(({ value }) => value)
|
||||
const events = decodeSessionSnapshotBody(bodyRecords)
|
||||
|
||||
return {
|
||||
header: header.value,
|
||||
headerLine: header.text,
|
||||
bodyRecords,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project persisted session JSONL into the committed replay-fixture format.
|
||||
* The header line and all body payloads remain unchanged; only ordinary
|
||||
* `seq`/`time` and packed-row `seq0`/`time0` envelopes are omitted.
|
||||
*
|
||||
* @param text - persisted or already-projected session JSONL.
|
||||
* @returns compact projected body JSONL with the original header line.
|
||||
*/
|
||||
export function projectSessionSnapshot(text: string): string {
|
||||
const records = parseJsonl(text)
|
||||
const header = records[0]
|
||||
if (header === undefined || header.value.type !== 'session') {
|
||||
throw new Error('session snapshot must start with a session header')
|
||||
}
|
||||
const body = records.slice(1).map(({ value }) => {
|
||||
omitSessionEventEnvelope(value)
|
||||
return JSON.stringify(value)
|
||||
})
|
||||
return [header.text, ...body, ''].join('\n')
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
type SessionScript,
|
||||
apply,
|
||||
deriveReplayScript,
|
||||
decodeSessionSnapshot,
|
||||
inject,
|
||||
installLlmReplay,
|
||||
loadReplayScript,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
name,
|
||||
parseSessionHeader,
|
||||
parseSessionLog,
|
||||
projectSessionSnapshot,
|
||||
resolveScriptedEntry,
|
||||
} from '../src/index.ts'
|
||||
|
||||
@@ -126,42 +124,14 @@ describe('parseSessionLog', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('session snapshot codec', () => {
|
||||
it('projects only body envelopes and retains the header line and payloads', () => {
|
||||
const header = ' {"type":"session","version":0,"id":"s1","createdAt":7} '
|
||||
const ordinary = {
|
||||
type: 'turn/start', seq: 0, time: 10,
|
||||
data: { turn: 1, seq: 41, time: 42 },
|
||||
}
|
||||
const packed = {
|
||||
type: 'text-chunks', seq0: 1, time0: 20,
|
||||
data: { turn: 1, step: 1, index: 0, dt: [3], texts: ['a', 'b'] },
|
||||
}
|
||||
const projected = projectSessionSnapshot([
|
||||
header,
|
||||
JSON.stringify(ordinary),
|
||||
JSON.stringify(packed),
|
||||
'',
|
||||
].join('\n'))
|
||||
const lines = projected.trimEnd().split('\n')
|
||||
|
||||
expect(lines[0]).toBe(header)
|
||||
expect(JSON.parse(lines[1] ?? '{}')).toStrictEqual({
|
||||
type: 'turn/start', data: ordinary.data,
|
||||
})
|
||||
expect(JSON.parse(lines[2] ?? '{}')).toStrictEqual({
|
||||
type: 'text-chunks', data: packed.data,
|
||||
})
|
||||
expect(projectSessionSnapshot(projected)).toBe(projected)
|
||||
})
|
||||
|
||||
describe('projected replay fixtures', () => {
|
||||
it('rejects half-present storage envelopes', () => {
|
||||
const fixture = [
|
||||
'{"type":"session","version":0,"id":"s1","createdAt":0}',
|
||||
'{"type":"turn/start","seq":0,"data":{"turn":1}}',
|
||||
'',
|
||||
].join('\n')
|
||||
expect(() => decodeSessionSnapshot(fixture)).toThrow(/both seq\/time or neither/)
|
||||
expect(() => parseSessionLog(fixture)).toThrow(/both seq\/time or neither/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -175,7 +145,7 @@ describe('session snapshot codec', () => {
|
||||
],
|
||||
])('rejects mixed projected and persisted body records', (first, second) => {
|
||||
const fixture = `{"type":"session"}\n${first}\n${second}\n`
|
||||
expect(() => decodeSessionSnapshot(fixture)).toThrow(/line 3 cannot mix projected and persisted body records/)
|
||||
expect(() => parseSessionLog(fixture)).toThrow(/line 3 cannot mix projected and persisted body records/)
|
||||
})
|
||||
|
||||
it('reports malformed storage records with their snapshot line', () => {
|
||||
@@ -185,30 +155,25 @@ describe('session snapshot codec', () => {
|
||||
'{"type":"text-chunks","data":{"turn":1,"step":1,"index":0,"dt":[],"texts":[]}}',
|
||||
'',
|
||||
].join('\n')
|
||||
expect(() => decodeSessionSnapshot(fixture)).toThrow(/session snapshot line 3:/)
|
||||
expect(() => parseSessionLog(fixture)).toThrow(/session snapshot line 3:/)
|
||||
})
|
||||
|
||||
it.each(['null', '1', '[]'])('rejects non-object JSONL records: %s', (record) => {
|
||||
expect(() => decodeSessionSnapshot(`${record}\n`)).toThrow(/line 1 must be a JSON object/)
|
||||
expect(() => parseSessionLog(`${record}\n`)).toThrow(/line 1 must be a JSON object/)
|
||||
})
|
||||
|
||||
it('reports invalid JSON with its line', () => {
|
||||
expect(() => projectSessionSnapshot('{"type":"session"}\n{broken}\n'))
|
||||
expect(() => parseSessionLog('{"type":"session"}\n{broken}\n'))
|
||||
.toThrow(/line 2 contains invalid JSON/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['decode', decodeSessionSnapshot],
|
||||
['project', projectSessionSnapshot],
|
||||
] as const)('requires a session header when attempting to %s', (_name, operation) => {
|
||||
expect(() => operation('')).toThrow(/must start with a session header/)
|
||||
expect(() => operation('{"type":"other"}\n')).toThrow(/must start with a session header/)
|
||||
it('requires a session header', () => {
|
||||
expect(() => parseSessionLog('')).toThrow(/must start with a session header/)
|
||||
expect(() => parseSessionLog('{"type":"other"}\n')).toThrow(/must start with a session header/)
|
||||
})
|
||||
|
||||
it('materializes an unknown empty storage row as an ordinary event', () => {
|
||||
const decoded = decodeSessionSnapshot('{"type":"session"}\n{}\n')
|
||||
expect(decoded.bodyRecords).toStrictEqual([{ seq: 0, time: 0 }])
|
||||
expect(decoded.events).toStrictEqual([{ seq: 0, time: 0 }])
|
||||
expect(parseSessionLog('{"type":"session"}\n{}\n')).toStrictEqual([{ seq: 0, time: 0 }])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Generated
-3
@@ -7750,9 +7750,6 @@ importers:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
'@deepseek-ai/dsh-llm-replay':
|
||||
specifier: workspace:^
|
||||
version: link:../llm-replay
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { decodeSessionSnapshot } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { canonicalSessionFixture } from './session-fixture-layout.ts'
|
||||
|
||||
const HEADER = ' {"type":"session","version":0,"id":"fixture","createdAt":1,"delegationDepth":0} '
|
||||
@@ -23,7 +23,7 @@ function unpackedFixture(): string {
|
||||
}
|
||||
|
||||
function decodedBody(content: string): SessionEvent[] {
|
||||
return decodeSessionSnapshot(content).events
|
||||
return parseSessionLog(content)
|
||||
}
|
||||
|
||||
describe('canonicalSessionFixture', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { packChunkRuns, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { decodeSessionSnapshot, omitSessionEventEnvelope } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
|
||||
/** One repository session fixture and its canonical projected representation. */
|
||||
export interface SessionFixtureLayout {
|
||||
@@ -26,7 +26,10 @@ function renderFixture(headerLine: string, events: readonly SessionEvent[]): str
|
||||
headerLine,
|
||||
...packChunkRuns(events).map((stored) => {
|
||||
const record = stored as unknown as Record<string, unknown>
|
||||
omitSessionEventEnvelope(record)
|
||||
delete record.seq
|
||||
delete record.time
|
||||
delete record.seq0
|
||||
delete record.time0
|
||||
return JSON.stringify(record)
|
||||
}),
|
||||
'',
|
||||
@@ -62,21 +65,21 @@ export function canonicalSessionFixture(content: string, label = '<session-fixtu
|
||||
}
|
||||
if (!isSessionHeader(headerValue)) return undefined
|
||||
|
||||
let decodedSource
|
||||
let events
|
||||
try {
|
||||
decodedSource = decodeSessionSnapshot(content)
|
||||
events = parseSessionLog(content)
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`${label}: ${detail}`, { cause: error })
|
||||
}
|
||||
const canonical = renderFixture(decodedSource.headerLine, decodedSource.events)
|
||||
const decoded = decodeSessionSnapshot(canonical).events
|
||||
const canonical = renderFixture(headerLine, events)
|
||||
const decoded = parseSessionLog(canonical)
|
||||
try {
|
||||
deepStrictEqual(withoutEnvelope(decoded), withoutEnvelope(decodedSource.events))
|
||||
deepStrictEqual(withoutEnvelope(decoded), withoutEnvelope(events))
|
||||
} catch (error) {
|
||||
throw new Error(`${label}: packed snapshot rewrite changed the event payload stream`, { cause: error })
|
||||
}
|
||||
if (renderFixture(decodedSource.headerLine, decoded) !== canonical) {
|
||||
if (renderFixture(headerLine, decoded) !== canonical) {
|
||||
throw new Error(`${label}: packed rewrite is not idempotent`)
|
||||
}
|
||||
return canonical
|
||||
|
||||
Reference in New Issue
Block a user