mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-13 04:03:30 +00:00
fix(session): preserve v2 migration compatibility
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/architecture/2026-09-01-v2-embedded-assistant-streams.md
|
||||
2026-09-01-v2-embedded-assistant-streams.md: e50af4848523c92daaac1bd8b1120c26cdf62bdc
|
||||
2026-09-01-v2-embedded-assistant-streams.zh.md: 34924a43150c34571a6c5f2e7837d1b662d2ec46
|
||||
2026-09-01-v2-embedded-assistant-streams.md: 805e52579be250ed372585c48fad434f7e15aadc
|
||||
2026-09-01-v2-embedded-assistant-streams.zh.md: 05e1a76f149a92c35af1ede28babfd2513773352
|
||||
|
||||
@@ -33,7 +33,7 @@ The Web follow adapter opts into these process-local frames and adds the last du
|
||||
|
||||
The adjacent migration validates the complete frozen v1 artifact, groups chunks by turn, step, terminal boundary, and exact message provenance, and then substitutes one settlement per attempt. A successful group's chunks move into its message. An unclaimed group becomes `assistant/attempt` at the last consumed chunk's position. Unrelated interleaved events retain their relative order, and survivors receive dense v2 sequence numbers.
|
||||
|
||||
The edge remaps the finite declared reference inventory: envelope provenance, surface replacement endpoints, command source events, compaction ranges and shadowed lists, and title message lists. A reference to a consumed chunk refuses migration; it is never redirected to a settlement with different meaning. The edge also refuses an inherited cut that splits an attempt.
|
||||
The edge remaps the finite declared reference inventory: envelope provenance, surface replacement endpoints, command source events, compaction ranges and shadowed lists, and title message lists. The model-visible text of a validated `session/title-llm-request` remains byte-identical in the source sequence namespace while its `messageSeqs` field moves to the v2 namespace; target validation therefore does not reconstruct that text from remapped sequences. A reference to a consumed chunk refuses migration; it is never redirected to a settlement with different meaning. The edge also refuses an inherited cut that splits an attempt.
|
||||
|
||||
The v2 physical header requires `isSeeded` and stores no numeric cut. A seeded artifact marks its exact cut with `session/end-seed { inherited: true }`; decoding derives the cut from the last tagged marker. The v2 codec writes one durable event per physical row, range-encodes only `sourceEventSeqs`, and validates physical envelopes without freezing ordinary event vocabulary or payload additions. The v1-to-v2 target validator separately freezes the released-v2 inventory, while current restoration uses the installed Session vocabulary. Frozen v0 and v1 codecs retain packed-row decoding for their immutable historical generations.
|
||||
|
||||
@@ -45,7 +45,7 @@ Generation selection and publication follow the [released Session migration deci
|
||||
|
||||
## Verification
|
||||
|
||||
The compact-stream tests pin exact accumulation and expansion for text, reasoning, tool arguments, raw chunks, timestamp gaps, malformed records, and detached snapshots. The v1-to-v2 tests cover successful and failed attempts, interleaving, dense sequence and reference remapping, seed-cut insertion and split refusal, strict source and target validation, one-row v2 encoding, provenance ranges, raw and Zstandard publication, and no-write current reads.
|
||||
The compact-stream tests pin exact accumulation and expansion for text, reasoning, tool arguments, raw chunks, timestamp gaps, malformed records, and detached snapshots. The v1-to-v2 tests cover successful and failed attempts, interleaving, dense sequence and reference remapping, source-sequence title framing, seed-cut insertion and split refusal, strict source and target validation, one-row v2 encoding, backend-compatible provenance ranges, raw and Zstandard publication, and no-write current reads.
|
||||
|
||||
The manual performance acceptance measures static catalog-routing overhead against direct released-v2 restoration of the same already parsed physical rows across three runs, 100 warmup pairs, and 600 measured pairs. It does not compare v1 with v2 or time backend I/O. Every pooled median and p95 regression must remain within 5%; the accepted run's worst p95 regression was 3.150%. `--smoke` reports a non-gating diagnostic sample.
|
||||
|
||||
|
||||
+2
-2
@@ -33,7 +33,7 @@ Web follow adapter 显式选择接收这些进程本地 frame,并为每个 sta
|
||||
|
||||
相邻迁移会校验完整的冻结 v1 产物,按 turn、step、terminal boundary 与精确 message provenance 对 chunk 分组,再为每个 attempt 替换一个 settlement。成功分组的 chunk 移入其 message。未被认领的分组会在最后一个被消费 chunk 的位置变成 `assistant/attempt`。无关的交错事件保持相对顺序,存活事件获得密集 v2 序号。
|
||||
|
||||
该迁移边会重映射有限的已声明引用清单:信封 provenance、surface replacement 端点、command source event、compaction range 与 shadowed list,以及 title message list。指向被消费 chunk 的引用会使迁移失败;它绝不会被重定向到含义不同的 settlement。该迁移边也会拒绝切开 attempt 的继承切点。
|
||||
该迁移边会重映射有限的已声明引用清单:信封 provenance、surface replacement 端点、command source event、compaction range 与 shadowed list,以及 title message list。经过校验的 `session/title-llm-request` 模型可见文本会在源序号命名空间中保持逐字节不变,而它的 `messageSeqs` 字段会迁移到 v2 命名空间;因此目标校验不会根据重映射后的序号重建该文本。指向被消费 chunk 的引用会使迁移失败;它绝不会被重定向到含义不同的 settlement。该迁移边也会拒绝切开 attempt 的继承切点。
|
||||
|
||||
v2 物理 header 要求 `isSeeded`,且不存储数值切点。带 seed 的产物用 `session/end-seed { inherited: true }` 标记其精确切点;解码从最后一个 tagged marker 推导切点。v2 编解码器为每个持久事件写一条物理行,只对 `sourceEventSeqs` 做范围编码,并在不冻结普通事件词汇或 payload 新增项的前提下校验物理 envelope。v1-to-v2 target validator 会另行冻结 released-v2 清单,current restoration 则使用 installed Session 词汇。冻结的 v0 与 v1 编解码器继续为不可变历史 generation 解码 packed row。
|
||||
|
||||
@@ -45,7 +45,7 @@ Generation 选择与发布遵循[已发布 Session 迁移决策](2026-08-31-rele
|
||||
|
||||
## 验证
|
||||
|
||||
紧凑 stream 测试固定 text、reasoning、tool argument、raw chunk、时间戳间隔、格式错误 record 与分离 snapshot 的精确累积和展开。v1 到 v2 测试覆盖成功与失败 attempt、交错、密集序号与引用重映射、seed 切点插入与切分拒绝、严格源与目标校验、每行一个事件的 v2 编码、provenance range、原始与 Zstandard 发布,以及无写入的当前读取。
|
||||
紧凑 stream 测试固定 text、reasoning、tool argument、raw chunk、时间戳间隔、格式错误 record 与分离 snapshot 的精确累积和展开。v1 到 v2 测试覆盖成功与失败 attempt、交错、密集序号与引用重映射、源序号 title framing、seed 切点插入与切分拒绝、严格源与目标校验、每行一个事件的 v2 编码、与 backend 兼容的 provenance range、原始与 Zstandard 发布,以及无写入的当前读取。
|
||||
|
||||
手工 performance acceptance 会在三轮、100 组 warmup pair 与 600 组 measured pair 下,针对同一批已经解析的物理 row,把静态 catalog routing 与直接 released-v2 restoration 比较;它不比较 v1 与 v2,也不计入 backend I/O。每个 pooled median 与 p95 regression 都必须保持在 5% 以内;已接受运行的最差 p95 regression 为 3.150%。`--smoke` 报告不参与 gate 的诊断 sample。
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ interface ToolLifecycle {
|
||||
export interface ReleasedRelationshipExtensions {
|
||||
/** Event types that must occur inside the current open step. */
|
||||
readonly stepEvents?: ReadonlySet<string>
|
||||
/** Title-request model input was source-validated and preserved across sequence remapping. */
|
||||
readonly preservedSourceTitleRequestText?: true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,7 +223,12 @@ export function assertReleasedArtifactRelationships(
|
||||
}
|
||||
case 'session/title':
|
||||
case 'session/title-llm-request':
|
||||
assertTitleSources(artifact.events, event, data)
|
||||
assertTitleSources(
|
||||
artifact.events,
|
||||
event,
|
||||
data,
|
||||
extensions.preservedSourceTitleRequestText !== true,
|
||||
)
|
||||
break
|
||||
case 'command/run': {
|
||||
const id = data['commandId'] as string
|
||||
@@ -408,6 +415,7 @@ function assertTitleSources(
|
||||
events: readonly SessionFormatEvent[],
|
||||
event: SessionFormatEvent,
|
||||
data: Record<string, SessionFormatJsonValue>,
|
||||
validateFramedText: boolean,
|
||||
): void {
|
||||
const seqs = data['messageSeqs'] as readonly number[]
|
||||
if (event.type === 'session/title') {
|
||||
@@ -444,7 +452,8 @@ function assertTitleSources(
|
||||
throw new SessionFormatError('session/title-llm-request messages do not represent messageSeqs')
|
||||
}
|
||||
const framed = content[0]
|
||||
if (framed === undefined || framed['type'] !== 'text' || framed['text'] !== expected) {
|
||||
if (framed === undefined || framed['type'] !== 'text'
|
||||
|| validateFramedText && framed['text'] !== expected) {
|
||||
throw new SessionFormatError('session/title-llm-request messages do not represent messageSeqs')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/session/session-format-v1-to-v2/README.md
|
||||
README.md: 5ead8532b2cce52a0cbf4c39bfe9e54ef14002f1
|
||||
README.zh.md: 1f41d24007f95f799a9667910acb93d494a2aa2e
|
||||
README.md: 0e2661259b89cdbf4d5e138fe6d7a8fdfeaaea56
|
||||
README.zh.md: c066b994a7fe63fa2628fe0d5b4ae950f67da72b
|
||||
|
||||
@@ -40,7 +40,7 @@ const migratedV2 = sessionFormatV1ToV2.migrate(decodedV1)
|
||||
|
||||
A successful v1 `assistant/message` must cite its complete ordered attempt. The migration removes the cited top-level chunks and obsolete message provenance, compacts the chunks without joining token boundaries, and stores the stream on that message. An unclaimed attempt becomes one log-only `assistant/attempt` at its final chunk position. Unrelated interleaved events keep their relative order.
|
||||
|
||||
The migration refuses a reference to a consumed chunk instead of redirecting it to a different semantic event. It remaps declared event provenance, surface replacements, command source events, compaction ranges and lists, and title message lists. A seeded source also refuses an inherited cut that splits an Assistant attempt; the target marks the exact cut with `session/end-seed { inherited: true }`.
|
||||
The migration refuses a reference to a consumed chunk instead of redirecting it to a different semantic event. It remaps declared event provenance, surface replacements, command source events, compaction ranges and lists, and title message lists. The already model-visible `session/title-llm-request.messages` text remains byte-identical after source validation, so target validation does not reinterpret the old sequence numbers embedded in that prompt. A seeded source also refuses an inherited cut that splits an Assistant attempt; the target marks the exact cut with `session/end-seed { inherited: true }`.
|
||||
|
||||
The v2 physical header requires `isSeeded` and does not store a numeric cut. The codec derives the cut from the last inherited end-seed marker, writes one event per row, range-encodes only `sourceEventSeqs`, and remains neutral to ordinary event vocabulary and payload growth. Strict migration-target validation freezes the released-v2 inventory and rejects unknown types or members. Current restoration instead admits event types known to the installed Session package plus unknown events carrying `ignorable: true`, then delegates payload and stream semantics to the installed current restorer. All paths retain strict header, event-envelope, sequence, and inherited-cut validation.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const migratedV2 = sessionFormatV1ToV2.migrate(decodedV1)
|
||||
|
||||
成功的 v1 `assistant/message` 必须引用其完整有序 attempt。迁移会移除这些顶层 chunk 和已停用的 message provenance,在不合并 token 边界的前提下压缩 chunk,并把 stream 存到该 message 上。未被 message 认领的 attempt 会在其最后一个 chunk 的位置变成一个仅日志可见的 `assistant/attempt`。无关的交错事件保持相对顺序。
|
||||
|
||||
如果引用指向被消费的 chunk,迁移会失败,而不会把它重定向到语义不同的事件。它会重映射已声明的事件 provenance、surface replacement、command source event、compaction range 与 list,以及 title message list。带 seed 的源若让继承切点切开一个 Assistant attempt,也会迁移失败;目标会用 `session/end-seed { inherited: true }` 标出精确切点。
|
||||
如果引用指向被消费的 chunk,迁移会失败,而不会把它重定向到语义不同的事件。它会重映射已声明的事件 provenance、surface replacement、command source event、compaction range 与 list,以及 title message list。已经对模型可见的 `session/title-llm-request.messages` 文本会在源校验后保持逐字节不变,因此目标校验不会重新解释该 prompt 中嵌入的旧序号。带 seed 的源若让继承切点切开一个 Assistant attempt,也会迁移失败;目标会用 `session/end-seed { inherited: true }` 标出精确切点。
|
||||
|
||||
v2 物理 header 要求 `isSeeded`,且不存储数值切点。编解码器从最后一个 inherited end-seed marker 推导切点,每行写入一个事件,只对 `sourceEventSeqs` 做范围编码,并对普通事件词汇与 payload 扩展保持中立。严格的迁移目标校验会冻结 released-v2 清单并拒绝未知 type 或 member。当前恢复则准入 installed Session package 已知的事件 type,以及携带 `ignorable: true` 的未知事件,再把 payload 与 stream 语义交给 installed current restorer。所有路径仍严格校验 header、event envelope、sequence 与 inherited cut。
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ function encodeProvenance(event: SessionFormatEvent): SessionFormatJsonObject {
|
||||
function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): readonly number[] {
|
||||
if (!Array.isArray(value)) throw new SessionFormatError('sourceEventSeqs must be an array')
|
||||
const output: number[] = []
|
||||
let hasRange = false
|
||||
for (const entry of value) {
|
||||
if (!Array.isArray(entry)) {
|
||||
output.push(sessionFormatCount(entry, 'sourceEventSeqs member'))
|
||||
@@ -180,6 +181,7 @@ function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): rea
|
||||
throw new SessionFormatError('sourceEventSeqs range exceeds its event seq')
|
||||
}
|
||||
for (let current = start; current <= end; current += 1) output.push(current)
|
||||
hasRange = true
|
||||
}
|
||||
const seen = new Set<number>()
|
||||
for (const source of output) {
|
||||
@@ -188,10 +190,14 @@ function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): rea
|
||||
}
|
||||
seen.add(source)
|
||||
}
|
||||
if (hasRange && output.some((source, index) => index > 0 && source <= (output[index - 1] as number))) {
|
||||
throw new SessionFormatError('sourceEventSeqs ranges must be strictly increasing')
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function encodeSeqRanges(values: readonly number[]): readonly SessionFormatJsonValue[] {
|
||||
if (values.some((value, index) => index > 0 && value <= (values[index - 1] as number))) return [...values]
|
||||
const output: SessionFormatJsonValue[] = []
|
||||
for (let index = 0; index < values.length;) {
|
||||
const start = values[index] as number
|
||||
|
||||
@@ -31,6 +31,7 @@ const LOG_OPTIONAL = ['ignorable'] as const
|
||||
const RELEASED_V2_EVENT_TYPE_SET = new Set(RELEASED_V2_EVENT_TYPES)
|
||||
const RELEASED_V2_RELATIONSHIP_EXTENSIONS = {
|
||||
stepEvents: new Set(['assistant/attempt']),
|
||||
preservedSourceTitleRequestText: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
SessionFormatEvent,
|
||||
SessionFormatJsonObject,
|
||||
} from '@deepseek-ai/dsh-session-format'
|
||||
import { decodeSeqRanges as decodeCurrentSeqRanges } from '@deepseek-ai/dsh-session'
|
||||
import { releasedV2SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v1-to-v2'
|
||||
|
||||
const minimalPhysicalHeader = {
|
||||
@@ -126,6 +127,21 @@ describe('releasedV2SessionFormatCodec rows', () => {
|
||||
expect(releasedV2SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows)).toStrictEqual(source)
|
||||
})
|
||||
|
||||
it('keeps non-monotonic provenance scalar-only for the current backend reader', () => {
|
||||
const sourceEventSeqs = [4, 5, 1, 2, 3]
|
||||
const source = artifact([
|
||||
feedback(0), feedback(1), feedback(2), feedback(3), feedback(4), feedback(5),
|
||||
userMessage(6, sourceEventSeqs),
|
||||
])
|
||||
|
||||
const encoded = releasedV2SessionFormatCodec.encodeArtifact(source)
|
||||
const stored = encoded.rows[6]?.['sourceEventSeqs']
|
||||
|
||||
expect(stored).toStrictEqual(sourceEventSeqs)
|
||||
expect(decodeCurrentSeqRanges(stored, 6)).toStrictEqual(sourceEventSeqs)
|
||||
expect(releasedV2SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows)).toStrictEqual(source)
|
||||
})
|
||||
|
||||
it('keeps the v2 physical codec vocabulary-neutral for current growth and a future source freeze', () => {
|
||||
const source = artifact([
|
||||
{ type: 'external/required', seq: 0, time: 1, data: { retained: true } },
|
||||
@@ -164,6 +180,7 @@ describe('releasedV2SessionFormatCodec rows', () => {
|
||||
['scalar at the event', [4], /unique earlier/],
|
||||
['duplicate scalars', [0, 0], /unique earlier/],
|
||||
['overlapping range and scalar', [[0, 1], 1], /unique earlier/],
|
||||
['non-monotonic range', [3, [0, 2]], /strictly increasing/],
|
||||
])('refuses malformed stored provenance: %s', (_name, sourceEventSeqs, message) => {
|
||||
const rows = [feedback(0), feedback(1), feedback(2), feedback(3), {
|
||||
...userMessage(4), sourceEventSeqs,
|
||||
|
||||
@@ -650,6 +650,52 @@ describe('sessionFormatV1ToV2', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves source-sequence text in a title request while remapping its references', () => {
|
||||
const framed = 'Generate the session title from this JSON array of human messages:\n'
|
||||
+ JSON.stringify([{ seq: 6, text: 'question' }])
|
||||
const source: SessionFormatArtifact = {
|
||||
header: {
|
||||
version: 1, id: 'v1-title-source-seq', createdAt: 1,
|
||||
isSeeded: false, delegationDepth: 0,
|
||||
},
|
||||
inheritedEventCount: 0,
|
||||
events: [
|
||||
event('turn/start', 0, 1, { turn: 1 }),
|
||||
event('step/start', 1, 2, { turn: 1, step: 1 }),
|
||||
event('assistant/chunk', 2, 3, {
|
||||
turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hello' },
|
||||
}),
|
||||
event('assistant/chunk', 3, 4, {
|
||||
turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'stop' } },
|
||||
}),
|
||||
{
|
||||
...event('assistant/message', 4, 5, { turn: 1, step: 1, message }),
|
||||
sourceEventSeqs: [2, 3], surfaceOp: 'append',
|
||||
},
|
||||
event('step/end', 5, 6, { turn: 1, step: 1 }),
|
||||
{ ...event('user/message', 6, 7, userMessage), surfaceOp: 'append' },
|
||||
event('session/title-llm-request', 7, 8, {
|
||||
titleProvider: 'title-1', messageSeqs: [6], route: { provider: 'mock', model: 'mock' },
|
||||
system: 'title',
|
||||
messages: [{
|
||||
id: 'title-request', role: 'user', content: [{ type: 'text', text: framed }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-session-title-llm' },
|
||||
}],
|
||||
maxTokens: 20,
|
||||
}),
|
||||
event('turn/end', 8, 9, { turn: 1, reason: { kind: 'completed' } }),
|
||||
],
|
||||
}
|
||||
|
||||
const migrated = sessionFormatV1ToV2.migrate(source)
|
||||
const titleRequest = migrated.events.find(event => event.type === 'session/title-llm-request')
|
||||
|
||||
expect(titleRequest?.data).toMatchObject({
|
||||
messageSeqs: [4],
|
||||
messages: [{ content: [{ text: framed }] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('remaps compaction summaries and closes prior-turn groups independently', () => {
|
||||
const source: SessionFormatArtifact = {
|
||||
header: {
|
||||
|
||||
Reference in New Issue
Block a user