feat(session): fork exact event prefixes with synthetic tail results

This commit is contained in:
Tianyi Cui committed 2026-09-17 00:00:44 +08:00
1 parent 88229fac33
commit 8696ec6cef
84 files changed
+3242 -1407

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 .agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.md
2026-09-11-session-controller-fork-turn-cut.md: 0c8d45c861df964a62586827e80c613704ed17f0
2026-09-11-session-controller-fork-turn-cut.zh.md: 729a42a596264ac618608d26cd52a1ad836b58db
2026-09-11-session-controller-fork-turn-cut.md: 9223acd4c0c7bbcc84a0bfeb72f3b57d4bf828e2
2026-09-11-session-controller-fork-turn-cut.zh.md: cae022ae97896c0b43633edb310afb305d690b4a
@@ -1,6 +1,7 @@
# Agent Note: Session Controller forks stop at the selected turn end
Status: implemented
Archived: 2026-09-16
English | [中文](2026-09-11-session-controller-fork-turn-cut.zh.md)
@@ -1,8 +1,9 @@
# Agent Note: Session Controller 分叉截到选中轮次的结束事件
Status: implemented
Archived: 2026-09-16
English | [中文](2026-09-11-session-controller-fork-turn-cut.md)
[English](2026-09-11-session-controller-fork-turn-cut.md) | 中文
## Problem
+3
View File
@@ -739,6 +739,9 @@
"bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:ad9dcedeb25ab3eddbc51660935abaf6e6e92eefb66fd14f8852ba5f30e725b5",
"bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:ce8983a9ffa3d1b59946aeecf0e10177c8316c3621d5d0718a03e8eedcd05fd9",
"bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:cd3ca0290f259a252e0b19fd0f15ac62232847be6f186e16f72652044a034bbe",
"bug-fix/2026-09-11-session-controller-fork-turn-cut.i18n.yaml": "sha256:4442529ced1c8e48d30f7b50c65ff03eb92dce9815beb438cedbf29f234bce47",
"bug-fix/2026-09-11-session-controller-fork-turn-cut.md": "sha256:a996f24a896c7fc5d81e015db3eae27d8a46c853287572e815623ce35eae1b12",
"bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md": "sha256:6486f0eacf5067ef9850dc32f1c27b2109a7d4e9e3c498366f5c38742bb53ac5",
"feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918",
"feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b",
"feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69",
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/feature/2026-08-18-arbitrary-seq-session-fork.md
2026-08-18-arbitrary-seq-session-fork.md: fe77ff74125af3e6cb1e6c5e82f0539951a46283
2026-08-18-arbitrary-seq-session-fork.zh.md: e5195fd24553d1c8c746d152c9196ada526954a3
@@ -0,0 +1,39 @@
# Agent Note: Exact-event session forks with shared crash repair
Status: implemented
English | [中文](2026-08-18-arbitrary-seq-session-fork.zh.md)
## Problem
A branch may need the history before a tool result arrives, including a historical position in a parent that has since completed. Restricting forks to completed turns prevents that choice. Reusing crash-recovery result wording would falsely imply that the parent was interrupted.
## Decision
`SessionStore.fork` accepts any existing integer event seq exactly. It owns source membership, child identity and cut validation. `buildForkSeed`, the only export of `dsh-session/fork`, copies the inclusive prefix, inserts the child's inherited `session/end-seed` marker, and closes the open tail. The Host selects and builds from one immutable observation. Omitted `atSeq` means the latest completed turn with its independent tail, stopping before the next turn or queued user input. An explicit seq never moves.
Fork and crash recovery share the same tool-pairing algorithm. Only an open step receives missing error results before `step/end`; an open turn then receives `turn/end`. Closed steps and turns remain unchanged, including historical failures with missing results. The internal cause selects `forked` versus `interrupted`, deterministic result IDs and model-visible wording. Fork wording describes missing inherited records and warns that the parent may have executed the call after the cut. Retry advice distinguishes read-only or idempotent operations from operations with side effects.
The inherited marker precedes synthetic closers. `inheritedEventCount` counts only copied parent events; the marker and closers are child-owned and are persisted before agent publication. A constructor accepts this complete seed only when its supplied cut identifies the final inherited marker. Nested forks retain ancestor markers within their copied prefix. `firstLiveSeq` remains the length of the complete constructor input, distinct from durable inheritance.
V4 admits not-started fork results without changing released V0–V3 validators. Its codec and relationship validator use a private canonical interrupted-result view for this checked variant, then return the original fork ID and message. The V3-to-V4 migration preserves existing events. The `forked` turn-end reason is acknowledged with the unreleased V4 header transition.
This replaces the [completed-turn-only controller policy](../../archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.md). Explicit cuts retain the requested event exactly; omitted cuts retain completed standalone work, including manual compaction replacements, until a core-owned turn or queued-input event begins. Plugins continue to own their brackets; the controller does not classify plugin events.
## Alternatives considered
**Move the cut to a completed step.** This changes an explicit historical choice and loses the requested context. Exact cuts with logged closers preserve that context.
**Repair during request construction.** Model-visible results must be logged, so the seed records them before the model request.
**Repair calls in closed steps.** Those failures already belong to the parent; this would expand fork into a new historical repair policy and diverge from crash recovery.
**Expose a Store-dependent fork wrapper.** Store creation rules remain in Store; the pure seed helper needs only event types and the internal closer, avoiding another public entry and a Store file move.
## Consequences
Fork closes the selected open tail, not earlier malformed history. Plugins own their unmatched brackets: no `compaction/end` is synthesized. The inherited marker lets compaction recognize an inherited stale start. Parent and child sequence numbers diverge after the cut. For a new fork, `firstLifecycleSeq` starts at the child-owned marker, so telemetry includes synthetic results and closers. Restored Sessions start this lifecycle after their stored prefix; the durable fork cut alone does not identify new capture work. The chat action retains completed-turn selection; finer event selection is backend functionality and adds no side chat.
## Testing
Session and repair tests cover exact cuts, both error classifications, closed failed steps, empty histories, plugin brackets and surface replacements. Agent-loop regressions verify the actual next model request, preserved results, turn numbering and no repeated tool execution. Host tests cover live and cold sources, exact rejection and queued-input exclusion. V4 tests round-trip original fork result data, reject malformed results and validate nested forks. The keyless `fork-mid-turn` browser scenario forks a cold recording through HTTP and continues through the composer with the branch wording rendered in its expected output.
@@ -0,0 +1,39 @@
# Agent Note:复用崩溃修复的精确事件会话分叉
Status: implemented
[English](2026-08-18-arbitrary-seq-session-fork.md) | 中文
## Problem
分支可能需要工具结果到来之前的历史,包括父会话已经完成后的历史位置。将 fork 限制到已完成轮次会阻止这种选择。复用崩溃恢复的结果文案则会错误暗示父会话已中断。
## Decision
`SessionStore.fork` 精确接受任意已有的整数事件 seq,并拥有源会话归属、子会话标识和切点校验。`dsh-session/fork` 唯一导出的 `buildForkSeed` 复制含切点的前缀,插入子会话的继承 `session/end-seed` 标记,再关闭开放尾部。Host 从同一份不可变观察中选点和构建。省略 `atSeq` 表示最近已完成轮次及其独立尾部,在下一轮次或排队用户输入前停止。显式 seq 永不移动。
Fork 与崩溃恢复共用相同的工具配对算法。只有开放步骤会在 `step/end` 前补缺失的错误结果;开放轮次随后补 `turn/end`。已关闭的步骤和轮次保持原样,包括缺少结果的历史失败。内部 cause 选择 `forked` 或 `interrupted`、确定性的结果 ID 及模型可见文案。Fork 文案描述缺少的继承记录,并提醒父会话可能已在切点后执行调用。重试建议区分只读或幂等操作与有副作用的操作。
继承标记位于合成结束事件之前。`inheritedEventCount` 只统计复制的父事件;标记与结束事件属于子会话,在 agent 发布前持久化。构造函数仅在给定切点指向最后一个继承标记时接受这种完整 seed。嵌套 fork 在复制前缀中保留祖先标记。`firstLiveSeq` 仍是完整构造输入的长度,与持久继承计数不同。
V4 接受未启动的 fork 结果,不修改已发布的 V0–V3 校验器。其 codec 和关系校验器为这一经过校验的变体提供内部规范 interrupted-result 视图,再返回原始 fork ID 和消息。V3-to-V4 迁移保留已有事件。`forked` 轮次结束原因与尚未发布的 V4 头版本变更一起记录兼容性确认。
本决策取代[仅按已结束轮次截取的 Controller 策略](../../archived/bug-fix/2026-09-11-session-controller-fork-turn-cut.md)。显式切点精确保留所选事件;省略切点时保留已完成的独立操作,包括手动压缩的替换事件,直到 core 所属的新轮次或排队输入事件开始。插件继续负责自己的括号关系;Controller 不对插件事件分类。
## Alternatives considered
**将切点移到已完成步骤。** 这会改变显式历史选择,丢失所需上下文。精确切点加已记录的结束事件保留该上下文。
**构建请求时修复。** 模型可见结果必须进入日志,因此 seed 在模型请求之前记录它们。
**修复已关闭步骤中的调用。** 这些失败原本属于父会话;这会将 fork 扩展为新的历史修复策略,偏离崩溃恢复行为。
**公开依赖 Store 的 fork 包装函数。** Store 的创建规则留在 Store;纯 seed helper 只需要事件类型和内部结束函数,避免增加公开入口或搬迁 Store 文件。
## Consequences
Fork 关闭所选开放尾部,不修复更早的畸形历史。插件拥有其未配对括号:不会合成 `compaction/end`。继承标记让 compaction 识别继承而来的陈旧 start。父子序号在切点后分离。 新 fork 的 `firstLifecycleSeq` 从子会话自有标记开始,因此 telemetry 包含合成结果与结束事件。恢复的 Session 从已存储前缀之后开始当前生命周期;仅凭持久化 fork 切点无法识别新的采集内容。聊天操作保留已完成轮次选择;更细的事件选择属于后端功能,不增加 side chat。
## Testing
Session 和 repair 测试覆盖精确切点、两种错误分类、已关闭失败步骤、空历史、插件括号和 surface 替换。Agent-loop 回归验证实际的下一次模型请求、结果保留、轮次编号及不重复执行工具。Host 测试覆盖实时与冷源会话、精确拒绝和排队输入隔离。V4 测试往返原始 fork 结果数据、拒绝畸形结果并验证嵌套 fork。无需密钥的 `fork-mid-turn` 浏览器场景通过 HTTP 对冷录制进行 fork,再通过输入框继续运行,并在预期输出中展示分支文案。
@@ -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/simplification/2026-07-28-remove-synthetic-log-only-turns.md
2026-07-28-remove-synthetic-log-only-turns.md: b35045d036dbb3ea18431b76cec2c41674de5607
2026-07-28-remove-synthetic-log-only-turns.zh.md: 3dbcd0d9b258f4ea9f4515ba009e75487a96c342
2026-07-28-remove-synthetic-log-only-turns.md: 073ac0888054c907087dd0c5f2d01b03a5e87455
2026-07-28-remove-synthetic-log-only-turns.zh.md: 67b24c7423ee37c40361cb937fdad42243362dfd
@@ -20,7 +20,7 @@ Core session invariants continue to enforce core-owned execution relations: turn
The title service appends `session/title` directly after its existing service, revision, cancellation, and live-session checks. The bundled model helper appends its literal `session/title-llm-request` record before dispatch. Persistence admits both through the bounded `session/event` path and drains them at ordinary checkpoints and lifecycle teardown; neither append forces a flush merely because it is between turns. A fallback, auxiliary request record, or accepted provider title may therefore appear after `turn/end` and before the next `turn/start`. Manual compaction uses the same between-turn capability for a `compaction/* { turn: null }` bracket, but explicitly flushes the closed attempt because `/compact` promises durability before releasing queued prompt admission.
`SessionStore.fork()` may end at any stable event position outside an open turn, not only at `turn/end`. This preserves standalone title and other plugin-owned log-only records in a default store fork while still rejecting a prefix cut through active execution. The [Session Controller fork decision](../bug-fix/2026-09-11-session-controller-fork-turn-cut.md) limits its completed-turn operation to the selected closing event.
`SessionStore.fork()` preserves standalone title and other plugin-owned log-only records. The [exact-event fork decision](../feature/2026-08-18-arbitrary-seq-session-fork.md) owns cut selection and child-owned closers for an open tail.
The historical [universal turn-enclosure decision](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md) remains useful only as the reason the synthetic mechanism was introduced. The [context-injection decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) established the current meaning: one turn represents one model-loop execution. The [queued manual compaction decision](../feature/2026-07-30-queued-manual-compaction.md) applies that rule to a durable multi-event bracket and owns its marker and admission semantics.
@@ -36,7 +36,7 @@ The historical [universal turn-enclosure decision](../../archived/architecture/2
## Verification
Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, plan-mode, PTC mode dispatch, and approval invariant companions reject their execution-scoped events when no turn is open; the compaction companion separately accepts a balanced `turn: null` manual bracket between turns and requires numeric owners to match an open turn. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. A JSONL round trip preserves a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol.
Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, plan-mode, PTC mode dispatch, and approval invariant companions reject their execution-scoped events when no turn is open; the compaction companion separately accepts a balanced `turn: null` manual bracket between turns and requires numeric owners to match an open turn. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. A JSONL round trip preserves a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol.
## Consequences
@@ -20,7 +20,7 @@ Status: implemented
标题服务会在完成既有的服务状态、修订、取消和活跃会话检查后,直接追加 `session/title`。随附模型辅助函数会在发起调用前追加其字面量 `session/title-llm-request` 记录。持久化通过有界 `session/event` 路径接纳两者,并在常规检查点与生命周期结束时排空;二者都不会仅因为位于轮次之间就强制刷写。因此,回退标题、辅助请求记录或已接受的提供方标题可以出现在 `turn/end` 之后、下一个 `turn/start` 之前。手动压缩(compaction)利用同一项轮次间能力记录 `compaction/* { turn: null }` 标记对,但会显式刷写已闭合的尝试,因为 `/compact` 承诺在放行排队中的提示词前完成持久化。
`SessionStore.fork()` 可以结束于开放轮次之外的任意稳定事件位置,而不限于 `turn/end`。这样,默认的 store fork 会保留独立标题和其他插件所属的纯日志记录,同时仍拒绝在活跃执行过程中截断前缀。[Session Controller 分叉决策](../bug-fix/2026-09-11-session-controller-fork-turn-cut.zh.md)将其已结束轮次操作限制在选中的结束事件处。
`SessionStore.fork()` 保留独立标题和其他插件所属的纯日志记录。[精确事件 fork 决策](../feature/2026-08-18-arbitrary-seq-session-fork.zh.md)负责切点选择与开放尾部的子会话自有结束事件。
历史上的[通用轮次封闭决策](../../archived/architecture/2026-06-15-turn-enclosure-invariant.md)如今只适合用于解释为何曾引入合成机制。[上下文注入决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.zh.md)确立了当前语义:一个轮次表示一次模型循环执行。[排队手动压缩决策](../feature/2026-07-30-queued-manual-compaction.zh.md)将该规则应用于持久多事件标记对,并拥有其标记与接纳语义。
@@ -36,7 +36,7 @@ Status: implemented
## 验证
核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、plan-mode、PTC mode 分发和审批的不变量配套组件会在没有开放轮次时拒绝其执行作用域事件;压缩配套组件则另外接受轮次之间平衡的 `turn: null` 手动标记对,并要求数字 owner匹配一个开放轮次。会话标题服务测试会在并发刷新、拒绝已脱离会话和接受最新修订的场景下,固定一个直接追加的回退事件。JSONL 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。
核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、plan-mode、PTC mode 分发和审批的不变量配套组件会在没有开放轮次时拒绝其执行作用域事件;压缩配套组件则另外接受轮次之间平衡的 `turn: null` 手动标记对,并要求数字 owner匹配一个开放轮次。会话标题服务测试会在并发刷新、拒绝已脱离会话和接受最新修订的场景下,固定一个直接追加的回退事件。JSONL 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。
## 后果
@@ -0,0 +1,42 @@
- button "System prompt":
- img
- img
- text: System prompt
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
- button "Copy":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
- text: Failed
- 'button "Read The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."':
- img
- text: "Read The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."
- text: Failed
- 'button "Tool call The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."':
- img
- text: "Tool call The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."
- button "System prompt":
- img
- img
- text: System prompt
- text: FORK_BRANCH_USER Summarize what this branch knows about the reads. {{clock}}
- button "Copy":
- img
- button "Thought for a while":
- text: Thought for a while
- img
- paragraph: FORK_BRANCH_FIRST this branch has no read results, so I would check what the parent completed before retrying. FORK_BRANCH_DONE.
- button "Copy":
- img
- button "Good response":
- img
- button "Bad response":
- img
- button "Branch into a new conversation":
- img
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
+188
View File
@@ -0,0 +1,188 @@
// Web e2e: fork a completed recording at its first tool/call over HTTP,
// verify both synthetic result texts, and continue the child through the composer.
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
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 type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, parseSeedFixture, seedSession,
watchConsole, webSnapshotMode, selectedSessionFixture, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/fork-mid-turn', import.meta.url))
// Borrowed read-only from its recorded-session owner.
const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'fork-mid-turn-web-e2e'
const PROMPT = 'FORK_BRANCH_USER Summarize what this branch knows about the reads.'
const FIRST_MARKER = 'FORK_BRANCH_FIRST'
const DONE_MARKER = 'FORK_BRANCH_DONE'
// The exact model-visible branch wordings (`openTurnClosers`, forked cause).
const OUTCOME_UNKNOWN_TEXT = 'The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
const NOT_STARTED_TEXT = 'The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
/** The child's one continuation stream, fully scripted (no recording). */
function continuationScript(): ReplayEntry[] {
const deltas = [
`${FIRST_MARKER} this branch has no read results, `,
`so I would check what the parent completed before retrying. ${DONE_MARKER}.`,
]
const response = deltas.join('')
const chunks: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'text' },
...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
{ type: 'usage', usage: { inputTokens: 512, outputTokens: 32 } },
{ type: 'finish', reason: { kind: 'stop' } },
]
return [{ kind: 'chunks', chunks }]
}
function toolResultText(event: SessionEvent<'tool/result'>): string {
return event.data.message.content[0].content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
describe('web e2e: exact-boundary fork seeds branch closers and continues', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let replayDir: string
let tripwire: ReturnType<typeof watchConsole>
let childId: SessionId
let forkAt: number
beforeAll(async () => {
replayDir = await mkdtemp(join(tmpdir(), 'dsh-fork-mid-turn-replay-'))
const replayOverride = join(replayDir, 'replay.override.json')
await writeFile(replayOverride, JSON.stringify(continuationScript()))
scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride,
// Decode timing is part of the rendered turn tail; keep it non-zero and
// deterministic in both isolated and parallel replay/refresh lanes.
paceMs: 5,
})
const fixture = await readFile(await selectedSessionFixture(SEED), 'utf8')
const firstCall = parseSeedFixture(fixture).events.find(event => event.type === 'tool/call')
if (firstCall === undefined) throw new Error('fork-mid-turn seed lacks a tool/call')
forkAt = firstCall.seq
await seedSession(scaffold, fixture, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
if (replayDir !== undefined) {
await rm(replayDir, { recursive: true, force: true })
.catch((error: unknown) => failures.push(error))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'fork-mid-turn e2e cleanup failed')
})
it.skipIf(MODE === 'record')('forks the cold source at its first tool/call and seeds forked closers', async () => {
const response = await scaffold.hostFetch('/api/session/fork', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request', rpcId: 'fork-mid-turn-exact', method: 'session/fork',
payload: { args: { request: { sessionId: SEED_ID, atSeq: forkAt } } },
}),
})
expect(response.ok).toBe(true)
const body = await response.json() as { result: { ok: boolean; value?: { sessionId: string } } }
expect(body.result.ok).toBe(true)
childId = SessionId(body.result.value!.sessionId)
const child = scaffold.ctx.sessions.get(childId)
if (child === undefined) throw new Error('fork did not publish a live child session')
expect(child.header.parentSession).toBe(SessionId(SEED_ID))
// inheritedEventCount counts only the copied parent prefix; the closers are child work.
expect(child.inheritedEventCount).toBe(forkAt + 1)
expect(child.snapshotEvents()[forkAt]?.type).toBe('tool/call')
const closers = child.snapshotEvents().slice(forkAt + 1, forkAt + 6)
expect(closers.map(event => event.type)).toEqual([
'session/end-seed', 'tool/result', 'tool/result', 'step/end', 'turn/end',
])
const [, dispatched, notStarted, , turnEnd] = closers
expect(dispatched?.type === 'tool/result' && dispatched.data.error?.code).toBe('TOOL_OUTCOME_UNKNOWN')
expect(dispatched?.type === 'tool/result' && toolResultText(dispatched)).toBe(OUTCOME_UNKNOWN_TEXT)
expect(notStarted?.type === 'tool/result' && notStarted.data.error?.code).toBe('TOOL_NOT_STARTED')
expect(notStarted?.type === 'tool/result' && toolResultText(notStarted)).toBe(NOT_STARTED_TEXT)
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'forked' })
}, 60_000)
it.skipIf(MODE === 'record')('continues the child through the composer as a resume of the branched log', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-fork-mid-turn'))
// The session-added frame published the child row; the list sorts by
// recency, so the just-forked child is the first session row after the
// group header (the seeded source stays last). The settled-turn assertion
// below proves the prompt really ran on the child.
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 15_000 }).toBe(3)
await page.locator('[role="treeitem"]').nth(1).click()
// The child renders the inherited prefix up to the cut.
await expect.poll(() => page.getByText('Use the read tool twice', { exact: false }).count(), { timeout: 15_000 })
.toBeGreaterThanOrEqual(1)
const composer = page.locator('[data-composer-input]').last()
await composer.waitFor({ timeout: 15_000 })
await writeComposerDraft(page, composer, PROMPT)
const settled = scaffold.whenTurnSettled(60_000)
await page.getByRole('button', { name: 'Send message', exact: true }).click()
await page.getByText(FIRST_MARKER, { exact: false }).last().waitFor({ timeout: 15_000 })
expect(await settled).toBe(childId)
await page.getByText(`${DONE_MARKER}.`, { exact: false }).last().waitFor({ timeout: 15_000 })
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
// The branch continuation is a resume over the seeded log: the synthetic
// closer ended turn 1, so the live turn is 2, and its first request logs
// a resume header rather than a fresh conversation header.
const child = scaffold.ctx.sessions.get(childId)
if (child === undefined) throw new Error('child session disappeared after its turn')
const live = child.snapshotEvents().filter(event => event.seq >= child.firstLiveSeq)
expect(live.filter(event => event.type === 'turn/start').map(event => event.data.turn)).toEqual([2])
expect(live.filter(event => event.type === 'request/header').map(event => event.data.reason)).toEqual(['resume'])
const reply = child.deriveMessages().at(-1)
expect(reply?.role).toBe('assistant')
expect(JSON.stringify(reply?.content)).toContain(DONE_MARKER)
}, 60_000)
it.skipIf(MODE === 'record')('matches the child conversation-flow aria golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-fork-mid-turn-aria'))
await page.getByText(`${DONE_MARKER}.`, { exact: false }).last().waitFor({ timeout: 10_000 })
const flow = page.locator('[data-chat-flow]')
await flow.getByText(/Ran for/).last().waitFor({ timeout: 10_000 })
const snapshot = (await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
it.skipIf(MODE === 'record')('kept the console clean and the fixture inventory closed', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
})
})
+1
View File
@@ -84,6 +84,7 @@
"tests/file-upload-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/open-in-app-ssh.e2e.ts",
"tests/fork-mid-turn.e2e.ts",
"tests/message-feedback.e2e.ts",
"tests/markdown-images.e2e.ts",
"tests/reference-composer.e2e.ts",
+2 -2
View File
@@ -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/event-producer-consumer.md
event-producer-consumer.md: 43dad3cdab75f7569cde6884c7b8f5b6be750650
event-producer-consumer.zh.md: ebd91068d3d8ea4ff3cd16c2fb3259e1e8d4036f
event-producer-consumer.md: db5fa01821a81c0d8c9aa53b4514cd1c05e0d00f
event-producer-consumer.zh.md: ee92b60f6d367292b41122bb0bc059f7405e476b
+9 -9
View File
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:578`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:584`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -54,10 +54,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:194`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:200`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
+9 -9
View File
@@ -23,11 +23,11 @@
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:578`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:584`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:592`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -56,10 +56,10 @@
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:194`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:200`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:72`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
+2 -2
View File
@@ -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/persistence-catalog.md
persistence-catalog.md: ff546fe1a7be96dce4026995b76ee3782df7e3ee
persistence-catalog.zh.md: 8b352447d2e8f2b37a85f108bd31c422dd0d3967
persistence-catalog.md: 0d53c0e7a9fc3f4c1d1e56b42b5842815ed19b26
persistence-catalog.zh.md: bd7810ceffe1edaca57505a63a5576bc354a3cfd
+59 -38
View File
@@ -73,7 +73,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover
| `event:tool/ptc-dispatch` | event | `b33142af8176323ffbb20762541be81c1d51b60a60887803836e0b146e5bef0c` | [`event:tool/ptc-dispatch`](#persistence-type-eventtoolptc-dispatch) |
| `event:tool/ptc-dispatch-start` | event | `ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93` | [`event:tool/ptc-dispatch-start`](#persistence-type-eventtoolptc-dispatch-start) |
| `event:tool/result` | event | `29af48b840d0cd9e48b6f50bf3b354f8f6340607c5b99220a48e74f60beac9e2` | [`event:tool/result`](#persistence-type-eventtoolresult) |
| `event:turn/end` | event | `bab768260853e13a7cf2e22e65af572a1e76a5b2c01f3cdc2ce09d39b87fb70b` | [`event:turn/end`](#persistence-type-eventturnend) |
| `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`event:turn/end`](#persistence-type-eventturnend) |
| `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`event:turn/start`](#persistence-type-eventturnstart) |
| `event:user/message` | event | `314765bdff29c7862fb6ce820f1773563ba3094a680d163ea21180a2591b8578` | [`event:user/message`](#persistence-type-eventusermessage) |
| `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`event:web/deepseek-search-llm-request`](#persistence-type-eventwebdeepseek-search-llm-request) |
@@ -152,7 +152,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}[T]
```
Sources: [`packages/core/session/src/types.ts:409`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:417`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:439`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:470`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:416`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:424`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:446`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:477`](../packages/core/session/src/types.ts)
## Events
@@ -279,7 +279,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:33`](../packages/inter
'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }
```
Source: [`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts)
<a id="assistantmessage--surface"></a>
@@ -309,7 +309,7 @@ Source: [`packages/core/session/src/types.ts:335`](../packages/core/session/src/
Types: [TokenUsage](subsystems/llm-streaming.md)
Source: [`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts)
### `command/*`
@@ -693,7 +693,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:47`](../packages/plan/plan-mode/s
'request/context': RequestContext
```
Source: [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:389`](../packages/core/session/src/types.ts)
<a id="requestheader--log-only"></a>
@@ -712,7 +712,7 @@ Source: [`packages/core/session/src/types.ts:382`](../packages/core/session/src/
}
```
Source: [`packages/core/session/src/types.ts:370`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts)
### `sandbox/*`
@@ -787,7 +787,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch
'session/end-seed': { inherited?: true }
```
Source: [`packages/core/session/src/types.ts:405`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:412`](../packages/core/session/src/types.ts)
<a id="sessiontitle--log-only"></a>
@@ -849,7 +849,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:81`](../packages/se
'step/end': { turn: number; step: number }
```
Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts)
<a id="stepstart--log-only"></a>
@@ -860,7 +860,7 @@ Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/
'step/start': { turn: number; step: number }
```
Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
### `subagent/*`
@@ -936,7 +936,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p
'system/message': { turn: number; step: number; message: SystemMessage }
```
Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts)
### `team/*`
@@ -1029,7 +1029,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s
Types: [ToolCallId](subsystems/core.md)
Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:348`](../packages/core/session/src/types.ts)
<a id="toolptc-dispatch--log-only"></a>
@@ -1110,7 +1110,7 @@ Source: [`packages/core/tools/src/types.ts:42`](../packages/core/tools/src/types
}
```
Source: [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts)
### `tool-workflow/*`
@@ -1190,7 +1190,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow
Types: [TurnEndReason](subsystems/session.md)
Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts)
<a id="turnstart--log-only"></a>
@@ -1206,7 +1206,7 @@ Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/
'turn/start': { turn: number }
```
Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts)
### `user/*`
@@ -1225,7 +1225,7 @@ Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/
'user/message': UserMessage
```
Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts)
### `web/*`
@@ -1790,7 +1790,7 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8`
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
Sources: [`packages/api/session-controller/src/types.ts:383`](../packages/api/session-controller/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:384`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -2072,7 +2072,7 @@ SHA-256: `c80c89da83c46db7a454f034c10f969e03cfb574859c7316e5bff683f5a14b0e`
SHA-256: `0c6412d3ba5e41adfaa6929c7d4093f2d52d8598c03f35844fc0d55e3070a3b1`
Sources: [`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -2350,7 +2350,7 @@ SHA-256: `1169b301aaabcd992657b93ec93750c43175dda81ada5cac086f14f2eaaeed6d`
SHA-256: `77b8491f946edfc8cc77ebae3ba49183759da31ba2dfa83d4c9e9e618c4b91d2`
Sources: [`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -2995,7 +2995,7 @@ SHA-256: `8ea13bcd983eab596dbfd41c5c2b62a0b318b71cd2a987a6fd752c6ced7e4489`
SHA-256: `2f0aefb72ab453c4bf21a6687f436775d359dc6f8f10410fd4c0615b20bd9d76`
Sources: [`packages/core/session/src/types.ts:370`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -3110,7 +3110,7 @@ SHA-256: `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e`
SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3`
Sources: [`packages/core/session/src/types.ts:405`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:412`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -3210,7 +3210,7 @@ SHA-256: `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05`
SHA-256: `ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545`
Sources: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -3343,7 +3343,7 @@ SHA-256: `69becfb6b2d3fd5da91518089454cae8ef33f1835637ec44dde35dd077fd4bae`
SHA-256: `2074f40221b76c75fd314246db558965dc3477dfb440bcc952b1eedc9778e366`
Sources: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -3613,7 +3613,7 @@ SHA-256: `3b1be838223869fe0a08210db85bf773796ed3f2373ac16555dff227cade0c48`
SHA-256: `e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1`
Sources: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:348`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -3657,7 +3657,7 @@ SHA-256: `ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93`
SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735`
Sources: [`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:24`](../packages/core/tools/src/types.ts)
Sources: [`packages/core/session/src/types.ts:370`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:24`](../packages/core/tools/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -3687,7 +3687,7 @@ SHA-256: `29af48b840d0cd9e48b6f50bf3b354f8f6340607c5b99220a48e74f60beac9e2`
SHA-256: `356aad27134a0e31af67c994ff8b9e4b91acfb6c4917b3bc1f7a62bbf82794d5`
Sources: [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -3748,7 +3748,7 @@ Sources: [`packages/util/values/src/index.ts:4`](../packages/util/values/src/ind
### `event:turn/end`
SHA-256: `bab768260853e13a7cf2e22e65af572a1e76a5b2c01f3cdc2ce09d39b87fb70b`
SHA-256: `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce`
| Property | Presence | Type |
|---|---|---|
@@ -3762,9 +3762,9 @@ SHA-256: `bab768260853e13a7cf2e22e65af572a1e76a5b2c01f3cdc2ce09d39b87fb70b`
### `event:turn/end.data`
SHA-256: `d3e447062a2847dad847dad5168a9508d92c484363dfc76fd82605aa7bd46285`
SHA-256: `e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654`
Sources: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -3882,6 +3882,18 @@ Sources: [`packages/core/session/src/types.ts:220`](../packages/core/session/src
|---|---|---|
| `kind` | required | `"interrupted"` |
<a id="persistence-type-eventturnenddatareason6"></a>
### `event:turn/end.data.reason[6]`
SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7`
Sources: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
| `kind` | required | `"forked"` |
<a id="persistence-type-eventturnstart"></a>
### `event:turn/start`
@@ -3902,7 +3914,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8`
SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d`
Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -4103,6 +4115,14 @@ SHA-256: `2da725e554cd89301d829ed9dc45a572c3cd1b93b5e91d0d71f007976d8a0863`
`"fork"`
<a id="persistence-type-forked"></a>
### `"forked"`
SHA-256: `b2245a906e13aa94f2d3cc6cf7b04cfa0267f7b7f4d468de1b00b1a314e54626`
`"forked"`
<a id="persistence-type-fresh"></a>
### `"fresh"`
@@ -4518,7 +4538,7 @@ One of:
SHA-256: `c55a891c7aac496de6ee64c91c1fb65728c977314013033e42c863428aada854`
Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -4532,7 +4552,7 @@ Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src
SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081`
Sources: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -4547,7 +4567,7 @@ Sources: [`packages/core/session/src/types.ts:242`](../packages/core/session/src
SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6`
Sources: [`packages/core/session/src/types.ts:261`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts)
One of:
@@ -4582,7 +4602,7 @@ Sources: [`packages/core/session/src/types.ts:93`](../packages/core/session/src/
SHA-256: `335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5`
Sources: [`packages/core/session/src/types.ts:439`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:446`](../packages/core/session/src/types.ts)
One of:
@@ -4609,18 +4629,19 @@ One of:
### `packages/core/session/src/types.ts#TurnEndReason`
SHA-256: `a5a8c15f08af1b0f26f40906e0797b6b0920fd01069e660becce35ab7fc48d77`
SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402`
Sources: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:231`](../packages/core/session/src/types.ts)
One of:
- [`event:turn/end.data.reason[0]`](#persistence-type-eventturnenddatareason0)
- [`event:turn/end.data.reason[6]`](#persistence-type-eventturnenddatareason6)
- [`event:turn/end.data.reason[5]`](#persistence-type-eventturnenddatareason5)
- [`event:assistant/attempt.data.stream[0][3].chunk[6].reason[2]`](#persistence-type-eventassistantattemptdatastream03chunk6reason2)
- [`event:turn/end.data.reason[3]`](#persistence-type-eventturnenddatareason3)
- [`event:turn/end.data.reason[1]`](#persistence-type-eventturnenddatareason1)
- [`event:turn/end.data.reason[2]`](#persistence-type-eventturnenddatareason2)
- [`event:turn/end.data.reason[0]`](#persistence-type-eventturnenddatareason0)
<a id="persistence-type-packagescoretoolssrcindextstoolrestriction"></a>
@@ -6219,7 +6240,7 @@ SHA-256: `7af85bf70d4eafce63e739adae38ea85501e68df3a0e22f97f3a0be90fd9e4cb`
SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825`
Sources: [`packages/core/session/src/types.ts:441`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:448`](../packages/core/session/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -6305,7 +6326,7 @@ SHA-256: `57f3f606ff8b6c84d9c443faace9d1dd10cca0354f37a4f515ef075f7c351b90`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
Sources: [`packages/api/session-controller/src/types.ts:378`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:379`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
+59 -38
View File
@@ -75,7 +75,7 @@
| `event:tool/ptc-dispatch` | event | `b33142af8176323ffbb20762541be81c1d51b60a60887803836e0b146e5bef0c` | [`event:tool/ptc-dispatch`](#persistence-type-eventtoolptc-dispatch) |
| `event:tool/ptc-dispatch-start` | event | `ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93` | [`event:tool/ptc-dispatch-start`](#persistence-type-eventtoolptc-dispatch-start) |
| `event:tool/result` | event | `29af48b840d0cd9e48b6f50bf3b354f8f6340607c5b99220a48e74f60beac9e2` | [`event:tool/result`](#persistence-type-eventtoolresult) |
| `event:turn/end` | event | `bab768260853e13a7cf2e22e65af572a1e76a5b2c01f3cdc2ce09d39b87fb70b` | [`event:turn/end`](#persistence-type-eventturnend) |
| `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`event:turn/end`](#persistence-type-eventturnend) |
| `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`event:turn/start`](#persistence-type-eventturnstart) |
| `event:user/message` | event | `314765bdff29c7862fb6ce820f1773563ba3094a680d163ea21180a2591b8578` | [`event:user/message`](#persistence-type-eventusermessage) |
| `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`event:web/deepseek-search-llm-request`](#persistence-type-eventwebdeepseek-search-llm-request) |
@@ -154,7 +154,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}[T]
```
来源:[`packages/core/session/src/types.ts:409`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:417`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:439`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:470`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:416`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:424`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:446`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:477`](../packages/core/session/src/types.ts)
## 事件
@@ -281,7 +281,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] }
```
来源:[`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts)
<a id="assistantmessage--surface"></a>
@@ -311,7 +311,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[TokenUsage](subsystems/llm-streaming.zh.md)
来源:[`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts)
### `command/*`
@@ -695,7 +695,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'request/context': RequestContext
```
来源:[`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:389`](../packages/core/session/src/types.ts)
<a id="requestheader--log-only"></a>
@@ -714,7 +714,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}
```
来源:[`packages/core/session/src/types.ts:370`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts)
### `sandbox/*`
@@ -789,7 +789,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'session/end-seed': { inherited?: true }
```
来源:[`packages/core/session/src/types.ts:405`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:412`](../packages/core/session/src/types.ts)
<a id="sessiontitle--log-only"></a>
@@ -851,7 +851,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'step/end': { turn: number; step: number }
```
来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts)
<a id="stepstart--log-only"></a>
@@ -862,7 +862,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'step/start': { turn: number; step: number }
```
来源:[`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts)
### `subagent/*`
@@ -938,7 +938,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'system/message': { turn: number; step: number; message: SystemMessage }
```
来源:[`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts)
### `team/*`
@@ -1031,7 +1031,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[ToolCallId](subsystems/core.zh.md)
来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:348`](../packages/core/session/src/types.ts)
<a id="toolptc-dispatch--log-only"></a>
@@ -1112,7 +1112,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}
```
来源:[`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts)
### `tool-workflow/*`
@@ -1192,7 +1192,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[TurnEndReason](subsystems/session.zh.md)
来源:[`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts)
<a id="turnstart--log-only"></a>
@@ -1208,7 +1208,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'turn/start': { turn: number }
```
来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts)
### `user/*`
@@ -1227,7 +1227,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'user/message': UserMessage
```
来源:[`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts)
### `web/*`
@@ -1792,7 +1792,7 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8`
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
来源:[`packages/api/session-controller/src/types.ts:383`](../packages/api/session-controller/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:384`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -2074,7 +2074,7 @@ SHA-256: `c80c89da83c46db7a454f034c10f969e03cfb574859c7316e5bff683f5a14b0e`
SHA-256: `0c6412d3ba5e41adfaa6929c7d4093f2d52d8598c03f35844fc0d55e3070a3b1`
来源:[`packages/core/session/src/types.ts:335`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -2352,7 +2352,7 @@ SHA-256: `1169b301aaabcd992657b93ec93750c43175dda81ada5cac086f14f2eaaeed6d`
SHA-256: `77b8491f946edfc8cc77ebae3ba49183759da31ba2dfa83d4c9e9e618c4b91d2`
来源:[`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:328`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -2997,7 +2997,7 @@ SHA-256: `8ea13bcd983eab596dbfd41c5c2b62a0b318b71cd2a987a6fd752c6ced7e4489`
SHA-256: `2f0aefb72ab453c4bf21a6687f436775d359dc6f8f10410fd4c0615b20bd9d76`
来源:[`packages/core/session/src/types.ts:370`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:377`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -3112,7 +3112,7 @@ SHA-256: `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e`
SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3`
来源:[`packages/core/session/src/types.ts:405`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:412`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -3212,7 +3212,7 @@ SHA-256: `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05`
SHA-256: `ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545`
来源:[`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:296`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -3345,7 +3345,7 @@ SHA-256: `69becfb6b2d3fd5da91518089454cae8ef33f1835637ec44dde35dd077fd4bae`
SHA-256: `2074f40221b76c75fd314246db558965dc3477dfb440bcc952b1eedc9778e366`
来源:[`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:317`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -3615,7 +3615,7 @@ SHA-256: `3b1be838223869fe0a08210db85bf773796ed3f2373ac16555dff227cade0c48`
SHA-256: `e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1`
来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:348`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -3659,7 +3659,7 @@ SHA-256: `ec38b5949af8eacaf00df002f4acbe344f934f8a061e9cdc65a52a48e5f6dd93`
SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735`
来源:[`packages/core/session/src/types.ts:363`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:24`](../packages/core/tools/src/types.ts)
来源:[`packages/core/session/src/types.ts:370`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:24`](../packages/core/tools/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -3689,7 +3689,7 @@ SHA-256: `29af48b840d0cd9e48b6f50bf3b354f8f6340607c5b99220a48e74f60beac9e2`
SHA-256: `356aad27134a0e31af67c994ff8b9e4b91acfb6c4917b3bc1f7a62bbf82794d5`
来源:[`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -3750,7 +3750,7 @@ SHA-256: `529e0ccd0e34079ab4ff21ed87f810cee0388246263bf22e8aba5986b56fae05`
### `event:turn/end`
SHA-256: `bab768260853e13a7cf2e22e65af572a1e76a5b2c01f3cdc2ce09d39b87fb70b`
SHA-256: `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce`
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -3764,9 +3764,9 @@ SHA-256: `bab768260853e13a7cf2e22e65af572a1e76a5b2c01f3cdc2ce09d39b87fb70b`
### `event:turn/end.data`
SHA-256: `d3e447062a2847dad847dad5168a9508d92c484363dfc76fd82605aa7bd46285`
SHA-256: `e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654`
来源:[`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -3884,6 +3884,18 @@ SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23`
|---|---|---|
| `kind` | 必需 | `"interrupted"` |
<a id="persistence-type-eventturnenddatareason6"></a>
### `event:turn/end.data.reason[6]`
SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7`
来源:[`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
| `kind` | 必需 | `"forked"` |
<a id="persistence-type-eventturnstart"></a>
### `event:turn/start`
@@ -3904,7 +3916,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8`
SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d`
来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -4105,6 +4117,14 @@ SHA-256: `2da725e554cd89301d829ed9dc45a572c3cd1b93b5e91d0d71f007976d8a0863`
`"fork"`
<a id="persistence-type-forked"></a>
### `"forked"`
SHA-256: `b2245a906e13aa94f2d3cc6cf7b04cfa0267f7b7f4d468de1b00b1a314e54626`
`"forked"`
<a id="persistence-type-fresh"></a>
### `"fresh"`
@@ -4520,7 +4540,7 @@ SHA-256: `96f4a9c81fc0f940ef71528a8cc66731ae4cf1f40d79680c5fe3c39f96d7723c`
SHA-256: `c55a891c7aac496de6ee64c91c1fb65728c977314013033e42c863428aada854`
来源:[`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:239`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -4534,7 +4554,7 @@ SHA-256: `c55a891c7aac496de6ee64c91c1fb65728c977314013033e42c863428aada854`
SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081`
来源:[`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -4549,7 +4569,7 @@ SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081`
SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6`
来源:[`packages/core/session/src/types.ts:261`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts)
以下类型之一:
@@ -4584,7 +4604,7 @@ SHA-256: `1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445`
SHA-256: `335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5`
来源:[`packages/core/session/src/types.ts:439`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:446`](../packages/core/session/src/types.ts)
以下类型之一:
@@ -4611,18 +4631,19 @@ SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0`
### `packages/core/session/src/types.ts#TurnEndReason`
SHA-256: `a5a8c15f08af1b0f26f40906e0797b6b0920fd01069e660becce35ab7fc48d77`
SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402`
来源:[`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:231`](../packages/core/session/src/types.ts)
以下类型之一:
- [`event:turn/end.data.reason[0]`](#persistence-type-eventturnenddatareason0)
- [`event:turn/end.data.reason[6]`](#persistence-type-eventturnenddatareason6)
- [`event:turn/end.data.reason[5]`](#persistence-type-eventturnenddatareason5)
- [`event:assistant/attempt.data.stream[0][3].chunk[6].reason[2]`](#persistence-type-eventassistantattemptdatastream03chunk6reason2)
- [`event:turn/end.data.reason[3]`](#persistence-type-eventturnenddatareason3)
- [`event:turn/end.data.reason[1]`](#persistence-type-eventturnenddatareason1)
- [`event:turn/end.data.reason[2]`](#persistence-type-eventturnenddatareason2)
- [`event:turn/end.data.reason[0]`](#persistence-type-eventturnenddatareason0)
<a id="persistence-type-packagescoretoolssrcindextstoolrestriction"></a>
@@ -6221,7 +6242,7 @@ SHA-256: `7af85bf70d4eafce63e739adae38ea85501e68df3a0e22f97f3a0be90fd9e4cb`
SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825`
来源:[`packages/core/session/src/types.ts:441`](../packages/core/session/src/types.ts)
来源:[`packages/core/session/src/types.ts:448`](../packages/core/session/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -6307,7 +6328,7 @@ SHA-256: `57f3f606ff8b6c84d9c443faace9d1dd10cca0354f37a4f515ef075f7c351b90`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
来源:[`packages/api/session-controller/src/types.ts:378`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:379`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -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/persistence-changes/2026-09-16-session-format-v4.md
2026-09-16-session-format-v4.md: 1148545e767802b6f6c275b1df3e3f323ea92609
2026-09-16-session-format-v4.zh.md: 47d4564610da2185ecba55fbc25225abc7c75ede
2026-09-16-session-format-v4.md: 83dbb637b19ee52163852220448f6593fa22fb47
2026-09-16-session-format-v4.zh.md: 82956ba5363b111fc946dc010aa691d189331098
@@ -9,7 +9,7 @@ English | [中文](2026-09-16-session-format-v4.zh.md)
## Summary
Advances the declared SessionHeader.version from 3 to 4 for the V4 integration writer. No declared event payload or envelope type changes.
Advances the declared SessionHeader.version from 3 to 4 for the V4 integration writer and adds the forked variant to turn/end.reason.
## Table of Contents
@@ -30,6 +30,10 @@ changes:
previous: "2026-09-11-initial"
after: "1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445"
decision: version-bump
- root: "event:turn/end"
previous: "2026-09-14-image-offload"
after: "0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce"
decision: version-bump
```
<a id="compatibility"></a>
@@ -39,12 +43,16 @@ The [V3-to-V4 migration](../../packages/session/session-format-v3-to-v4/README.m
Historical read opens prepare the result in memory. Write opens revalidate child membership and revisions before publishing the current successor beside unchanged predecessor files. Already-written V4 files do not rerun this incoming edge, so unreleased integration uses disposable homes. Delivery-generation checks keep historical acknowledgements from becoming active V4 watermarks. V3 readers refuse the newer generation; the [V3 schema reference](historical-formats/v3.md) preserves the outgoing declarations.
The same unreleased transition adds `forked` to `turn/end.reason`. Exact-cut forks append child-owned error results and closers after the inherited marker. V4 admits checked not-started fork results with deterministic branch-specific IDs and wording; released V0–V3 validators and recorded predecessor generations remain unchanged.
<a id="verification"></a>
## Verification
`pnpm exec vitest run packages/session/session-format-v3-to-v4/tests packages/session/session-format-catalog/tests packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts` passed 140 tests across nine files, with one skipped test. The suites cover catalog completion, inherited cuts, child-evidence refusal, and JSONL preparation/publication.
Before the writer change, `pnpm run verify-persistence-catalog` passed and `pnpm run verify-persistence-formats --archive 3` captured the V3 schema. The V4 header schema digest and its generated companion remain unchanged.
Before the writer change, `pnpm run verify-persistence-catalog` passed and `pnpm run verify-persistence-formats --archive 3` captured the V3 schema. The V4 header schema digest remains unchanged.
The focused Session, agent-loop, Session Controller, V4, chat-view, and compaction suites passed 1,523 tests across 63 files after integration of exact-cut forks. V4 fork tests retain original IDs and text across encoding, decoding, and restoration, reject malformed results, and validate nested inherited cuts.
<a id="dev-note"></a>
## Dev Note
@@ -81,6 +81,334 @@
}
]
}
},
{
"key": "event:turn/end",
"kind": "event",
"event": "turn/end",
"surface": false,
"digest": "0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce",
"schema": {
"root": 0,
"nodes": [
{
"kind": "object",
"properties": [
{
"name": "data",
"type": 1,
"optional": false
},
{
"name": "ignorable",
"type": 31,
"optional": true
},
{
"name": "seq",
"type": 16,
"optional": false
},
{
"name": "time",
"type": 16,
"optional": false
},
{
"name": "type",
"type": 32,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "reason",
"type": 2,
"optional": false
},
{
"name": "turn",
"type": 16,
"optional": false
}
],
"indices": []
},
{
"kind": "union",
"types": [
3,
5,
7,
9,
11,
13,
18
]
},
{
"kind": "object",
"properties": [
{
"name": "kind",
"type": 4,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "blocked"
},
{
"kind": "object",
"properties": [
{
"name": "kind",
"type": 6,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "completed"
},
{
"kind": "object",
"properties": [
{
"name": "kind",
"type": 8,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "forked"
},
{
"kind": "object",
"properties": [
{
"name": "kind",
"type": 10,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "interrupted"
},
{
"kind": "object",
"properties": [
{
"name": "kind",
"type": 12,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "max-tokens"
},
{
"kind": "object",
"properties": [
{
"name": "error",
"type": 14,
"optional": false
},
{
"name": "kind",
"type": 17,
"optional": false
}
],
"indices": []
},
{
"kind": "object",
"properties": [
{
"name": "code",
"type": 15,
"optional": false
},
{
"name": "message",
"type": 15,
"optional": false
},
{
"name": "offloadImages",
"type": 16,
"optional": true
},
{
"name": "providerRetryAfterMs",
"type": 16,
"optional": true
},
{
"name": "requestId",
"type": 15,
"optional": true
},
{
"name": "status",
"type": 16,
"optional": true
}
],
"indices": []
},
{
"kind": "primitive",
"type": "string"
},
{
"kind": "primitive",
"type": "number"
},
{
"kind": "literal",
"value": "error"
},
{
"kind": "object",
"properties": [
{
"name": "kind",
"type": 19,
"optional": false
},
{
"name": "reason",
"type": 20,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "aborted"
},
{
"kind": "union",
"types": [
21,
23,
25,
27,
29
]
},
{
"kind": "object",
"properties": [
{
"name": "kind",
"type": 22,
"optional": false
},
{
"name": "reason",
"type": 15,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "hook"
},
{
"kind": "object",
"properties": [
{
"name": "kind",
"type": 24,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "parent"
},
{
"kind": "object",
"properties": [
{
"name": "kind",
"type": 26,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "user"
},
{
"kind": "object",
"properties": [
{
"name": "kind",
"type": 28,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "disposed"
},
{
"kind": "object",
"properties": [
{
"name": "kind",
"type": 30,
"optional": false
}
],
"indices": []
},
{
"kind": "literal",
"value": "legacy"
},
{
"kind": "literal",
"value": true
},
{
"kind": "literal",
"value": "turn/end"
}
]
}
}
],
"types": []
@@ -9,7 +9,7 @@ kind: persistence-change
## 概述
将声明的 SessionHeader.version 从 3 推进到 4,用于 V4 集成写入器。事件载荷与信封的声明类型均未变化。
将 V4 集成写入方声明的 SessionHeader.version 从 3 推进到 4,并向 turn/end.reason 添加 forked 变体。
## 目录
@@ -30,6 +30,10 @@ changes:
previous: "2026-09-11-initial"
after: "1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445"
decision: version-bump
- root: "event:turn/end"
previous: "2026-09-14-image-offload"
after: "0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce"
decision: version-bump
```
<a id="compatibility"></a>
@@ -39,12 +43,16 @@ changes:
历史读取打开在内存中准备结果。写入打开在重新校验子项成员与修订后,将当前后继发布到未修改的前代文件旁。已写入的 V4 文件不会重新运行该迁入边,因此未发布集成使用可丢弃的 home。Delivery generation 校验防止历史确认成为有效的 V4 水位。V3 读取方拒绝更新的 generation;[V3 schema 参考](historical-formats/v3.zh.md)保留原声明。
同一个未发布迁移向 `turn/end.reason` 添加 `forked`。精确切点的 fork 在继承标记之后追加子会话自有的错误结果和结束事件。V4 接纳经过校验、使用确定性分支 ID 和文案的未启动 fork 结果;已发布的 V0–V3 校验器和已记录的前驱代际保持不变。
<a id="verification"></a>
## 验证
`pnpm exec vitest run packages/session/session-format-v3-to-v4/tests packages/session/session-format-catalog/tests packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts` 的九个文件共 140 个测试通过,一个测试跳过。这些测试覆盖 catalog 补全、继承切点、子日志证据拒绝,以及 JSONL 准备与发布。
写入器变更前,`pnpm run verify-persistence-catalog` 通过,且 `pnpm run verify-persistence-formats --archive 3` 已捕获 V3 schema。V4 header schema digest 及其生成的配套文件保持不变。
写入器变更前,`pnpm run verify-persistence-catalog` 通过,且 `pnpm run verify-persistence-formats --archive 3` 已捕获 V3 schema。V4 header schema digest 保持不变。
精确切点 fork 集成后,聚焦的 Session、agent-loop、Session Controller、V4、chat-view 和 compaction 测试共 63 个文件、1,523 个测试通过。V4 fork 测试确认编码、解码和恢复保留原始 ID 与文本,拒绝格式错误的结果,并验证嵌套继承切点。
<a id="dev-note"></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/persistence-changes/historical-formats/README.md
README.md: 35b09b11b41f80bfe138829099ceff98123ca4bc
README.zh.md: 409bfe99922b5689321072c75cfd599f75ad96f3
README.md: 795b3e686b4c1b164b09d9cdc8569f0be18ba72b
README.zh.md: d705c8ac40ba057176a58ad41c1937a7c2843eae
@@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant.
| 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 60 / 468 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 60 / 470 |
<!-- persistence-format-index:end -->
@@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式
| 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 60 / 468 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 60 / 470 |
<!-- persistence-format-index:end -->
+1013 -906
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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/subsystems/persistence.md
persistence.md: 7e18e9bbfec8c7aa81525da33ad3cffa4681ea73
persistence.zh.md: 8250fb93b20dc33c2df705fd2c7092bc8dea155c
persistence.md: 53a001f438793740a55acd5abf45536164e63031
persistence.zh.md: 9f3c18a82885df51f62cb2d9cda901d483ffd65e
+2 -2
View File
@@ -203,8 +203,8 @@ interface CreateSessionOptions {
readonly seed?: readonly SessionEvent[]
/**
* Exact fork-inherited prefix length when `meta.isSeeded` is true. The
* constructor seed is exactly this inherited prefix; the constructor
* appends the child-owned tagged marker at the cut.
* constructor appends the child-owned tagged marker at the cut unless
* the seed already includes it followed by child-owned fork closers.
*/
readonly inheritedEventCount?: SessionLogOffset
/**
+2 -2
View File
@@ -203,8 +203,8 @@ interface CreateSessionOptions {
readonly seed?: readonly SessionEvent[]
/**
* Exact fork-inherited prefix length when `meta.isSeeded` is true. The
* constructor seed is exactly this inherited prefix; the constructor
* appends the child-owned tagged marker at the cut.
* constructor appends the child-owned tagged marker at the cut unless
* the seed already includes it followed by child-owned fork closers.
*/
readonly inheritedEventCount?: SessionLogOffset
/**
+2 -2
View File
@@ -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/subsystems/session-telemetry.md
session-telemetry.md: f452dc6fa9064689664bcaed08205d30def822e6
session-telemetry.zh.md: 59c817cca945e3f972e30104104f44fb15006aa5
session-telemetry.md: a76eee81ef018cbd2c230debc6e2105e6e727c43
session-telemetry.zh.md: 90b09f4a2394822c9b738430f5c8e0e54cbce508
+2 -2
View File
@@ -55,7 +55,7 @@ interface SessionTelemetryRecord {
}
```
Every canonical [session event](session.md), including each `assistant/message` or `assistant/attempt` with its complete compact stream and every plugin-merged type the seam never heard of, passes through whole as one ordered ledger record. Process-local `agent/assistant-stream` frames do not enter this durable feed. A new Session object starts at its lifecycle boundary unless the backend selects `includeHistory`; re-adopting the same object resumes after its handoff cursor. Delivery is best-effort: the cursor marks handed-off, not delivered, and records can be lost (crash, reload window) or duplicated (new-object replay, SDK retries), so receivers dedupe ledger records on `(session.id, session.format_version, event.seq)`; ops records deliberately omit that identity — they are signals to alert on, not entries to sum, and tolerate duplicates instead.
Every canonical [session event](session.md), including each `assistant/message` or `assistant/attempt` with its complete compact stream and every plugin-merged type the seam never heard of, passes through whole as one ordered ledger record. Process-local `agent/assistant-stream` frames do not enter this durable feed. A new fork starts at its child-owned suffix, including the inherited marker and fork closers; restored Sessions start after their stored prefix, including restored forks. The backend can select `includeHistory` to include the complete prefix; re-adopting the same object resumes after its handoff cursor. Delivery is best-effort: the cursor marks handed-off, not delivered, and records can be lost (crash, reload window) or duplicated (new-object replay, SDK retries), so receivers dedupe ledger records on `(session.id, session.format_version, event.seq)`; ops records deliberately omit that identity — they are signals to alert on, not entries to sum, and tolerate duplicates instead.
## The sharing disclosure
@@ -80,7 +80,7 @@ type SessionTelemetryCapture = 'live' | 'on-demand'
interface SessionTelemetryCaptureOptions {
/** Follow live events, or wait for explicit capture; defaults to live. */
capture?: SessionTelemetryCapture
/** Include stored history before this lifecycle; defaults to false. */
/** Include inherited fork history and stored history from earlier lifecycles; defaults to false. */
includeHistory?: boolean
}
```
+2 -2
View File
@@ -55,7 +55,7 @@ interface SessionTelemetryRecord {
}
```
每条权威[会话事件](session.zh.md)都会完整透传为一条有序 ledger 记录,包括每个携带完整紧凑 stream 的 `assistant/message` 或 `assistant/attempt`,以及该 seam 从未听说过、由插件合并进来的类型。进程本地 `agent/assistant-stream` frame 不进入该持久 feed。新 Session 对象从其生命周期边界开始,除非后端选择 `includeHistory`;重新收养同一对象时会从 handoff 游标之后继续。投递是尽力而为的:游标标记的是「已交接」而非「已送达」,记录可能丢失(崩溃、重载窗口)也可能重复(新对象回放、SDK 重试),因此接收端对 ledger 记录基于 `(session.id, session.format_version, event.seq)` 去重;ops 记录刻意省略这类标识——它们是用于告警的信号,而非用于累加的条目,重复被容忍而非被去重。
每条权威[会话事件](session.zh.md)都会完整透传为一条有序 ledger 记录,包括每个携带完整紧凑 stream 的 `assistant/message` 或 `assistant/attempt`,以及该 seam 从未听说过、由插件合并进来的类型。进程本地 `agent/assistant-stream` frame 不进入该持久 feed。新 fork 从子会话自有后缀开始,包括继承标记和 fork 结束事件;恢复的 Session 从已存储前缀之后开始,包括恢复的 fork。后端可以选择 `includeHistory` 来包含完整前缀;重新收养同一对象时会从 handoff 游标之后继续。投递是尽力而为的:游标标记的是「已交接」而非「已送达」,记录可能丢失(崩溃、重载窗口)也可能重复(新对象回放、SDK 重试),因此接收端对 ledger 记录基于 `(session.id, session.format_version, event.seq)` 去重;ops 记录刻意省略这类标识——它们是用于告警的信号,而非用于累加的条目,重复被容忍而非被去重。
## 共享披露
@@ -80,7 +80,7 @@ type SessionTelemetryCapture = 'live' | 'on-demand'
interface SessionTelemetryCaptureOptions {
/** Follow live events, or wait for explicit capture; defaults to live. */
capture?: SessionTelemetryCapture
/** Include stored history before this lifecycle; defaults to false. */
/** Include inherited fork history and stored history from earlier lifecycles; defaults to false. */
includeHistory?: boolean
}
```
+2 -2
View File
@@ -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/subsystems/session.md
session.md: b509cc87ebfb276687aa4eb0072718b7b578347f
session.zh.md: 5bf663ca1218c229584a17265b049dc6143283c6
session.md: 0986121c41de29a71ac1e2200d1c6bdabe0178f3
session.zh.md: 8a896282ca3700661bde95ad26ea43e922040889
+33 -25
View File
@@ -458,29 +458,26 @@ declare class Session {
/** The session identity, derived from its durable header's single copy. */
get id(): SessionId;
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
* seed (0 without one). Events with smaller seq values entered through
* construction — replay, fork, or resume — and were never published on the
* `session/event` firehose (constructor seeds do not emit). This offset marks
* the constructor-input boundary for lifecycle ownership and persistence
* adoption; consumers that need complete canonical history still start at
* seq 0. Distinct from {@link inheritedEventCount}, the DURABLE
* fork-lineage cut: a resumed session's constructor seed is its full stored
* log, while the inherited count keeps the original fork value — this field is the
* in-process construction fact.
* The constructor seed length (0 without one), before any marker appended
* during construction. Seed events never publish on `session/event`. A
* marker appended before the store attaches occupies this seq without
* publishing either; otherwise this seq is available for the next append.
*
* Not persisted itself: a seeded session projects it into the log as the
* `session/end-seed` event, which is what a consumer reading STORED history
* reads. Locate the LAST such event, not necessarily one at this seq — a
* seed already ending in one is not re-marked, so reopening an untouched
* session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
* this field in-process: it is exact before the marker reaches storage.
*
* When this lifecycle appends the marker, it occupies this seq before the
* store attaches and therefore does not publish either. Otherwise this seq
* holds an ordinary published write.
* This in-process offset is not persisted. A fork seed can already contain
* the child's inherited marker and synthetic closers, so its child-owned
* history starts at {@link inheritedEventCount}, before this offset. A
* resumed Session's seed contains its full stored log, while its inherited
* count keeps the durable fork cut. Consumers needing complete canonical
* history start at seq 0.
*/
readonly firstLiveSeq: SessionLogOffset;
/**
* First event produced for this object lifecycle. A new fork includes its
* child-owned seed marker and closers; a restored Session starts after its
* complete stored prefix. This in-process capture offset is not persisted.
*/
readonly firstLifecycleSeq: SessionLogOffset;
/**
* Create a detached session by validating and snapshotting borrowed seed
* events and storage metadata.
@@ -698,6 +695,13 @@ interface TurnEndReasonMap {
* the events recorded before the crash remain intact.
*/
interrupted: { kind: 'interrupted' }
/**
* Fork-seed construction closed a turn that was still open at the fork
* boundary in the source session. Only fork seeds carry this marker — the
* loop never emits it — and the source events before the boundary remain
* intact in the child.
*/
forked: { kind: 'forked' }
}
```
@@ -834,8 +838,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote('rename') rename(request: SessionRenameRequest): Promise<SessionRenameValue>
/**
* Fork one cold-readable completed-turn prefix into a new Session.
* @param request - source Session and optional event anchor.
* Fork one cold-readable exact event prefix into a new Session. An omitted
* boundary selects the latest completed-turn prefix; an open cut receives
* synthetic fork closers.
* @param request - source Session and optional exact inclusive event boundary.
* @returns the new Session identity.
*/
@Remote('fork') fork(request: SessionForkRequest): Promise<SessionForkValue>
@@ -1030,10 +1036,12 @@ get(id: SessionId): Session | undefined
list(): Session[]
/**
* Create a live child session from a stable prefix of a live source.
* Create a live child session from an exact prefix of a live source.
* `boundary` is an inclusive source event seq; omitted means the source's
* current last event. The selected slice may end with a between-turn event
* but must not end inside an open turn.
* current last event. An open tail receives synthetic tool results and
* step/turn closers with the forked cause. Closed steps and turns remain
* unchanged, including any failed tool calls already missing results.
* `inheritedEventCount` counts only copied source events, excluding these closers.
*
* @param source - Live source session object or id.
* @param boundary - Inclusive source event seq to fork through; omitted means
+33 -25
View File
@@ -460,29 +460,26 @@ declare class Session {
/** The session identity, derived from its durable header's single copy. */
get id(): SessionId;
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
* seed (0 without one). Events with smaller seq values entered through
* construction — replay, fork, or resume — and were never published on the
* `session/event` firehose (constructor seeds do not emit). This offset marks
* the constructor-input boundary for lifecycle ownership and persistence
* adoption; consumers that need complete canonical history still start at
* seq 0. Distinct from {@link inheritedEventCount}, the DURABLE
* fork-lineage cut: a resumed session's constructor seed is its full stored
* log, while the inherited count keeps the original fork value — this field is the
* in-process construction fact.
* The constructor seed length (0 without one), before any marker appended
* during construction. Seed events never publish on `session/event`. A
* marker appended before the store attaches occupies this seq without
* publishing either; otherwise this seq is available for the next append.
*
* Not persisted itself: a seeded session projects it into the log as the
* `session/end-seed` event, which is what a consumer reading STORED history
* reads. Locate the LAST such event, not necessarily one at this seq — a
* seed already ending in one is not re-marked, so reopening an untouched
* session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
* this field in-process: it is exact before the marker reaches storage.
*
* When this lifecycle appends the marker, it occupies this seq before the
* store attaches and therefore does not publish either. Otherwise this seq
* holds an ordinary published write.
* This in-process offset is not persisted. A fork seed can already contain
* the child's inherited marker and synthetic closers, so its child-owned
* history starts at {@link inheritedEventCount}, before this offset. A
* resumed Session's seed contains its full stored log, while its inherited
* count keeps the durable fork cut. Consumers needing complete canonical
* history start at seq 0.
*/
readonly firstLiveSeq: SessionLogOffset;
/**
* First event produced for this object lifecycle. A new fork includes its
* child-owned seed marker and closers; a restored Session starts after its
* complete stored prefix. This in-process capture offset is not persisted.
*/
readonly firstLifecycleSeq: SessionLogOffset;
/**
* Create a detached session by validating and snapshotting borrowed seed
* events and storage metadata.
@@ -702,6 +699,13 @@ interface TurnEndReasonMap {
* the events recorded before the crash remain intact.
*/
interrupted: { kind: 'interrupted' }
/**
* Fork-seed construction closed a turn that was still open at the fork
* boundary in the source session. Only fork seeds carry this marker — the
* loop never emits it — and the source events before the boundary remain
* intact in the child.
*/
forked: { kind: 'forked' }
}
```
@@ -838,8 +842,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote('rename') rename(request: SessionRenameRequest): Promise<SessionRenameValue>
/**
* Fork one cold-readable completed-turn prefix into a new Session.
* @param request - source Session and optional event anchor.
* Fork one cold-readable exact event prefix into a new Session. An omitted
* boundary selects the latest completed-turn prefix; an open cut receives
* synthetic fork closers.
* @param request - source Session and optional exact inclusive event boundary.
* @returns the new Session identity.
*/
@Remote('fork') fork(request: SessionForkRequest): Promise<SessionForkValue>
@@ -1034,10 +1040,12 @@ get(id: SessionId): Session | undefined
list(): Session[]
/**
* Create a live child session from a stable prefix of a live source.
* Create a live child session from an exact prefix of a live source.
* `boundary` is an inclusive source event seq; omitted means the source's
* current last event. The selected slice may end with a between-turn event
* but must not end inside an open turn.
* current last event. An open tail receives synthetic tool results and
* step/turn closers with the forked cause. Closed steps and turns remain
* unchanged, including any failed tool calls already missing results.
* `inheritedEventCount` counts only copied source events, excluding these closers.
*
* @param source - Live source session object or id.
* @param boundary - Inclusive source event seq to fork through; omitted means
+2 -1
View File
@@ -27,7 +27,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
case 'blocked':
case 'error':
return 'end_turn'
/* v8 ignore next 2 -- TurnEndReason is closed and every member is handled above */
/* v8 ignore next 2 -- TurnEndReason is merge-extensible; every live-turn member is
* handled above, and seed-only variants (`forked`) never end an ACP prompt turn. */
default:
return 'end_turn'
}
@@ -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/api/session-controller/README.md
README.md: bc42c6520ddfb6af2bd68ab31d14caa424394c25
README.zh.md: 0a79ebd6558d4a5d3e6e8342f88810ae59bf5f70
README.md: 4c0a194c99c30ceb419655461bb543878da29e33
README.zh.md: 2715b2ffd1c24faa6580f27a01c0265628ee7876
+1 -1
View File
@@ -41,7 +41,7 @@ The Session object also carries local submission echoes: `session.beginSubmissio
The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent.
Fork copies history through the selected completed turn, including its `turn/end`. Events after that point, including queued input and model-setting changes, are excluded. An omitted or past-end anchor selects the last completed turn; an anchor inside an unfinished turn is rejected.
Fork copies the exact inclusive event prefix selected by `atSeq`, including a cut inside an open turn. The child records its inherited marker before synthetic fork results and closing events. Omitting `atSeq` selects the latest completed turn and its standalone tail, stopping before the next turn or queued input; a nonexistent event is rejected. The chat action selects a completed turn.
<a id="session-media-references"></a>
## Session media references
+1 -1
View File
@@ -41,7 +41,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。
分叉复制截至选中已结束轮次的历史,并包含其 `turn/end`。该位置之后的事件均被排除,包括排队输入和模型设置变更。省略锚点或锚点超出日志末尾时,选择最后一个已结束轮次;位于未结束轮次内的锚点会被拒绝。
Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在开放轮次内截取。子会话在合成的 fork 结果和结束事件之前记录继承标记。省略 `atSeq` 时选择最近已结束轮次及其独立尾部,在下一轮次或排队输入之前停止;不存在的事件会被拒绝。聊天操作选择已结束轮次。
<a id="session-media-references"></a>
## 会话媒体引用
@@ -80,12 +80,13 @@ export interface ISessions {
signal: AbortSignal,
): Promise<RemoteResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
/**
* Fork a session from a completed-turn prefix of the source; on resolution
* the child is in the list store and `open()` can target it.
* @param opts - source session id, the optional event seq anchoring the
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* Fork a session from an exact inclusive prefix of the source; on
* resolution the child is in the list store and `open()` can target it.
* @param opts - source session id, the optional exact inclusive boundary
* seq (a real event seq the caller already knows; a cut inside an open
* turn is balanced Host-side with synthetic closers, and omission selects
* the latest completed-turn prefix), and whether to increment an
* inherited durable title before resolving.
* @returns the child session id.
* @throws when the fork fails, or when a requested child-title rename fails after creation.
*/
@@ -524,11 +524,12 @@ export class SessionManager {
/**
* Contract session.fork; on success merge the child into summaries
* immediately (same synchronous-addressability guarantee as create). The
* child carries the source's history, so it is never blank; lineage rides
* parentSessionId so the list nests it under its source. A child published
* before Workspace attachment fails is also reconciled into the list.
* @param opts - source session and the optional seq anchoring the cut.
* immediately (same synchronous-addressability guarantee as create).
* Blankness starts provisionally true so the authoritative Host summary can
* preserve it or lower it after an exact cut before the first `turn/start`;
* lineage rides parentSessionId. A child published before Workspace
* attachment fails is also reconciled into the list.
* @param opts - source session and the optional exact inclusive boundary seq.
* @returns the fork result (the child session id).
*/
async fork(
@@ -544,7 +545,7 @@ export class SessionManager {
: workspaceAttachSessionId(result.error)
if (childId !== undefined) {
this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
sessionId: childId, updatedAt: Date.now(), running: false, blank: true,
parentSessionId: opts.sessionId,
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
} })
@@ -402,16 +402,14 @@ export class ClientSessions implements ISessions {
}
/**
* Fork a session from a completed-turn prefix of the source (same
* Fork a session from an exact inclusive prefix of the source (same
* synchronous-addressability guarantee as {@link ClientSessions.create}:
* on resolution the child is in the list store and open() can target it).
* @param opts - source session id, the optional event seq anchoring the
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* A fractional anchor floors to a real event seq: the frozen nodes of an
* interrupted turn carry flow-ordering seqs between two events, and the
* wire takes integers only.
* @param opts - source session id, the optional exact inclusive boundary
* seq (a real event seq the caller already knows; a cut inside an open
* turn is balanced Host-side with synthetic closers, and omission selects
* the latest completed-turn prefix), and whether to increment an
* inherited durable title before resolving.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
@@ -426,10 +424,7 @@ export class ClientSessions implements ISessions {
: undefined
const result = await this.manager.fork({
sessionId: opts.sessionId,
// Flooring lands inside the anchor's own turn (every turn opens with a
// turn/start), so the host's first-turn/end-at-or-after cut still ends
// on that turn — never clipped back to the previous one.
...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(Math.floor(opts.atSeq)) }),
...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(opts.atSeq) }),
})
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()
+29 -17
View File
@@ -14,6 +14,7 @@ import {
ReasoningEffortId, assistantStreamChunks, createUserMessage, freezeMessage,
} from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import { buildForkSeed } from '@deepseek-ai/dsh-session/fork'
import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
@@ -66,6 +67,22 @@ function hasPromptContent(content: readonly PromptContentCandidate[]): boolean {
return content.some(part => part.type !== 'text' || part.text.trim().length > 0)
}
/**
* Resolve the omitted-`atSeq` default to the latest completed-turn prefix,
* including standalone events before the next turn begins.
*/
function latestCompletedPrefixBoundary(events: readonly SessionEvent[]): SessionSeq | undefined {
const lastTurnEnd = events.findLast(event => event.type === 'turn/end')
if (lastTurnEnd === undefined) return undefined
let boundary = lastTurnEnd.seq
for (const next of events.slice(boundary + 1)) {
if (next.type === 'turn/start' || (next.type === 'user/message' && next.surfaceOp === 'append')
|| next.type === 'agent/inbox/spliced') break
boundary = next.seq
}
return boundary
}
/** Implements Session business commands delegated by the Session Controller Remote service. */
export class SessionCommandController {
/**
@@ -195,8 +212,10 @@ export class SessionCommandController {
}
/**
* Create a new ordinary Session from one completed-turn prefix.
* @param request - source Session and optional event anchor.
* Create a new ordinary Session from an exact event prefix. An explicit
* `atSeq` is the inclusive cut; an omitted value selects the latest
* completed-turn prefix. An open cut receives synthetic fork closers.
* @param request - source Session and optional exact event boundary.
* @returns the new Session identity.
*/
async fork(request: SessionForkRequest): Promise<SessionForkValue> {
@@ -223,24 +242,17 @@ export class SessionCommandController {
)
}
using source = observed
const lastSeq = source.events.at(-1)?.seq ?? -1
const anchoredBoundary = atSeq === undefined
? undefined
: source.events.find(event => event.type === 'turn/end' && event.seq >= atSeq)
const boundary = anchoredBoundary
?? (atSeq === undefined || atSeq > lastSeq
? source.events.findLast(event => event.type === 'turn/end')
: undefined)
if (boundary === undefined) {
const boundary = atSeq ?? latestCompletedPrefixBoundary(source.events)
if (boundary === undefined || source.events[boundary]?.seq !== boundary) {
throw new RemoteError(
'session/fork-unavailable',
atSeq !== undefined && atSeq <= lastSeq
? `session "${request.sessionId}" has not completed the turn containing event ${String(atSeq)}`
: `session "${request.sessionId}" has no completed turn to fork from`,
request.atSeq === undefined
? `session "${request.sessionId}" has no completed turn to fork from`
: `event ${String(request.atSeq)} does not exist in session "${request.sessionId}" (last seq: ${String(source.events.at(-1)?.seq ?? 'none')})`,
{ sessionId: request.sessionId },
)
}
const cut = SessionLogOffset(boundary.seq + 1)
const seed = buildForkSeed(source.events, boundary)
let workspace: Workspace | undefined
try {
workspace = await this.forkWorkspace(source.header)
@@ -257,8 +269,8 @@ export class SessionCommandController {
const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
await this.ctx.agents.create({
sessionId: childId,
seed: source.events.slice(0, cut),
inheritedEventCount: cut,
seed,
inheritedEventCount: SessionLogOffset(boundary + 1),
meta: {
...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }),
parentSession: source.header.id,
+4 -2
View File
@@ -339,8 +339,10 @@ export class SessionController extends TypertRemoteService {
}
/**
* Fork one cold-readable completed-turn prefix into a new Session.
* @param request - source Session and optional event anchor.
* Fork one cold-readable exact event prefix into a new Session. An omitted
* boundary selects the latest completed-turn prefix; an open cut receives
* synthetic fork closers.
* @param request - source Session and optional exact inclusive event boundary.
* @returns the new Session identity.
*/
@Remote('fork')
@@ -303,6 +303,7 @@ export interface SessionRenameValue {
/** Session fork request. */
export interface SessionForkRequest {
readonly sessionId: SessionId
/** Exact inclusive source event seq; omission selects the latest completed-turn prefix. */
readonly atSeq?: number
}
@@ -217,6 +217,10 @@ describe('Session fork failures', () => {
const controller = new SessionCommandController(ctx, controllerAgents(), '/default')
await expectFailure(controller.fork({ sessionId: source.id }), 'session/fork-unavailable')
await expect(controller.fork({ sessionId: source.id, atSeq: 0 })).rejects.toMatchObject({
code: 'session/fork-unavailable',
message: 'event 0 does not exist in session "empty-source" (last seq: none)',
})
await ctx.fiber.dispose()
})
@@ -590,8 +590,13 @@ describe('remaining branches', () => {
expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({
sessionId: S2,
parentSessionId: S1,
blank: false,
blank: true,
})])
manager.handleSessionAdded(summary(S2, { blank: true, parentSessionId: S1 }))
expect(manager.getListSnapshot().items[0]?.blank).toBe(true)
manager.handleSessionAdded(summary(S2, { blank: false, parentSessionId: S1 }))
expect(manager.getListSnapshot().items[0]?.blank).toBe(false)
})
it('reconciles a preallocated id after an ordinary transport failure', async ({ mock, remote }) => {
@@ -5,18 +5,23 @@ import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { ToolCallId, createMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import SessionStore, { TOOL_OUTCOME_UNKNOWN, SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
import {
createSessionTestRemote, installSessionReadTestServices, testSessionPersistence,
} from './test-remote.ts'
const sid = (id: string): SessionId => id as SessionId
const message = (text: string) => createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
})
function request<P>(payload: P): P {
return payload
@@ -90,7 +95,49 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, {
})
describe('sessions.fork', () => {
it.each(['message', 'turn-end', 'omitted', 'past-end'] as const)(
it('keeps the completed manual compaction checkpoint when atSeq is omitted', async () => {
const ctx = new Context()
try {
await mountAgentLoopTestDependencies(ctx)
const harness = await mountAgentLoopTestHarness(ctx)
await ctx.plugin(TokenMeter)
const compact = new BasicCompactionEngine(ctx, { auto: false })
const adapter = new MockAdapter([
textResponse('reply'), textResponse('checkpoint'), textResponse('continued'),
])
ctx.llm.registerAdapter(['mock'], adapter)
ctx.provide('workspaceRegistry', { list: () => [] } as never)
const source = await harness.create(sid('manual-compaction-source'), { provider: 'mock', model: 'mock' })
const originalPrompt = 'older conversation history '.repeat(60)
source.followup(message(originalPrompt))
await source.whenIdle()
expect(await compact.compactNow(source, new AbortController().signal)).not.toBeNull()
const compacted = source.session.snapshotEvents()
expect(compacted.at(-1)).toMatchObject({ type: 'compaction/end', data: { turn: null } })
source.inbox.append('next-turn', message('queued after compaction'))
const response = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp',
}).fork({ sessionId: source.id })
if (!response.ok) throw response.error
const child = ctx.agents.get(response.value.sessionId)!
expect(child.session.inheritedEventCount).toBe(compacted.length)
expect(child.session.snapshotEvents().slice(0, child.session.inheritedEventCount)).toEqual(compacted)
expect(child.session.deriveMessages()).toEqual(source.session.deriveMessages())
child.followup(message('continue from checkpoint'))
await child.whenIdle()
const requestText = JSON.stringify(adapter.requests.at(-1)?.messages)
expect(requestText).toContain('checkpoint')
expect(requestText).toContain('continue from checkpoint')
expect(requestText).not.toContain(originalPrompt)
expect(requestText).not.toContain('queued after compaction')
} finally {
await ctx.fiber.dispose()
}
})
it.each(['turn-end', 'omitted'] as const)(
'excludes the next user input when forking at %s', async (anchor) => {
const ctx = new Context()
try {
@@ -100,23 +147,17 @@ describe('sessions.fork', () => {
ctx.llm.registerAdapter(['mock'], adapter)
ctx.provide('workspaceRegistry', { list: () => [] } as never)
const source = await harness.create(sid('source'), { provider: 'mock', model: 'mock' })
const message = (text: string) => createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
})
source.followup(message('A'))
await source.whenIdle()
const boundary = source.session.snapshotEvents().at(-1)!.seq
if (anchor === 'message' || anchor === 'turn-end') {
if (anchor === 'turn-end') {
source.followup(message('B'))
await source.whenIdle()
} else {
source.inbox.append('next-turn', message('B'))
}
const original = source.session.snapshotEvents()
const atSeq = anchor === 'omitted' ? undefined
: anchor === 'past-end' ? source.session.seq + 1
: anchor === 'turn-end' ? boundary
: original.find(event => event.type === 'assistant/message')!.seq
const atSeq = anchor === 'omitted' ? undefined : boundary
const response = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp',
}).fork({ sessionId: source.id, ...(atSeq === undefined ? {} : { atSeq }) })
@@ -137,10 +178,10 @@ describe('sessions.fork', () => {
},
)
it('cuts at the anchored completed turn and records lineage and cwd', async () => {
it('cuts exactly at a completed turn and records lineage and cwd', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-source', 2)
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 1 }))
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 2 }))
expect(response.ok ? null : response.error).toBeNull()
if (!response.ok) return
const child = ctx.sessions.get(response.value.sessionId)
@@ -184,7 +225,7 @@ describe('sessions.fork', () => {
const response = await remote(ctx).fork(request({ sessionId: grandchild.id }))
expect(response.ok).toBe(true)
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
expect(attachSession).toHaveBeenCalledWith(response.value.sessionId)
expect(ctx.sessions.get(response.value.sessionId)?.header).toMatchObject({
@@ -239,7 +280,7 @@ describe('sessions.fork', () => {
const response = await remote(ctx).fork(request({ sessionId: sourceId }))
expect(response.ok).toBe(true)
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
expect(resume).not.toHaveBeenCalled()
expect(ctx.agents.get(sourceId)).toBeUndefined()
@@ -251,30 +292,62 @@ describe('sessions.fork', () => {
await ctx.fiber.dispose()
})
it('uses the last completed turn only for omitted and past-end anchors', async () => {
it('uses the latest completed prefix only when atSeq is omitted', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-tail', 2, 'open')
const proxy = remote(ctx)
const expectedTypes = [
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'turn/end',
'session/end-seed',
]
const omitted = await proxy.fork(request({ sessionId: source.id }))
expect(omitted.ok).toBe(true)
if (omitted.ok) {
expect(ctx.sessions.get(omitted.value.sessionId)?.snapshotEvents().map(event => event.type))
.toEqual(expectedTypes)
.toEqual([
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'turn/end',
'session/end-seed',
])
}
const pastEnd = await proxy.fork(request({ sessionId: source.id, atSeq: 999 }))
expect(pastEnd.ok).toBe(true)
if (pastEnd.ok) {
expect(ctx.sessions.get(pastEnd.value.sessionId)?.snapshotEvents().map(event => event.type))
.toEqual(expectedTypes)
}
expect(pastEnd).toMatchObject({
ok: false,
error: { code: 'session/fork-unavailable', details: { sessionId: source.id } },
})
if (!pastEnd.ok) expect(pastEnd.error.message).toMatch(/does not exist/)
await ctx.fiber.dispose()
})
it('rejects an omitted boundary when no turn has completed', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-never-completed', 0, 'open')
const response = await remote(ctx).fork(request({ sessionId: source.id }))
expect(response).toMatchObject({
ok: false,
error: { code: 'session/fork-unavailable', details: { sessionId: source.id } },
})
if (!response.ok) expect(response.error.message).toMatch(/no completed turn/)
await ctx.fiber.dispose()
})
it.each([false, true])('inherits standalone tail events with the next turn open: %s', async (openNextTurn) => {
const ctx = await composed()
try {
const source = await liveAgent(ctx, 'session-standalone-tail', 1)
source.append('session/title', { title: 'After the turn', messageSeqs: [], source: { kind: 'user' } })
const inherited = source.snapshotEvents()
if (openNextTurn) source.append('turn/start', { turn: 2 })
const response = await remote(ctx).fork({ sessionId: source.id })
if (!response.ok) throw response.error
const child = ctx.sessions.get(response.value.sessionId)!
expect(child.inheritedEventCount).toBe(inherited.length)
expect(child.snapshotEvents().slice(0, child.inheritedEventCount)).toEqual(inherited)
expect(child.snapshotEvents().slice(child.inheritedEventCount).map(event => event.type))
.toEqual(['session/end-seed'])
} finally {
await ctx.fiber.dispose()
}
})
it('rejects invalid fork anchors before reading or creating a Session', async () => {
const ctx = await composed()
const proxy = remote(ctx)
@@ -287,33 +360,128 @@ describe('sessions.fork', () => {
await ctx.fiber.dispose()
})
it('cuts through an aborted turn: stopped is closed, not open', async () => {
it('cuts before an aborted turn/end and synthesizes a forked closer', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-aborted', 1, 'aborted')
// What a stopped message's fork button anchors on: the frozen node sits
// one event before its turn/end, floored client-side to that event's seq.
const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
expect(response.ok).toBe(true)
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
const child = ctx.sessions.get(response.value.sessionId)
expect(child?.snapshotEvents().map(event => event.type)).toEqual([
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'session/end-seed', 'turn/end',
])
const closer = child?.snapshotEvents().at(-1)
expect(closer?.type === 'turn/end' && closer.data.reason).toEqual({ kind: 'forked' })
await ctx.fiber.dispose()
})
it('cuts exactly inside a live open turn and preserves the source', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-open', 1, 'open')
const boundary = source.snapshotEvents().at(-1)?.seq ?? 0
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary }))
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
const child = ctx.sessions.get(response.value.sessionId)
expect(child?.snapshotEvents().map(event => event.type)).toEqual([
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'session/end-seed', 'turn/end',
])
const closer = child?.snapshotEvents().at(-1)
expect(closer?.type === 'turn/end' && closer.data.reason).toEqual({ kind: 'forked' })
expect(child?.inheritedEventCount).toBe(boundary + 1)
expect(source.snapshotEvents().at(-1)?.type).toBe('user/message')
await ctx.fiber.dispose()
})
it('does not extend an explicit boundary through standalone tail events', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-exact', 1)
const boundary = source.snapshotEvents().at(-1)?.seq ?? 0
source.append('session/title', { title: 'after boundary', messageSeqs: [], source: { kind: 'fallback' } })
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary }))
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
expect(ctx.sessions.get(response.value.sessionId)?.snapshotEvents().map(event => event.type)).toEqual([
'turn/start', 'user/message', 'turn/end',
'turn/start', 'user/message', 'turn/end',
'session/end-seed',
'turn/start', 'user/message', 'turn/end', 'session/end-seed',
])
await ctx.fiber.dispose()
})
it('rejects an in-log anchor whose turn is still open', async () => {
it('repairs a cold open-step cut without resuming its Agent', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-open', 1, 'open')
const anchor = source.snapshotEvents().at(-1)?.seq ?? 0
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
expect(response).toMatchObject({
ok: false,
error: { code: 'session/fork-unavailable', details: { sessionId: source.id } },
const sourceId = sid('session-cold-mid-turn')
const callId = ToolCallId('cold-dangling')
const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id: sourceId, createdAt: 1, cwd: '/proj', isSeeded: false }
const events = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{
type: 'user/message',
seq: 2,
time: 3,
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{
type: 'assistant/message',
seq: 3,
time: 4,
data: {
stream: [],
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
},
surfaceOp: 'append',
},
{ type: 'tool/call', seq: 4, time: 5, data: { turn: 1, step: 1, callId, name: 'read', arguments: '{}' } },
{ type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
{
type: 'turn/end',
seq: 6,
time: 7,
data: { turn: 1, reason: { kind: 'error', error: { message: 'scheduler failed', code: 'UNKNOWN' } } },
},
] as SessionEvent[]
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events }),
}) as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const response = await remote(ctx).fork(request({ sessionId: sourceId, atSeq: 4 }))
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
expect(resume).not.toHaveBeenCalled()
const child = ctx.sessions.get(response.value.sessionId)
expect(child?.snapshotEvents().map(event => event.type)).toEqual([
'turn/start', 'step/start', 'user/message', 'assistant/message',
'tool/call', 'session/end-seed', 'tool/result', 'step/end', 'turn/end',
])
expect(child?.inheritedEventCount).toBe(5)
expect(child?.snapshotEvents().at(-3)).toMatchObject({
type: 'tool/result',
data: {
turn: 1,
step: 1,
message: { source: { callId } },
error: { code: TOOL_OUTCOME_UNKNOWN },
},
sourceEventSeqs: [4],
})
if (!response.ok) expect(response.error.message).toMatch(/has not completed/)
const closer = child?.snapshotEvents().at(-1)
expect(closer?.type === 'turn/end' && closer.data.reason).toEqual({ kind: 'forked' })
expect(child?.deriveMessages().at(-1)).toMatchObject({ source: { kind: 'tool', callId } })
await ctx.fiber.dispose()
})
@@ -343,7 +511,7 @@ describe('sessions.fork', () => {
})
source.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary.seq }))
expect(response.ok).toBe(true)
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
const child = ctx.agents.get(response.value.sessionId)
if (child === undefined) throw new Error('fork did not publish the child agent')
@@ -1010,13 +1010,12 @@ describe('fork', () => {
})
})
it('floors a fractional anchor to the real event seq the wire accepts', async ({ bench }) => {
it('sends the exact boundary seq verbatim: callers pass real event seqs', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 'source', cwd: '/work' }])
b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') }))
// The frozen node of an interrupted turn carries turnEnd.seq - 0.9.
await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child')
await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41 })).resolves.toBe('child')
expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: 'source', atSeq: 41 })
})
@@ -45,7 +45,9 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
text={assistantText(closing.blocks)}
time={closing.time}
clock="end"
onBranch={() => { forkAt(closing.finalNode.seq) }}
// The branch action owns boundary resolution: it sends the real
// turn/end seq it already has, and the Host cuts exactly there.
onBranch={() => { forkAt(data.seq) }}
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
className={css.actions}
extraActions={assistantActions}
@@ -1032,7 +1032,9 @@ describe('ChatView', () => {
expect(branchButtons).toHaveLength(1)
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBeNull()
fireEvent.click(branchButtons[0]!)
expect(h.forkAt).toHaveBeenCalledWith(1)
// The branch action sends the real turn/end seq (the exact inclusive
// Host boundary), not the assistant node seq.
expect(h.forkAt).toHaveBeenCalledWith(3)
})
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
@@ -1991,7 +1993,7 @@ describe('ChatView', () => {
expect(buttons).toHaveLength(1)
expect(buttons[0]!.getAttribute('aria-disabled')).toBeNull()
fireEvent.click(buttons[0]!)
expect(h.forkAt.mock.calls).toEqual([[2]])
expect(h.forkAt.mock.calls).toEqual([[3]])
})
it('disables fork when the indexed Turn has a later steering Node', () => {
@@ -24,7 +24,7 @@ import type {
StreamChunk,
TokenUsage,
} from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionStore, { buildForkSeed, Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import LlmRuntime from '@deepseek-ai/dsh-llm'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
@@ -477,6 +477,28 @@ describe('compactNow transaction and failure classification', () => {
expect(compact.calls).toHaveLength(1)
})
it('ignores an unmatched bracket a mid-turn fork seed cut open', async () => {
// A fork boundary inside the source's live compaction bracket: the seed
// keeps the unmatched compaction/start and balances only the turn with
// forked closers — never a synthetic compaction/end. The child's end-seed
// marker proves the inherited lock stale, so compaction is not blocked.
const { compact } = detachedService()
const original = closedConversation(2)
original.append('turn/start', { turn: 3 })
original.append('compaction/start', {
compactionId: CompactionId('forked-manual-compaction'),
turn: 3,
})
const seed = buildForkSeed(original.snapshotEvents(), original.snapshotEvents().at(-1)!.seq)
expect(seed.filter(event => event.type === 'compaction/end')).toEqual([])
expect(seed.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'forked' } } })
const child = Session.create(SessionId('forked-orphan'), seed)
const agent = fakeAgent(child, () => () => undefined)
await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
expect(compact.calls).toHaveLength(1)
})
it('scans a stale orphan independently of later repaired turn state', async () => {
const { compact } = detachedService()
const original = closedConversation(2)
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import LlmRuntime, { createUserMessage, ToolCallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmRuntime, { type Message, createUserMessage, ToolCallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools'
@@ -603,6 +603,149 @@ describe('turn numbering continues across seeded sessions', () => {
expect(turns).toEqual([2])
})
it('an agent over a mid-turn fork seed continues past the synthetic forked closer as a resume', async () => {
const adapter = new MockAdapter([textResponse('branched reply')])
const ctx = await harness(adapter)
// A source cut open mid-turn: turn 1 completed under a logged header, turn 2
// claimed its prompt but never closed.
const source = ctx.sessions.create(SessionId('mid-turn-source'))
source.append('turn/start', { turn: 1 })
source.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
source.append('request/header', {
header: { config: { provider: 'mock', model: 'mock' } },
reason: 'initial',
})
source.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
source.append('turn/start', { turn: 2 })
source.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'second' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const boundary = source.snapshotEvents().at(-1)!.seq
const child = ctx.sessions.fork(source, boundary, SessionId('mid-turn-child'))
const closer = child.snapshotEvents().at(-1)
expect(closer?.type === 'turn/end' && closer.data.reason).toEqual({ kind: 'forked' })
const { agent: forked } = await ctx.agents.create({
sessionId: SessionId('mid-turn-agent'), seed: child.snapshotEvents(),
inheritedEventCount: child.inheritedEventCount, meta: { isSeeded: true },
agentOptions: { provider: 'mock', model: 'mock' },
})
const turns: number[] = []
const headerReasons: string[] = []
ctx.on('session/event', (session, event) => {
if (session !== forked.session) return
if (event.type === 'turn/start') turns.push(event.data.turn)
if (event.type === 'request/header') headerReasons.push(event.data.reason)
})
forked.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
await waitForIdle(ctx, forked)
// The synthetic closer ended turn 2, so the branch's first live turn is 3,
// and its first request over the seeded log is a resume, not a new header.
expect(turns).toEqual([3])
expect(headerReasons).toEqual(['resume'])
const reply = forked.session.deriveMessages().at(-1)
expect(reply?.role).toBe('assistant')
expect(reply?.content).toEqual([{ type: 'text', text: 'branched reply' }])
})
})
/** Assert the call/result ordering delivered to the model, including missing results. */
function expectPairedTools(messages: readonly Message[]): void {
const pending = new Set<ToolCallId>()
for (const message of messages) {
if (pending.size > 0) expect(message.source.kind).toBe('tool')
for (const block of message.content) {
if (block.type === 'tool-call') {
expect(pending.has(block.id)).toBe(false)
pending.add(block.id)
} else if (block.type === 'tool-result') {
expect(pending.delete(block.toolCallId)).toBe(true)
}
}
}
expect([...pending]).toEqual([])
}
describe('forked tool history reaches the next model request', () => {
it.each([
['assistant/message', 0, 2],
['tool/call', 0, 2],
['tool/result', 0, 1],
['tool/result', 1, 0],
['step/end', 0, 0],
] as const)('cuts after %s[%i] and supplies %i missing results', async (type, occurrence, missing) => {
const first = toolCallResponse('call-first', 'first', {}).slice(0, -2)
const second = toolCallResponse('call-second', 'second', {}).map(chunk => (
'index' in chunk ? { ...chunk, index: 1 } : chunk
))
const adapter = new MockAdapter([
[...first, ...second], textResponse('parent complete'), textResponse('child complete'),
])
const ctx = await harness(adapter)
try {
await mountInvariants(ctx)
const executions: string[] = []
for (const name of ['first', 'second']) {
ctx.tools.register(defineContentToolFixture({
name, description: '', parameters: {},
execute: () => {
executions.push(name)
return Promise.resolve([{ type: 'text', text: `${name} result` }])
},
}))
}
const parent = await ctx.agentLoop.create(SessionId('paired-parent'), { provider: 'mock', model: 'mock' })
send(parent, 'run both tools')
await waitForIdle(ctx, parent)
const source = parent.session.snapshotEvents()
const boundary = source.filter(event => event.type === type)[occurrence]
if (boundary === undefined) throw new Error(`parent has no ${type}[${occurrence}]`)
expect(source.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
expectPairedTools(adapter.requests[1]!.messages)
const child = ctx.sessions.fork(parent.session, boundary.seq, SessionId('paired-child'))
expect(child.snapshotEvents().slice(0, boundary.seq + 1)).toEqual(source.slice(0, boundary.seq + 1))
const closers = child.snapshotEvents().slice(boundary.seq + 1, child.firstLiveSeq)
const results = closers.filter(event => event.type === 'tool/result')
expect(results).toHaveLength(missing)
expect(closers.filter(event => event.type === 'step/end')).toHaveLength(type === 'step/end' ? 0 : 1)
expect(closers.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'forked' } } })
for (const result of results) {
const text = result.data.message.content[0].content[0]
if (text?.type !== 'text') throw new Error('fork result has no text')
expect(text.text).toContain('The parent session may have')
}
const inheritedResults = source.slice(0, boundary.seq + 1).filter(event => event.type === 'tool/result')
const expectedResults = [...inheritedResults, ...results].map(event => event.data.message)
const { agent: forked } = await ctx.agents.create({
sessionId: SessionId('paired-child-agent'), seed: child.snapshotEvents(),
inheritedEventCount: child.inheritedEventCount, meta: { isSeeded: true },
agentOptions: { provider: 'mock', model: 'mock' },
})
send(forked, 'continue this branch')
await waitForIdle(ctx, forked)
const request = adapter.requests[2]
if (request === undefined) throw new Error('child never reached the adapter')
expectPairedTools(request.messages)
expect(request.messages.filter(message => message.source.kind === 'tool')).toEqual(expectedResults)
expect(expectedResults.map(message => message.source.callId)).toEqual([ToolCallId('call-first'), ToolCallId('call-second')])
expect(() => {
expectPairedTools(request.messages.filter(message => (
message.source.kind !== 'tool' || message.source.callId !== ToolCallId('call-second')
)))
}).toThrow()
expect(executions).toEqual(['first', 'second'])
expect(parent.session.snapshotEvents()).toEqual(source)
expect(forked.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'child complete' }])
} finally {
await ctx.fiber.dispose()
}
})
})
describe('discriminated SessionEvent narrows without casts', () => {
+5 -4
View File
@@ -48,10 +48,11 @@ function accountsForClaim(reason: TurnEndReason): boolean {
case 'interrupted':
case 'error':
return true
/* v8 ignore next 4 -- unreachable: the one unnamed built-in, `max-tokens`, requires a step,
* so its turn short-circuits as stepped before this call, and `TurnEndReasonMap` is
* merge-extensible, so a backend-added variant cannot be listed; an unnameable ending over
* consumed input must not read as success. */
/* v8 ignore next 5 -- unreachable: `max-tokens` requires a step and short-circuits
* before this call; `forked` exists only in constructor seed history, while production
* callers fold an operation-owned suffix. `TurnEndReasonMap` is merge-extensible, so a
* backend-added variant cannot be listed; an unnameable ending over consumed input must
* not read as success. */
default:
return true
}
+4 -5
View File
@@ -86,11 +86,10 @@ export interface CreateAgentOptions {
/** Exact fork-inherited prefix length when the session metadata sets `isSeeded`. */
readonly inheritedEventCount?: SessionLogOffset
/**
* Initial replay/fork history. A fork supplies a balanced completed-turn
* prefix of the parent's log. The complete seed must be contiguous from seq
* 0, carry only lossless-JSON data, and contain no open turn/step or dangling
* tool call. The factory passes it to the session's durable
* validator/snapshot boundary before publication.
* Initial replay/fork history, contiguous from seq 0 with lossless-JSON data.
* A fork supplies an exact parent prefix, its inherited marker, and closers
* for the open tail. Previously closed steps and turns remain unchanged.
* The factory validates and snapshots the seed before publication.
*/
readonly seed?: readonly SessionEvent[]
/** Per-agent options (model, …). */
+2 -2
View File
@@ -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/core/session/README.md
README.md: 3e120780c711a3e35088291ea33b883c9a3653c6
README.zh.md: 6dbb593e04a9e153693bf32533747a163c71d7cf
README.md: b853dfd6e0d4617cad7f1a635f3b69a19e24e162
README.zh.md: 6347aa5f346f148d0f077d7c8d8a263b9c7bc8b1
+4 -4
View File
@@ -63,7 +63,7 @@ Session log positions use two numeric types. `SessionSeq` identifies an existing
### Fork a session
`ctx.sessions.fork(source, boundary?, childSessionId?)` selects source events through an inclusive `boundary` seq (default: the current last event), requires the prefix to end outside an open turn, and creates a live child session with lineage metadata. A tool-time delegation that must branch mid-turn clips to a completed prefix instead.
`ctx.sessions.fork(source, boundary?, childSessionId?)` copies an exact inclusive event prefix (default: the last event) from a live source. `buildForkSeed` in `dsh-session/fork` places an inherited marker after the copied events, adds missing error tool results only for the open step, and closes that step and turn with a `forked` reason. Closed steps and turns remain unchanged, including historical missing results. The marker and closers belong to the child; `inheritedEventCount` counts only the copied prefix.
The logical `SessionHeader.isSeeded` field reports whether fork history exists without exposing a positional integer. `Session.inheritedEventCount` retains the exact checked `SessionLogOffset`; `ownEvents()` returns events at and after that cut, and `isOwnSeq(seq)` accepts only an existing child-owned position. A low-level seeded constructor must supply an explicit `seed` and `inheritedEventCount` because the constructor seed can contain child-owned setup events after the inherited prefix.
@@ -147,11 +147,11 @@ Appended surface entries are resent on later steps. A `replace` surface operatio
Appended surface entries preserve reusable prefixes. A `replace` operation invalidates reuse from the first shadowed message even though the underlying event log stays append-only.
### Crash-repair result
### Crash-repair and fork results
#### What the model sees
If recovery finds an assistant tool request with no durable `tool/call`, its synthetic `TOOL_NOT_STARTED` result says `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.` If a durable `tool/call` has no result, its `TOOL_OUTCOME_UNKNOWN` result says `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.`
If recovery finds an assistant tool request with no durable `tool/call`, its synthetic `TOOL_NOT_STARTED` result says `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.` If a durable `tool/call` has no result, its `TOOL_OUTCOME_UNKNOWN` result says `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.` Fork-generated results describe only the inherited records: the parent may have started or completed a call after the selected event. `TOOL_NOT_STARTED` means the prefix contains no start record; `TOOL_OUTCOME_UNKNOWN` means it contains a start but no result. Both tell the model to retry only read-only or idempotent operations without further checks; operations with side effects require external verification or user input. See the [fork decision](../../../.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.md).
#### Token effect
@@ -182,7 +182,7 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
These limits define when the session store needs special care. They are current package constraints, not a task backlog.
- **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the fork API.
- **Live-source Store API** — persisted-but-unloaded sources use the Host observation path. Fork closes only the open tail and does not repair missing results in previously closed steps.
- **`SESSION_FORMAT_VERSION` names the [current logical representation](../../../docs/session-format-status.md)** — the current reader rejects retired `header.system` and validates `system/message` payloads and protected-head rewrites. Historical headers and events belong to adjacent format packages; the [adjacent migration chain](../../session/session-format-catalog/README.md) converts supported history before constructing `Session`, and the write-open path publishes only the current-format successor. Equal-version unknown events require the envelope's explicit `ignorable` marker, which does not promise safe structural migration ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md)).
- **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.
- **No session tree beyond fork** — a pi-style entry tree over branched sessions is deferred unless a consumer needs more than boundary-based forking.
+5 -3
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
### 创建与检查会话
`ctx.sessions.create()` 构建绑定到调用方 fiber 的实时会话;`get(id)` 与 `list()` 查找会话,`fork()` 从实时会话的稳定前缀创建子会话。
`ctx.sessions.create()` 构建绑定到调用方 fiber 的实时会话;`get(id)` 与 `list()` 查找会话,`fork()` 从实时会话的精确前缀创建子会话。
```text
const session = ctx.sessions.create(sessionId, { meta: { cwd: '/workspace' } })
@@ -63,7 +63,7 @@ session.deriveMessages() // the derived model history
### 派生会话的 fork
`ctx.sessions.fork(source, boundary?, childSessionId?)` 选取截至 `boundary` 事件序号(含该事件)的源事件(默认:当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。必须在轮次中途分支的工具时委派会裁剪到已完成前缀。
`ctx.sessions.fork(source, boundary?, childSessionId?)` 从实时源会话复制包含切点的精确事件前缀(默认:最后一个事件)。`dsh-session/fork` 的 `buildForkSeed` 在复制事件之后放置继承标记,仅为开放步骤补缺失的错误工具结果,并以 `forked` 原因关闭步骤和轮次。已关闭的步骤与轮次保持原样,包括历史缺失结果。标记和结束事件属于子会话;`inheritedEventCount` 只统计复制的前缀。
逻辑 `SessionHeader.isSeeded` 字段报告是否存在 fork 历史,而不公开位置整数。`Session.inheritedEventCount` 保留经过校验的精确 `SessionLogOffset`;`ownEvents()` 返回从该切点开始的事件,`isOwnSeq(seq)` 只接受已存在且由子会话拥有的位置。底层带 seed 构造必须显式提供 `seed` 与 `inheritedEventCount`,因为构造 seed 可以在继承前缀之后包含子会话自有的设置事件。
@@ -153,6 +153,8 @@ session.deriveMessages() // the derived model history
如果恢复发现 assistant 工具请求没有持久 `tool/call`,其合成 `TOOL_NOT_STARTED` 结果内容为 `The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.`。如果持久 `tool/call` 没有结果,其 `TOOL_OUTCOME_UNKNOWN` 结果内容为 `The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.`。
Fork 生成的结果只描述继承记录:父会话可能已经在所选事件之后启动或完成调用。`TOOL_NOT_STARTED` 表示前缀中没有启动记录;`TOOL_OUTCOME_UNKNOWN` 表示有启动记录但没有结果。两者都要求模型仅对只读或幂等操作直接重试;有副作用的操作需要先验证外部状态或询问用户。参见 [fork 决策](../../../.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.zh.md)。
#### Token 影响
未受损会话的 token 增量为零。恢复时,每个修复后的调用都会添加保留的、针对具体风险的错误文本。
@@ -182,7 +184,7 @@ session.deriveMessages() // the derived model history
这些限制说明会话存储何时需要特别留意。它们是当前包约束,不是任务积压。
- **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;fork API 不支持对已持久化但未加载的会话进行 fork。
- **Store API 仅接受活跃源会话** — 已持久化但未加载的源会话通过 Host 观察路径处理。Fork 仅闭合开放尾部,不修复先前已闭合步骤中缺失的结果。
- **`SESSION_FORMAT_VERSION` 命名[当前逻辑表示](../../../docs/session-format-status.zh.md)**——当前读取器拒绝已退役的 `header.system`,并校验 `system/message` 载荷与受保护头节点的重写。历史 header 与事件归相邻格式包所有;[相邻迁移链](../../session/session-format-catalog/README.zh.md)在构造 `Session` 前转换受支持的历史,写打开只发布当前格式的后继代际。同版本未知事件要求信封显式带有 `ignorable` 标记,但这不保证结构迁移的安全性([机制](../../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md))。
- **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
- **fork 之外没有会话树**:基于分支会话的 pi 风格条目树被推迟,除非消费方需要超越基于边界的 forking 的能力。
+4
View File
@@ -26,6 +26,10 @@
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./fork": {
"types": "./lib/types/fork.d.ts",
"default": "./lib/types/fork.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json",
"./surface": {
+30
View File
@@ -0,0 +1,30 @@
/**
* Fork seed construction over an exact source-event prefix.
* @module @deepseek-ai/dsh-session/fork
*/
import { openTurnClosers } from './repair.ts'
import { SessionSeq } from './types.ts'
import type { SessionEvent, SessionSeq as SessionSeqType } from './types.ts'
/**
* Copy an inclusive event prefix, mark its inherited cut, and close its open tail with forked results
* and step/turn endings. Closed steps and turns are preserved unchanged.
* The caller validates that the boundary is an existing contiguous event seq;
* Session construction snapshots the borrowed events before publication.
*
* @param events - source log with contiguous seqs from zero.
* @param boundary - inclusive source event seq the child inherits through.
* @returns a new array retaining the source event objects, followed by synthetic
* closers outside the inherited prefix counted by `inheritedEventCount`.
*/
export function buildForkSeed(events: readonly SessionEvent[], boundary: SessionSeqType): SessionEvent[] {
const prefix = events.slice(0, boundary + 1)
prefix.push({
type: 'session/end-seed', seq: SessionSeq(boundary + 1),
// oxlint-disable-next-line typescript/no-non-null-assertion -- the caller validated this exact source event.
time: events[boundary]!.time,
data: { inherited: true },
})
return prefix.concat(openTurnClosers(prefix, { kind: 'forked' }))
}
+53 -55
View File
@@ -20,6 +20,9 @@ import { SurfaceManager, validateSessionEventData, validateSurfaceMetadata } fro
import type { SessionSurface, SessionMessageProjection } from './surface.ts'
import { foldRequestHeader } from './request-header.ts'
import { buildForkSeed } from './fork.ts'
export { buildForkSeed } from './fork.ts'
export * from './types.ts'
export { SessionPreparation } from './preparation.ts'
export type { SessionPreparationOptions } from './preparation.ts'
@@ -472,30 +475,27 @@ export class Session {
}
/**
* The first seq appended IN THIS PROCESS: the length of the constructor
* seed (0 without one). Events with smaller seq values entered through
* construction — replay, fork, or resume — and were never published on the
* `session/event` firehose (constructor seeds do not emit). This offset marks
* the constructor-input boundary for lifecycle ownership and persistence
* adoption; consumers that need complete canonical history still start at
* seq 0. Distinct from {@link inheritedEventCount}, the DURABLE
* fork-lineage cut: a resumed session's constructor seed is its full stored
* log, while the inherited count keeps the original fork value — this field is the
* in-process construction fact.
* The constructor seed length (0 without one), before any marker appended
* during construction. Seed events never publish on `session/event`. A
* marker appended before the store attaches occupies this seq without
* publishing either; otherwise this seq is available for the next append.
*
* Not persisted itself: a seeded session projects it into the log as the
* `session/end-seed` event, which is what a consumer reading STORED history
* reads. Locate the LAST such event, not necessarily one at this seq — a
* seed already ending in one is not re-marked, so reopening an untouched
* session leaves that event at a smaller seq than `firstLiveSeq`. Prefer
* this field in-process: it is exact before the marker reaches storage.
*
* When this lifecycle appends the marker, it occupies this seq before the
* store attaches and therefore does not publish either. Otherwise this seq
* holds an ordinary published write.
* This in-process offset is not persisted. A fork seed can already contain
* the child's inherited marker and synthetic closers, so its child-owned
* history starts at {@link inheritedEventCount}, before this offset. A
* resumed Session's seed contains its full stored log, while its inherited
* count keeps the durable fork cut. Consumers needing complete canonical
* history start at seq 0.
*/
readonly firstLiveSeq: SessionLogOffset
/**
* First event produced for this object lifecycle. A new fork includes its
* child-owned seed marker and closers; a restored Session starts after its
* complete stored prefix. This in-process capture offset is not persisted.
*/
readonly firstLifecycleSeq: SessionLogOffset
/**
* Create a detached session by validating and snapshotting borrowed seed
* events and storage metadata.
@@ -605,16 +605,22 @@ export class Session {
if (inheritedEventCount > this.log.length) {
throw new Error('session inherited event count exceeds its event log')
}
if (mode === 'snapshot' && this.header.isSeeded && inheritedEventCount !== this.log.length) {
throw new Error('seeded session constructor seed must equal its inherited prefix')
const seedMarker = this.log[inheritedEventCount]
const markedSeed = seedMarker?.type === 'session/end-seed' && seedMarker.data.inherited === true
if (mode === 'snapshot' && this.header.isSeeded && inheritedEventCount !== this.log.length && !markedSeed) {
throw new Error('seeded session constructor seed must equal its inherited prefix or mark its inherited cut')
}
if (markedSeed && this.log.slice(inheritedEventCount + 1).some(event => event.type === 'session/end-seed' && event.data.inherited === true)) {
throw new Error('session inherited event count must identify the final inherited marker')
}
this.inheritedEventCount = inheritedEventCount
this.firstLifecycleSeq = mode === 'snapshot' && this.header.isSeeded ? inheritedEventCount : this.firstLiveSeq
// A fresh seeded child always owns one tagged marker at its inherited cut,
// even when the copied prefix already ends in an ancestor marker. Restore
// retains that durable marker and appends only the ordinary resume marker.
if (seed !== undefined && mode === 'snapshot' && this.header.isSeeded) {
if (seed !== undefined && mode === 'snapshot' && this.header.isSeeded && !markedSeed) {
this.append('session/end-seed', { inherited: true })
} else if (seed !== undefined && this.log.at(-1)?.type !== 'session/end-seed') {
} else if (seed !== undefined && !(mode === 'snapshot' && this.header.isSeeded) && this.log.at(-1)?.type !== 'session/end-seed') {
this.append('session/end-seed', {})
}
}
@@ -879,16 +885,14 @@ export type SessionForkSource = Session | SessionId
* Rejection codes for session forking: the fork source id is unknown to the
* live store (`SESSION_NOT_FOUND`) or names a session object that is not the
* store's live instance (`SESSION_NOT_LIVE`); the requested child id is
* already taken (`SESSION_ALREADY_EXISTS`); the boundary is not a contiguous
* existing seq (`INVALID_BOUNDARY`); or the selected prefix ends inside an
* open turn (`OPEN_TURN`).
* already taken (`SESSION_ALREADY_EXISTS`); or the boundary is not a contiguous
* existing seq (`INVALID_BOUNDARY`).
*/
export type SessionForkErrorCode =
| 'SESSION_NOT_FOUND'
| 'SESSION_NOT_LIVE'
| 'SESSION_ALREADY_EXISTS'
| 'INVALID_BOUNDARY'
| 'OPEN_TURN'
/** Typed error for session fork rejections. */
export class SessionForkError extends Error {
@@ -1220,10 +1224,12 @@ export class SessionStore extends Service {
}
/**
* Create a live child session from a stable prefix of a live source.
* Create a live child session from an exact prefix of a live source.
* `boundary` is an inclusive source event seq; omitted means the source's
* current last event. The selected slice may end with a between-turn event
* but must not end inside an open turn.
* current last event. An open tail receives synthetic tool results and
* step/turn closers with the forked cause. Closed steps and turns remain
* unchanged, including any failed tool calls already missing results.
* `inheritedEventCount` counts only copied source events, excluding these closers.
*
* @param source - Live source session object or id.
* @param boundary - Inclusive source event seq to fork through; omitted means
@@ -1238,10 +1244,13 @@ export class SessionStore extends Service {
throw new SessionForkError(`session "${childSessionId}" already exists`, 'SESSION_ALREADY_EXISTS')
}
const liveSource = this._resolveForkSource(source)
const seed = this._forkSeed(liveSource, boundary)
// oxlint-disable-next-line typescript/no-deprecated -- Existing fork snapshot read; migration deferred.
const events = liveSource.snapshotEvents()
const resolved = this._forkBoundary(liveSource.id, events, boundary)
const seed = resolved === undefined ? [] : buildForkSeed(events, resolved)
return this.create(childSessionId, {
seed,
inheritedEventCount: SessionLogOffset(seed.length),
inheritedEventCount: SessionLogOffset(resolved === undefined ? 0 : resolved + 1),
meta: {
...liveSource.header.cwd !== undefined ? { cwd: liveSource.header.cwd } : {},
parentSession: liveSource.id,
@@ -1250,50 +1259,39 @@ export class SessionStore extends Service {
})
}
private _forkSeed(session: Session, requestedBoundary: SessionSeq | undefined): readonly SessionEvent[] {
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
const lastEvent = session.snapshotEvents().at(-1)
private _forkBoundary(
sessionId: SessionId, events: readonly SessionEvent[], requestedBoundary: SessionSeq | undefined,
): SessionSeq | undefined {
const lastEvent = events.at(-1)
let boundary: SessionSeq
if (requestedBoundary !== undefined) {
boundary = requestedBoundary
} else {
if (lastEvent === undefined) return []
if (lastEvent === undefined) return undefined
boundary = lastEvent.seq
}
if (!Number.isSafeInteger(boundary) || boundary < 0) {
throw new SessionForkError(
`fork boundary for session "${session.id}" must be a non-negative safe integer, got ${String(boundary)}`,
`fork boundary for session "${sessionId}" must be a non-negative safe integer, got ${String(boundary)}`,
'INVALID_BOUNDARY',
)
}
if (boundary >= session.seq) {
if (boundary >= events.length) {
const lastSeq = lastEvent?.seq
throw new SessionForkError(
`fork boundary ${boundary} does not exist in session "${session.id}" (last seq: ${lastSeq ?? 'none'})`,
`fork boundary ${boundary} does not exist in session "${sessionId}" (last seq: ${lastSeq ?? 'none'})`,
'INVALID_BOUNDARY',
)
}
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
const boundaryEvent = session.eventAt(boundary)
const boundaryEvent = events[boundary]
if (boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
throw new SessionForkError(
`fork boundary ${boundary} does not match a contiguous event seq in session "${session.id}"`,
`fork boundary ${boundary} does not match a contiguous event seq in session "${sessionId}"`,
'INVALID_BOUNDARY',
)
}
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
const events = session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(boundary + 1))
const lastTurnBoundary = events
.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
if (lastTurnBoundary?.type === 'turn/start') {
throw new SessionForkError(
`fork boundary ${boundary} in session "${session.id}" ends inside open turn ${lastTurnBoundary.data.turn}`,
'OPEN_TURN',
)
}
return events
return boundary
}
private _resolveForkSource(source: SessionForkSource): Session {
+61 -16
View File
@@ -1,7 +1,10 @@
/**
* Crash-recovery repair for an interrupted session log. It preserves a fully
* written final turn and supplies the missing tool, step, and turn boundaries
* needed to resume with a provider-valid transcript.
* Synthetic closer events that balance a session log whose tail turn is open.
* Two producers share the mechanism: crash recovery closes an interrupted
* persisted log on reload, and fork-seed construction closes a prefix cut
* inside the source's open turn. Both preserve every fully written event and
* close the unfinished step and turn. Calls in already closed steps remain
* unchanged, including any missing results.
* @module @deepseek-ai/dsh-session/repair
*/
@@ -17,16 +20,47 @@ export const TOOL_NOT_STARTED = 'TOOL_NOT_STARTED'
/** Recovery code for a recorded tool call whose completed outcome was not durably recorded. */
export const TOOL_OUTCOME_UNKNOWN = 'TOOL_OUTCOME_UNKNOWN'
/**
* Why an open tail turn is closed with synthetic events: `interrupted` is
* crash recovery over a persisted log; `forked` is a fork seed cut inside the
* source's open turn. The cause selects the synthetic `turn/end` reason, the
* model-visible wording of synthetic error tool results, and the
* deterministic synthetic message-id prefix. The error codes
* ({@link TOOL_NOT_STARTED} / {@link TOOL_OUTCOME_UNKNOWN}) are shared: both
* causes state the same fact about the call's recorded lifecycle.
*/
export type OpenTurnCloseCause = { readonly kind: 'interrupted' } | { readonly kind: 'forked' }
/** Model-visible wording of the synthetic error tool results, keyed by cause. */
const CLOSER_TEXT = {
interrupted: {
started: 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
notStarted: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
},
forked: {
started: 'The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
notStarted: 'The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
},
} as const
/**
* Return deterministic synthetic events that close an open tail turn. Unmatched
* calls receive error results first, followed by an open `step/end` and an
* interrupted `turn/end`; sequences continue the log and timestamps reuse the
* last real event. A balanced or empty log returns no events.
* calls in its open step receive error results, followed by `step/end` and a
* `turn/end` carrying the cause's reason. Calls in closed steps remain unchanged.
* Sequences continue the log and timestamps reuse the last real event. A balanced or empty log returns no
* events.
*
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
* Package-internal: each cause has exactly one owner, so external callers go
* through {@link interruptedTurnClosers} (persistence crash recovery) or
* `buildForkSeed` in `./fork.ts` (fork seeds) instead of selecting a cause.
*
* @param events - the log to scan: a valid committed prefix, possibly ending
* inside an open turn (a crash tail or a mid-turn fork cut).
* @param cause - why the turn is being closed; selects the `turn/end` reason
* and the model-visible wording of synthetic error tool results.
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
*/
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
export function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurnCloseCause): SessionEvent[] {
let openTurn: number | null = null
let openStep: number | null = null
// Reset at each turn boundary so earlier calls cannot leak into tail repair.
@@ -76,7 +110,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
}
}
// Balanced log (no crash mid-turn): nothing to close. An open turn implies
// Balanced log (no open tail turn): nothing to close. An open turn implies
// `events` is non-empty (its turn/start was logged), so `last` exists.
const last = events.at(-1)
if (openTurn === null || last === undefined) return []
@@ -88,12 +122,12 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
const time = last.time
const closers: SessionEvent[] = []
// Close calls before their step: providers reject dangling assistant calls,
// and Map insertion order preserves their transcript order.
// Close calls before their step; Map insertion order preserves transcript order.
const text = CLOSER_TEXT[cause.kind]
for (const [callId, { step, callSeq }] of pendingCalls) {
const started = callSeq !== undefined
const message: ToolResultMessage = deepFreeze({
id: brandString<MessageId>(`interrupted-tool-result-${callId}-${seq}`),
id: brandString<MessageId>(`${cause.kind}-tool-result-${callId}-${seq}`),
role: 'user',
source: { kind: 'tool', callId },
content: [{
@@ -102,9 +136,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
isError: true,
content: [{
type: 'text',
text: started
? 'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.'
: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
text: started ? text.started : text.notStarted,
}],
}],
})
@@ -130,6 +162,19 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
if (openStep !== null) {
closers.push({ type: 'step/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, step: openStep } })
}
closers.push({ type: 'turn/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, reason: { kind: 'interrupted' } } })
closers.push({ type: 'turn/end', seq: SessionSeq(seq++), time, data: { turn: openTurn, reason: { kind: cause.kind } } })
return closers
}
/**
* Crash-recovery entry point: synthetic closers that balance a persisted log
* whose tail turn was interrupted. Used by crash-recovery callers; fork
* seeds receive their `forked`-cause closers through `buildForkSeed` in
* `./fork.ts`, and cause selection stays internal to those two owners.
*
* @param events - the persisted log to scan, possibly ending inside an open turn.
* @returns the synthetic `interrupted` closer events to append after `events`; empty when the log is already balanced.
*/
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
return openTurnClosers(events, { kind: 'interrupted' })
}
+9 -2
View File
@@ -139,8 +139,8 @@ export interface CreateSessionOptions {
readonly seed?: readonly SessionEvent[]
/**
* Exact fork-inherited prefix length when `meta.isSeeded` is true. The
* constructor seed is exactly this inherited prefix; the constructor
* appends the child-owned tagged marker at the cut.
* constructor appends the child-owned tagged marker at the cut unless
* the seed already includes it followed by child-owned fork closers.
*/
readonly inheritedEventCount?: SessionLogOffset
/**
@@ -218,6 +218,13 @@ export interface TurnEndReasonMap {
* the events recorded before the crash remain intact.
*/
interrupted: { kind: 'interrupted' }
/**
* Fork-seed construction closed a turn that was still open at the fork
* boundary in the source session. Only fork seeds carry this marker — the
* loop never emits it — and the source events before the boundary remain
* intact in the child.
*/
forked: { kind: 'forked' }
}
/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
+216 -71
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage, ToolCallId , createMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionForkError, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import { createUserMessage, ToolCallId, createMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionForkError, SessionId, SessionLogOffset, SessionSeq, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SurfaceEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
@@ -172,7 +172,7 @@ describe('SessionStore.fork', () => {
const boundary = child.snapshotEvents().at(-1)
expect(boundary).toMatchObject({ type: 'session/end-seed' })
expect(boundary!.seq).toBeGreaterThan(open.seq)
expect(child.firstLiveSeq).toBe(open.seq + 1)
expect(child.firstLiveSeq).toBe(open.seq + 2)
expect(inherited(child).at(-1)).toMatchObject({ type: 'test/bracket-open', data: { id: 'op-1' } })
})
@@ -231,73 +231,6 @@ describe('SessionStore.fork', () => {
.toThrow(new SessionForkError('session "same-id" is not the live store instance', 'SESSION_NOT_LIVE'))
})
it('rejects selected slices whose boundary is inside an open turn', async () => {
const { ctx, sessions } = await setup()
const cases: [string, (session: Session) => number][] = [
['turn/start', (session) => {
session.append('turn/start', { turn: 1 })
return lastSeq(session)
}],
['step/start', (session) => {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
return lastSeq(session)
}],
['user/message', (session) => {
session.append('turn/start', { turn: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'open' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
return lastSeq(session)
}],
['assistant/message', (session) => {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
stream: [],
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'partial' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
return lastSeq(session)
}],
['tool/call', (session) => {
const callId = ToolCallId('call-open')
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
stream: [],
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
return lastSeq(session)
}],
]
for (const [lastType, build] of cases) {
const source = ctx.sessions.create(SessionId(`open-${lastType}`))
const boundary = build(source)
expect(() => sessions.fork(source, SessionSeq(boundary)))
.toThrow(new SessionForkError(`fork boundary ${boundary} in session "open-${lastType}" ends inside open turn 1`, 'OPEN_TURN'))
}
})
it('rejects a child session id that is already live with a typed fork error', async () => {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId('parent'))
@@ -318,3 +251,215 @@ describe('SessionStore.fork', () => {
.toThrow(new SessionForkError('session "child" already exists', 'SESSION_ALREADY_EXISTS'))
})
})
describe('fork boundaries inside an open turn', () => {
/** Open turn 2 after a closed turn 1 and return the source. */
async function openTurnSource(id: string): Promise<{ ctx: Context; sessions: SessionStore; source: Session }> {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId(id))
appendClosedTurn(source, 1, 'first')
source.append('turn/start', { turn: 2 })
return { ctx, sessions, source }
}
/** Append the open step + assistant tool request of turn 2. */
function appendToolRequest(session: Session, callId: ToolCallId): void {
session.append('step/start', { turn: 2, step: 1 })
session.append('assistant/message', {
stream: [],
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
}
it('closes an empty open turn with a synthetic turn/end {forked} outside seedLength', async () => {
const { sessions, source } = await openTurnSource('open-empty')
const child = sessions.fork(source, lastSeq(source), SessionId('open-empty-child'))
const seed = child.snapshotEvents()
expect(seed.slice(0, source.snapshotEvents().length)).toEqual(source.snapshotEvents())
expect(seed.at(-1)).toMatchObject({
type: 'turn/end',
data: { turn: 2, reason: { kind: 'forked' } },
})
// The closer is child work: `seedLength` names only events that exist in
// the parent's log, so telemetry receivers stitching a fork's prefix from
// the parent's stream on `(parent_id, seed_length)` read exactly the
// copied prefix.
expect(child.inheritedEventCount).toBe(source.snapshotEvents().length)
})
it('closes only the turn when the cut lands on a closed step boundary', async () => {
const { sessions, source } = await openTurnSource('open-step-closed')
source.append('step/start', { turn: 2, step: 1 })
source.append('assistant/message', {
stream: [],
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'step done' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
source.append('step/end', { turn: 2, step: 1 })
const child = sessions.fork(source, lastSeq(source), SessionId('open-step-closed-child'))
expect(child.snapshotEvents().slice(source.snapshotEvents().length + 1).map(e => e.type)).toEqual(['turn/end'])
})
it('answers an assistant call cut before its tool/call with a forked not-started error result', async () => {
const { sessions, source } = await openTurnSource('open-not-started')
appendToolRequest(source, ToolCallId('call-ns'))
const child = sessions.fork(source, lastSeq(source), SessionId('open-not-started-child'))
const closers = child.snapshotEvents().slice(source.snapshotEvents().length + 1)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data).toMatchObject({
message: {
id: expect.stringMatching(/^forked-tool-result-call-ns-/) as unknown,
source: { callId: ToolCallId('call-ns') },
},
error: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
})
// The synthetic result is part of the child's model-visible history, not a
// request-time patch: the derived messages end with the error tool result.
const derived = child.deriveMessages()
expect(derived.at(-1)).toMatchObject({
role: 'user',
source: { kind: 'tool', callId: ToolCallId('call-ns') },
content: [{ type: 'tool-result', isError: true }],
})
})
it('answers a dispatched call cut before its result with an unknown-outcome error citing the tool/call', async () => {
const { sessions, source } = await openTurnSource('open-unknown')
appendToolRequest(source, ToolCallId('call-uo'))
const call = source.append('tool/call', { turn: 2, step: 1, callId: ToolCallId('call-uo'), name: 'bash', arguments: '{}' })
const child = sessions.fork(source, lastSeq(source), SessionId('open-unknown-child'))
const closers = child.snapshotEvents().slice(source.snapshotEvents().length + 1)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data.error).toEqual({
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
})
expect((result as SurfaceEvent).sourceEventSeqs).toEqual([call.seq])
})
it('omits a failed assistant attempt from derived history', async () => {
const { sessions, source } = await openTurnSource('open-mid-stream')
source.append('step/start', { turn: 2, step: 1 })
source.append('assistant/attempt', {
turn: 2, step: 1, stream: [],
})
const child = sessions.fork(source, lastSeq(source), SessionId('open-mid-stream-child'))
expect(child.snapshotEvents().slice(source.snapshotEvents().length + 1).map(e => e.type)).toEqual(['step/end', 'turn/end'])
expect(child.deriveMessages().some(message => message.role === 'assistant')).toBe(false)
})
it.each([false, true])('preserves a closed step with an unanswered call (turn ended: %s)', async (turnEnded) => {
const { sessions, source } = await openTurnSource('dangling-closed-step')
appendToolRequest(source, ToolCallId('call-dangling'))
source.append('tool/call', {
turn: 2, step: 1, callId: ToolCallId('call-dangling'), name: 'bash', arguments: '{}',
})
source.append('step/end', { turn: 2, step: 1 })
if (turnEnded) {
source.append('turn/end', {
turn: 2, reason: { kind: 'error', error: { message: 'scheduler failed', code: 'UNKNOWN' } },
})
}
const copiedPrefix = source.snapshotEvents()
const child = sessions.fork(source)
expect(inherited(child).slice(0, copiedPrefix.length)).toEqual(copiedPrefix)
expect(child.snapshotEvents().slice(copiedPrefix.length + 1)).toEqual(turnEnded ? [] : [{
type: 'turn/end', seq: SessionSeq(copiedPrefix.length + 1), time: copiedPrefix.at(-1)!.time,
data: { turn: 2, reason: { kind: 'forked' } },
}])
expect(child.inheritedEventCount).toBe(copiedPrefix.length)
expect(child.deriveMessages()).toEqual(source.deriveMessages())
})
it('leaves a plugin bracket left open at the cut untouched (log-only locks belong to their owner)', async () => {
const { sessions, source } = await openTurnSource('open-bracket')
source.append('test/bracket-open', { id: 'op-live' })
const child = sessions.fork(source, lastSeq(source), SessionId('open-bracket-child'))
const closers = child.snapshotEvents().slice(source.snapshotEvents().length + 1)
expect(closers.map(e => e.type)).toEqual(['turn/end'])
// The unmatched bracket sits before the child's end-seed marker, so its
// owning plugin decides its staleness from that boundary.
expect(child.snapshotEvents()[child.inheritedEventCount]).toMatchObject({ type: 'session/end-seed', data: { inherited: true } })
})
})
describe('fork boundaries around a surface replacement', () => {
/**
* A source whose open turn 2 replaces turn 1's user message with a summary
* (the compaction surface pattern: a `user/message` with a range replace op).
*/
async function replacementSource(id: string): Promise<{
sessions: SessionStore
source: Session
replacedSeq: SessionSeq
replacementSeq: SessionSeq
}> {
const { ctx, sessions } = await setup()
const source = ctx.sessions.create(SessionId(id))
appendClosedTurn(source, 1, 'original request')
const replacedSeq = firstUserMessage(source.snapshotEvents()).seq
source.append('turn/start', { turn: 2 })
const replacement = source.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary of earlier work' }],
source: { kind: 'plugin', plugin: 'compaction' },
}), {
surfaceOp: { op: 'replace', startSeq: replacedSeq, endSeq: replacedSeq },
sourceEventSeqs: [replacedSeq],
})
return { sessions, source, replacedSeq, replacementSeq: replacement.seq }
}
it('derives the original message for a cut before the replacement', async () => {
const { sessions, source, replacementSeq } = await replacementSource('replace-before')
const child = sessions.fork(source, SessionSeq(replacementSeq - 1), SessionId('replace-before-child'))
expect(child.deriveMessages().map(message => message.content)).toEqual([
[{ type: 'text', text: 'original request' }],
])
})
it('derives the compacted surface for a cut after the replacement', async () => {
const { sessions, source, replacementSeq } = await replacementSource('replace-after')
const child = sessions.fork(source, replacementSeq, SessionId('replace-after-child'))
expect(child.deriveMessages().map(message => message.content)).toEqual([
[{ type: 'text', text: 'summary of earlier work' }],
])
expect(child.snapshotEvents().at(-1)).toMatchObject({
type: 'turn/end',
data: { turn: 2, reason: { kind: 'forked' } },
})
})
})
+111 -28
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { ToolCallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { interruptedTurnClosers as repairInterruptedTurn, SessionSeq, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import { openTurnClosers as closeOpenTurn, type OpenTurnCloseCause } from '../src/repair.ts'
import type { SessionEvent as LogicalSessionEvent, SurfaceEvent } from '../src/index.ts'
interface SessionEvent {
@@ -16,6 +17,11 @@ function interruptedTurnClosers(events: readonly SessionEvent[]): LogicalSession
return repairInterruptedTurn(events as unknown as readonly LogicalSessionEvent[])
}
function openTurnClosers(events: readonly SessionEvent[], cause: OpenTurnCloseCause): LogicalSessionEvent[] {
for (const event of events) SessionSeq(event.seq)
return closeOpenTurn(events as unknown as readonly LogicalSessionEvent[], cause)
}
/**
* Unit coverage for the crash-recovery closer synthesis. The persistence
* contract exercises it end-to-end through the real JSONL provider; these tests pin the
@@ -28,26 +34,28 @@ function interruptedTurnClosers(events: readonly SessionEvent[]): LogicalSession
const userTurnStart = (turn: number, seq: number): SessionEvent =>
({ type: 'turn/start', seq, time: seq, data: { turn } })
describe('interruptedTurnClosers', () => {
const causes: OpenTurnCloseCause[] = [{ kind: 'interrupted' }, { kind: 'forked' }]
describe.each(causes)('openTurnClosers (cause %o)', (cause) => {
it('returns nothing for a balanced log (ends on turn/end)', () => {
const balanced: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
]
expect(interruptedTurnClosers(balanced)).toEqual([])
expect(openTurnClosers(balanced, cause)).toEqual([])
})
it('returns nothing for an empty log', () => {
expect(interruptedTurnClosers([])).toEqual([])
expect(openTurnClosers([], cause)).toEqual([])
})
it('closes an open turn with no open step (turn/end {interrupted} only)', () => {
it('closes an open turn with no open step (turn/end with the cause reason only)', () => {
const events: SessionEvent[] = [userTurnStart(1, 0)]
const closers = interruptedTurnClosers(events)
const closers = openTurnClosers(events, cause)
expect(closers.map(e => e.type)).toEqual(['turn/end'])
const end = closers[0]!
expect(end.seq).toBe(1)
expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' })
expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: cause.kind })
})
it('closes an open step before the turn (step/end then turn/end)', () => {
@@ -55,7 +63,7 @@ describe('interruptedTurnClosers', () => {
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
]
const closers = interruptedTurnClosers(events)
const closers = openTurnClosers(events, cause)
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
expect(closers.map(e => e.seq)).toEqual([2, 3])
})
@@ -79,7 +87,7 @@ describe('interruptedTurnClosers', () => {
}),
} },
]
const closers = interruptedTurnClosers(events)
const closers = openTurnClosers(events, cause)
// tool/result (for the orphaned call) → step/end → turn/end, contiguous seqs.
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
@@ -88,14 +96,12 @@ describe('interruptedTurnClosers', () => {
turn: 2,
step: 1,
message: {
id: `${cause.kind}-tool-result-call-1-3`,
source: { callId: ToolCallId('call-1') },
content: [{ isError: true }],
},
error: { code: TOOL_NOT_STARTED },
})
expect(result.type === 'tool/result' && result.data.message.content[0].content).toEqual([{
type: 'text', text: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
}])
})
it('does NOT synthesize a result for a tool-call that already has one', () => {
@@ -125,11 +131,11 @@ describe('interruptedTurnClosers', () => {
} },
]
// The call is answered, so only the open step + turn need closing.
const closers = interruptedTurnClosers(events)
const closers = openTurnClosers(events, cause)
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
it('does NOT synthesize a result after the owning step already closed', () => {
it('preserves an unanswered assistant call after the owning step already closed', () => {
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
@@ -149,14 +155,15 @@ describe('interruptedTurnClosers', () => {
{ type: 'step/end', seq: 3, time: 3, data: { turn: 2, step: 1 } },
]
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['turn/end'])
expect(closers[0]?.seq).toBe(4)
const closers = openTurnClosers(events, cause)
expect(closers).toEqual([{
type: 'turn/end', seq: 4, time: 3, data: { turn: 2, reason: cause },
}])
})
it('synthesizes results only for the still-open turn, not a committed earlier turn', () => {
// Turn 1 completed with its own tool call+result (balanced). Turn 2 crashed
// with an unanswered call. Only turn 2's call must get a synthetic result.
// Turn 1 completed with its own tool call+result (balanced). Turn 2 was cut
// open with an unanswered call. Only turn 2's call must get a synthetic result.
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
@@ -199,7 +206,7 @@ describe('interruptedTurnClosers', () => {
}),
} },
]
const closers = interruptedTurnClosers(events)
const closers = openTurnClosers(events, cause)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data.message.source.callId).toBe('new-call')
@@ -223,7 +230,7 @@ describe('interruptedTurnClosers', () => {
},
}),
} },
// call-a got answered before the crash; call-b did not.
// call-a got answered before the cut; call-b did not.
{ type: 'tool/result', seq: 3, time: 3, data: {
turn: 1, step: 1,
message: createToolResultMessage({
@@ -233,7 +240,7 @@ describe('interruptedTurnClosers', () => {
}),
} },
]
const closers = interruptedTurnClosers(events)
const closers = openTurnClosers(events, cause)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data.message.source.callId).toBe('call-b')
@@ -258,7 +265,7 @@ describe('interruptedTurnClosers', () => {
} },
{ type: 'tool/call', seq: 3, time: 3, data: { turn: 1, step: 1, callId: ToolCallId('call-1'), name: 'bash', arguments: '{}' } },
]
const closers = interruptedTurnClosers(events)
const closers = openTurnClosers(events, cause)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect((result as SurfaceEvent).surfaceOp).toBe('append')
@@ -266,11 +273,6 @@ describe('interruptedTurnClosers', () => {
expect(result.type === 'tool/result' && result.data.error).toEqual({
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
})
if (result.type !== 'tool/result' || result.data.message.content[0].content[0]?.type !== 'text') {
throw new Error('expected a text tool result')
}
expect(result.data.message.content[0].content[0].text).toContain('retry only if the operation is read-only or idempotent')
expect(result.data.message.content[0].content[0].text).toContain('first verify external state or ask the user')
})
it('handles tool/call without a matching assistant/message entry gracefully', () => {
@@ -281,8 +283,89 @@ describe('interruptedTurnClosers', () => {
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'tool/call', seq: 2, time: 2, data: { turn: 1, step: 1, callId: ToolCallId('orphan'), name: 'bash', arguments: '{}' } },
]
const closers = interruptedTurnClosers(events)
const closers = openTurnClosers(events, cause)
// No pending calls → no synthetic tool/result, just step/end + turn/end.
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
})
describe('openTurnClosers model-visible wording', () => {
/** An open turn with one unanswered call; `started` also logs its tool/call. */
function openCall(started: boolean): SessionEvent[] {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: ToolCallId('call-1'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
]
if (started) {
events.push({ type: 'tool/call', seq: 3, time: 3, data: { turn: 1, step: 1, callId: ToolCallId('call-1'), name: 'bash', arguments: '{}' } })
}
return events
}
function resultText(closers: LogicalSessionEvent[]): string {
const result = closers[0]
if (result?.type !== 'tool/result' || result.data.message.content[0].content[0]?.type !== 'text') {
throw new Error('expected a text tool result')
}
return result.data.message.content[0].content[0].text
}
// The exact wording is model-visible: pinned verbatim per cause and lifecycle.
it.each([
[
{ kind: 'interrupted' } satisfies OpenTurnCloseCause,
false,
'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
],
[
{ kind: 'interrupted' } satisfies OpenTurnCloseCause,
true,
'The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
],
[
{ kind: 'forked' } satisfies OpenTurnCloseCause,
false,
'The history inherited by this branch has no record of this tool call starting. The parent session may have executed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
],
[
{ kind: 'forked' } satisfies OpenTurnCloseCause,
true,
'The history inherited by this branch records this tool call starting but does not include its result. The parent session may have completed it after the fork point. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly.',
],
])('cause %o, started=%s pins its exact text', (cause, started, text) => {
expect(resultText(openTurnClosers(openCall(started), cause))).toBe(text)
})
it('shares the error codes across causes: they state the same call lifecycle fact', () => {
for (const cause of causes) {
const notStarted = openTurnClosers(openCall(false), cause)[0]
expect(notStarted?.type === 'tool/result' && notStarted.data.error).toEqual({
name: 'ToolNotStartedError', code: TOOL_NOT_STARTED,
})
const started = openTurnClosers(openCall(true), cause)[0]
expect(started?.type === 'tool/result' && started.data.error).toEqual({
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
})
}
})
it('interruptedTurnClosers is the public interrupted-cause entry (cause selection stays internal)', () => {
const events = openCall(true)
expect(interruptedTurnClosers(events)).toEqual(openTurnClosers(events, { kind: 'interrupted' }))
const end = interruptedTurnClosers(events).at(-1)
expect(end?.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' })
})
})
@@ -107,6 +107,23 @@ describe('Session log positions', () => {
expect(child.isOwnSeq(SessionSeq(3))).toBe(true)
})
it('accepts child closers after the inherited marker and rejects a later inherited marker', () => {
const id = SessionId('marked-fork')
const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id, createdAt: 1, isSeeded: true }
const seed = [
{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
{ type: 'session/end-seed', seq: SessionSeq(1), time: 2, data: { inherited: true } },
{ type: 'turn/end', seq: SessionSeq(2), time: 3, data: { turn: 1, reason: { kind: 'forked' } } },
] as const
const child = Session.create(id, seed, header, SessionLogOffset(1))
expect(child.snapshotEvents()).toEqual(seed)
expect(child.ownEvents().map(event => event.type)).toEqual(['session/end-seed', 'turn/end'])
expect(child.firstLiveSeq).toBe(3)
expect(() => Session.create(id, [...seed, {
type: 'session/end-seed', seq: SessionSeq(3), time: 4, data: { inherited: true },
}], header, SessionLogOffset(1))).toThrow('final inherited marker')
})
it('requires a separately supplied inherited cut for a seeded header', () => {
const id = SessionId('missing-cut')
expect(() => Session.create(id, [], {
@@ -206,8 +206,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>',
description: 'Fork a session from a completed-turn prefix of the source; on resolution the child is in the list store and `open()` can target it.',
parameters: [{ name: 'opts', description: 'source session id, the optional event seq anchoring the cut (the boundary is the first turn/end at or after it; an in-log anchor in an open turn is unavailable rather than clipped backward), and whether to increment an inherited durable title before resolving.' }],
description: 'Fork a session from an exact inclusive prefix of the source; on resolution the child is in the list store and `open()` can target it.',
parameters: [{ name: 'opts', description: 'source session id, the optional exact inclusive boundary seq (a real event seq the caller already knows; a cut inside an open turn is balanced Host-side with synthetic closers, and omission selects the latest completed-turn prefix), and whether to increment an inherited durable title before resolving.' }],
returns: 'the child session id.',
throws: ['when the fork fails, or when a requested child-title rename fails after creation.'],
},
@@ -1656,8 +1656,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: '@Remote(\'fork\') fork(request: SessionForkRequest): Promise<SessionForkValue>',
description: 'Fork one cold-readable completed-turn prefix into a new Session.',
parameters: [{ name: 'request', description: 'source Session and optional event anchor.' }],
description: 'Fork one cold-readable exact event prefix into a new Session. An omitted boundary selects the latest completed-turn prefix; an open cut receives synthetic fork closers.',
parameters: [{ name: 'request', description: 'source Session and optional exact inclusive event boundary.' }],
returns: 'the new Session identity.',
},
{
@@ -2072,7 +2072,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'fork(source: SessionForkSource, boundary?: SessionSeq, childSessionId?: SessionId): Session',
description: 'Create a live child session from a stable prefix of a live source. `boundary` is an inclusive source event seq; omitted means the source\'s current last event. The selected slice may end with a between-turn event but must not end inside an open turn.',
description: 'Create a live child session from an exact prefix of a live source. `boundary` is an inclusive source event seq; omitted means the source\'s current last event. An open tail receives synthetic tool results and step/turn closers with the forked cause. Closed steps and turns remain unchanged, including any failed tool calls already missing results. `inheritedEventCount` counts only copied source events, excluding these closers.',
parameters: [{ name: 'source', description: 'Live source session object or id.' }, { name: 'boundary', description: 'Inclusive source event seq to fork through; omitted means the source\'s current last event, and omitted on an empty source forks an empty child.' }, { name: 'childSessionId', description: 'Optional child session id; omitted delegates to `SessionStore`\'s id policy.' }],
returns: 'The created live child session.',
},
@@ -5458,7 +5458,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Session',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset, projections?: readonly SessionMessageProjection[]): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState, projections?: readonly SessionMessageProjection[]): Session;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent<T>\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n readonly inheritedEventCount: SessionLogOffset;\n get id(): SessionId;\n readonly firstLiveSeq: SessionLogOffset;\n readonly firstLifecycleSeq: SessionLogOffset;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader, inheritedEventCount?: SessionLogOffset, projections?: readonly SessionMessageProjection[]): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader, inheritedEventCount: SessionLogOffset, eventState: SessionSeedEventState, projections?: readonly SessionMessageProjection[]): Session;\n eventAt(seq: SessionSeq): SessionEvent | undefined;\n snapshotEvents(fromSeq: SessionLogOffset = SessionLogOffset(0), toSeqExclusive: SessionLogOffset = this.seq): readonly SessionEvent[];\n ownEvents(): readonly SessionEvent[];\n isOwnSeq(seq: SessionSeq): boolean;\n get seq(): SessionLogOffset;\n append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent<T>\n ] : [\n ]): SessionEvent<T>;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}',
},
{
name: 'SessionAccess',
@@ -6614,7 +6614,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'TurnEndReasonMap',
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: \'blocked\';\n };\n error: {\n kind: \'error\';\n error: LlmFailure;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}',
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: \'blocked\';\n };\n error: {\n kind: \'error\';\n error: LlmFailure;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n forked: {\n kind: \'forked\';\n };\n}',
},
{
name: 'TypertCodec',
@@ -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-v3-to-v4/README.md
README.md: 4eada9da45fdd241b315feeee959705818c169b9
README.zh.md: 72c47cf5c1448275cc2eeb02a4d9baae00b47d2c
README.md: 8181860dd816aa2aecbd10a5e3a4dc4703d167db
README.zh.md: 640a20e29b265a9f9cad9f931f08ad0fefb7b04a
@@ -70,6 +70,8 @@ An existing own parent catalog entry does not require an available child descrip
Migration retains every source event, order, sequence, timestamp, and payload, appending missing own catalog records sorted by creation time then child id. Appended records use the last source event time, or header creation time for an empty log. Catalog field and uniqueness checks apply only after the final inherited cut; inherited payloads remain opaque and do not establish own membership. Existing catalog extensions remain intact.
V4 also admits fork-generated `TOOL_NOT_STARTED` results with deterministic `forked-tool-result-<callId>-<seq>` IDs and branch-specific text. Validation checks the advertised call and error result, using a private canonical V3 repair view; persisted and restored data retain the original fork ID and wording. Later surface replacements, including tool-result pruning, retain that identity and validate their source references through the released rules. Released V0–V3 validators remain unchanged.
<a id="understand-the-implementation"></a>
## Understand the implementation
@@ -70,6 +70,8 @@ const artifact = restore.finish()
迁移保留每个来源事件及其顺序、序号、时间和 payload,仅按创建时间、子 id 排序追加缺失的自身目录记录。追加时间使用来源最后一个事件的时间,空日志使用 header 创建时间。目录字段与唯一性检查仅作用于最终继承截点之后的记录;继承的 payload 保持不透明,也不计入自身成员关系。已有目录的扩展字段原样保留。
V4 还接受 fork 生成的 `TOOL_NOT_STARTED` 结果,使用确定性的 `forked-tool-result-<callId>-<seq>` ID 和分支专用文案。校验通过内部规范 V3 修复视图检查声明的调用和错误结果;持久化与恢复的数据保留原始 fork ID 和文案。后续 surface 替换(包括工具结果裁剪)保留该身份,并通过已发布规则校验其源引用。已发布的 V0–V3 校验器保持不变。
<a id="understand-the-implementation"></a>
## 理解实现
@@ -1,9 +1,10 @@
/** V4 framing retains released V3 event encoding and structural admission. */
import { SessionFormatError, isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatCodec, SessionFormatCurrentEncoder, SessionFormatHeader } from '@deepseek-ai/dsh-session-format'
import { SessionFormatError, isSessionFormatJsonObject, snapshotSessionFormatJson } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatCodec, SessionFormatCurrentEncoder, SessionFormatHeader, SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
import { assertV3RowAdmission, releasedV3SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v2-to-v3'
import { assertReleasedV4Header } from './validation.ts'
import { forkResultValidationView } from './fork-result.ts'
/** V4 codec preserves the released V3 physical event representation. */
export const releasedV4SessionFormatCodec = Object.freeze({
@@ -13,13 +14,31 @@ export const releasedV4SessionFormatCodec = Object.freeze({
},
createDecoder(value, recovery) {
const decoder = releasedV3SessionFormatCodec.createDecoder(physicalV3(value), recovery)
return { ...decoder, header: { ...decoder.header, version: 4 } }
return {
...decoder,
header: { ...decoder.header, version: 4 },
decodeRow(row, context) {
const view = forkResultValidationView(row)
decoder.decodeRow(view, {
emitRun: context.emitRun.bind(context),
emitEvent(event) {
context.emitEvent(view === row ? event : {
...event, data: snapshotSessionFormatJson((row as SessionFormatEvent).data, 'V4 fork result data'),
})
},
})
},
}
},
encodeHeader(header, inheritedEventCount) {
assertReleasedV4Header(header)
return { ...releasedV3SessionFormatCodec.encodeHeader({ ...header, version: 3 }, inheritedEventCount), version: 4 }
},
encodeEvent: releasedV3SessionFormatCodec.encodeEvent,
encodeEvent(event) {
const view = forkResultValidationView(event)
const encoded = releasedV3SessionFormatCodec.encodeEvent(view)
return view === event ? encoded : { ...encoded, data: snapshotSessionFormatJson(event.data, 'V4 fork result data') }
},
} satisfies SessionFormatCodec & SessionFormatCurrentEncoder)
/**
@@ -27,7 +46,7 @@ export const releasedV4SessionFormatCodec = Object.freeze({
* @param row - parsed physical row before framing and source-event range decoding.
*/
export function assertV4RowAdmission(row: unknown): void {
assertV3RowAdmission(row)
assertV3RowAdmission(forkResultValidationView(row))
}
function physicalV3(value: unknown): SessionFormatHeader {
@@ -0,0 +1,52 @@
/** V4 fork-result admission for the private released-validator view. */
import { SessionFormatError, isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
/**
* Present a V4 not-started fork result to the frozen V3 validators.
* Only the private view changes; codecs preserve the original result data.
* Tool-result rows encode one event each. Replacements retain the original
* identity suffix while the released validator checks their surface references.
* @param row - logical event or physical event row.
* @returns the original row, or its validated fork result represented as a V3 repair.
*/
export function forkResultValidationView<T>(row: T): T {
if (!isSessionFormatJsonObject(row) || row['type'] !== 'tool/result') return row
const data = row['data']
if (!isSessionFormatJsonObject(data)) return row
const error = data['error']
const message = data['message']
if (!isSessionFormatJsonObject(error) || error['code'] !== 'TOOL_NOT_STARTED'
|| !isSessionFormatJsonObject(message) || typeof message['id'] !== 'string'
|| !message['id'].startsWith('forked-tool-result-')) return row
const source = message['source']
const callId = isSessionFormatJsonObject(source) ? source['callId'] : undefined
const prefix = `forked-tool-result-${String(callId)}-`
const suffix = message['id'].slice(prefix.length)
const operation = row['surfaceOp']
const replacement = isSessionFormatJsonObject(operation) && operation['op'] === 'replace'
const sequence = Number(suffix)
const content = message['content']
const block: unknown = Array.isArray(content) && content.length === 1 ? content[0] : undefined
const result = isSessionFormatJsonObject(block) ? block['content'] : undefined
const text: unknown = Array.isArray(result) && result.length === 1 ? result[0] : undefined
if (typeof callId !== 'string' || !message['id'].startsWith(prefix)
|| !/^(0|[1-9]\d*)$/.test(suffix) || !Number.isSafeInteger(sequence)
|| (replacement ? typeof row['seq'] !== 'number' || sequence >= row['seq'] : sequence !== row['seq'])
|| error['name'] !== 'ToolNotStartedError'
|| (!replacement && (row['sourceEventSeqs'] !== undefined || operation !== 'append')) || !isSessionFormatJsonObject(block)
|| block['type'] !== 'tool-result' || block['isError'] !== true || block['toolCallId'] !== callId
|| !isSessionFormatJsonObject(text) || text['type'] !== 'text' || typeof text['text'] !== 'string') {
throw new SessionFormatError('invalid V4 not-started fork result')
}
const canonical: SessionFormatJsonObject = {
...data,
message: {
...message,
id: `interrupted-tool-result-${callId}-${suffix}`,
content: [{ ...block, content: [{ ...text, text: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.' }] }],
},
}
return { ...row, data: canonical }
}
@@ -4,6 +4,7 @@ import { SessionFormatError, isSessionFormatJsonObject, sessionFormatCount } fro
import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatHeader, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import { assertReleasedV3Header, restoreReleasedV3Artifact } from '@deepseek-ai/dsh-session-format-v2-to-v3'
import { catalogFact } from './facts.ts'
import { forkResultValidationView } from './fork-result.ts'
/**
* Validate V4 metadata with the unchanged released V3 header fields.
@@ -23,7 +24,8 @@ export function assertReleasedV4Header(header: SessionFormatHeader): void {
export function restoreReleasedV4Artifact(artifact: SessionFormatArtifact, knownEventTypes: ReadonlySet<string>): SessionFormatArtifact {
assertReleasedV4Header(artifact.header)
assertReleasedV4Relationships(artifact)
const events = artifact.events.map((event): SessionFormatEvent => {
const events = artifact.events.map((original): SessionFormatEvent => {
const event = forkResultValidationView(original)
if (event.type !== 'session-log-deepseek/delivery-accepted') return event
const data = event.data as SessionFormatJsonObject
const version = data['sessionFormatVersion']
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import ToolResultPruner from '@deepseek-ai/dsh-compaction-tool-result-pruner'
import { SessionFormatEventCollector, type SessionFormatEvent, type SessionFormatArtifact, type SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import { releasedV3SessionFormatCodec, restoreReleasedV3Artifact } from '@deepseek-ai/dsh-session-format-v2-to-v3'
import { Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import { buildForkSeed } from '@deepseek-ai/dsh-session/fork'
import { createMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { releasedV4SessionFormatCodec as codec, assertV4RowAdmission, restoreReleasedV4Artifact } from '../src/index.ts'
type MutableResult = {
data: {
message: { id: string; content: { toolCallId: string; isError: boolean; content: unknown[] }[] }
error: { name: string }
}
sourceEventSeqs?: number[]
}
function fork() {
const source = Session.create(SessionId('fork-parent'))
source.append('turn/start', { turn: 1 })
source.append('step/start', { turn: 1, step: 1 })
source.append('assistant/message', {
turn: 1, step: 1, stream: [],
message: createMessage({ role: 'assistant', source: { kind: 'model', provider: 'mock', model: 'mock' },
content: [{ type: 'tool-call', id: ToolCallId('read-one'), name: 'read', arguments: '{}' }] }),
}, { surfaceOp: 'append' })
const seed = buildForkSeed(source.snapshotEvents(), SessionSeq(2))
return Session.create(SessionId('fork-child'), seed, {
version: 4, id: SessionId('fork-child'), createdAt: 1, delegationDepth: 0, isSeeded: true, parentSession: source.id,
}, SessionLogOffset(3))
}
function artifact(session = fork()): SessionFormatArtifact {
return JSON.parse(JSON.stringify({
header: session.header, events: session.snapshotEvents(), inheritedEventCount: session.inheritedEventCount,
})) as SessionFormatArtifact
}
function restore(events: readonly SessionFormatEvent[]) {
return restoreReleasedV4Artifact({ ...artifact(), events }, new Set(events.map(event => event.type)))
}
describe('V4 fork results', () => {
it('persists original fork IDs and wording, restores relationships and retains nested inherited cuts', () => {
const child = fork()
const original = artifact(child).events
const before = JSON.stringify(original)
const physical = codec.encodeHeader(artifact(child).header, child.inheritedEventCount)
const decoder = codec.createDecoder(physical, 'strict')
const output = new SessionFormatEventCollector()
for (const event of original) {
const row = codec.encodeEvent(event)
expect(() => { assertV4RowAdmission(row) }).not.toThrow()
decoder.decodeRow(row, output)
}
expect(decoder.finish(output)).toBe(3)
expect(output.values).toEqual(original)
expect(restore(output.values).events).toBe(output.values)
expect(JSON.stringify(original)).toBe(before)
const result = child.snapshotEvents().find(event => event.type === 'tool/result')!
expect(result.data.message.id).toBe(`forked-tool-result-read-one-${result.seq}`)
expect(JSON.stringify(result)).toContain('The parent session may have executed it after the fork point.')
expect(() => restoreReleasedV3Artifact({ header: { ...artifact(child).header, version: 3 }, events: original, inheritedEventCount: 3 }, new Set(original.map(event => event.type)))).toThrow('TOOL_NOT_STARTED')
// V3 framing alone does not validate advertised-tool relationships.
expect(releasedV3SessionFormatCodec.encodeEvent(original[result.seq]!)).toBeDefined()
const nested = Session.create(SessionId('fork-grandchild'), buildForkSeed(child.snapshotEvents(), result.seq), {
version: 4, id: SessionId('fork-grandchild'), createdAt: 1, delegationDepth: 0, isSeeded: true, parentSession: child.id,
}, SessionLogOffset(result.seq + 1))
expect(restoreReleasedV4Artifact(artifact(nested), new Set(original.map(event => event.type)))).toBeDefined()
})
it('round trips a pruned fork result while preserving its original message identity', async () => {
const ctx = new Context()
try {
new SessionProjectionRegistry(ctx)
new TokenMeter(ctx)
const pruner = new ToolResultPruner(ctx, { thresholdChars: 300, headChars: 100, tailChars: 100 })
const child = fork()
const original = child.snapshotEvents().find(event => event.type === 'tool/result')!
child.append('turn/start', { turn: 2 })
child.append('step/start', { turn: 2, step: 1 })
const pruned = pruner.pruneSession(child)
expect(pruned.pruned).toHaveLength(1)
child.append('step/end', { turn: 2, step: 1 })
child.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
const events = artifact(child).events
const replacement = events[pruned.pruned[0]!.replacementSeq]!
expect(replacement.data).toMatchObject({ message: { id: original.data.message.id } })
expect(replacement.sourceEventSeqs).toEqual([original.seq])
expect(JSON.stringify(replacement)).toContain('pruned')
const decoder = codec.createDecoder(codec.encodeHeader(artifact(child).header, child.inheritedEventCount), 'strict')
const output = new SessionFormatEventCollector()
for (const event of events) decoder.decodeRow(codec.encodeEvent(event), output)
expect(decoder.finish(output)).toBe(child.inheritedEventCount)
expect(output.values).toEqual(events)
expect(restore(output.values).events).toEqual(events)
const data = replacement.data as SessionFormatJsonObject
const message = data['message'] as SessionFormatJsonObject
const invalid = [
{ ...replacement, sourceEventSeqs: [replacement.seq] },
{ ...replacement, data: { ...data, message: { ...message, id: `forked-tool-result-read-one-${replacement.seq}` } } },
]
for (const changed of invalid) {
expect(() => restore(events.map(event => event === replacement ? changed : event))).toThrow()
}
} finally {
await ctx.fiber.dispose()
}
})
it('rejects incomplete persisted result fields through V4 restoration', () => {
const original = artifact().events
const result = original.find(event => event.type === 'tool/result')!
const data = result.data as SessionFormatJsonObject
const message = data['message'] as SessionFormatJsonObject
const variants = [
null,
{ message },
{ ...data, message: { ...message, source: null } },
{ ...data, message: { ...message, content: [] } },
{ ...data, message: { ...message, content: [null] } },
]
for (const invalid of variants) {
const events = original.map(event => event === result ? { ...event, data: invalid } : event)
expect(() => restore(events)).toThrow()
}
})
it.each([
['ID sequence', (row: MutableResult) => { row.data.message.id = 'forked-tool-result-read-one-0' }],
['error name', (row: MutableResult) => { row.data.error.name = 'OtherError' }],
['call ID', (row: MutableResult) => { row.data.message.content[0]!.toolCallId = 'different' }],
['error result', (row: MutableResult) => { row.data.message.content[0]!.isError = false }],
['source references', (row: MutableResult) => { row.sourceEventSeqs = [2] }],
['content', (row: MutableResult) => { row.data.message.content[0]!.content = [] }],
] as const)('rejects malformed fork %s', (_name, mutate) => {
const events = structuredClone(artifact().events)
const result = events.find(event => event.type === 'tool/result')!
mutate(result as unknown as MutableResult)
expect(() => restore(events)).toThrow()
expect(() => { assertV4RowAdmission(result) }).toThrow()
})
})
@@ -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-telemetry/README.md
README.md: ba503adf55409ada47dc89b68ee37029ab0381d6
README.zh.md: 5e7237ddac46f4eaab2850186153158e3d36b99a
README.md: c7d76686415ee4874e29ec191d8456c31a3019f4
README.zh.md: 2e9bc7e6619f30d18660ef2a11ab703b02647aa3
+1 -1
View File
@@ -78,7 +78,7 @@ Live capture registers Session events, flush hints, shutdown markers, and agent/
### The handoff cursor
A module-scope `WeakMap<Session, seq>` records the highest sequence handed off, not delivered. Re-adopting the same object resumes after that cursor. Capture normally starts at `firstLiveSeq`; explicit `includeHistory: true` starts an unhanded object at seq 0, including restored or fork history. The backend owns capture authorization. Stored history does not itself authorize capture; the OTel backend waits for new explicit feedback. Receivers deduplicate repeated records by `(session.id, session.format_version, event.seq)`.
A module-scope `WeakMap<Session, seq>` records the highest sequence handed off, not delivered. Re-adopting the same object resumes after that cursor. Capture starts at `firstLifecycleSeq`: a new fork includes its child-owned marker and closers, while a restored Session excludes its stored prefix, including prior child turns; explicit `includeHistory: true` starts an unhanded object at seq 0, including restored or fork history. The backend owns capture authorization. Stored history does not itself authorize capture; the OTel backend waits for new explicit feedback. Receivers deduplicate repeated records by `(session.id, session.format_version, event.seq)`.
</details>
@@ -78,7 +78,7 @@ seam 建立在一个边界之上:harness 的职责止于 `emit()`。完整事
### handoff 游标
模块作用域的 `WeakMap<Session, seq>` 记录已交接而非已投递的最高序号。重新收养同一对象时从该游标之后继续。捕获通常从 `firstLiveSeq` 开始;显式 `includeHistory: true` 从未交接对象的 seq 0 开始,包含恢复或 fork 历史。后端负责捕获授权。存储的历史本身不授权捕获;OTel 后端等待新的显式反馈。接收方按 `(session.id, session.format_version, event.seq)` 对重复记录去重。
模块级 `WeakMap<Session, seq>` 记录已经移交的最高序号,而不是已送达的最高序号。再次接管同一对象时从该游标之后继续。采集从 `firstLifecycleSeq` 开始:新 fork 包含子会话自有的标记与 fork 结束事件;恢复的 Session 排除已存储前缀,包括先前的子会话轮次。显式 `includeHistory: true` 会让尚未移交的对象从 seq 0 开始,包括恢复或 fork 历史。Backend 负责采集授权;已有存储历史本身不构成授权,OTel backend 等待新的显式反馈。接收方通过 `(session.id, session.format_version, event.seq)` 对重复记录去重。
</details>
@@ -33,7 +33,7 @@ export type SessionTelemetryCapture = 'live' | 'on-demand'
export interface SessionTelemetryCaptureOptions {
/** Follow live events, or wait for explicit capture; defaults to live. */
capture?: SessionTelemetryCapture
/** Include stored history before this lifecycle; defaults to false. */
/** Include inherited fork history and stored history from earlier lifecycles; defaults to false. */
includeHistory?: boolean
}
@@ -149,8 +149,9 @@ export class SessionTelemetryCoordinator {
* @param throughSeq - optional last sequence included in this capture.
*/
captureSession(session: Session, throughSeq?: SessionSeqType): void {
const start = session.firstLifecycleSeq
const cursor = handoffCursor.get(session)
?? (this.options.includeHistory === true || session.firstLiveSeq === 0 ? -1 : SessionSeq(session.firstLiveSeq - 1))
?? (this.options.includeHistory === true || start === 0 ? -1 : SessionSeq(start - 1))
// Containment is PER EVENT: one rejected record is withheld fail-closed
// while the rest of the historical replay proceeds.
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
@@ -164,9 +165,9 @@ export class SessionTelemetryCoordinator {
/**
* Adopt a session and replay after its handoff cursor, then follow live events.
* New objects include inherited and restored history only with includeHistory;
* otherwise replay starts at the constructor boundary. Re-adopting the same
* object resumes after its cursor.
* New fork objects include child-owned seed markers and closers. Restored
* objects start after the stored prefix, including restored forks. includeHistory starts either object at seq 0;
* re-adopting the same object resumes after its cursor.
* @param session - the live session to adopt; a second adoption is a no-op.
*/
private adopt(session: Session): void {
@@ -1,4 +1,4 @@
import { createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { createAssistantMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
/**
* Coordinator semantics against a bare fake backend — the RFC's named unit
* tier for the seam: adoption (fresh, seeded, re-adoption via the handoff
@@ -299,8 +299,8 @@ describe('SessionTelemetryCoordinator on-demand capture', () => {
})
const parent = liveSession(ctx, 'history-parent')
appendTurn(parent)
const child = ctx.sessions.create(SessionId('history-child'), { seed: [...parent.snapshotEvents()] })
const boundary = child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const child = ctx.sessions.fork(parent, undefined, SessionId('history-child'))
const boundary = child.snapshotEvents().at(-1)!
child.append('turn/start', { turn: 2 })
expect(backend.records).toEqual([])
coordinator.captureSession(child, boundary.seq)
@@ -387,6 +387,96 @@ describe('SessionTelemetryCoordinator on-demand capture', () => {
})
describe('SessionTelemetryCoordinator adoption', () => {
it.each([
{ capture: 'live', includeHistory: false },
{ capture: 'on-demand', includeHistory: false },
{ capture: 'live', includeHistory: true },
{ capture: 'on-demand', includeHistory: true },
] as const)('restores a seeded Session with $capture capture and includeHistory=$includeHistory', async (options) => {
const ctx = new Context()
try {
await ctx.plugin(SessionStore)
const parent = liveSession(ctx, 'cold-parent')
appendTurn(parent)
parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const child = ctx.sessions.fork(parent, undefined, SessionId('cold-child'))
child.append('turn/start', { turn: 2 })
child.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
const resumedId = SessionId('restored-child')
const resumed = Session.fromRestore(resumedId, child.snapshotEvents(), { ...child.header, id: resumedId },
child.inheritedEventCount, 'shared-frozen')
const backend = new FakeBackend()
let coordinator!: SessionTelemetryCoordinator
await ctx.plugin({
name: 'seeded-resume-telemetry', inject: ['sessions'],
apply: (inner: Context) => { coordinator = new SessionTelemetryCoordinator(inner, backend, options) },
})
ctx.sessions.enter(resumed)
ctx.sessions.announce(resumed)
const expected = resumed.snapshotEvents().slice(options.includeHistory ? 0 : resumed.firstLiveSeq)
const captured = () => backend.ledger().filter(record => record.attributes['session.id'] === resumedId)
expect(captured().map(record => record.attributes['event.seq']))
.toEqual(options.capture === 'live' ? expected.map(event => event.seq) : [])
coordinator.captureSession(resumed)
coordinator.captureSession(resumed)
expect(captured().map(record => record.attributes['event.seq'])).toEqual(expected.map(event => event.seq))
expect(expected.at(-1)?.type).toBe('session/end-seed')
} finally {
await ctx.fiber.dispose()
}
})
it.each(['live', 'on-demand'] as const)('captures the child-owned fork marker and closers in %s mode', async (capture) => {
const { ctx, backend, coordinator, fiber } = await setup(new FakeBackend(), capture)
try {
const parent = liveSession(ctx, 'fork-parent')
appendTurn(parent)
parent.append('step/start', { turn: 1, step: 1 })
parent.append('assistant/message', {
turn: 1, step: 1, stream: [],
message: createAssistantMessage({
content: [{ type: 'tool-call', id: ToolCallId('fork-call'), name: 'bash', arguments: '{}' }],
source: { provider: 'mock', model: 'mock' },
}),
}, { surfaceOp: 'append' })
parent.append('tool/call', {
turn: 1, step: 1, callId: ToolCallId('fork-call'), name: 'bash', arguments: '{}',
})
const child = ctx.sessions.fork(parent, undefined, SessionId('fork-child'))
const ownEvents = child.snapshotEvents().slice(child.inheritedEventCount)
expect(ownEvents.map(event => event.type)).toEqual([
'session/end-seed', 'tool/result', 'step/end', 'turn/end',
])
expect(child.firstLiveSeq).toBe(child.inheritedEventCount + ownEvents.length)
const captured = () => backend.ledger().filter(record => record.attributes['session.id'] === child.id)
expect(captured().map(record => record.attributes['event.seq']))
.toEqual(capture === 'live' ? ownEvents.map(event => event.seq) : [])
coordinator.captureSession(child)
coordinator.captureSession(child)
expect(captured().map(record => record.attributes['event.seq'])).toEqual(ownEvents.map(event => event.seq))
expect(captured().map(record => record.body)).toEqual(ownEvents.map(event => event.data))
await fiber.dispose()
const second = new FakeBackend()
let reloaded!: SessionTelemetryCoordinator
await ctx.plugin({
name: 'fork-telemetry-reloaded',
inject: ['sessions'],
apply: (inner: Context) => {
reloaded = new SessionTelemetryCoordinator(inner, second, { capture })
},
})
reloaded.captureSession(child)
expect(second.ledger()).toEqual([])
const next = child.append('turn/start', { turn: 2 })
reloaded.captureSession(child)
expect(second.ledger().map(record => record.attributes['event.seq'])).toEqual([next.seq])
} finally {
await ctx.fiber.dispose()
}
})
it('replays a new fork object from its constructor boundary without its inherited prefix', async () => {
const backend = new FakeBackend()
const ctx = new Context()
+4 -3
View File
@@ -253,9 +253,10 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR
case undefined:
case 'completed':
return droppedUnrun ? 'aborted' : 'completed'
/* v8 ignore next 3 -- `TurnEndReason` is merge-extensible, so this arm needs a
* backend that adds a variant; treating an unnameable reason as success would
* report failed work as completed. */
/* v8 ignore next 4 -- `forked` appears only in constructor seed history, while
* this function reads an epoch-owned suffix. `TurnEndReason` is merge-extensible,
* so a backend-added variant cannot be listed; treating an unnameable reason as
* success would report failed work as completed. */
default:
return 'error'
}
+1
View File
@@ -89,6 +89,7 @@
"@deepseek-ai/dsh-session/types": ["./packages/core/session/src/types.ts"],
"@deepseek-ai/dsh-session/chunk-rows": ["./packages/core/session/src/chunk-rows.ts"],
"@deepseek-ai/dsh-session/surface": ["./packages/core/session/src/surface.ts"],
"@deepseek-ai/dsh-session/fork": ["./packages/core/session/src/fork.ts"],
"@deepseek-ai/dsh-session-projection/types": ["./packages/session/session-projection/src/types.ts"],
"@deepseek-ai/dsh-tool-todo/types": ["./packages/todo/tool-todo/src/types.ts"],
"@deepseek-ai/dsh-tool-todo/client": ["./packages/todo/tool-todo/src/client.ts"],
+1
View File
@@ -74,6 +74,7 @@
"apps/web/tests/file-upload-round.e2e.ts",
"apps/web/tests/message-actions.e2e.ts",
"apps/web/tests/open-in-app-ssh.e2e.ts",
"apps/web/tests/fork-mid-turn.e2e.ts",
"apps/web/tests/message-feedback.e2e.ts",
"apps/web/tests/markdown-images.e2e.ts",
"apps/web/tests/reference-composer.e2e.ts",