Merge latest docs/i18n-batch-core into docs/i18n-batch-cds-postmortem
This commit is contained in:
102 files changed
+2887
-158
No files matched your search
@@ -18,7 +18,7 @@ Persistence is an abstract **capability seam** ([capability seams](2026-06-13-ca
|
||||
Key choices recorded here because they are durable, contested, and surprising:
|
||||
|
||||
- **The canonical durable log persists every `SessionEvent` verbatim, including `assistant/chunk`.** `deriveMessages()` skips chunks, and a chunk-filtered rollout (Codex's `policy.rs`) is tempting — but `seq = log.length` and the load-validation `events[i].seq === i` require a *contiguous* log; filtering chunks out would leave holes and break both the contract and resume. A chunk-filtered projection is possible later as a derived view with its own renumbering, but it is NOT the canonical log.
|
||||
- **Append-only; a crashed turn is closed, never truncated.** Events through a flushed `turn/end` are never rewritten, and the loop flushes only at turn end. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends error results for unanswered tool calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
|
||||
- **Append-only; a crashed turn is closed, never truncated.** Flushed events are never rewritten. The [semantic checkpoint policy](../bug-fix/2026-07-21-semantic-session-checkpoints.md) drains the request before model dispatch, a recorded top-level call before tool dispatch, and the complete response/result batch after a step; the loop drains the final turn boundary. Because one interrupted turn may contain substantial valid work, `load` preserves its contiguous, parseable events and appends risk-classified error results for unanswered assistant calls, a missing `step/end`, and `turn/end` with `{ kind: 'interrupted' }`. The synthetic results keep resumed provider transcripts valid. Only an incomplete final record is discarded; a parse error or sequence gap at or before the last real `turn/end` is corruption and makes the session unloadable.
|
||||
- **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows.
|
||||
- **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).)
|
||||
- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent.
|
||||
|
||||
@@ -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
|
||||
2026-07-21-semantic-session-checkpoints.md: 4bca02fe3893ac39621ed79a000ca8f86db4ff67
|
||||
2026-07-21-semantic-session-checkpoints.zh.md: 1f187eb6448a3c9ca6784ec2bddd7295be2706d7
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Semantic session checkpoints
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-21-semantic-session-checkpoints.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Persistence buffered every synchronous `session/event` until the loop's final turn checkpoint. A turn is the correct conversational transaction, but it is too coarse as the only crash-recovery point: a hard crash during a long model request or tool call could discard the whole in-flight turn, including the request envelope needed to identify what had been attempted. A tool call with no result was also repaired with one undifferentiated interruption error, so the resumed model could not tell whether execution had started and could retry a side effect blindly.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-session-checkpoint-policy` owns semantic durability barriers as a zero-config plugin beside a persistence backend. It wraps `llm/stream` lazily and flushes the live session after `request/header` is logged but before the adapter stream is constructed. It wraps top-level `tools/execute` after ordered pre-execute policy and flushes the recorded `tool/call` before the tool body; nested dispatches reuse the outer model-visible call. It flushes at `agent/post-step` after the assistant message and ordered results are recorded. The loop's existing final `turn/end` checkpoint remains the closing boundary.
|
||||
|
||||
Persistence and checkpoint scheduling remain separate Cordis plugins. A backend makes requested `session/flush` boundaries durable but does not choose them; loading it without this policy is valid and retains the loop's coarser checkpoints. First-party persisted apps and runtimes explicitly mount both, while a specialized deployment may intentionally omit or replace the policy. Registration order governs whether events appended by other `agent/post-step` listeners join this checkpoint; the loop-owned assistant message and ordered results always precede the event.
|
||||
|
||||
Checkpoint failure and cancellation are fail-closed at effect boundaries. A rejected request checkpoint prevents adapter dispatch; a rejected tool checkpoint becomes an error result without invoking the tool body. If cancellation lands while the tool checkpoint is pending, the policy rechecks the signal and returns the canonical `ABORTED_BEFORE_DISPATCH` result. A rejected post-step checkpoint stops continuation before another model request. Persistence serialization continues to belong to the coordinator, so concurrent tool checkpoints cannot duplicate event sequences.
|
||||
|
||||
The ACP app owns its bridge, checkpoint policy, and persistence backend in one ordered Cordis effect. Cordis unloads sibling plugin effects concurrently, so independent mounts would let persistence detach while bridge teardown was still closing an interrupted turn. The composite lifecycle unloads the bridge first, waits for its agents to quiesce and flush the real `step/end` and `turn/end`, then removes checkpoint scheduling and persistence.
|
||||
|
||||
Crash repair distinguishes durable evidence. An assistant tool request without a `tool/call` becomes `TOOL_NOT_STARTED` and may be retried if still needed. A durable `tool/call` without a result becomes `TOOL_OUTCOME_UNKNOWN`; its model-visible result permits retry only for read-only or idempotent operations and directs the model to verify external state or ask the user before deciding about side-effecting work. A provider that supports idempotency keys can receive the stable `callId`, but the Harness does not claim generic exactly-once effects.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Flushing every event or streaming chunk minimizes loss but turns local append and `fsync` latency into the hot path and destabilizes streaming throughput. Moving the barriers into `agent-loop` prevents omission for that loop but hides checkpoint policy inside the mechanism and removes Cordis-level replacement and ordering. Keeping turn-only flush preserves throughput but loses the request and execution intent needed for safe recovery. Automatically retrying every unmatched call is safe only for a subset of tools and can duplicate irreversible effects.
|
||||
|
||||
## Consequences
|
||||
|
||||
Hard-crash recovery retains the complete model request, durable tool intent, and complete settled step at the nearest semantic boundary while allowing partial streaming chunks since the previous boundary to remain lossy. Default CLI, TUI, ACP, Python SDK runtime, headless persistence tests, and JSON-RPC compositions mount the policy with their persistence backend. Unit tests cover ordering, cancellation during a checkpoint, fail-closed behavior, nested dispatch, disposal, and Loader shape; a real child process killed with `SIGKILL` proves request and tool-intent recovery through JSONL, and the shared persistence contract proves both recovery classifications across backends. Keyless ACP snapshots prove both that retry-risk guidance reaches resumed history and the next model turn and that graceful cancellation persists the loop's real closing boundaries.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 语义会话检查点
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-21-semantic-session-checkpoints.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
持久化机制会缓冲所有同步 `session/event`,直到 agent loop(智能体循环)执行最后的轮次检查点才写入。一个轮次是正确的对话事务,但作为唯一的崩溃恢复点过于粗粒度:如果在耗时的模型请求或工具调用期间发生硬崩溃,整个进行中的轮次都可能丢失,其中包括识别已尝试操作所需的请求封套。系统还会使用同一种不作区分的中断错误,修复没有结果的工具调用,因此恢复运行的模型无法判断调用是否已经开始,可能会盲目重试带有副作用的操作。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-session-checkpoint-policy` 以零配置插件的形式与持久化后端共同加载,并负责语义持久性屏障。该插件惰性包装 `llm/stream`,在记录 `request/header` 之后、构造适配器流之前,刷新活动会话。该插件还在有序的执行前策略之后包装顶层 `tools/execute`,在进入工具主体前刷新已记录的 `tool/call`;嵌套分发则复用外层模型可见调用。它还会在 `agent/post-step` 时刷新会话,此时模型消息与按序结果都已记录。现有的最终 `turn/end` 检查点仍是轮次的收尾边界。
|
||||
|
||||
持久化与检查点调度仍是相互独立的 Cordis 插件。后端使请求的 `session/flush` 边界持久化,但不选择边界;只加载后端而不加载本策略仍是有效组合,并保留循环提供的较粗检查点。第一方持久化应用与运行时会显式加载两者,专用部署则可以有意省略或替换本策略。注册顺序决定其他 `agent/post-step` 监听器追加的事件是否会纳入本检查点;循环自身记录的助手消息与有序结果始终先于该事件。
|
||||
|
||||
检查点失败与取消在副作用边界上采取失败关闭策略。请求检查点被拒绝时,系统不会分发给适配器;工具检查点被拒绝时,系统会返回错误结果,不调用工具主体。如果在工具检查点等待期间收到取消,策略会重新检查信号,并返回标准的 `ABORTED_BEFORE_DISPATCH` 结果。步骤后检查点被拒绝时,系统会在发起下一个模型请求前停止继续执行。持久化写入的串行化仍由协调器负责,因此并发的工具检查点不会产生重复的事件序号。
|
||||
|
||||
ACP(Agent Client Protocol)应用在一个有序 Cordis effect 中统一持有其桥接层、检查点策略与持久化后端。Cordis 会并发卸载同级插件的 effect;如果分别加载,桥接层仍在为被中断的轮次收尾时,持久化后端就可能已经卸载。组合生命周期会先卸载桥接层,等待其各 agent 达到静止,并刷新真实的 `step/end` 与 `turn/end`,再移除检查点调度与持久化。
|
||||
|
||||
崩溃修复会区分持久化证据。如果模型发出了工具请求,却没有 `tool/call`,系统会将其标记为 `TOOL_NOT_STARTED`;如果仍有需要,可以重试。如果持久化的 `tool/call` 没有结果,系统会将其标记为 `TOOL_OUTCOME_UNKNOWN`;对应的模型可见结果只允许重试只读或幂等操作,并指示模型在决定是否重试有副作用的工作前,先验证外部状态或询问用户。支持幂等键的模型提供方可以获取稳定的 `callId`,但 Harness 不承诺通用的副作用恰好执行一次保证。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
刷新每个事件或流式分片虽能尽可能减少丢失,但会把本地追加与 `fsync` 延迟带入热路径,破坏流式输出的吞吐稳定性。将这些屏障放入 `agent-loop`,虽能防止该循环漏装,却会将检查点策略隐藏在机制中,并失去 Cordis 层的替换与排序能力。仅保留轮次刷新可以维持吞吐量,但会丢失安全恢复所需的请求与执行意图。自动重试所有未匹配调用只对部分工具安全,可能会重复不可逆的副作用。
|
||||
|
||||
## 后果
|
||||
|
||||
发生硬崩溃时,崩溃恢复会在最近的语义边界保留完整的模型请求、持久化的工具意图与完整且已结束的步骤,但允许上一个边界之后的部分流式分片仍可能丢失。默认的 CLI(命令行界面)、TUI、ACP、Python SDK 运行时、headless 持久化测试与 JSON-RPC 组合都会在持久化后端旁加载该策略。单元测试覆盖顺序、检查点期间的取消、失败关闭行为、嵌套分发、dispose(资源释放)与 Loader 形状;一个被 `SIGKILL` 终止的真实子进程通过 JSONL 证明系统可以恢复请求与工具意图,共享持久化契约则证明各后端都支持这两种恢复分类。无密钥 ACP 快照既证明重试风险指引会进入恢复后的历史记录与下一个模型轮次,也证明取消流程正常收尾时,系统会持久化由循环实际生成的闭合边界。
|
||||
@@ -18,6 +18,8 @@ A snapshot test boots the real ACP example, drives its stdio protocol from a det
|
||||
|
||||
Each scenario's `session.jsonl` is harvested from a real run. `assistant/chunk` events reproduce the model streams; tool, message, and boundary events capture the harness behavior. One ordinary session artifact therefore serves as both replay source and behavioral expected output.
|
||||
|
||||
When a scenario pins an alternative physical storage layout, its fixture is mechanically derived from a real unpacked counterpart. The scenario test requires every intended storage-row kind and exact event-for-event equality after decoding before the ordinary replay and log comparison proves that the assembled process consumes and reproduces that layout.
|
||||
|
||||
### Replay derives the model script from the log
|
||||
|
||||
`llm-replay` short-circuits the provider-agnostic `llm/stream` waterfall. `deriveReplayScript()` groups recorded chunks by `(turn, step)` and serves one group per model call. The loop makes one stream call per step, so the grouping is exact and includes error finish chunks without special handling.
|
||||
|
||||
@@ -18,7 +18,7 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su
|
||||
|
||||
**`src/normalize.ts`** — the pure normalizers, hook-free by policy: when a future event carries a new volatile field (an approval duration, say), the shared normalizer learns it in the same change, keeping one home for what "normalized" means rather than per-suite scrub extensions.
|
||||
|
||||
**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record/refresh fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL a `scrubSystemPrompts` fixed point, non-pinning fixtures also `scrubRequestHeaders` fixed points). A scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are its ordered primary/child inventory, so the scenario table declares policy without duplicating a child count. The pinned-header contract ([pinned-header Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each header class flags exactly one `pinsHeader` scenario, whose `system-prompt.expected.md` and JSONL tool list split the composed header into reviewable artifacts; the uniformity guard compares both against every live header in that class. A pinning scenario declares any legitimate changed-header count, and its Markdown artifact records every full changed prompt. The pure helpers (`sessionFixtureNames`, `fixtureContext`, `normalizedHeaders`, `normalizedSystemPrompts`, `formatSystemPromptSnapshot`, `headerChangeCount`) are exported from the module for direct unit coverage.
|
||||
**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record/refresh fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL a `scrubSystemPrompts` fixed point, non-pinning fixtures also `scrubRequestHeaders` fixed points). Refresh expands packed timing envelopes before aligning existing volatile event times, so switching between packed and unpacked layouts cannot shift later records; fresh chunk-fragment arrays remain authoritative because their boundaries are replay behavior. A scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are its ordered primary/child inventory, so the scenario table declares policy without duplicating a child count. The pinned-header contract ([pinned-header Agent Note](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each header class flags exactly one `pinsHeader` scenario, whose `system-prompt.expected.md` and JSONL tool list split the composed header into reviewable artifacts; the uniformity guard compares both against every live header in that class. A pinning scenario declares any legitimate changed-header count, and its Markdown artifact records every full changed prompt. The pure helpers (`sessionFixtureNames`, `fixtureContext`, `normalizedHeaders`, `normalizedSystemPrompts`, `formatSystemPromptSnapshot`, `headerChangeCount`) are exported from the module for direct unit coverage.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -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
|
||||
2026-07-16-persistent-pty-sessions.md: 3028d3a527e177f2b30c557dc45443af99783d6c
|
||||
2026-07-16-persistent-pty-sessions.zh.md: f244992abccc9c107bc2cf4da392dc39d39d14cc
|
||||
@@ -0,0 +1,170 @@
|
||||
# Agent Note: persistent PTY sessions
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-16-persistent-pty-sessions.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness can run foreground and background commands, edit files, and delegate work, but it cannot continue an interactive terminal conversation across tool calls. Each `bash` foreground run starts a fresh shell, so shell-local cwd, exported variables, virtual-environment activation, functions, job-control state, and interactive child processes end with that call.
|
||||
|
||||
That gap excludes workflows whose state lives in a terminal rather than a file: stepping through `gdb`, exploring in a Python or Node REPL, driving a line-oriented editor such as `ed`, or returning to a shell after interrupting its foreground command. The generic [`ctx.tasks`](../../../../packages/tasks/README.md) runtime retains background-operation handles and output, but it does not provide interactive stdin or terminal semantics.
|
||||
|
||||
The existing `bash`, `read`, `write`, and `edit` tools remain the reliable default for bounded, auditable operations. A PTY is an additional capability for work that genuinely requires terminal state, not evidence that those tools are defective or candidates for removal.
|
||||
|
||||
## Proposal
|
||||
|
||||
Add an optional `packages/pty/` capability family that exposes agent-owned, persistent, line-oriented PTY sessions. It follows the repository's [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md), coexists with the existing command and filesystem tools, and does not change `agent-loop`.
|
||||
|
||||
The first delivery supports interactive shells and line-oriented REPLs on Linux and macOS. Full-screen terminal applications, keystroke sequences, BEL-triggered control flow, session restoration after process loss, and cross-agent session sharing are explicitly deferred until the basic lifecycle is proven.
|
||||
|
||||
### Package topology
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `dsh-pty` | `PtyService`, branded `PtySessionId`, backend registry, owner-scoped session contract, and result types | `ctx.pty` |
|
||||
| `dsh-pty-local` | [`node-pty`](https://github.com/microsoft/node-pty)-based local backend, platform process inspection, bounded terminal buffer, sandbox resolution, and process-tree supervision | registers a backend on `ctx.pty` |
|
||||
| `dsh-tool-pty` | Six model-facing tools, task-runtime integration for background sends, guidance, and ACP render intents | registers on `ctx.tools` |
|
||||
|
||||
Idle detection is backend behavior, not a second public seam. A remote or container backend may have authoritative readiness signals that do not resemble local `/proc` inspection; every `PtyBackend` therefore returns the common send result while owning its detection mechanism internally.
|
||||
|
||||
### Agent ownership and identity
|
||||
|
||||
`PtyService` stores live sessions process-locally, but every session is owned by the exact `Agent` passed through the tool execution context. The service mints an opaque `PtySessionId`; an optional model-chosen `name` is display metadata and is unique only within that owner. Every operation targets `sessionId`, and `list`/`read`/`signal`/`kill` reject callers other than the owner.
|
||||
|
||||
The initial design has no plugin-load auto-start sessions. `pty_spawn` creates a session only during an agent tool call, when ownership and the owning event-sourced session are known. Deployments that later need declarative startup must compose it through unpublished agent setup rather than create shared global terminals.
|
||||
|
||||
Agent-scope disposal closes registrations first, then awaits quiescent teardown of every owned PTY. Backend or tool-plugin reload does not orphan sessions: ownership lives in `PtyService` until the agent ends, following the same service-owned-record pattern as [`ctx.tasks`](../../../../packages/tasks/tasks/README.md).
|
||||
|
||||
### Security and process boundary
|
||||
|
||||
A registered `shell` backend constrains how a terminal starts; it does not constrain commands typed after startup. `dsh-pty-local` therefore applies two protections before spawning:
|
||||
|
||||
- It builds a scrubbed child environment using the same credential-shaped-name policy as `bash-local`, removing ambient `*KEY*`, `*SECRET*`, `*TOKEN*`, and harness-managed variables unless an explicit trusted mapping supplies them.
|
||||
- Its `sandbox` config is `required | optional | disabled`, defaulting to `required`. `required` fails plugin load when `ctx.sandbox` is unavailable; `optional` uses the provider when present; `disabled` is an explicit unconfined opt-in. The selected provider wraps the session argv once and remains the process boundary for the PTY lifetime.
|
||||
|
||||
Sandboxing confines local process effects but does not make arbitrary shell input safe: network calls and other external side effects remain governed by deployment policy. Tool descriptions state that PTY sessions are less auditable than one-shot tools and should be used only when persistence or interactive stdin is necessary.
|
||||
|
||||
The implementation uses only public `node-pty` capabilities: child PID, `data` and `exit` notifications, `write`, `resize`, and `kill`. It does not assume access to the native master fd or call `waitpid` from TypeScript. Platform process inspectors derive process-group and session membership from `/proc` on Linux and `ps` on macOS.
|
||||
|
||||
### Six model-facing tools
|
||||
|
||||
| Tool | Purpose | Result |
|
||||
|---|---|---|
|
||||
| `pty_spawn` | Create an owner-scoped session from a registered backend type | `{ sessionId, name, type, motd }` |
|
||||
| `pty_send` | Send text, optionally submit Enter, and wait for readiness or register a background task | bounded viewport plus wait and session status; background also returns `taskId` |
|
||||
| `pty_read` | Read a bounded page from retained scrollback | `{ text, totalLines, lineBegin, lineEnd, truncated }` |
|
||||
| `pty_signal` | Send one allowed signal to the current foreground process group | `{ delivered, targetPgid }` |
|
||||
| `pty_kill` | Close one session and await process-tree quiescence | `{ killed }` |
|
||||
| `pty_list` | List the caller's live sessions | owner-scoped session summaries |
|
||||
|
||||
`pty_send({ sessionId, text, submit?, background? })` treats `text` as UTF-8 bytes and resolves `submit` to `true` in the tool implementation. When `submit` is true it writes the platform Enter sequence after the text; when false it writes only the text, allowing control characters and REPL fragments without hidden content heuristics.
|
||||
|
||||
Foreground sends return a bounded rendered delta and two independent facts: `waitReason` (`stdin_read | inferred_idle | timeout | session_exit`) and `sessionStatus` (`running` or `exited` with exit code or signal). `session_exit` refers to the PTY's top-level shell process, not an arbitrary foreground command whose status the shell consumes. A timeout never implies process exit.
|
||||
|
||||
With `background: true`, `dsh-tool-pty` registers the in-flight send on `ctx.tasks` and returns immediately with `taskId`. `task_output(wait: true)` waits, reads incremental output, and records the final result; `task_kill` forwards cancellation as `SIGINT` and escalates only through the PTY backend's owned teardown path. If the task surface is absent, background mode fails before writing input. No PTY-specific `sleep` tool or general wake-up seam is added.
|
||||
|
||||
`pty_read` pages backward from the newest retained line. The backend enforces both line and UTF-8 byte caps on retained scrollback and the complete returned value, so one oversized line cannot bypass the bound. `truncated` distinguishes retention loss from an ordinary viewport delta.
|
||||
|
||||
`pty_signal` accepts the closed set `SIGINT | SIGTERM | SIGKILL | SIGTSTP | SIGHUP`. The backend resolves the terminal foreground process group at execution time. `SIGKILL` is rejected when that group is the top-level shell, directing the caller to `pty_kill`; a failed group lookup fails the operation instead of signaling a guessed PID.
|
||||
|
||||
### Local readiness detection
|
||||
|
||||
The local backend runs three bounded tiers. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, and `timeoutMs`.
|
||||
|
||||
On Linux, the inspector reads the shell's terminal foreground PGID from `/proc/<shellPid>/stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1.
|
||||
|
||||
On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path.
|
||||
|
||||
Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.tasks`, signal the foreground group, or inspect from another session.
|
||||
|
||||
`node-pty` data notifications feed one streaming decoder and terminal parser. Parser carry state handles UTF-8 and terminal query sequences split across chunks. The first delivery normalizes line-oriented output and detects alternate-screen entry, but it does not promise correct interaction with a full-screen application.
|
||||
|
||||
### Model-visible output and durability
|
||||
|
||||
The existing durable `tool/call` and `tool/result` events are the source of truth for text sent by the model and rendered output returned to it. `pty_spawn` returns its MOTD through the logged tool result; foreground `send`/`read`/`list`/`signal`/`kill` results are logged the same way. The PTY packages do not duplicate raw byte streams into custom session events.
|
||||
|
||||
Background sends use the existing task completion notice and `task_output` result path, so any output that reaches a later model request is likewise durable. Raw terminal bytes remain bounded process-local state and are neither persisted nor restorable. A future opt-in transcript sink would need its own retention, credential, and privacy contract.
|
||||
|
||||
### Process-tree teardown
|
||||
|
||||
The top-level `node-pty` child is treated as the POSIX session leader, but the owned resource is the complete OS process session, not one PID. On close, the backend stops callbacks, sends `SIGTERM` to all still-matching session members, closes the PTY, awaits `node-pty` exit plus process-inspector quiescence, then sends `SIGKILL` to verified survivors after configurable `disposeGraceMs`. Membership snapshots include process-start identity so PID reuse cannot redirect escalation.
|
||||
|
||||
Teardown reports root exit and survivor cleanup independently. It does not claim success merely because the shell exited; disposal resolves only after no captured session member remains or returns a structured cleanup failure naming the survivors.
|
||||
|
||||
### Composition and rollout
|
||||
|
||||
The example composition remains opt-in and safe by default:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
'@deepseek-ai/dsh-sandbox-local':
|
||||
'@deepseek-ai/dsh-pty':
|
||||
'@deepseek-ai/dsh-pty-local':
|
||||
config:
|
||||
sandbox: required
|
||||
scrollbackLines: 10000
|
||||
scrollbackMaxBytes: 4194304
|
||||
maxReadBytes: 262144
|
||||
pollIntervalMs: 50
|
||||
exactProbeAfterMs: 150
|
||||
idleSilenceMs: 3000
|
||||
timeoutMs: 30000
|
||||
disposeGraceMs: 3000
|
||||
'@deepseek-ai/dsh-tool-pty':
|
||||
```
|
||||
|
||||
The package ships concise tool guidance explaining persistent state, owner isolation, uncertain idle results, cleanup, and the preference for existing one-shot tools when interaction is unnecessary. It does not add a global system-prompt recommendation or mount PTY in shipped defaults.
|
||||
|
||||
### Deferred work
|
||||
|
||||
- Full-screen TUI support, named key sequences, BEL interruption, terminal resize tools, and alternate-screen snapshots require a separately proven model-facing contract.
|
||||
- Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited.
|
||||
- Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol.
|
||||
- Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work.
|
||||
- Windows/ConPTY support requires a backend with Windows-native process ownership and signaling semantics.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Replace `bash`, filesystem tools, or task tools with PTY.** Rejected. One-shot tools retain stronger validation, approval, sandbox, output-bound, and replay contracts. PTY is reserved for interactive state.
|
||||
|
||||
**Add persistent mode to `bash`.** Rejected. Returning on readiness rather than process exit, retaining a process tree across calls, and exposing interactive stdin create a different ownership and failure contract.
|
||||
|
||||
**Require native master-fd access from `node-pty`.** Rejected. Its public API exposes no master fd. The local backend instead derives foreground and session membership from supported OS process metadata and treats unreadable metadata as a detector miss.
|
||||
|
||||
**Publish `PtyIdleDetector` as a replaceable registry.** Rejected. Only the local backend needs these platform probes, while remote backends may receive readiness over their own protocol. Backend replacement already provides the necessary extension point.
|
||||
|
||||
**Add a PTY-specific `sleep` tool.** Rejected. `ctx.tasks` already owns bounded waiting, cancellation, completion notices, and model-facing collection. A second general wake mechanism would cross the agent-loop boundary and duplicate that contract.
|
||||
|
||||
**Include TUI sequences and BEL handling in the first delivery.** Rejected. The source prototype treats those paths as timing-sensitive and still records unresolved alternate-screen and interaction failures. Line-oriented PTY use proves the core value without making those unverified behaviors foundational.
|
||||
|
||||
**Use an out-of-process daemon immediately.** Rejected for the initial in-process capability because current persistent front doors already keep a Cordis context alive. A daemon becomes justified by cross-process restoration or multi-client attachment, both deferred here.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `packages/pty/{pty,pty-local,tool-pty}` build as the interface, local implementation, and model consumer; backend registrations dispose cleanly.
|
||||
- Every live PTY has one service-minted `PtySessionId`, one exact `Agent` owner, owner-fenced operations, and awaited cleanup on agent disposal; concurrent agents may reuse display names without sharing state.
|
||||
- `dsh-pty-local` uses only public `node-pty` APIs and contains no master-fd or TypeScript `waitpid` assumption.
|
||||
- Environment tests prove credential-shaped ambient variables are absent. `sandbox: required` fails at load without a provider, and real composition proves the provider wraps the long-lived session process.
|
||||
- Linux fixtures cover pipelines, a stdin-reading non-leader process, a stdin-reading non-main thread, unreadable process memory, supported UAPI syscall tables, unsupported architectures, and false-positive rejection. macOS process-inspector logic reaches 100% coverage on Linux, and macOS CI drives a real bash and Python REPL.
|
||||
- Foreground tests exercise `stdin_read`, `inferred_idle`, `timeout`, and top-level session exit without treating a foreground command exit as directly observable.
|
||||
- Background sends register `ctx.tasks` work, return before readiness, stream bounded output through `task_output`, honor task cancellation, and fail before writing when the task surface is absent.
|
||||
- Scrollback and every model-facing result enforce final UTF-8 byte bounds, including a single oversized line and multibyte boundary cases.
|
||||
- `pty_signal` resolves the live foreground group, rejects lookup failure and shell-targeted `SIGKILL`, and never falls back to a guessed PID.
|
||||
- Disposal tests start foreground and background descendants, including a signal-ignoring child, then prove every captured process identity is gone immediately after awaited agent disposal.
|
||||
- A test-only `cordis.yml` boots through the Loader on Linux and macOS, mounts the real local backend plus sandbox, and drives spawn/send/read/signal/kill/list through the real tool registry. ACP and headless snapshots pin the six schemas, bounded results, errors, and render intents.
|
||||
- TUI, sequence, BEL, auto-start, Windows, and crash-restoration behavior are absent from the public schema and documented as deferred rather than simulated by fixtures.
|
||||
- Package READMEs and JSDoc document configuration, ownership, failure, cancellation, bounds, sandboxing, model-visible effects, and limitations; `docs/architecture.md` and generated catalogs update with the implementation.
|
||||
- The repository CI-equivalent sequence in root `AGENTS.md` passes, including `test:coverage`, snapshots, documentation, build, hygiene, and built-entry smokes.
|
||||
|
||||
## Risks
|
||||
|
||||
**Idle below Linux Tier 1 is heuristic.** Output silence cannot distinguish a prompt from sleep or network I/O. The typed result preserves uncertainty, and bounded timeout plus task waiting and signaling keep control with the model.
|
||||
|
||||
**Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic.
|
||||
|
||||
**A shell can cause external side effects.** Session sandboxing and environment scrubbing reduce local exposure but do not undo pushes, API calls, or messages. Deployments that cannot tolerate those effects must omit PTY or add network policy.
|
||||
|
||||
**Process loss destroys terminal state.** In-process sessions do not survive a harness crash or restart, and raw scrollback is not durable. Important work must be committed to files or another durable system.
|
||||
|
||||
**`node-pty` is a native dependency.** Installation, supported Node versions, prebuild availability, and platform behavior require built-artifact smokes on every supported OS.
|
||||
@@ -0,0 +1,170 @@
|
||||
# Agent Note: 持久化 PTY 会话
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-16-persistent-pty-sessions.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
harness 可以运行前台与后台命令、编辑文件和委派工作,但无法跨工具调用延续一次交互式终端对话。每次 `bash` 前台运行都会启动一个新 shell,因此 shell 内的 cwd、导出变量、虚拟环境激活状态、函数、job control 状态和交互式子进程都会随本次调用结束。
|
||||
|
||||
这个缺口排除了状态驻留在终端而不是文件中的工作流,例如单步调试 `gdb`、在 Python 或 Node REPL 中探索、驱动 `ed` 这类行式编辑器,或者中断前台命令后回到原 shell。通用的 [`ctx.tasks`](../../../../packages/tasks/README.md) 运行时可以保留后台操作句柄和输出,但不提供交互式 stdin 或终端语义。
|
||||
|
||||
现有 `bash`、`read`、`write` 和 `edit` 工具仍是有界、可审计操作的可靠默认选项。PTY 是对确实需要终端状态的工作的补充功能,不说明这些工具有缺陷,更不意味着要移除它们。
|
||||
|
||||
## 提案
|
||||
|
||||
新增可选的 `packages/pty/` 功能家族,向模型提供由 agent 拥有、持久化且面向行式交互的 PTY 会话。它遵循仓库的 [capability pattern](../../implemented/architecture/2026-06-13-capability-seams.md),与现有命令和文件系统工具并存,并且不修改 `agent-loop`。
|
||||
|
||||
首次交付在 Linux 和 macOS 上支持交互式 shell 与行式 REPL。全屏终端应用、按键序列、BEL 触发的控制流、进程丢失后的会话恢复以及跨 agent 共享会话都明确推迟,直到基础生命周期得到验证。
|
||||
|
||||
### 包拓扑
|
||||
|
||||
| 包 | 角色 | ctx key |
|
||||
|---|---|---|
|
||||
| `dsh-pty` | `PtyService`、branded `PtySessionId`、后端注册表、按 owner 隔离的会话契约和结果类型 | `ctx.pty` |
|
||||
| `dsh-pty-local` | 基于 [`node-pty`](https://github.com/microsoft/node-pty) 的本地后端、平台进程检查、有界终端缓冲、沙箱解析和进程树监管 | 在 `ctx.pty` 上注册后端 |
|
||||
| `dsh-tool-pty` | 6 个面向模型的工具、后台发送的 task 运行时集成、使用指引和 ACP render intent | 注册到 `ctx.tools` |
|
||||
|
||||
idle 检测属于后端行为,不是第二条公共 seam。远程或容器后端可能拥有完全不同于本地 `/proc` 检查的权威就绪信号;因此每个 `PtyBackend` 都返回统一的发送结果,同时在内部拥有自己的检测机制。
|
||||
|
||||
### agent 所有权与身份
|
||||
|
||||
`PtyService` 在进程内保存活会话,但每个会话都由工具执行上下文传入的确切 `Agent` 拥有。服务铸造不透明的 `PtySessionId`;模型可选填的 `name` 只是显示元数据,仅在该 owner 内唯一。所有操作都以 `sessionId` 为目标,`list`/`read`/`signal`/`kill` 会拒绝 owner 之外的调用方。
|
||||
|
||||
初始设计不提供插件加载期 auto-start 会话。`pty_spawn` 只在 agent 工具调用期间创建会话,此时所有权和所属的事件溯源会话都已确定。若部署后续需要声明式启动,必须通过尚未发布的 agent setup 组合,而不能创建全局共享终端。
|
||||
|
||||
agent scope dispose 时先关闭注册,再等待全部所属 PTY 静默退出。后端或工具插件 reload 不会遗留会话:所有权持续存放在 `PtyService` 中,直到 agent 结束,与 [`ctx.tasks`](../../../../packages/tasks/tasks/README.md) 的服务持有记录模式一致。
|
||||
|
||||
### 安全与进程边界
|
||||
|
||||
注册的 `shell` 后端只约束终端如何启动,不约束启动后输入的命令。因此 `dsh-pty-local` 在 spawn 前应用两层保护:
|
||||
|
||||
- 它使用与 `bash-local` 相同的凭证形态名称策略构建清洗后的子进程环境,移除环境中的 `*KEY*`、`*SECRET*`、`*TOKEN*` 和 harness 管理的变量,除非显式的可信映射提供这些值。
|
||||
- 它的 `sandbox` 配置为 `required | optional | disabled`,默认 `required`。`required` 在缺少 `ctx.sandbox` 时于插件加载期失败;`optional` 在提供方存在时使用;`disabled` 是显式选择无约束模式。所选提供方只包装一次会话 argv,并在 PTY 的整个生命周期中充当进程边界。
|
||||
|
||||
沙箱限制本地进程副作用,但不会让任意 shell 输入自动安全:网络调用和其他外部副作用仍由部署策略治理。工具描述会说明 PTY 会话比一次性工具更难审计,只应在确实需要持久状态或交互式 stdin 时使用。
|
||||
|
||||
实现只使用 `node-pty` 的公共功能:子进程 PID、`data` 与 `exit` 通知、`write`、`resize` 和 `kill`。它不假设能访问原生 master fd,也不从 TypeScript 调用 `waitpid`。平台进程检查器在 Linux 上通过 `/proc`、在 macOS 上通过 `ps` 推导进程组和会话成员关系。
|
||||
|
||||
### 6 个面向模型的工具
|
||||
|
||||
| 工具 | 用途 | 结果 |
|
||||
|---|---|---|
|
||||
| `pty_spawn` | 从已注册的后端类型创建按 owner 隔离的会话 | `{ sessionId, name, type, motd }` |
|
||||
| `pty_send` | 发送文本、可选提交 Enter,并等待就绪或注册一个后台任务 | 有界 viewport、等待状态和会话状态;后台模式还返回 `taskId` |
|
||||
| `pty_read` | 从保留的 scrollback 读取一个有界页 | `{ text, totalLines, lineBegin, lineEnd, truncated }` |
|
||||
| `pty_signal` | 向当前前台进程组发送一种允许的信号 | `{ delivered, targetPgid }` |
|
||||
| `pty_kill` | 关闭一个会话并等待进程树静默退出 | `{ killed }` |
|
||||
| `pty_list` | 列出调用方的活会话 | 按 owner 隔离的会话摘要 |
|
||||
|
||||
`pty_send({ sessionId, text, submit?, background? })` 将 `text` 视为 UTF-8 字节,并由工具实现在解析阶段把 `submit` 默认成 `true`。`submit` 为 true 时先写入文本,再写入平台 Enter 序列;为 false 时只写文本,使控制字符和 REPL 片段无需隐藏的内容启发式即可发送。
|
||||
|
||||
前台发送返回有界的渲染增量和两个独立事实:`waitReason`(`stdin_read | inferred_idle | timeout | session_exit`)与 `sessionStatus`(`running`,或携带退出码或信号的 `exited`)。`session_exit` 指 PTY 顶层 shell 进程退出,不指由 shell 消费状态的任意前台命令。timeout 从不意味着进程已经退出。
|
||||
|
||||
当 `background: true` 时,`dsh-tool-pty` 在 `ctx.tasks` 上注册进行中的发送,并立即返回 `taskId`。`task_output(wait: true)` 负责等待、读取增量输出并记录最终结果;`task_kill` 将取消转发为 `SIGINT`,只有 PTY 后端拥有的 teardown 路径可以升级信号。若 task 对外接口不存在,后台模式必须在写入输入前失败。设计不新增 PTY 专用的 `sleep` 工具或通用唤醒 seam。
|
||||
|
||||
`pty_read` 从最新保留行向后分页。后端同时对保留的 scrollback 和完整返回值执行行数与 UTF-8 字节上限,因此单个超长行无法绕过限制。`truncated` 用于区分保留数据丢失与普通 viewport 增量。
|
||||
|
||||
`pty_signal` 接受闭合集 `SIGINT | SIGTERM | SIGKILL | SIGTSTP | SIGHUP`。后端在执行时解析终端前台进程组。当目标组是顶层 shell 时拒绝 `SIGKILL`,并指引调用方使用 `pty_kill`;进程组解析失败时操作直接失败,而不是向猜测的 PID 发送信号。
|
||||
|
||||
### 本地就绪检测
|
||||
|
||||
本地后端执行 3 个有界层级。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs` 和 `timeoutMs`。
|
||||
|
||||
在 Linux 上,检查器从 `/proc/<shellPid>/stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。
|
||||
|
||||
macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入并在 Linux 上完成 unit 覆盖率,同时由 macOS CI job 驱动真实 PTY 和进程表路径。
|
||||
|
||||
Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.tasks` 等待、向前台组发信号,或从另一个会话排查。
|
||||
|
||||
`node-pty` data 通知进入同一个流式 decoder 和终端 parser。parser 的 carry 状态处理跨 chunk 的 UTF-8 与终端查询序列。首次交付只规范化行式输出并检测 alternate-screen 进入,不承诺正确操作全屏应用。
|
||||
|
||||
### 模型可见输出与持久性
|
||||
|
||||
现有持久化 `tool/call` 与 `tool/result` 事件是模型发送文本和返回给模型的渲染输出的真源。`pty_spawn` 通过已记录的工具结果返回 MOTD;前台 `send`/`read`/`list`/`signal`/`kill` 结果走同一路径记录。PTY 包不会把原始字节流重复写入自定义会话事件。
|
||||
|
||||
后台发送复用现有后台任务完成通知和 `task_output` 结果路径,因此进入后续模型请求的任何输出同样持久化。原始终端字节只作为有界的进程内状态存在,既不持久化也不可恢复。未来的 opt-in transcript sink 必须拥有独立的保留、凭证和隐私契约。
|
||||
|
||||
### 进程树 teardown
|
||||
|
||||
顶层 `node-pty` 子进程视为 POSIX 会话 leader,但所属资源是完整的 OS 进程会话,而不是一个 PID。关闭时,后端先停止 callback,再向仍匹配的会话成员发送 `SIGTERM`、关闭 PTY、等待 `node-pty` exit 与进程检查器确认静默,然后在可配置的 `disposeGraceMs` 后向已验证的存活者发送 `SIGKILL`。成员快照包含进程启动身份,避免 PID 复用把升级信号发给无关进程。
|
||||
|
||||
teardown 独立报告根进程退出与存活进程清理。它不会只因 shell 退出就声称成功;dispose 只有在已捕获的会话成员全部消失后才完成,否则返回结构化清理失败并列出存活者。
|
||||
|
||||
### 组合与推行
|
||||
|
||||
示例组合保持 opt-in,并采用安全默认值:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
'@deepseek-ai/dsh-sandbox-local':
|
||||
'@deepseek-ai/dsh-pty':
|
||||
'@deepseek-ai/dsh-pty-local':
|
||||
config:
|
||||
sandbox: required
|
||||
scrollbackLines: 10000
|
||||
scrollbackMaxBytes: 4194304
|
||||
maxReadBytes: 262144
|
||||
pollIntervalMs: 50
|
||||
exactProbeAfterMs: 150
|
||||
idleSilenceMs: 3000
|
||||
timeoutMs: 30000
|
||||
disposeGraceMs: 3000
|
||||
'@deepseek-ai/dsh-tool-pty':
|
||||
```
|
||||
|
||||
包会提供简洁的工具指引,说明持久状态、owner 隔离、不确定的 idle 结果、清理,以及无需交互时优先使用现有一次性工具。它不增加全局 system prompt 推荐,也不在已发布的默认配置中挂载 PTY。
|
||||
|
||||
### 推迟的工作
|
||||
|
||||
- 全屏 TUI 支持、命名按键序列、BEL 中断、终端 resize 工具和 alternate-screen 快照需要另行验证面向模型的契约。
|
||||
- 声明式 per-agent 启动需要 agent-setup 组合点;仍然禁止插件加载期全局会话。
|
||||
- harness 进程丢失后的会话恢复需要进程外 owner 和版本化协议。
|
||||
- 网络出口策略与外部副作用回滚超出 PTY 范围,继续作为独立安全工作。
|
||||
- Windows/ConPTY 支持需要具备 Windows 原生进程所有权与信号语义的后端。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**用 PTY 替换 `bash`、文件系统工具或 task 工具。**拒绝。一次性工具拥有更强的校验、审批、沙箱、输出上限和回放契约。PTY 只服务交互式状态。
|
||||
|
||||
**给 `bash` 增加持久模式。**拒绝。按就绪而不是进程退出返回、跨调用保留进程树、暴露交互式 stdin 会形成不同的所有权和失败契约。
|
||||
|
||||
**要求从 `node-pty` 获取原生 master fd。**拒绝。它的公共 API 不暴露 master fd。本地后端改为从受支持的 OS 进程元数据推导前台组和 session 成员,并把不可读元数据视为 detector miss。
|
||||
|
||||
**发布可替换注册表 `PtyIdleDetector`。**拒绝。只有本地后端需要这些平台 probe,远程后端可能通过自己的协议接收就绪状态。替换后端已经提供所需扩展点。
|
||||
|
||||
**新增 PTY 专用 `sleep` 工具。**拒绝。`ctx.tasks` 已经拥有有界等待、取消、完成通知和面向模型的收集。第二套通用唤醒机制会跨越 agent loop(智能体循环)边界并重复该契约。
|
||||
|
||||
**在首次交付包含 TUI sequence 与 BEL 处理。**拒绝。源 prototype 将这些路径视为 timing-sensitive,且仍记录未解决的 alternate-screen 和交互失败。行式 PTY 已能证明核心价值,无需把未经验证的行为放进基础层。
|
||||
|
||||
**立即采用进程外 daemon。**初始的进程内功能不采用,因为当前持久 front door 已能维持 Cordis context。跨进程恢复或多客户端 attach 会让 daemon 变得合理,但两者都已推迟。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- `packages/pty/{pty,pty-local,tool-pty}` 分别作为接口、本地实现和模型消费方构建;后端注册可干净 dispose。
|
||||
- 每个活 PTY 都有一个由服务铸造的 `PtySessionId`、一个确切的 `Agent` owner、按 owner 隔离的操作,并在 agent dispose 时等待清理;并发 agent 可以复用显示名称而不共享状态。
|
||||
- `dsh-pty-local` 只使用 `node-pty` 公共 API,不包含 master-fd 或 TypeScript `waitpid` 假设。
|
||||
- 环境测试证明凭证形态的环境变量不存在。缺少提供方时 `sandbox: required` 在加载期失败,REAL-composition 测试证明提供方包装长活会话进程。
|
||||
- Linux fixture(测试前置数据)覆盖 shell 管道、读取 stdin 的非 leader 进程、读取 stdin 的非主线程、不可读进程内存、受支持的 UAPI syscall 表、不支持的架构和误报拒绝。macOS 进程检查逻辑在 Linux 上达到 100% 覆盖率,macOS CI 驱动真实 bash 与 Python REPL。
|
||||
- 前台测试覆盖 `stdin_read`、`inferred_idle`、`timeout` 和顶层会话退出,不把前台命令退出当作可直接观察事件。
|
||||
- 后台发送注册 `ctx.tasks` work、在就绪前返回、通过 `task_output` 流式提供有界输出、遵守 task cancellation,并在 task 对外接口缺失时于写入前失败。
|
||||
- scrollback 与每个面向模型的结果都对最终 UTF-8 字节执行上限,包括单个超长行和多字节边界情况。
|
||||
- `pty_signal` 解析活跃前台组,拒绝查询失败和指向 shell 的 `SIGKILL`,且绝不回退到猜测的 PID。
|
||||
- dispose 测试启动前台与后台子进程,包括忽略信号的子进程,然后证明等待 agent dispose 后每个捕获的进程身份立即消失。
|
||||
- 测试专用 `cordis.yml` 在 Linux 与 macOS 上通过 Loader 启动,挂载真实本地后端与沙箱,并通过真实工具注册表驱动 spawn/send/read/signal/kill/list。ACP 与 headless 快照固定 6 个 schema、有界结果、错误和 render intent。
|
||||
- TUI、sequence、BEL、auto-start、Windows 和 crash-restoration 行为不出现在公共 schema 中,并记录为推迟事项,而不是由 fixture 模拟。
|
||||
- 包 README 与 JSDoc 记录配置、所有权、失败、取消、上限、沙箱、模型可见影响和限制;实现同时更新 `docs/architecture.md` 与生成目录。
|
||||
- 根 `AGENTS.md` 中的仓库 CI 等价序列通过,包括 `test:coverage`、快照、文档、构建、hygiene 和 built-entry smoke。
|
||||
|
||||
## 风险
|
||||
|
||||
**Linux Tier 1 之外的 idle 都是启发式结果。**输出静默无法区分 prompt、sleep 和网络 I/O。类型化结果保留不确定性,有界 timeout、task 等待与信号让模型仍能掌握控制权。
|
||||
|
||||
**持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何 prompt 都无法让状态持久化变成确定行为。
|
||||
|
||||
**Shell 可以造成外部副作用。**会话沙箱和环境清洗降低本地暴露,但无法撤销 push、API 调用或消息发送。无法容忍这些副作用的部署必须省略 PTY 或增加网络策略。
|
||||
|
||||
**进程丢失会销毁终端状态。**进程内会话无法跨 harness crash 或 restart 存活,原始 scrollback 也不持久化。重要工作必须提交到文件或其他持久系统。
|
||||
|
||||
**`node-pty` 是原生依赖。**安装、支持的 Node 版本、prebuild 可用性和平台行为都需要在每个支持 OS 上运行 built-artifact smoke。
|
||||
@@ -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
|
||||
architecture.md: d7555368b583203cee664da3615c4fb4fb680c30
|
||||
architecture.zh.md: bafe59dee0559d5969c7400bdae85be0d012b3b5
|
||||
architecture.md: d1a69f18f0ff043045a768f1972ea9687cbed21b
|
||||
architecture.zh.md: 1a870b26a2866999f323bc3d91300d5b4792033d
|
||||
@@ -62,7 +62,7 @@ Waterfall events behave like around-middleware: a listener delegates by calling
|
||||
|
||||
## Default Loop Lifecycle
|
||||
|
||||
The shipped loop drains prompt-to-checkpoint work through plugin-visible services and events.
|
||||
The shipped loop runs prompt-to-checkpoint work through plugin services and events.
|
||||
|
||||
A **session** is append-only. Each ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits the preceding claimed turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it; a **step** is one model request plus tools. In the [sequence below](agent-lifecycle.md), quotes mark durable events.
|
||||
|
||||
@@ -90,7 +90,7 @@ forever:
|
||||
agent/pre-step
|
||||
snapshot the derived messages (the reconstruction boundary)
|
||||
'step/start'
|
||||
agent/request (config only) -> log request/header -> llm/stream (frozen)
|
||||
agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen)
|
||||
on final adapter-path or terminal in-band failure:
|
||||
'step/end'
|
||||
agent/request-error(original error, failure facts, immutable prior failures, signal)
|
||||
@@ -102,10 +102,10 @@ forever:
|
||||
schedule tool calls by ctx.tools.executionMode:
|
||||
exclusive -> one-call barrier
|
||||
parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start
|
||||
each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
|
||||
each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute
|
||||
each model-order result -> ordered tools/post-execute -> 'tool/result'
|
||||
append accepted tool-batch context after all recorded results, then steering
|
||||
agent/post-step
|
||||
agent/post-step -> checkpoint complete response/results
|
||||
'step/end'
|
||||
agent/turn-continuation
|
||||
agent/turn-stop (terminal policy)
|
||||
@@ -126,7 +126,7 @@ The turn contains failures. Adapter failures close the step before `agent/reques
|
||||
|
||||
Other failures use `agent/error`. Cancellation and disposal beat recovery; undispatched tool calls get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. The turn signal retires before `turn/end`. Effective `cancel()` emits its typed cause before clearing queues and aborting; observers cannot veto, idle calls emit nothing, and durability records `aborted`. Disposal awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)).
|
||||
|
||||
Every session event is turn-enclosed. Reloading preserves an interrupted tail and closes it with a synthetic `interrupted` turn end. Failures after durable turn close report only through `agent/error` because no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants.
|
||||
Session events are turn-enclosed. Reload closes an interrupted tail with a synthetic `interrupted` turn end. Post-close failures report only through `agent/error`; no safe in-turn position remains. Each turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) owns the variants.
|
||||
|
||||
### Agent Handles
|
||||
|
||||
@@ -144,7 +144,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw
|
||||
|
||||
**Model-visible ⟺ logged**: the log reconstructs every request — messages at `step/start` fronted by the header's session prefix, and headers by folding `request/header` — and the package-owned `dsh-agent-loop/invariant` can assert it through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)).
|
||||
|
||||
Durability is a plugin concern. Backends buffer synchronous `session/event` notifications; the loop awaits a turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract.
|
||||
Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)).
|
||||
|
||||
`ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)).
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 `
|
||||
|
||||
## 默认循环生命周期
|
||||
|
||||
已交付的循环通过插件可见的服务和事件,持续处理从提示词到检查点的工作。
|
||||
已交付的循环通过插件服务和事件,处理从提示词到检查点的工作。
|
||||
|
||||
**会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。
|
||||
|
||||
@@ -90,7 +90,7 @@ forever:
|
||||
agent/pre-step
|
||||
snapshot the derived messages (the reconstruction boundary)
|
||||
'step/start'
|
||||
agent/request (config only) -> log request/header -> llm/stream (frozen)
|
||||
agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen)
|
||||
on final adapter-path or terminal in-band failure:
|
||||
'step/end'
|
||||
agent/request-error(original error, failure facts, immutable prior failures, signal)
|
||||
@@ -102,10 +102,10 @@ forever:
|
||||
schedule tool calls by ctx.tools.executionMode:
|
||||
exclusive -> one-call barrier
|
||||
parallel -> rolling pool, <= maxParallelToolCalls in flight; reclassify before start
|
||||
each start -> 'tool/call' -> ordered tools/pre-execute -> concurrent tools/execute
|
||||
each start -> 'tool/call' -> ordered tools/pre-execute -> checkpoint -> concurrent tools/execute
|
||||
each model-order result -> ordered tools/post-execute -> 'tool/result'
|
||||
append accepted tool-batch context after all recorded results, then steering
|
||||
agent/post-step
|
||||
agent/post-step -> checkpoint complete response/results
|
||||
'step/end'
|
||||
agent/turn-continuation
|
||||
agent/turn-stop (terminal policy)
|
||||
@@ -126,7 +126,7 @@ forever:
|
||||
|
||||
其他故障使用 `agent/error`。取消和资源释放均优先于恢复;尚未分派的工具调用会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。轮次信号会在 `turn/end` 前失效。实际生效的 `cancel()` 会在清空队列和中止前发出类型化原因;观察方不能否决该操作,空闲状态下的调用不发出任何事件,持久化会记录 `aborted`。dispose(资源释放)会等待系统停稳([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。
|
||||
|
||||
每个会话事件都包围在轮次内。重新加载会保留中断的日志尾部,并用合成的 `interrupted` 轮次结束事件将其闭合。持久轮次关闭后的故障只通过 `agent/error` 报告,因为此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。
|
||||
会话事件均位于轮次边界内。重新加载会用合成的 `interrupted` 轮次结束事件闭合中断的日志尾部。关闭后的故障只通过 `agent/error` 报告;此时已没有安全的轮次内位置。每个轮次有一个 `TurnEndReason`;各变体由 [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) 统一定义。
|
||||
|
||||
### Agent 句柄
|
||||
|
||||
@@ -144,7 +144,7 @@ forever:
|
||||
|
||||
**模型可见 ⟺ 已记录**:日志可以重建每个请求,包括由请求头会话前缀置于开头的 `step/start` 时消息,以及通过折叠 `request/header` 得到的请求头;开发期不变量会断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。
|
||||
|
||||
持久性由插件负责。后端会缓冲同步的 `session/event` 通知;循环等待轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约。
|
||||
持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。
|
||||
|
||||
`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。
|
||||
|
||||
|
||||
+17
-5
@@ -60,6 +60,8 @@ export interface Config {
|
||||
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
|
||||
packChunks?: boolean
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
persistenceCompression?: JsonlCompression
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
|
||||
@@ -79,7 +81,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
|
||||
|
||||
Source: [`packages/examples/acp-demo/src/index.ts:38`](../packages/examples/acp-demo/src/index.ts)
|
||||
Source: [`packages/examples/acp-demo/src/index.ts:41`](../packages/examples/acp-demo/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-agent-loop`
|
||||
|
||||
@@ -270,7 +272,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
|
||||
|
||||
Source: [`packages/examples/cli-demo/src/index.ts:25`](../packages/examples/cli-demo/src/index.ts)
|
||||
Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-demo/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-code-runtime-worker`
|
||||
|
||||
@@ -638,7 +640,7 @@ export interface ReplayModelConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/support/llm-replay/src/index.ts:385`](../packages/support/llm-replay/src/index.ts)
|
||||
Source: [`packages/support/llm-replay/src/index.ts:387`](../packages/support/llm-replay/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-retry`
|
||||
|
||||
@@ -889,7 +891,7 @@ Source: [`packages/sandbox/sandbox-policy/src/index.ts:44`](../packages/sandbox/
|
||||
Requires: `sessions`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: where the JSONL backend keeps its session logs (`root` is required — no default). */
|
||||
/** Plugin config: where the JSONL backend keeps its session logs, and the packed-row write switch. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Root directory for all session files. Required (no default): a default of
|
||||
@@ -897,6 +899,15 @@ export interface Config {
|
||||
* (bash calls, subprocesses). Sessions group under per-cwd subdirectories.
|
||||
*/
|
||||
root: string
|
||||
/**
|
||||
* Write runs of consecutive `assistant/chunk` delta events as packed
|
||||
* `text-chunks`/`reasoning-chunks`/`tool-call-chunks` rows (lossless,
|
||||
* ~60% smaller logs measured on a real session). Off by default while
|
||||
* snapshot fixtures stay in the one-event-per-line layout: recording with
|
||||
* packing on rewrites every golden `session.jsonl`. READING packed rows is
|
||||
* unconditional — a log's layout never depends on this switch.
|
||||
*/
|
||||
packChunks?: boolean
|
||||
/** Physical encoding; defaults to checksummed Zstandard frames. */
|
||||
compression?: JsonlCompression
|
||||
}
|
||||
@@ -1576,7 +1587,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts)
|
||||
|
||||
Source: [`packages/examples/tui-demo/src/index.ts:32`](../packages/examples/tui-demo/src/index.ts)
|
||||
Source: [`packages/examples/tui-demo/src/index.ts:33`](../packages/examples/tui-demo/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-user-approval`
|
||||
|
||||
@@ -1796,6 +1807,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
|
||||
- `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
|
||||
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
|
||||
|
||||
@@ -569,7 +569,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:68`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:70`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/disposed` — emit
|
||||
|
||||
@@ -590,7 +590,7 @@ Emitted once when an announced session leaves the store, including publication r
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:80`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/event` — emit
|
||||
|
||||
@@ -613,7 +613,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:90`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:92`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/flush` — parallel
|
||||
|
||||
@@ -634,7 +634,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:100`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:102`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `subagent/*`
|
||||
|
||||
|
||||
@@ -1059,7 +1059,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
|
||||
|
||||
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:592`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:594`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.sessionTitle` — `SessionTitleService`
|
||||
|
||||
|
||||
@@ -530,6 +530,6 @@ The hook bridges' `hook/invoked` / `hook/result` provenance pairs (from `@deepse
|
||||
|
||||
## Durability contract
|
||||
|
||||
What a persistence backend relies on: the durable log persists every event verbatim, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting checked by the session invariant companion, is a breaking change to the on-disk format.
|
||||
What a persistence backend relies on: the durable log persists every event losslessly, **including** `assistant/chunk` — `seq` must stay contiguous, so chunks cannot be filtered out of the canonical log. A backend may choose its own storage encoding for an event batch as long as `load` returns the exact appended events (the JSONL backend's opt-in packed chunk rows are such an encoding — see [persistence.md](persistence.md)). All `event.data` must be JSON-serializable; `Session.append` enforces this at the source (throwing on non-serializable data), so a bad event never enters the log and `session.events` always equals what a backend can persist. Adding an event type that carries non-serializable data, or that breaks the turn/step nesting checked by the session invariant companion, is a breaking change to the on-disk format.
|
||||
|
||||
The backends that consume this contract are on [persistence.md](persistence.md).
|
||||
@@ -12,7 +12,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:172`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) |
|
||||
| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic) |
|
||||
| `agent/post-step` | `serial` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:191`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
||||
@@ -30,11 +30,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:90`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:100`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
@@ -42,7 +42,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:123`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`timeout-policy`](../packages/timeout/timeout-policy) |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:93`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:105`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:82`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
|
||||
|
||||
+14
-3
@@ -98,6 +98,7 @@ flowchart TD
|
||||
pkg_hooks_codex["hooks-codex"]
|
||||
end
|
||||
subgraph group_session_persistence["packages/session-persistence"]
|
||||
pkg_session_checkpoint_policy["session-checkpoint-policy"]
|
||||
pkg_session_persistence["session-persistence"]
|
||||
pkg_session_persistence_jsonl["session-persistence-jsonl"]
|
||||
pkg_session_persistence_sqlite["session-persistence-sqlite"]
|
||||
@@ -518,6 +519,12 @@ flowchart TD
|
||||
pkg_hooks_codex --> pkg_session
|
||||
pkg_hooks_codex --> pkg_session_persistence
|
||||
pkg_hooks_codex --> pkg_tools
|
||||
pkg_session_checkpoint_policy --> pkg_agent
|
||||
pkg_session_checkpoint_policy --> pkg_invariants
|
||||
pkg_session_checkpoint_policy --> pkg_llm
|
||||
pkg_session_checkpoint_policy --> pkg_session
|
||||
pkg_session_checkpoint_policy --> pkg_session_persistence
|
||||
pkg_session_checkpoint_policy --> pkg_tools
|
||||
pkg_agent_loop_testkit --> pkg_agent
|
||||
pkg_agent_loop_testkit --> pkg_invariants
|
||||
pkg_agent_loop_testkit --> pkg_llm
|
||||
@@ -672,6 +679,7 @@ flowchart TD
|
||||
pkg_acp_demo --> pkg_command_goal
|
||||
pkg_acp_demo --> pkg_commands
|
||||
pkg_acp_demo --> pkg_invariants
|
||||
pkg_acp_demo --> pkg_session_checkpoint_policy
|
||||
pkg_acp_demo --> pkg_session_persistence_jsonl
|
||||
pkg_acp_demo --> pkg_tools
|
||||
pkg_acp_demo --> pkg_user_interaction
|
||||
@@ -682,6 +690,7 @@ flowchart TD
|
||||
pkg_cli_demo --> pkg_invariants
|
||||
pkg_cli_demo --> pkg_llm
|
||||
pkg_cli_demo --> pkg_session
|
||||
pkg_cli_demo --> pkg_session_checkpoint_policy
|
||||
pkg_cli_demo --> pkg_session_persistence_jsonl
|
||||
pkg_cli_demo --> pkg_tools
|
||||
pkg_cli_demo --> pkg_workspace_context
|
||||
@@ -694,6 +703,7 @@ flowchart TD
|
||||
pkg_tui_demo --> pkg_invariants
|
||||
pkg_tui_demo --> pkg_llm
|
||||
pkg_tui_demo --> pkg_session
|
||||
pkg_tui_demo --> pkg_session_checkpoint_policy
|
||||
pkg_tui_demo --> pkg_session_persistence_jsonl
|
||||
pkg_tui_demo --> pkg_tool_ask_user
|
||||
pkg_tui_demo --> pkg_tools
|
||||
@@ -802,6 +812,7 @@ flowchart TD
|
||||
| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) |
|
||||
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
|
||||
| [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | `session-persistence` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
|
||||
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
|
||||
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
|
||||
@@ -822,6 +833,6 @@ flowchart TD
|
||||
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) |
|
||||
@@ -0,0 +1,45 @@
|
||||
# Keyless replay counterpart of packed-chunks.cordis.yml. Patches do not
|
||||
# compose across includes, so this applies the packChunks config and the
|
||||
# DeepSeek-to-replay swap directly to `cordis.yml`.
|
||||
- id: base
|
||||
name: '@cordisjs/plugin-include'
|
||||
config:
|
||||
path: ./cordis.yml
|
||||
patches:
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
disabled: true
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
config:
|
||||
runnerCommand:
|
||||
- bash
|
||||
- -c
|
||||
- while [ "$1" != "--" ]; do shift; done; shift; exec "$@"
|
||||
- passthrough-runner
|
||||
runnerFailureSignatures:
|
||||
- 'passthrough-runner: profile rejected'
|
||||
- id: acp-agent
|
||||
name: '@deepseek-ai/dsh-acp-demo'
|
||||
config:
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
persistenceCompression: 'none'
|
||||
packChunks: true
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
persona: |
|
||||
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
- insert:
|
||||
- id: llm-replay
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
config:
|
||||
providers:
|
||||
- id: deepseek
|
||||
name: DeepSeek
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
- id: deepseek-v4-pro
|
||||
@@ -0,0 +1,23 @@
|
||||
# The packed-chunk-rows overlay: the base tree with the JSONL backend's
|
||||
# `packChunks` switched on, so delta-chunk runs persist as packed storage rows.
|
||||
# A config patch replaces the whole app config, so unchanged base fields are
|
||||
# restated below.
|
||||
- id: base
|
||||
name: '@cordisjs/plugin-include'
|
||||
config:
|
||||
path: ./cordis.yml
|
||||
patches:
|
||||
- id: acp-agent
|
||||
name: '@deepseek-ai/dsh-acp-demo'
|
||||
config:
|
||||
provider: deepseek
|
||||
model: deepseek-v4-flash
|
||||
persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions'
|
||||
persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'"
|
||||
packChunks: true
|
||||
workspaceContext:
|
||||
maxBytes: 65536
|
||||
persona: |
|
||||
You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
@@ -1,7 +1,10 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { expect, it } from 'vitest'
|
||||
import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* The acp-agent example's snapshot suite: the scenario table for
|
||||
@@ -32,8 +35,18 @@ const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cor
|
||||
const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url))
|
||||
const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url))
|
||||
const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url))
|
||||
const PACKED_CHUNKS_CONFIG = fileURLToPath(new URL('../packed-chunks.cordis.yml', import.meta.url))
|
||||
const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url))
|
||||
const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url))
|
||||
const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
|
||||
const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny'
|
||||
|
||||
function fixtureRecords(name: string): unknown[] {
|
||||
return readFileSync(join(SNAPSHOTS_DIR, name, 'session.jsonl'), 'utf8')
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
.map(line => JSON.parse(line) as unknown)
|
||||
}
|
||||
|
||||
function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] {
|
||||
switch (value) {
|
||||
@@ -73,6 +86,10 @@ const SCENARIOS: Scenario[] = [
|
||||
// Its prompt and tool-schema sidecars pin the composed header.
|
||||
{ name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
|
||||
{ name: 'tool-call-turn', hasModelTurn: true, recorded: true },
|
||||
// Authored from the real PACKED_CHUNKS_SOURCE recording under the same app
|
||||
// composition. The contract below pins decoded equality and all three row
|
||||
// kinds; replay additionally proves the assembled app re-packs identically.
|
||||
{ name: 'packed-chunks', hasModelTurn: true, recorded: false, configPath: PACKED_CHUNKS_CONFIG },
|
||||
// The fs overlay only adds the spill stack (the sandboxed filesystem tools
|
||||
// live in the base tree), so these scenarios share the default header class.
|
||||
{
|
||||
@@ -232,7 +249,20 @@ const SCENARIOS: Scenario[] = [
|
||||
|
||||
defineAcpSnapshotSuite({
|
||||
agent: AGENT,
|
||||
snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'),
|
||||
snapshotsDir: SNAPSHOTS_DIR,
|
||||
scenarios: SCENARIOS,
|
||||
mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT),
|
||||
})
|
||||
|
||||
it('packed ACP fixture retains every chunk row kind without changing the logical session', () => {
|
||||
const source = fixtureRecords(PACKED_CHUNKS_SOURCE)
|
||||
const packed = fixtureRecords('packed-chunks')
|
||||
const rowTypes = packed.flatMap((record) => {
|
||||
if (record === null || typeof record !== 'object') return []
|
||||
const type = (record as { type?: unknown }).type
|
||||
return type === 'text-chunks' || type === 'reasoning-chunks' || type === 'tool-call-chunks' ? [type] : []
|
||||
})
|
||||
|
||||
expect([...new Set(rowTypes)].sort()).toStrictEqual(['reasoning-chunks', 'text-chunks', 'tool-call-chunks'])
|
||||
expect([packed[0], ...packed.slice(1).flatMap(record => decodeStorageRecord(record))]).toStrictEqual(source)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"type":"session","version":0,"id":"semantic-checkpoint-replay","createdAt":1,"delegationDepth":0}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "text" },
|
||||
{ "type": "text-delta", "index": 0, "text": "I will verify the external state before deciding whether to retry the side-effecting operation." },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "text", "text": "I will verify the external state before deciding whether to retry the side-effecting operation." } },
|
||||
{ "type": "finish", "reason": { "kind": "stop" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Perform one side-effecting remote mutation."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"assistant/message","seq":3,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"}},"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":4,"time":0,"data":{"turn":1,"step":1,"callId":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}}
|
||||
{"type":"tool/result","seq":5,"time":0,"data":{"turn":1,"step":1,"callId":"unknown-outcome-call","content":[{"type":"text","text":"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."}],"isError":true,"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[4]}
|
||||
{"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"interrupted"}}}
|
||||
{"type":"turn/start","seq":8,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":10,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":11,"time":0,"data":{"turn":2,"step":1}}
|
||||
{"type":"request/header","seq":12,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}
|
||||
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}}
|
||||
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":17,"time":0,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"}},"sourceEventSeqs":[13,14,15,16],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":18,"time":0,"data":{"turn":2,"step":1}}
|
||||
{"type":"turn/end","seq":19,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Perform one side-effecting remote mutation."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"unknown-outcome-call","title":"write_remote","kind":"other","status":"in_progress","rawInput":{"value":1}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"unknown-outcome-call","status":"failed","content":[{"type":"content","content":{"type":"text","text":"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."}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Perform one side-effecting remote mutati","updatedAt":"{{updatedAt}}"}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
launchAcpTestAgent,
|
||||
normalizeSessionLog,
|
||||
normalizeStdout,
|
||||
scrubRequestHeaders,
|
||||
type AgentUnderTest,
|
||||
type NormalizeContext,
|
||||
} from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'semantic-checkpoint-snapshots/tool-outcome-unknown')
|
||||
const replayFixture = join(fixtureDir, 'replay.jsonl')
|
||||
const replayOverride = join(fixtureDir, 'replay.override.json')
|
||||
const stdoutExpected = join(fixtureDir, 'stdout.expected.jsonl')
|
||||
const sessionExpected = join(fixtureDir, 'session.expected.jsonl')
|
||||
const sessionId = SessionId('semantic-checkpoint-unknown-outcome')
|
||||
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
|
||||
|
||||
const agent: AgentUnderTest = {
|
||||
binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
|
||||
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
|
||||
tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
|
||||
}
|
||||
|
||||
async function seedInterruptedSession(root: string, cwd: string): Promise<string> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
const meta: SessionHeader = {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: sessionId,
|
||||
createdAt: 1,
|
||||
cwd,
|
||||
delegationDepth: 0,
|
||||
}
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 10, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'user/message', seq: 1, time: 11, data: { content: [{ type: 'text', text: 'Perform one side-effecting remote mutation.' }], source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'step/start', seq: 2, time: 12, data: { turn: 1, step: 1 } },
|
||||
{
|
||||
type: 'assistant/message',
|
||||
seq: 3,
|
||||
time: 13,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('unknown-outcome-call'), name: 'write_remote', arguments: '{"value":1}' }],
|
||||
provenance: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'tool/call',
|
||||
seq: 4,
|
||||
time: 14,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('unknown-outcome-call'),
|
||||
name: 'write_remote',
|
||||
arguments: '{"value":1}',
|
||||
},
|
||||
},
|
||||
]
|
||||
try {
|
||||
await ctx.sessionPersistence.create(meta)
|
||||
await ctx.sessionPersistence.append(sessionId, events)
|
||||
const location = ctx.sessionPersistence.locate(meta)
|
||||
if (location === undefined) throw new Error('JSONL backend did not locate the seeded session')
|
||||
return location.path
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
describe('semantic checkpoint recovery snapshot', () => {
|
||||
it('loads an unknown tool outcome and carries retry-risk guidance into the next model turn', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-semantic-snapshot-cwd-'))
|
||||
const sessionsRoot = await mkdtemp(join(tmpdir(), 'dsh-semantic-snapshot-sessions-'))
|
||||
let launched: ReturnType<typeof launchAcpTestAgent> | undefined
|
||||
try {
|
||||
const sessionPath = await seedInterruptedSession(sessionsRoot, cwd)
|
||||
launched = launchAcpTestAgent({
|
||||
agent,
|
||||
cwd,
|
||||
env: {
|
||||
DSH_SNAPSHOT: 'replay',
|
||||
DSH_SNAPSHOT_FILE: replayFixture,
|
||||
DSH_SNAPSHOT_OVERRIDE: replayOverride,
|
||||
DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
|
||||
},
|
||||
})
|
||||
await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await launched.client.loadSession({ sessionId, cwd, mcpServers: [] })
|
||||
await launched.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'Continue safely from the interrupted operation.' }],
|
||||
})
|
||||
await launched.close()
|
||||
|
||||
const normalization: NormalizeContext = { sessionIds: [sessionId], cwd }
|
||||
const stdout = normalizeStdout(launched.rawStdout(), normalization)
|
||||
const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization))
|
||||
if (refreshing) {
|
||||
await writeFile(stdoutExpected, stdout)
|
||||
await writeFile(sessionExpected, session)
|
||||
}
|
||||
expect(stdout).toBe(await readFile(stdoutExpected, 'utf8'))
|
||||
expect(session).toBe(await readFile(sessionExpected, 'utf8'))
|
||||
expect(session).toContain('TOOL_OUTCOME_UNKNOWN')
|
||||
expect(session).toContain('Do not retry blindly.')
|
||||
} finally {
|
||||
await launched?.close('SIGKILL').catch(() => undefined)
|
||||
await Promise.all([
|
||||
rm(cwd, { recursive: true, force: true }),
|
||||
rm(sessionsRoot, { recursive: true, force: true }),
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,9 @@
|
||||
"op": "promptAndCancel",
|
||||
"text": "Run two shell commands: wait for cancellation, then write skipped.txt.",
|
||||
"afterUpdate": "tool_call",
|
||||
"waitForFile": { "path": "started.txt" },
|
||||
"waitForToolCallUpdate": "call_skipped"
|
||||
}
|
||||
},
|
||||
{ "op": "waitForTurnEnd" }
|
||||
]
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{ "type": "block-start", "index": 0, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_wait", "name": "bash", "argumentsDelta": "{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_wait", "name": "bash", "arguments": "{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" } },
|
||||
{ "type": "tool-call-delta", "index": 0, "id": "call_wait", "name": "bash", "argumentsDelta": "{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" },
|
||||
{ "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_wait", "name": "bash", "arguments": "{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}" } },
|
||||
{ "type": "block-start", "index": 1, "blockType": "tool-call" },
|
||||
{ "type": "tool-call-delta", "index": 1, "id": "call_skipped", "name": "bash", "argumentsDelta": "{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}" },
|
||||
{ "type": "block-end", "index": 1, "block": { "type": "tool-call", "id": "call_skipped", "name": "bash", "arguments": "{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}" } },
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
{"type":"step/start","seq":3,"time":1784437195076,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1784437195076,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_wait","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_wait","name":"bash","argumentsDelta":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_skipped","name":"bash","argumentsDelta":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":10}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1784437195077,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":14,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}
|
||||
{"type":"assistant/message","seq":13,"time":1784437195078,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"},{"type":"tool-call","id":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":14,"time":1784437195078,"data":{"turn":1,"step":1,"callId":"call_wait","name":"bash","arguments":"{\"command\":\"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\",\"description\":\"Wait until cancellation\"}"}}
|
||||
{"type":"tool/result","seq":15,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_wait","content":[{"type":"text","text":"Error: command aborted"}],"isError":true},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":16,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","name":"bash","arguments":"{\"command\":\"printf skipped > skipped.txt\",\"description\":\"Write skipped marker\"}"}}
|
||||
{"type":"tool/result","seq":17,"time":1784437195089,"data":{"turn":1,"step":1,"callId":"call_skipped","content":[{"type":"text","text":"Error: tool call aborted before dispatch"}],"isError":true,"error":{"name":"AbortError","code":"ABORTED_BEFORE_DISPATCH"}},"sourceEventSeqs":[16],"surfaceOp":"append"}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Run two shell commands: wait","updatedAt":"{{updatedAt}}"}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"node -e \"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\"","kind":"execute","status":"in_progress","rawInput":"node -e \"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\"","content":[{"type":"content","content":{"type":"text","text":"Wait until cancellation"}}]}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: command aborted\n```"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skipped","title":"printf skipped > skipped.txt","kind":"execute","status":"in_progress","rawInput":"printf skipped > skipped.txt","content":[{"type":"content","content":{"type":"text","text":"Write skipped marker"}}]}}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "promptAndCancel", "text": "Start a long task; this turn will be cancelled mid-stream." }
|
||||
{ "op": "promptAndCancel", "text": "Start a long task; this turn will be cancelled mid-stream." },
|
||||
{ "op": "waitForTurnEnd" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{"type":"session","version":0,"id":"ff1c1e99-3bd4-4ef8-a954-80d607d628ba","createdAt":1783352165190,"cwd":"/tmp/acp-snap-cwd-wDnkVo","delegationDepth":0}
|
||||
{"type":"turn/start","seq":0,"time":1783352165195,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1783352165196,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo HELLO. Report the tool result you got back verbatim, then stop."}],"source":{"kind":"user"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1783352165196,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1783352165198,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1783352165199,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1783352165899,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1783352165899,"data":{"turn":1,"step":1,"index":0,"dt":[149,27,0,0,1,0,0,28,0,1,0,0,28,0,27,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," report"," the"," result"," verb","atim","."]}}
|
||||
{"type":"assistant/chunk","seq":23,"time":1783352166218,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"tool-call-chunks","seq0":24,"time0":1783352166218,"data":{"turn":1,"step":1,"index":1,"dt":[32,0,0,28,1,0,0,29,0,1,0,27,1,28,0,0,0,29,0,28,0,0,0,31],"id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," HE","LL","O","\"",", ","\"","description","\"",": ","\"","Run"," echo"," HE","LL","O","\"","}"]}}
|
||||
{"type":"assistant/chunk","seq":49,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."}}}}
|
||||
{"type":"assistant/chunk","seq":50,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":51,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}}}}
|
||||
{"type":"assistant/chunk","seq":52,"time":1783352166512,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":53,"time":1783352166514,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2878,"outputTokens":83,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":54,"time":1783352166514,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}}
|
||||
{"type":"hook/invoked","seq":55,"time":1783352166515,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}}
|
||||
{"type":"hook/result","seq":56,"time":1783352166528,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":12.367074000000684}}
|
||||
{"type":"tool/result","seq":57,"time":1783352166528,"data":{"turn":1,"step":1,"callId":"call_00_JliP571Bh0QQ8QExbSPk0080","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true},"sourceEventSeqs":[54],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":58,"time":1783352166529,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":59,"time":1783352166529,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":60,"time":1783352167307,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":61,"time0":1783352167308,"data":{"turn":1,"step":2,"index":0,"dt":[132,29,0,0,0,0,1,27,0,28,1,0,31,0,25,0,29,1,1,0],"texts":["The"," bash"," tool"," is"," disabled"," by"," policy","."," I"," need"," to"," report"," this"," error"," verb","atim"," back"," to"," the"," user","."]}}
|
||||
{"type":"assistant/chunk","seq":82,"time":1783352167613,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"text-chunks","seq0":83,"time0":1783352167613,"data":{"turn":1,"step":2,"index":1,"dt":[30,29,29,0,1,28,0,1,0,0,0,26,1,0,0,0,28,0,31,0,25,30,1,0,27,1,0,31,1],"texts":["The"," tool"," returned",":\n\n",">"," Error",":"," bash"," is"," disabled"," by"," policy"," in"," this"," session","\n\n","I"," cannot"," run"," the"," command"," because"," the"," bash"," tool"," is"," disabled"," by"," policy","."]}}
|
||||
{"type":"assistant/chunk","seq":113,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."}}}}
|
||||
{"type":"assistant/chunk","seq":114,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}}}}
|
||||
{"type":"assistant/chunk","seq":115,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}}}}
|
||||
{"type":"assistant/chunk","seq":116,"time":1783352167933,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":117,"time":1783352167934,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."},{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":167,"outputTokens":52,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":118,"time":1783352167934,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":119,"time":1783352167934,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,75 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}},{"name":"plan","description":"Enter plan mode","input":{"hint":"[message]"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Use the bash tool to","updatedAt":"{{updatedAt}}"}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" simple"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_JliP571Bh0QQ8QExbSPk0080","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Run echo HELLO"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_JliP571Bh0QQ8QExbSPk0080","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\nError: bash is disabled by policy in this session\n```"}}]}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" disabled"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" this"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" error"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" returned"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":">"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" Error"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" disabled"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" in"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" this"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" session"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n\n"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"I"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" cannot"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" run"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" command"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" because"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" bash"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" disabled"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" by"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "bash",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'bash is disabled by policy in this session' >&2; exit 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as SessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
|
||||
|
||||
@@ -70,7 +71,10 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
|
||||
// Durable JSONL persistence is opt-in: only the resume e2e needs it, and the
|
||||
// other suites stay file-free. Loaded last so a resume's deferred
|
||||
// `ctx.inject(['sessionPersistence'])` resolves once this is present.
|
||||
if (options.persistenceRoot !== undefined) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot })
|
||||
if (options.persistenceRoot !== undefined) {
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot })
|
||||
await ctx.plugin(SessionCheckpointPolicy)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
config:
|
||||
root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions'
|
||||
|
||||
- id: session-checkpoints
|
||||
name: '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
|
||||
- id: subagent
|
||||
name: '@deepseek-ai/dsh-subagent'
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-sandbox-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*",
|
||||
"@deepseek-ai/dsh-spill-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-spill-policy": "workspace:*",
|
||||
"@deepseek-ai/dsh-tui-demo": "workspace:*",
|
||||
|
||||
@@ -99,7 +99,10 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
|
||||
actions: [
|
||||
...SELECT_PRO_MODEL,
|
||||
{ waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/plan exercise the TUI\r' },
|
||||
{ waitFor: 'How should the scripted run proceed?', send: '\r' },
|
||||
// The question text first appears in the streamed tool-call card. Wait
|
||||
// for the dialog's input legend so Enter cannot arrive before it owns
|
||||
// terminal input when pre-dispatch policy yields.
|
||||
{ waitFor: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' },
|
||||
{ waitFor: 'Decision received. Scripted TUI run complete.', send: '' },
|
||||
// Session title: the first user message drives the first-message-llm
|
||||
// provider's tool-less title call; the scripted adapter answers it, the
|
||||
|
||||
@@ -317,6 +317,10 @@
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/session-persistence/session-checkpoint-policy": {
|
||||
"entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"],
|
||||
"project": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
},
|
||||
"packages/util/paths": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts"
|
||||
|
||||
@@ -48,6 +48,10 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` recursively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization.
|
||||
|
||||
### Chunk-row storage codec (`chunk-rows.ts`)
|
||||
|
||||
Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/chunk` lines whose JSON envelopes dwarf their payloads. `packChunkRuns(events)` packs each run of ≥3 consecutive same-block delta chunks into one storage row — `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` (bare slash-less tags: storage vocabulary, not `SessionEventMap` members) — and `decodeStorageRecord(value)` expands a parsed line back into its exact events (`seq0`/`time0` + per-member `dt` gaps reconstruct every `seq`/`time`). The encoder whitelists exact shapes and stores anything unrecognized verbatim; the decoder validates row-tagged values and throws on malformation. Owned here so the JSONL backend and the fixture readers (`dsh-llm-replay`, `dsh-acp-snapshot`) share one codec; the write-side switch is the backend's `packChunks` config.
|
||||
|
||||
### Surface types
|
||||
|
||||
- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
|
||||
@@ -107,11 +111,11 @@ Appended surface entries preserve reusable prefixes. A `replace` operation inval
|
||||
|
||||
#### What the model sees
|
||||
|
||||
If a persisted turn ended with unanswered tool calls, each synthetic error result contains exactly `Tool call interrupted by a crash; no result was recorded.`
|
||||
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.`
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero tokens in an intact session. Each repaired call adds this retained error text on resume.
|
||||
Zero tokens in an intact session. Each repaired call adds its retained risk-specific error text on resume.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Lossless storage packing for `assistant/chunk` delta runs. Providers stream
|
||||
* token-sized deltas, so a log stores hundreds of near-identical event lines
|
||||
* whose JSON envelopes dwarf their payloads (~56× measured on a real DeepSeek
|
||||
* session). This module packs each run of consecutive same-block delta chunks
|
||||
* into ONE storage row — `text-chunks`, `reasoning-chunks`, or
|
||||
* `tool-call-chunks` — and expands rows back to the exact original events.
|
||||
*
|
||||
* Storage rows are a durable-encoding vocabulary, NOT session events: they
|
||||
* never enter `Session.events`, have no `SessionEventMap` entry, and use bare
|
||||
* (slash-less) type tags so a reader cannot confuse them with the event
|
||||
* taxonomy (precedent: the JSONL header line's `session` tag). The encoder
|
||||
* whitelists exact shapes — anything it does not fully recognize is stored
|
||||
* verbatim, so unknown fields or future chunk variants lose compression, never
|
||||
* data. The decoder validates before expanding and fails loud on a malformed
|
||||
* row-tagged value instead of silently dropping a whole run.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/chunk-rows
|
||||
*/
|
||||
|
||||
import { CallId, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from './types.ts'
|
||||
|
||||
/** The chunk kinds that may pack; block boundaries, usage, and finish chunks always stay one event per line. */
|
||||
type DeltaKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta'
|
||||
|
||||
/** A run member: an `assistant/chunk` event whose exact shape the encoder whitelisted. */
|
||||
type DeltaEvent = SessionEvent<'assistant/chunk'>
|
||||
|
||||
/**
|
||||
* Fields shared by every packed run: placement, block correlation, and member
|
||||
* timestamps as gaps. Member `k` reconstructs as seq `seq0 + k` and time
|
||||
* `time0` plus the first `k` gaps; a gap may be negative when the wall clock
|
||||
* stepped backwards between events.
|
||||
*/
|
||||
interface RunDataBase {
|
||||
turn: number
|
||||
step: number
|
||||
/** The stream block index every member shares. */
|
||||
index: number
|
||||
/** Epoch-ms gaps between consecutive members; length is one less than the member count. */
|
||||
dt: number[]
|
||||
}
|
||||
|
||||
/** Payload of a `text-chunks`/`reasoning-chunks` row: one entry per member, never joined — token boundaries are data. */
|
||||
interface TextRunData extends RunDataBase {
|
||||
texts: string[]
|
||||
}
|
||||
|
||||
/** Payload of a `tool-call-chunks` row: the run-constant call identity plus each member's raw arguments fragment. */
|
||||
interface ToolCallRunData extends RunDataBase {
|
||||
id: CallId
|
||||
/** Present iff every member carried it, with one uniform value (a mixed run never packs). */
|
||||
name?: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A packed run of consecutive delta chunk events, discriminated on `type`.
|
||||
* `seq0`/`time0` anchor the first member; text and reasoning rows share the
|
||||
* {@link TextRunData} payload, tool-call rows carry {@link ToolCallRunData}.
|
||||
*/
|
||||
export type ChunkRow =
|
||||
| { type: 'text-chunks'; seq0: number; time0: number; data: TextRunData }
|
||||
| { type: 'reasoning-chunks'; seq0: number; time0: number; data: TextRunData }
|
||||
| { type: 'tool-call-chunks'; seq0: number; time0: number; data: ToolCallRunData }
|
||||
|
||||
/** One durable log line's JSON value: a session event verbatim, or a packed chunk row. */
|
||||
export type StorageRecord = SessionEvent | ChunkRow
|
||||
|
||||
/**
|
||||
* Minimum members before a run packs. Below it a row's envelope rivals the
|
||||
* event lines it replaces. A format constant, not a tunable: both layouts
|
||||
* decode identically, so changing it never invalidates stored logs.
|
||||
*/
|
||||
const MIN_RUN = 3
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** Exact-key check: `value` has every key in `keys` and nothing else. */
|
||||
function hasExactKeys(value: object, keys: readonly string[]): boolean {
|
||||
return Object.keys(value).length === keys.length && keys.every(k => Object.hasOwn(value, k))
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an event for packing: its delta kind when the ENTIRE shape
|
||||
* (envelope, data, chunk — exact keys, primitive types, integer seq/time) is
|
||||
* whitelisted, else `undefined` (store verbatim). Inputs come from live typed
|
||||
* appends AND parsed fixture files, so the checks are structural, not
|
||||
* type-trusted. Integer times keep gap encoding exact: a fractional time would
|
||||
* reconstruct through float subtraction/addition, which need not round-trip.
|
||||
*/
|
||||
function classify(event: SessionEvent): DeltaKind | undefined {
|
||||
if (event.type !== 'assistant/chunk') return undefined
|
||||
if (!hasExactKeys(event, ['type', 'seq', 'time', 'data'])) return undefined
|
||||
if (!Number.isSafeInteger(event.seq) || event.seq < 0 || !Number.isSafeInteger(event.time)) return undefined
|
||||
const data: unknown = event.data
|
||||
if (!isRecord(data) || !hasExactKeys(data, ['turn', 'step', 'chunk'])) return undefined
|
||||
if (typeof data.turn !== 'number' || typeof data.step !== 'number') return undefined
|
||||
const chunk = data.chunk
|
||||
if (!isRecord(chunk) || typeof chunk.index !== 'number') return undefined
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return hasExactKeys(chunk, ['type', 'index', 'text']) && typeof chunk.text === 'string'
|
||||
? chunk.type
|
||||
: undefined
|
||||
case 'tool-call-delta': {
|
||||
const shapeOk = hasExactKeys(chunk, ['type', 'index', 'id', 'argumentsDelta'])
|
||||
|| (hasExactKeys(chunk, ['type', 'index', 'id', 'name', 'argumentsDelta']) && typeof chunk.name === 'string')
|
||||
return shapeOk && typeof chunk.id === 'string' && typeof chunk.argumentsDelta === 'string'
|
||||
? chunk.type
|
||||
: undefined
|
||||
}
|
||||
// Whitelist fall-through over parsed data: block-start/end, usage, finish,
|
||||
// and any future chunk variant stay one event per line.
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool-call fields of a whitelisted delta chunk (only after {@link classify} returned `'tool-call-delta'`). */
|
||||
function toolCallOf(event: DeltaEvent): { id: string; name?: string } {
|
||||
return event.data.chunk as { id: string; name?: string }
|
||||
}
|
||||
|
||||
/** The block index of a whitelisted delta chunk (not every {@link StreamChunk} variant carries one). */
|
||||
function indexOf(event: DeltaEvent): number {
|
||||
return (event.data.chunk as { index: number }).index
|
||||
}
|
||||
|
||||
/** Whether `next` extends a run ending in `prev` (same kind already checked by the caller). */
|
||||
function continues(prev: DeltaEvent, next: DeltaEvent, kind: DeltaKind): boolean {
|
||||
if (next.seq !== prev.seq + 1) return false
|
||||
// Two safe-integer times can sit further apart than a double subtracts
|
||||
// exactly (2^53-1 and its negation differ by ~2^54); a rounded gap would
|
||||
// decode to a different timestamp. The check is exact in both directions: a
|
||||
// true gap within safe range subtracts without rounding and passes, while a
|
||||
// true gap beyond it rounds to a value that is itself beyond and fails.
|
||||
if (!Number.isSafeInteger(next.time - prev.time)) return false
|
||||
if (next.data.turn !== prev.data.turn || next.data.step !== prev.data.step) return false
|
||||
if (indexOf(next) !== indexOf(prev)) return false
|
||||
if (kind !== 'tool-call-delta') return true
|
||||
const a = toolCallOf(prev)
|
||||
const b = toolCallOf(next)
|
||||
// `name` must match in presence AND value — a mixed run is not representable.
|
||||
return a.id === b.id && Object.hasOwn(a, 'name') === Object.hasOwn(b, 'name') && a.name === b.name
|
||||
}
|
||||
|
||||
/** Build the row for a completed run (`run.length >= MIN_RUN`, uniform per {@link continues}). */
|
||||
function buildRow(kind: DeltaKind, run: readonly DeltaEvent[]): ChunkRow {
|
||||
const first = run[0] as DeltaEvent
|
||||
const base = {
|
||||
turn: first.data.turn,
|
||||
step: first.data.step,
|
||||
index: indexOf(first),
|
||||
dt: run.slice(1).map((event, i) => event.time - (run[i] as DeltaEvent).time),
|
||||
}
|
||||
const envelope = { seq0: first.seq, time0: first.time }
|
||||
if (kind === 'tool-call-delta') {
|
||||
const call = toolCallOf(first)
|
||||
return {
|
||||
type: 'tool-call-chunks',
|
||||
...envelope,
|
||||
data: {
|
||||
...base,
|
||||
id: CallId(call.id),
|
||||
...Object.hasOwn(call, 'name') ? { name: call.name as string } : {},
|
||||
args: run.map(event => (event.data.chunk as { argumentsDelta: string }).argumentsDelta),
|
||||
},
|
||||
}
|
||||
}
|
||||
const data = { ...base, texts: run.map(event => (event.data.chunk as { text: string }).text) }
|
||||
return kind === 'text-delta'
|
||||
? { type: 'text-chunks', ...envelope, data }
|
||||
: { type: 'reasoning-chunks', ...envelope, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack an event batch for storage: each run of at least {@link MIN_RUN}
|
||||
* consecutive whitelisted same-kind, same-block delta chunk events becomes one
|
||||
* {@link ChunkRow}; every other event passes through verbatim, in order.
|
||||
* Pure and stateless — safe over any array, including a batch whose runs were
|
||||
* split by flush boundaries (the split runs simply pack per batch).
|
||||
*
|
||||
* @param events - the batch to encode, in log order.
|
||||
* @returns the storage records to write, one JSONL line each.
|
||||
*/
|
||||
export function packChunkRuns(events: readonly SessionEvent[]): StorageRecord[] {
|
||||
const out: StorageRecord[] = []
|
||||
let kind: DeltaKind | undefined
|
||||
let run: DeltaEvent[] = []
|
||||
const flush = (): void => {
|
||||
if (kind !== undefined && run.length >= MIN_RUN) out.push(buildRow(kind, run))
|
||||
else out.push(...run)
|
||||
kind = undefined
|
||||
run = []
|
||||
}
|
||||
for (const event of events) {
|
||||
const k = classify(event)
|
||||
if (k === undefined) {
|
||||
flush()
|
||||
out.push(event)
|
||||
continue
|
||||
}
|
||||
const delta = event as DeltaEvent
|
||||
const last = run[run.length - 1]
|
||||
if (k === kind && last !== undefined && continues(last, delta, k)) {
|
||||
run.push(delta)
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
kind = k
|
||||
run = [delta]
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
/** Throw the uniform malformed-row diagnostic. */
|
||||
function malformed(tag: string, why: string): never {
|
||||
throw new Error(`malformed ${tag} storage row: ${why}`)
|
||||
}
|
||||
|
||||
/** Validate the shared run-data fields and the payload/dt arity; returns the member payload. */
|
||||
function validateRunData(tag: string, data: Record<string, unknown>, payloadKey: 'texts' | 'args'): string[] {
|
||||
if (typeof data.turn !== 'number' || typeof data.step !== 'number' || typeof data.index !== 'number') {
|
||||
malformed(tag, 'turn/step/index must be numbers')
|
||||
}
|
||||
const payload = data[payloadKey]
|
||||
if (!Array.isArray(payload) || payload.length === 0 || payload.some(entry => typeof entry !== 'string')) {
|
||||
malformed(tag, `${payloadKey} must be a non-empty string array`)
|
||||
}
|
||||
const dt = data.dt
|
||||
if (!Array.isArray(dt) || dt.some(gap => !Number.isSafeInteger(gap))) {
|
||||
malformed(tag, 'dt must be an array of safe integers')
|
||||
}
|
||||
if (dt.length !== payload.length - 1) {
|
||||
malformed(tag, `dt length ${dt.length} does not match ${payload.length} members`)
|
||||
}
|
||||
return payload as string[]
|
||||
}
|
||||
|
||||
/** Validate a row-tagged parsed value's envelope and data, throwing on any malformation. */
|
||||
function validateRow(value: Record<string, unknown>, tag: ChunkRow['type']): ChunkRow {
|
||||
if (!hasExactKeys(value, ['type', 'seq0', 'time0', 'data'])) {
|
||||
malformed(tag, 'envelope must be exactly {type, seq0, time0, data}')
|
||||
}
|
||||
if (!Number.isSafeInteger(value.seq0) || (value.seq0 as number) < 0) {
|
||||
malformed(tag, 'seq0 must be a non-negative safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(value.time0)) {
|
||||
malformed(tag, 'time0 must be a safe integer')
|
||||
}
|
||||
const data = value.data
|
||||
if (!isRecord(data)) malformed(tag, 'data must be an object')
|
||||
let payload: string[]
|
||||
if (tag === 'tool-call-chunks') {
|
||||
const withName = hasExactKeys(data, ['turn', 'step', 'index', 'id', 'name', 'dt', 'args'])
|
||||
if (!withName && !hasExactKeys(data, ['turn', 'step', 'index', 'id', 'dt', 'args'])) {
|
||||
malformed(tag, 'data must be exactly {turn, step, index, id, name?, dt, args}')
|
||||
}
|
||||
if (typeof data.id !== 'string' || (withName && typeof data.name !== 'string')) {
|
||||
malformed(tag, 'id (and name when present) must be strings')
|
||||
}
|
||||
payload = validateRunData(tag, data, 'args')
|
||||
} else {
|
||||
if (!hasExactKeys(data, ['turn', 'step', 'index', 'dt', 'texts'])) {
|
||||
malformed(tag, 'data must be exactly {turn, step, index, dt, texts}')
|
||||
}
|
||||
payload = validateRunData(tag, data, 'texts')
|
||||
}
|
||||
// Reconstruction bounds. The encoder only packs runs whose member seqs and
|
||||
// times are all safe integers, so a running value that leaves safe range is
|
||||
// outside any encoder's image: float arithmetic would round it to a
|
||||
// different number than exact arithmetic, a silent corruption. Within safe
|
||||
// range every step is exact, so the first departure is always caught.
|
||||
if (!Number.isSafeInteger((value.seq0 as number) + payload.length - 1)) {
|
||||
malformed(tag, 'member seqs must stay safe integers')
|
||||
}
|
||||
let time = value.time0 as number
|
||||
for (const gap of data.dt as number[]) {
|
||||
time += gap
|
||||
if (!Number.isSafeInteger(time)) malformed(tag, 'member times must stay safe integers')
|
||||
}
|
||||
return value as unknown as ChunkRow
|
||||
}
|
||||
|
||||
/** Expand a validated row back into its exact original events, in order. */
|
||||
function expandRow(row: ChunkRow): SessionEvent[] {
|
||||
const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts
|
||||
const events: SessionEvent[] = []
|
||||
let time = row.time0
|
||||
for (let k = 0; k < members.length; k++) {
|
||||
if (k > 0) time += row.data.dt[k - 1] as number
|
||||
let chunk: StreamChunk
|
||||
switch (row.type) {
|
||||
case 'text-chunks':
|
||||
chunk = { type: 'text-delta', index: row.data.index, text: members[k] as string }
|
||||
break
|
||||
case 'reasoning-chunks':
|
||||
chunk = { type: 'reasoning-delta', index: row.data.index, text: members[k] as string }
|
||||
break
|
||||
case 'tool-call-chunks':
|
||||
chunk = {
|
||||
type: 'tool-call-delta',
|
||||
index: row.data.index,
|
||||
id: row.data.id,
|
||||
...Object.hasOwn(row.data, 'name') ? { name: row.data.name as string } : {},
|
||||
argumentsDelta: members[k] as string,
|
||||
}
|
||||
break
|
||||
/* v8 ignore next 2 -- validateRow only returns the three row tags */
|
||||
default:
|
||||
return assertNever(row, 'chunk-rows expandRow')
|
||||
}
|
||||
events.push({
|
||||
type: 'assistant/chunk',
|
||||
seq: row.seq0 + k,
|
||||
time,
|
||||
data: { turn: row.data.turn, step: row.data.step, chunk },
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one parsed JSONL line value into the session event(s) it stores.
|
||||
* Chunk-row-tagged values validate and expand (a malformed row throws — it is
|
||||
* corrupt storage, and treating it as an event would silently drop a whole
|
||||
* run); every other value passes through as a single event, unvalidated,
|
||||
* exactly as readers treated event lines before packing existed.
|
||||
*
|
||||
* @param value - one line's `JSON.parse` result.
|
||||
* @returns the stored events, in log order.
|
||||
*/
|
||||
export function decodeStorageRecord(value: unknown): SessionEvent[] {
|
||||
if (!isRecord(value)) return [value as SessionEvent]
|
||||
const tag = value.type
|
||||
if (tag !== 'text-chunks' && tag !== 'reasoning-chunks' && tag !== 'tool-call-chunks') {
|
||||
return [value as SessionEvent]
|
||||
}
|
||||
return expandRow(validateRow(value, tag))
|
||||
}
|
||||
@@ -22,7 +22,9 @@ import { foldRequestHeader } from './request-header.ts'
|
||||
export * from './types.ts'
|
||||
export { isJsonValue, snapshotJsonValue } from './json.ts'
|
||||
export type { JsonValue } from './json.ts'
|
||||
export { interruptedTurnClosers } from './repair.ts'
|
||||
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
|
||||
export { decodeStorageRecord, packChunkRuns } from './chunk-rows.ts'
|
||||
export type { ChunkRow, StorageRecord } from './chunk-rows.ts'
|
||||
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
|
||||
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
@@ -10,6 +10,7 @@ import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { TOOL_NOT_STARTED } from './repair.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session'
|
||||
|
||||
@@ -133,8 +134,8 @@ function validateEvent(
|
||||
break
|
||||
}
|
||||
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step, fail)
|
||||
const syntheticInterrupted = event.data.isError && event.data.error?.code === 'interrupted'
|
||||
if (!trace.pendingCalls.has(event.data.callId) && !syntheticInterrupted) {
|
||||
const syntheticNotStarted = event.data.isError && event.data.error?.code === TOOL_NOT_STARTED
|
||||
if (!trace.pendingCalls.has(event.data.callId) && !syntheticNotStarted) {
|
||||
fail(`tool/result for ${event.data.callId} with no prior tool/call in this step`)
|
||||
}
|
||||
pendingCalls = { kind: 'delete', callId: event.data.callId }
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from './types.ts'
|
||||
|
||||
/** Recovery code for an assistant tool request that never reached a recorded call start. */
|
||||
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'
|
||||
|
||||
/**
|
||||
* Return deterministic synthetic events that close an open tail turn. Unmatched
|
||||
* calls receive error results first, followed by an open `step/end` and an
|
||||
@@ -82,6 +88,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
// Close calls before their step: providers reject dangling assistant calls,
|
||||
// and Map insertion order preserves their transcript order.
|
||||
for (const [callId, { step, callSeq }] of pendingCalls) {
|
||||
const started = callSeq !== undefined
|
||||
closers.push({
|
||||
type: 'tool/result',
|
||||
seq: seq++,
|
||||
@@ -90,12 +97,19 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
turn: openTurn,
|
||||
step,
|
||||
callId,
|
||||
content: [{ type: 'text', text: 'Tool call interrupted by a crash; no result was recorded.' }],
|
||||
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.',
|
||||
}],
|
||||
isError: true,
|
||||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||||
error: started
|
||||
? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
|
||||
: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
...callSeq !== undefined ? { sourceEventSeqs: [callSeq] } : {},
|
||||
...started ? { sourceEventSeqs: [callSeq] } : {},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Chunk-row codec tests: pack/expand round-trip losslessness (example-based and
|
||||
* property-based), run-boundary rules, whitelist fall-through, and decoder
|
||||
* validation failures.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session'
|
||||
import type { ChunkRow, SessionEvent, StorageRecord } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Build an `assistant/chunk` event with the exact live-append shape. */
|
||||
function chunkEvent(seq: number, time: number, chunk: StreamChunk, turn = 1, step = 1): SessionEvent {
|
||||
return { type: 'assistant/chunk', seq, time, data: { turn, step, chunk } }
|
||||
}
|
||||
|
||||
/** Sequential delta events (contiguous seqs, fixed 10ms gaps) of one kind. */
|
||||
function deltaRun(kind: 'text-delta' | 'reasoning-delta', count: number, seq0 = 0, index = 0): SessionEvent[] {
|
||||
return Array.from({ length: count }, (_, k) =>
|
||||
chunkEvent(seq0 + k, 1000 + 10 * k, { type: kind, index, text: `t${k}` }))
|
||||
}
|
||||
|
||||
/** Decode a packed record list back to a flat event list. */
|
||||
function decodeAll(records: readonly StorageRecord[]): SessionEvent[] {
|
||||
return records.flatMap(record => decodeStorageRecord(JSON.parse(JSON.stringify(record))))
|
||||
}
|
||||
|
||||
describe('packChunkRuns', () => {
|
||||
it('packs a text-delta run into one text-chunks row and round-trips it', () => {
|
||||
const events = deltaRun('text-delta', 5)
|
||||
const packed = packChunkRuns(events)
|
||||
expect(packed).toHaveLength(1)
|
||||
const row = packed[0] as ChunkRow
|
||||
expect(row.type).toBe('text-chunks')
|
||||
expect(row.seq0).toBe(0)
|
||||
expect(row.time0).toBe(1000)
|
||||
expect(row.data).toMatchObject({ turn: 1, step: 1, index: 0, dt: [10, 10, 10, 10], texts: ['t0', 't1', 't2', 't3', 't4'] })
|
||||
expect(decodeAll(packed)).toStrictEqual(events)
|
||||
})
|
||||
|
||||
it('packs reasoning and tool-call runs under their own tags', () => {
|
||||
const reasoning = deltaRun('reasoning-delta', 3)
|
||||
const toolCall = [4, 5, 6].map(seq =>
|
||||
chunkEvent(seq, 1000 + seq, { type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'write', argumentsDelta: `a${seq}` }))
|
||||
const packed = packChunkRuns([...reasoning, ...toolCall])
|
||||
expect(packed.map(r => (r as ChunkRow).type)).toStrictEqual(['reasoning-chunks', 'tool-call-chunks'])
|
||||
const row = packed[1] as ChunkRow & { type: 'tool-call-chunks' }
|
||||
expect(row.data).toMatchObject({ id: 'c1', name: 'write', args: ['a4', 'a5', 'a6'] })
|
||||
expect(decodeAll(packed)).toStrictEqual([...reasoning, ...toolCall])
|
||||
})
|
||||
|
||||
it('packs a name-less tool-call run and round-trips field absence', () => {
|
||||
const events = [0, 1, 2].map(seq =>
|
||||
chunkEvent(seq, 1000, { type: 'tool-call-delta', index: 0, id: CallId('c1'), argumentsDelta: `a${seq}` }))
|
||||
const packed = packChunkRuns(events)
|
||||
expect(packed).toHaveLength(1)
|
||||
expect(Object.hasOwn((packed[0] as ChunkRow).data, 'name')).toBe(false)
|
||||
const decoded = decodeAll(packed)
|
||||
expect(decoded).toStrictEqual(events)
|
||||
expect(decoded.every(e => !Object.hasOwn((e.data as { chunk: object }).chunk, 'name'))).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves runs shorter than three events verbatim', () => {
|
||||
const events = deltaRun('text-delta', 2)
|
||||
expect(packChunkRuns(events)).toStrictEqual(events)
|
||||
})
|
||||
|
||||
it('leaves non-delta chunks and non-chunk events verbatim between runs', () => {
|
||||
const events: SessionEvent[] = [
|
||||
chunkEvent(0, 1000, { type: 'block-start', index: 0, blockType: 'text' }),
|
||||
...deltaRun('text-delta', 3, 1),
|
||||
chunkEvent(4, 1040, { type: 'block-end', index: 0, block: { type: 'text', text: 't0t1t2' } }),
|
||||
{ type: 'step/end', seq: 5, time: 1050, data: { turn: 1, step: 1 } },
|
||||
]
|
||||
const packed = packChunkRuns(events)
|
||||
expect(packed).toHaveLength(4)
|
||||
expect((packed[1] as ChunkRow).type).toBe('text-chunks')
|
||||
expect(decodeAll(packed)).toStrictEqual(events)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a seq gap', deltaRun('text-delta', 3).map((e, k) => ({ ...e, seq: k === 2 ? 9 : e.seq }))],
|
||||
['a kind switch', [...deltaRun('text-delta', 2), ...deltaRun('reasoning-delta', 1, 2)]],
|
||||
['a block-index switch', [...deltaRun('text-delta', 2), ...deltaRun('text-delta', 1, 2, 7)]],
|
||||
['a step switch', deltaRun('text-delta', 3).map((e, k) => k === 2 ? chunkEvent(e.seq, e.time, (e.data as { chunk: StreamChunk }).chunk, 1, 2) : e)],
|
||||
])('breaks a run on %s (both halves too short to pack)', (_label, events) => {
|
||||
expect(packChunkRuns(events as SessionEvent[])).toStrictEqual(events)
|
||||
})
|
||||
|
||||
it('breaks a tool-call run on call-id or name change', () => {
|
||||
const call = (seq: number, id: string, name?: string): SessionEvent =>
|
||||
chunkEvent(seq, 1000, { type: 'tool-call-delta', index: 0, id: CallId(id), ...name !== undefined ? { name } : {}, argumentsDelta: 'a' })
|
||||
const idSwitch = [call(0, 'c1', 'w'), call(1, 'c1', 'w'), call(2, 'c2', 'w')]
|
||||
expect(packChunkRuns(idSwitch)).toStrictEqual(idSwitch)
|
||||
const namePresence = [call(0, 'c1', 'w'), call(1, 'c1', 'w'), call(2, 'c1')]
|
||||
expect(packChunkRuns(namePresence)).toStrictEqual(namePresence)
|
||||
})
|
||||
|
||||
it('stores an off-whitelist delta verbatim (extra field, bad type, fractional time)', () => {
|
||||
const extraField = { ...chunkEvent(0, 1000, { type: 'text-delta', index: 0, text: 'x' }), surfaceOp: 'append' }
|
||||
const badText = chunkEvent(1, 1001, { type: 'text-delta', index: 0, text: 7 as unknown as string })
|
||||
const fractionalTime = chunkEvent(2, 1001.5, { type: 'text-delta', index: 0, text: 'y' })
|
||||
const events = [extraField, badText, fractionalTime] as SessionEvent[]
|
||||
expect(packChunkRuns(events)).toStrictEqual(events)
|
||||
})
|
||||
|
||||
it('breaks a run on a time gap beyond safe-integer range (subtraction would round)', () => {
|
||||
// Both endpoints are safe integers, but their true difference (~2^54)
|
||||
// exceeds exact double range: b - a rounds, so a + (b - a) !== b and a
|
||||
// packed row would decode to a different timestamp.
|
||||
const a = Number.MIN_SAFE_INTEGER
|
||||
const b = Number.MAX_SAFE_INTEGER - 1
|
||||
expect(a + (b - a)).not.toBe(b) // the rounding this guard exists for
|
||||
const events = [
|
||||
chunkEvent(0, a, { type: 'text-delta', index: 0, text: 'x' }),
|
||||
chunkEvent(1, b, { type: 'text-delta', index: 0, text: 'y' }),
|
||||
chunkEvent(2, b + 1, { type: 'text-delta', index: 0, text: 'z' }),
|
||||
]
|
||||
expect(packChunkRuns(events)).toStrictEqual(events) // split at the gap; halves too short
|
||||
expect(decodeAll(packChunkRuns(events))).toStrictEqual(events)
|
||||
})
|
||||
|
||||
it('stores a delta with an off-whitelist data envelope verbatim (parsed-fixture shapes)', () => {
|
||||
const mk = (seq: number, data: unknown): SessionEvent =>
|
||||
({ type: 'assistant/chunk', seq, time: 1000, data } as SessionEvent)
|
||||
const events = [
|
||||
mk(0, 'not-an-object'),
|
||||
mk(1, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' }, extra: 1 }),
|
||||
mk(2, { turn: 'x', step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } }),
|
||||
mk(3, { turn: 1, step: 1, chunk: 'not-an-object' }),
|
||||
mk(4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 'x', text: 'a' } }),
|
||||
mk(5, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 7, argumentsDelta: 'a' } }),
|
||||
mk(6, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 'c', name: 7, argumentsDelta: 'a' } }),
|
||||
]
|
||||
expect(packChunkRuns(events)).toStrictEqual(events)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeStorageRecord', () => {
|
||||
it('passes non-row values through as single events, unvalidated', () => {
|
||||
const event = { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }
|
||||
expect(decodeStorageRecord(event)).toStrictEqual([event])
|
||||
expect(decodeStorageRecord('junk')).toStrictEqual(['junk'])
|
||||
expect(decodeStorageRecord(null)).toStrictEqual([null])
|
||||
})
|
||||
|
||||
it('reconstructs timestamps through negative dt gaps (clock stepped back)', () => {
|
||||
const events = [
|
||||
chunkEvent(0, 1000, { type: 'text-delta', index: 0, text: 'a' }),
|
||||
chunkEvent(1, 990, { type: 'text-delta', index: 0, text: 'b' }),
|
||||
chunkEvent(2, 995, { type: 'text-delta', index: 0, text: 'c' }),
|
||||
]
|
||||
expect(decodeAll(packChunkRuns(events))).toStrictEqual(events)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-object data', { type: 'text-chunks', seq0: 0, time0: 1, data: 'x' }],
|
||||
['an envelope with extra keys', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] }, extra: 1 }],
|
||||
['a negative seq0', { type: 'text-chunks', seq0: -1, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }],
|
||||
['a non-finite time0', { type: 'text-chunks', seq0: 0, time0: Infinity, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }],
|
||||
['a fractional time0', { type: 'text-chunks', seq0: 0, time0: 1.5, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a'] } }],
|
||||
['a data shape mismatch', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], args: ['a'] } }],
|
||||
['a non-string member', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: [7] } }],
|
||||
['an empty member list', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: [] } }],
|
||||
['a dt arity mismatch', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [1, 2], texts: ['a', 'b'] } }],
|
||||
['a non-finite dt gap', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [NaN], texts: ['a', 'b'] } }],
|
||||
['a fractional dt gap', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0.5], texts: ['a', 'b'] } }],
|
||||
['a member seq leaving safe range', { type: 'text-chunks', seq0: Number.MAX_SAFE_INTEGER, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] } }],
|
||||
['a member time leaving safe range', { type: 'text-chunks', seq0: 0, time0: Number.MAX_SAFE_INTEGER, data: { turn: 1, step: 1, index: 0, dt: [1], texts: ['a', 'b'] } }],
|
||||
['a non-numeric turn', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 'x', step: 1, index: 0, dt: [], texts: ['a'] } }],
|
||||
['a tool-call row without id', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], args: ['a'] } }],
|
||||
['a tool-call row with non-string id', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 7, dt: [], args: ['a'] } }],
|
||||
['a tool-call row with non-string name', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 'c', name: 7, dt: [], args: ['a'] } }],
|
||||
])('throws on %s', (_label, row) => {
|
||||
expect(() => decodeStorageRecord(row)).toThrow(/malformed .* storage row/)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Property: pack∘decode is the identity over arbitrary event batches ---
|
||||
|
||||
const deltaChunkArb: fc.Arbitrary<StreamChunk> = fc.oneof(
|
||||
fc.record({ type: fc.constant<'text-delta'>('text-delta'), index: fc.nat(2), text: fc.string() }),
|
||||
fc.record({ type: fc.constant<'reasoning-delta'>('reasoning-delta'), index: fc.nat(2), text: fc.string() }),
|
||||
fc.record({
|
||||
type: fc.constant<'tool-call-delta'>('tool-call-delta'),
|
||||
index: fc.nat(2),
|
||||
id: fc.constantFrom(CallId('c1'), CallId('c2')),
|
||||
argumentsDelta: fc.string(),
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant<'tool-call-delta'>('tool-call-delta'),
|
||||
index: fc.nat(2),
|
||||
id: fc.constantFrom(CallId('c1'), CallId('c2')),
|
||||
name: fc.constantFrom('write', 'read'),
|
||||
argumentsDelta: fc.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
const boundaryChunkArb: fc.Arbitrary<StreamChunk> = fc.oneof(
|
||||
fc.record({ type: fc.constant<'block-start'>('block-start'), index: fc.nat(2), blockType: fc.constant<'text'>('text') }),
|
||||
fc.record({ type: fc.constant<'finish'>('finish'), reason: fc.constant({ kind: 'stop' as const }) }),
|
||||
)
|
||||
|
||||
/**
|
||||
* Batches with contiguous seqs, arbitrary timestamps, mixed chunk kinds and
|
||||
* turn/step placement. Times draw from the FULL safe-integer range (not just
|
||||
* realistic clocks) so the property exercises the gap-overflow guard: two safe
|
||||
* endpoints can differ by more than a double subtracts exactly.
|
||||
*/
|
||||
const batchArb: fc.Arbitrary<SessionEvent[]> = fc.array(
|
||||
fc.record({
|
||||
chunk: fc.oneof({ weight: 4, arbitrary: deltaChunkArb }, { weight: 1, arbitrary: boundaryChunkArb }),
|
||||
time: fc.oneof(
|
||||
{ weight: 4, arbitrary: fc.integer({ min: 995, max: 9000 }) },
|
||||
{ weight: 1, arbitrary: fc.integer({ min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }) },
|
||||
),
|
||||
turn: fc.nat(1),
|
||||
step: fc.nat(1),
|
||||
}),
|
||||
{ maxLength: 40 },
|
||||
// JSON round-trip normalizes fast-check's null-prototype records into the
|
||||
// plain objects real log events are (the log is JSON), so equality compares
|
||||
// values, not prototypes.
|
||||
).map(entries => JSON.parse(JSON.stringify(
|
||||
entries.map((entry, k) => chunkEvent(k, entry.time, entry.chunk, entry.turn, entry.step)),
|
||||
)) as SessionEvent[])
|
||||
|
||||
describe('chunk-row codec properties', () => {
|
||||
it('JSON-serialized pack∘decode reproduces every batch exactly', () => {
|
||||
fc.assert(fc.property(batchArb, (events) => {
|
||||
expect(decodeAll(packChunkRuns(events))).toStrictEqual(events)
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
@@ -256,7 +256,7 @@ describe('session-log invariants', () => {
|
||||
})).toThrow(/outside any open turn/)
|
||||
})
|
||||
|
||||
it('allows interrupted repair results and unresolved calls at step end', async () => {
|
||||
it('allows not-started repair results and unresolved calls at step end', async () => {
|
||||
const repaired = (await setup()).ctx.sessions.create()
|
||||
expect(() => {
|
||||
repaired.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -267,7 +267,7 @@ describe('session-log invariants', () => {
|
||||
callId: CallId('crashed'),
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||||
error: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
|
||||
}, { surfaceOp: 'append' })
|
||||
repaired.append('step/end', { turn: 1, step: 1 })
|
||||
repaired.append('turn/end', { turn: 1, reason: { kind: 'interrupted' } })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { interruptedTurnClosers } from '../src/index.ts'
|
||||
import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
|
||||
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
|
||||
|
||||
/**
|
||||
@@ -47,9 +47,7 @@ describe('interruptedTurnClosers', () => {
|
||||
expect(closers.map(e => e.seq)).toEqual([2, 3])
|
||||
})
|
||||
|
||||
it('synthesizes an error tool/result for a tool-call the crash left unanswered', () => {
|
||||
// A step issued one tool call (in the assistant message) but crashed before
|
||||
// the tool/result was logged — the classic mid-tool crash.
|
||||
it('marks an assistant tool request with no recorded call as not started', () => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(2, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
|
||||
@@ -64,8 +62,11 @@ describe('interruptedTurnClosers', () => {
|
||||
expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
|
||||
const result = closers[0]!
|
||||
expect(result.type === 'tool/result' && result.data).toMatchObject({
|
||||
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: 'interrupted' },
|
||||
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: TOOL_NOT_STARTED },
|
||||
})
|
||||
expect(result.type === 'tool/result' && result.data.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', () => {
|
||||
@@ -152,6 +153,14 @@ describe('interruptedTurnClosers', () => {
|
||||
const result = closers[0]!
|
||||
expect((result as SurfaceEvent).surfaceOp).toBe('append')
|
||||
expect((result as SurfaceEvent).sourceEventSeqs).toEqual([3])
|
||||
expect(result.type === 'tool/result' && result.data.error).toEqual({
|
||||
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
|
||||
})
|
||||
if (result.type !== 'tool/result' || result.data.content[0]?.type !== 'text') {
|
||||
throw new Error('expected a text tool result')
|
||||
}
|
||||
expect(result.data.content[0].text).toContain('retry only if the operation is read-only or idempotent')
|
||||
expect(result.data.content[0].text).toContain('first verify external state or ask the user')
|
||||
})
|
||||
|
||||
it('handles tool/call without a matching assistant/message entry gracefully', () => {
|
||||
|
||||
@@ -15,13 +15,14 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
|
||||
| `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it |
|
||||
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
|
||||
| `@deepseek-ai/dsh-session-checkpoint-policy` | semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints |
|
||||
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
|
||||
| ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately |
|
||||
| ~~`@deepseek-ai/dsh-user-approval`~~ | **omitted by default** — permission policy is deployment-specific; sandbox/approval leaves opt in and the ACP bridge then supplies the answerer |
|
||||
| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../../ui/acp/README.md)) |
|
||||
| ~~`hmr`~~ | **omitted** — the editor owns the subprocess |
|
||||
|
||||
Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead.
|
||||
The app owns this cluster through one ordered Cordis effect. Teardown drains the ACP bridge before removing the checkpoint policy or persistence backend, so a graceful disconnect persists the real closing `step/end` and `turn/end` events rather than leaving crash recovery to synthesize them. Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -42,6 +43,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
|
||||
| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
|
||||
| `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` |
|
||||
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
|
||||
| `packChunks` | `false` | write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`) |
|
||||
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
|
||||
|
||||
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay), a bash executor, and optionally a `ctx.fs` provider. Workspace context becomes a no-op without `ctx.fs`; the shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) selects `dsh-sandbox-policy`, `dsh-fs-sandbox`, `dsh-fs-policy`, and `dsh-tool-fs` so baseline instructions and model-facing `read`/`write`/`edit` share one provider, sandbox mode, workspace root, and observed-version policy.
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
|
||||
"@deepseek-ai/dsh-app-boot": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
@@ -60,6 +61,7 @@
|
||||
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}),
|
||||
* human-command registry, JSONL session persistence, and the
|
||||
* {@link @deepseek-ai/dsh-acp} bridge. It writes nothing to stdout.
|
||||
* {@link @deepseek-ai/dsh-acp} bridge. The app owns those plugins through one
|
||||
* ordered lifecycle so ACP sessions quiesce before persistence detaches. It
|
||||
* writes nothing to stdout.
|
||||
* It pre-creates no agents and leaves adapters, executors, and optional tools to
|
||||
* the leaf, which must likewise avoid stdout loggers. Named exports are
|
||||
* required so Loader retains this plugin's `Config` schema (see
|
||||
@@ -21,6 +23,7 @@ import SessionPersistenceJsonl, {
|
||||
JsonlCompressionSchema,
|
||||
type JsonlCompression,
|
||||
} from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'acp-demo'
|
||||
@@ -54,6 +57,8 @@ export interface Config {
|
||||
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
|
||||
packChunks?: boolean
|
||||
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
|
||||
persistenceCompression?: JsonlCompression
|
||||
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
|
||||
@@ -86,6 +91,7 @@ export const Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
sessionTitle: agentCore.SessionTitleConfigSchema,
|
||||
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
|
||||
packChunks: z.boolean().default(false),
|
||||
persistenceCompression: JsonlCompressionSchema,
|
||||
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
@@ -101,17 +107,27 @@ export const Config: z<Config> = z.object({
|
||||
* NO agents (its `agents` list defaults to `[]`) and carries the deployment
|
||||
* `persona`; the JSONL backend persists under `persistenceRoot`; the ACP
|
||||
* bridge owns stdout for JSON-RPC and creates one agent per `session/new`
|
||||
* from the provider/model pair. No logger, no `hmr` — stdout stays pure.
|
||||
* from the provider/model pair. The composite effect unloads in reverse order,
|
||||
* keeping checkpoint and persistence listeners attached until ACP agents have
|
||||
* flushed their closing events. No logger, no `hmr` — stdout stays pure.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const goals = config.goals ?? {}
|
||||
ctx.plugin(CommandService)
|
||||
if (goals !== false) ctx.plugin(commandGoal)
|
||||
ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals })
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(SessionPersistenceJsonl, {
|
||||
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
})
|
||||
ctx.plugin(acp, { provider: config.provider, model: config.model })
|
||||
ctx.effect(function* () {
|
||||
yield ctx.plugin(CommandService).dispose
|
||||
if (goals !== false) yield ctx.plugin(commandGoal).dispose
|
||||
yield ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }).dispose
|
||||
yield ctx.plugin(UserInteractionService).dispose
|
||||
// Same rationale as the Config schema above: each front door forwards its own
|
||||
// persistence passthroughs rather than sharing a facade with stdio-demo.
|
||||
/* jscpd:ignore-start */
|
||||
yield ctx.plugin(SessionPersistenceJsonl, {
|
||||
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
|
||||
...config.packChunks !== undefined ? { packChunks: config.packChunks } : {},
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
}).dispose
|
||||
/* jscpd:ignore-end */
|
||||
yield ctx.plugin(sessionCheckpointPolicy).dispose
|
||||
yield ctx.plugin(acp, { provider: config.provider, model: config.model }).dispose
|
||||
}, 'acp-demo.composition')
|
||||
}
|
||||
@@ -35,7 +35,8 @@ const dshPackages = [
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
|
||||
'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', 'util/paths',
|
||||
'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl',
|
||||
'ui/acp', 'examples/acp-demo', 'util/paths',
|
||||
]
|
||||
const vendorPackages = [
|
||||
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
{
|
||||
"path": "../../ui/tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-checkpoint-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
|
||||
@@ -58,6 +59,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -15,6 +15,7 @@ import SessionPersistenceJsonl, {
|
||||
JsonlCompressionSchema,
|
||||
type JsonlCompression,
|
||||
} from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
|
||||
|
||||
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
|
||||
@@ -94,4 +95,5 @@ export function apply(ctx: Context, config: Config): void {
|
||||
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
})
|
||||
ctx.plugin(sessionCheckpointPolicy)
|
||||
}
|
||||
@@ -24,7 +24,8 @@ const dshPackages = [
|
||||
'examples/agent-spine-demo', 'examples/cli-demo', 'core/agent', 'core/session',
|
||||
'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
|
||||
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl',
|
||||
'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy',
|
||||
'session-persistence/session-persistence-jsonl',
|
||||
'context/workspace-context',
|
||||
'spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention',
|
||||
]
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../agent-spine-demo"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-checkpoint-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tui": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
|
||||
@@ -67,6 +68,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
|
||||
@@ -21,6 +21,7 @@ import SessionPersistenceJsonl, {
|
||||
JsonlCompressionSchema,
|
||||
type JsonlCompression,
|
||||
} from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as uiTui from '@deepseek-ai/dsh-tui'
|
||||
@@ -119,6 +120,7 @@ export function composeTuiApp(ctx: Context, config: Config): void {
|
||||
root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT,
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
})
|
||||
ctx.plugin(sessionCheckpointPolicy)
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(uiTui, {
|
||||
...config.ui,
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('dsh-tui-demo app', () => {
|
||||
'CommandService',
|
||||
'command-goal',
|
||||
'SessionPersistenceJsonl',
|
||||
'session-checkpoint-policy',
|
||||
'UserInteractionService',
|
||||
'ui-tui',
|
||||
'agent-spine-demo',
|
||||
@@ -52,7 +53,7 @@ describe('dsh-tui-demo app', () => {
|
||||
])
|
||||
expect(calls[0]?.config).toBeUndefined()
|
||||
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
|
||||
const tuiConfig = calls[4]?.config as { sessionId: string }
|
||||
const tuiConfig = calls[5]?.config as { sessionId: string }
|
||||
expect(tuiConfig).toMatchObject({
|
||||
welcome: 'TUI ready',
|
||||
resumeCommand: 'dsh --resume {session}',
|
||||
@@ -60,7 +61,7 @@ describe('dsh-tui-demo app', () => {
|
||||
maxToolOutputLines: 3,
|
||||
})
|
||||
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
|
||||
const spineConfig = calls[5]?.config as {
|
||||
const spineConfig = calls[6]?.config as {
|
||||
readonly agents: Array<Record<string, unknown>>
|
||||
readonly goals: Record<string, never>
|
||||
readonly maxParallelToolCalls: number
|
||||
@@ -95,8 +96,8 @@ describe('dsh-tui-demo app', () => {
|
||||
|
||||
expect(calls[2]?.config).toEqual({ root: './.sessions' })
|
||||
// No configured welcome forwards none: the TUI banner sweeps in without a subtitle.
|
||||
expect(calls[4]?.config).toEqual({ sessionId: 'persisted-session' })
|
||||
expect((calls[5]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
|
||||
expect(calls[5]?.config).toEqual({ sessionId: 'persisted-session' })
|
||||
expect((calls[6]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
|
||||
id: 'main',
|
||||
resumeSessionId: 'persisted-session',
|
||||
})
|
||||
@@ -112,12 +113,12 @@ describe('dsh-tui-demo app', () => {
|
||||
workspaceContext: false,
|
||||
})
|
||||
|
||||
const tuiConfig = calls[3]?.config as { sessionId: string }
|
||||
const tuiConfig = calls[4]?.config as { sessionId: string }
|
||||
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
|
||||
expect((calls[4]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
|
||||
expect((calls[5]?.config as { agents: Array<Record<string, unknown>> }).agents[0])
|
||||
.toMatchObject({ sessionId: tuiConfig.sessionId })
|
||||
expect(calls.map(call => call.name)).not.toContain('command-goal')
|
||||
expect(calls[4]?.config).toMatchObject({ goals: false })
|
||||
expect(calls[5]?.config).toMatchObject({ goals: false })
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape so the Loader keeps its schema', () => {
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
{
|
||||
"path": "../../ui/tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-checkpoint-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ The durable session-persistence seam and its storage backends. The interface pac
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `session-persistence/` | Persistence seam + shared write coordinator | `ctx.sessionPersistence` |
|
||||
| `session-checkpoint-policy/` | Semantic durability barriers for agent requests and tool execution | (wraps `ctx.llm` / `ctx.tools`, listens on agent events) |
|
||||
| `session-persistence-jsonl/` | JSONL-sidecar persistence backend | (registers `ctx.sessionPersistence`) |
|
||||
| `session-persistence-sqlite/` | SQLite persistence backend | (registers `ctx.sessionPersistence`) |
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# dsh-session-checkpoint-policy
|
||||
|
||||
Semantic durability policy for persisted agents. It checkpoints the event-sourced session before a model adapter receives a request, before a top-level tool body may produce an external side effect, and after a step has recorded its complete assistant message and ordered tool results. The final `turn/end` checkpoint remains owned by `dsh-agent-loop`.
|
||||
|
||||
## Plugin (namespace: `session-checkpoint-policy`)
|
||||
|
||||
This zero-config function plugin consumes `ctx.sessions`, `ctx.llm`, `ctx.tools`, and the presence of `ctx.sessionPersistence`. Load it beside one persistence backend:
|
||||
|
||||
```yaml
|
||||
- id: session-persistence
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
|
||||
- id: session-checkpoints
|
||||
name: '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
```
|
||||
|
||||
Persistence and checkpoint scheduling are intentionally separate Cordis plugins. A persistence backend makes each requested `session/flush` durable; this policy chooses the request, tool-dispatch, and completed-step checkpoints. Loading a backend without this policy is valid and retains checkpoints requested by the loop, including final `turn/end`, but crash recovery may lose the rest of an in-flight turn. First-party persisted apps and runtimes mount both plugins explicitly; a specialized deployment may deliberately omit or replace the policy.
|
||||
|
||||
The policy wraps `llm/stream` lazily, so the downstream stream is not constructed until the live session's buffered request events are durable. It wraps `tools/execute` after pre-execute policy and guards; a top-level tool body runs only after its recorded call is durable. If cancellation lands while that flush is pending, the wrapper returns the canonical `ABORTED_BEFORE_DISPATCH` result without entering the tool body. Nested tool dispatches reuse the outer model-visible call's checkpoint. `agent/post-step` persists the complete response/result batch before continuation work.
|
||||
|
||||
The loop records its assistant message and ordered tool results before dispatching `agent/post-step`, so the policy always captures that core batch. An event appended by another `agent/post-step` listener is captured at this checkpoint only when that listener is registered before the policy; Cordis registration order is the explicit composition rule for such extensions.
|
||||
|
||||
Checkpoint rejection is fail-closed at the model and tool boundaries: neither the adapter nor the top-level tool body runs. A post-step rejection fails the turn before another request starts. Concurrent tool checkpoints share the session store's serialized persistence drain and cannot duplicate sequence numbers.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Interrupted calls
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The plugin adds no prompt or tool schema. A hard crash after a tool checkpoint but before its result leaves a durable unmatched call; session recovery supplies the model-visible `TOOL_OUTCOME_UNKNOWN` result owned by `dsh-session`. The message permits retry for read-only or idempotent work and requires state verification or user confirmation for calls that may have side effects.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Successful checkpoints add no tokens and do not change the request. Recovery adds one short tool-result message to balance the interrupted transcript.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The repair result is appended after the reusable prefix, so it does not invalidate earlier cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The policy durably records execution intent, not generic exactly-once effects. Side-effecting tools should forward `exec.callId` as an idempotency key when their provider supports one.
|
||||
- Streaming `assistant/chunk` events have no per-chunk checkpoint. They reach storage with the next semantic checkpoint, so a hard crash may lose the current partial response.
|
||||
- A persisted call without a result cannot prove whether its external effect completed. Recovery therefore records an unknown outcome instead of retrying automatically.
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-checkpoint-policy",
|
||||
"description": "Semantic session durability checkpoints before model requests and tool side effects",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Semantic durability checkpoints for model requests, top-level tool dispatch,
|
||||
* and completed agent steps.
|
||||
* @module @deepseek-ai/dsh-session-checkpoint-policy
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
/** Cordis plugin name used by Loader diagnostics. */
|
||||
export const name = 'session-checkpoint-policy'
|
||||
|
||||
/** Services whose request, tool, session, and persistence boundaries this policy joins. */
|
||||
export const inject = ['llm', 'sessionPersistence', 'sessions', 'tools']
|
||||
|
||||
/**
|
||||
* Delay construction of the downstream model stream until the complete logged
|
||||
* request prefix is durable. A checkpoint rejection prevents adapter dispatch.
|
||||
*
|
||||
* @param ctx - plugin context that owns the session store.
|
||||
* @param session - live session named by the model request.
|
||||
* @param next - downstream `llm/stream` chain.
|
||||
* @returns a stream that checkpoints before requesting its first chunk.
|
||||
*/
|
||||
function afterCheckpoint(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
next: () => AsyncIterable<StreamChunk>,
|
||||
): AsyncIterable<StreamChunk> {
|
||||
return (async function* (): AsyncIterable<StreamChunk> {
|
||||
await ctx.sessions.flush(session)
|
||||
yield* next()
|
||||
})()
|
||||
}
|
||||
|
||||
/** Materialize the canonical result for a call cancelled before tool dispatch. */
|
||||
function abortedBeforeDispatchResult(): ToolExecutionResult {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install semantic checkpoint listeners. Loop-built model calls checkpoint the
|
||||
* logged request before adapter dispatch; top-level tool calls checkpoint their
|
||||
* recorded call before the tool body; post-step checkpoints retain the complete
|
||||
* response/result batch. Nested tool dispatches reuse the durable outer call.
|
||||
*
|
||||
* Checkpoint failures are fail-closed at the model and tool side-effect
|
||||
* boundaries: the downstream adapter or tool body is not invoked.
|
||||
*
|
||||
* @param ctx - plugin context that owns the listeners.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.on('llm/stream', (options, next): AsyncIterable<StreamChunk> => {
|
||||
if (options.sessionId === undefined) return next()
|
||||
const session = ctx.sessions.get(options.sessionId)
|
||||
return session === undefined ? next() : afterCheckpoint(ctx, session, next)
|
||||
})
|
||||
|
||||
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
|
||||
if (exec.agent === undefined || exec.parent !== undefined) return next()
|
||||
await ctx.sessions.flush(exec.agent.session)
|
||||
if (exec.signal.aborted) return abortedBeforeDispatchResult()
|
||||
return next()
|
||||
})
|
||||
|
||||
ctx.on('agent/post-step', (agent): Promise<void> => ctx.sessions.flush(agent.session))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-checkpoint-policy`.
|
||||
* @module @deepseek-ai/dsh-session-checkpoint-policy/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-checkpoint-policy-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: checkpoint ordering is enforced at the intercepted waterfall and
|
||||
* persistence seams; this stateless policy owns no independent mutable relation.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,106 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { access, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import SessionStore, {
|
||||
SessionId, TOOL_OUTCOME_UNKNOWN,
|
||||
type SessionEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
|
||||
const childScript = fileURLToPath(new URL('./fixtures/crash-child.ts', import.meta.url))
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const sessionId = SessionId('semantic-checkpoint-crash')
|
||||
const roots: string[] = []
|
||||
const CHILD_FAILPOINT_TIMEOUT_MS = 30_000
|
||||
|
||||
async function waitForFile(path: string): Promise<void> {
|
||||
const deadline = Date.now() + CHILD_FAILPOINT_TIMEOUT_MS
|
||||
for (;;) {
|
||||
try {
|
||||
await access(path)
|
||||
return
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
if (Date.now() >= deadline) throw new Error(`crash child did not reach failpoint ${path}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
|
||||
async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), `dsh-semantic-${mode}-`))
|
||||
roots.push(root)
|
||||
const marker = join(root, 'failpoint')
|
||||
const child = spawn(process.execPath, ['--import', tsxLoader, childScript, mode, root, marker], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') },
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
try {
|
||||
await waitForFile(marker)
|
||||
const markerText = await readFile(marker, 'utf8')
|
||||
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
||||
child.once('close', (code, signal) => { resolve({ code, signal }) })
|
||||
})
|
||||
child.kill('SIGKILL')
|
||||
const exit = await closed
|
||||
expect(exit).toEqual({ code: null, signal: 'SIGKILL' })
|
||||
return { root, markerText }
|
||||
} catch (error: unknown) {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
|
||||
throw new Error(`crash child failed: ${stderr}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function load(root: string): Promise<SessionEvent[]> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
try {
|
||||
return (await ctx.sessionPersistence.load(sessionId)).events
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('semantic checkpoint hard-crash recovery', () => {
|
||||
it('persists the complete request before model dispatch', async () => {
|
||||
const crashed = await crashAt('request')
|
||||
expect(crashed.markerText).toBe('request-dispatched')
|
||||
const events = await load(crashed.root)
|
||||
expect(events.map(event => event.type)).toEqual([
|
||||
'turn/start', 'user/message', 'step/start', 'request/header', 'step/end', 'turn/end',
|
||||
])
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'turn/end', data: { reason: { kind: 'interrupted' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('persists tool intent before a side effect and repairs its missing result as unknown', async () => {
|
||||
const crashed = await crashAt('tool')
|
||||
expect(crashed.markerText).toBe('tool-side-effect')
|
||||
const events = await load(crashed.root)
|
||||
expect(events.some(event => event.type === 'assistant/message')).toBe(true)
|
||||
expect(events.some(event => event.type === 'tool/call')).toBe(true)
|
||||
const result = events.find(event => event.type === 'tool/result')
|
||||
expect(result?.type === 'tool/result' && result.data.error).toEqual({
|
||||
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
|
||||
})
|
||||
if (result?.type !== 'tool/result' || result.data.content[0]?.type !== 'text') {
|
||||
throw new Error('expected a text tool result')
|
||||
}
|
||||
expect(result.data.content[0].text).toContain('Do not retry blindly.')
|
||||
})
|
||||
})
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { Context } from 'cordis'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as checkpointPolicy from '../../src/index.ts'
|
||||
|
||||
function waitForCrash(): Promise<never> {
|
||||
return new Promise(() => { setInterval(() => {}, 60_000) })
|
||||
}
|
||||
|
||||
const [mode, root, marker] = process.argv.slice(2)
|
||||
if ((mode !== 'request' && mode !== 'tool') || root === undefined || marker === undefined) {
|
||||
throw new Error('usage: crash-child.ts <request|tool> <persistence-root> <marker>')
|
||||
}
|
||||
const persistenceRoot = root
|
||||
const failpoint = marker
|
||||
|
||||
class CrashAdapter extends LlmAdapter {
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (mode === 'request') {
|
||||
await writeFile(failpoint, 'request-dispatched')
|
||||
await waitForCrash()
|
||||
return
|
||||
}
|
||||
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
|
||||
yield {
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: { type: 'tool-call', id: CallId('crash-call'), name: 'crash_tool', arguments: '{}' },
|
||||
}
|
||||
yield { type: 'finish', reason: { kind: 'tool-calls' } }
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: persistenceRoot, compression: 'none' })
|
||||
await ctx.plugin(checkpointPolicy)
|
||||
ctx.llm.registerAdapter(['crash'], new CrashAdapter())
|
||||
ctx.tools.register({
|
||||
name: 'crash_tool',
|
||||
description: 'records an external effect and never returns',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
await writeFile(failpoint, 'tool-side-effect')
|
||||
return waitForCrash()
|
||||
},
|
||||
})
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('semantic-checkpoint-crash'),
|
||||
agentOptions: { provider: 'crash', model: 'crash' },
|
||||
})
|
||||
handle.agent.send([{ type: 'text', text: 'exercise the crash boundary' }])
|
||||
await waitForCrash()
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import LlmService, { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import * as checkpointPolicy from '../src/index.ts'
|
||||
|
||||
const contexts: Context[] = []
|
||||
|
||||
class TestPersistence extends SessionPersistence {
|
||||
locate(_meta: SessionHeader): undefined { return undefined }
|
||||
create(_meta: SessionHeader): Promise<void> { return Promise.resolve() }
|
||||
append(_id: SessionId, _events: readonly SessionEvent[]): Promise<void> { return Promise.resolve() }
|
||||
load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
return Promise.reject(new Error('not used'))
|
||||
}
|
||||
list(): Promise<SessionHeader[]> { return Promise.resolve([]) }
|
||||
}
|
||||
|
||||
class RecordingAdapter extends LlmAdapter {
|
||||
constructor(private readonly order: string[]) { super() }
|
||||
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.order.push('adapter')
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(TestPersistence)
|
||||
await ctx.plugin(checkpointPolicy)
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function drain(stream: AsyncIterable<StreamChunk>): Promise<void> {
|
||||
for await (const _chunk of stream) { /* drain */ }
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
})
|
||||
|
||||
describe('session-checkpoint-policy request boundary', () => {
|
||||
it('awaits the live session checkpoint before constructing the downstream model stream', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('request-checkpoint'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', async () => {
|
||||
order.push('flush:start')
|
||||
await gate.promise
|
||||
order.push('flush:end')
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
|
||||
|
||||
const pending = drain(ctx.llm.stream({
|
||||
provider: 'mock', model: 'mock', messages: [], sessionId: session.id,
|
||||
}))
|
||||
await Promise.resolve()
|
||||
expect(order).toEqual(['flush:start'])
|
||||
gate.resolve(undefined)
|
||||
await pending
|
||||
expect(order).toEqual(['flush:start', 'flush:end', 'adapter'])
|
||||
})
|
||||
|
||||
it('delegates a request without a live session without checkpointing', async () => {
|
||||
const ctx = await setup()
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', () => { order.push('flush') })
|
||||
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
|
||||
await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [] }))
|
||||
expect(order).toEqual(['adapter'])
|
||||
})
|
||||
|
||||
it('delegates an already-detached session id without checkpointing', async () => {
|
||||
const ctx = await setup()
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', () => { order.push('flush') })
|
||||
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
|
||||
await drain(ctx.llm.stream({
|
||||
provider: 'mock', model: 'mock', messages: [], sessionId: SessionId('detached'),
|
||||
}))
|
||||
expect(order).toEqual(['adapter'])
|
||||
})
|
||||
|
||||
it('does not dispatch the adapter when the checkpoint rejects', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('request-failure'))
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
|
||||
ctx.llm.registerAdapter(['mock'], new RecordingAdapter(order))
|
||||
await expect(drain(ctx.llm.stream({
|
||||
provider: 'mock', model: 'mock', messages: [], sessionId: session.id,
|
||||
}))).rejects.toThrow('disk unavailable')
|
||||
expect(order).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-checkpoint-policy tool and step boundaries', () => {
|
||||
it('awaits the checkpoint before a top-level tool body', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('tool-checkpoint'))
|
||||
const agent = { session } as Agent
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', async () => {
|
||||
order.push('flush:start')
|
||||
await gate.promise
|
||||
order.push('flush:end')
|
||||
})
|
||||
ctx.tools.register({
|
||||
name: 'write', description: 'side effect', parameters: {},
|
||||
execute: async () => { order.push('tool'); return [] },
|
||||
})
|
||||
|
||||
const pending = ctx.tools.execute({
|
||||
callId: CallId('write-1'), name: 'write', arguments: {}, agent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(order).toEqual(['flush:start'])
|
||||
gate.resolve(undefined)
|
||||
await expect(pending).resolves.toMatchObject({ isError: false })
|
||||
expect(order).toEqual(['flush:start', 'flush:end', 'tool'])
|
||||
})
|
||||
|
||||
it('does not dispatch when cancellation lands during the tool checkpoint', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('tool-checkpoint-cancel'))
|
||||
const agent = { session } as Agent
|
||||
const controller = new AbortController()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
ctx.on('session/flush', async () => {
|
||||
order.push('flush:start')
|
||||
await gate.promise
|
||||
order.push('flush:end')
|
||||
})
|
||||
ctx.tools.register({
|
||||
name: 'write', description: 'side effect', parameters: {},
|
||||
execute: async () => { order.push('tool'); return [] },
|
||||
})
|
||||
|
||||
const pending = ctx.tools.execute({
|
||||
callId: CallId('write-cancelled'), name: 'write', arguments: {}, agent,
|
||||
signal: controller.signal,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(order).toEqual(['flush:start'])
|
||||
controller.abort('cancelled during checkpoint')
|
||||
gate.resolve(undefined)
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
|
||||
})
|
||||
expect(order).toEqual(['flush:start', 'flush:end'])
|
||||
})
|
||||
|
||||
it('turns a rejected checkpoint into an error result without running the tool body', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('tool-failure'))
|
||||
const agent = { session } as Agent
|
||||
let ran = false
|
||||
ctx.on('session/flush', () => Promise.reject(new Error('disk unavailable')))
|
||||
ctx.tools.register({
|
||||
name: 'write', description: 'side effect', parameters: {},
|
||||
execute: async () => { ran = true; return [] },
|
||||
})
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('write-2'), name: 'write', arguments: {}, agent,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Error: disk unavailable' }])
|
||||
expect(ran).toBe(false)
|
||||
})
|
||||
|
||||
it('reuses the outer checkpoint for a nested tool dispatch', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('nested-tool'))
|
||||
const agent = { session } as Agent
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
ctx.tools.register({ name: 'nested', description: 'nested', parameters: {}, execute: async () => [] })
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('nested-1'), name: 'nested', arguments: {}, agent,
|
||||
parent: Symbol('outer') as never,
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
expect(flushes).toBe(0)
|
||||
})
|
||||
|
||||
it('checkpoints the complete recorded step at agent/post-step', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('post-step'))
|
||||
const agent = { session } as Agent
|
||||
const flushed: string[] = []
|
||||
ctx.on('session/flush', (current) => { flushed.push(current.id) })
|
||||
await agentEvents(ctx, agent).serial(
|
||||
'agent/post-step', 1, 1, new AbortController().signal,
|
||||
)
|
||||
expect(flushed).toEqual([session.id])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session-checkpoint-policy lifecycle', () => {
|
||||
it('removes its wrappers when the owning fiber is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(TestPersistence)
|
||||
const session = ctx.sessions.create(SessionId('disposed-policy'))
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
ctx.llm.registerAdapter(['mock'], new RecordingAdapter([]))
|
||||
const fiber = await ctx.plugin(checkpointPolicy)
|
||||
await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [], sessionId: session.id }))
|
||||
expect(flushes).toBe(1)
|
||||
await fiber.dispose()
|
||||
await drain(ctx.llm.stream({ provider: 'mock', model: 'mock', messages: [], sessionId: session.id }))
|
||||
expect(flushes).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the Loader-safe namespace plugin shape', () => {
|
||||
expect('default' in checkpointPolicy).toBe(false)
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(checkpointPolicy) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(checkpointPolicy)
|
||||
expect(unwrapped.name).toBe('session-checkpoint-policy')
|
||||
expect(unwrapped.inject).toEqual(['llm', 'sessionPersistence', 'sessions', 'tools'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,7 +11,8 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
|
||||
<encoded-id>.jsonl # only with compression: 'none'
|
||||
```
|
||||
|
||||
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent line is one `SessionEvent` JSON, **verbatim including `assistant/chunk`** so `seq` stays contiguous (`events[i].seq === i`).
|
||||
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
|
||||
- A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically.
|
||||
- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision).
|
||||
|
||||
## Config
|
||||
@@ -19,6 +20,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
|
||||
| Key | Type | Notes |
|
||||
|---|---|---|
|
||||
| `root` | `string` (required) | Root directory for all session files. **No default** — a `process.cwd()` default would scatter files as the process's cwd changes (bash calls, subprocesses). |
|
||||
| `packChunks` | `boolean` (default `false`) | Write delta-chunk runs as packed rows (~60% smaller logical logs measured on a real coding session). Off, the written logical layout is byte-identical to the pre-packing format; reading packed rows works regardless of this switch. Off by default while the snapshot goldens stay one-event-per-line — recording with packing on rewrites every fixture `session.jsonl`. |
|
||||
| `compression` | `'zstd' \| 'none'` | Defaults to `'zstd'`; `'none'` retains newline-delimited UTF-8 text. |
|
||||
|
||||
`locate(meta)` returns `{ kind: 'jsonl', path }` using the resolved absolute root and the same cwd-bucket/id encoding as materialization. It performs no filesystem I/O: the target can be returned before the file exists, and an existing file contains only the last flushed prefix.
|
||||
@@ -32,7 +34,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
|
||||
## Durability and crash semantics
|
||||
|
||||
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
|
||||
- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length.
|
||||
- **Append-only.** Flushed events are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length.
|
||||
- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects.
|
||||
- **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
|
||||
|
||||
@@ -46,7 +48,7 @@ The plugin buffers frozen session events and drains them on flush or disposal. A
|
||||
|
||||
#### What the model sees
|
||||
|
||||
JSONL storage contributes no live prompt or schema. Loading restores stored surface history and preserves prior request headers for reconstruction; the new loop composes its current envelope. Each unanswered call in an interrupted tail is balanced with the exact error text `Tool call interrupted by a crash; no result was recorded.` Raw `assistant/chunk` records do not duplicate messages.
|
||||
JSONL storage contributes no live prompt or schema. Loading restores stored surface history and preserves prior request headers for reconstruction; the new loop composes its current envelope. Recovery balances an assistant request without a durable call with `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, which tells the model to retry only read-only or idempotent work and to verify possible side effects or ask the user. Raw `assistant/chunk` records do not duplicate messages.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Physical encoding selected for JSONL session artifacts. */
|
||||
export type JsonlCompression = 'zstd' | 'none'
|
||||
@@ -151,17 +152,26 @@ export function logPath(
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize one event as a JSONL line (no trailing newline).
|
||||
* @param event - the event to serialize verbatim.
|
||||
* @returns the event's single-line JSON text; the writer adds the newline.
|
||||
* Serialize an event batch as JSONL lines (no trailing newline). With
|
||||
* `packChunks` on, delta-chunk runs pack into `text-chunks` /
|
||||
* `reasoning-chunks` / `tool-call-chunks` storage rows; off writes one event
|
||||
* per line, byte-identical to the pre-packing layout. Reading is layout-blind
|
||||
* either way ({@link scanLog} always decodes rows), so the switch only shapes
|
||||
* NEW bytes.
|
||||
* @param events - the batch to serialize, in log order.
|
||||
* @param packChunks - whether to pack delta runs into storage rows.
|
||||
* @returns the batch's JSONL text; the writer adds the final newline.
|
||||
*/
|
||||
export function eventLine(event: SessionEvent): string {
|
||||
return JSON.stringify(event)
|
||||
export function eventLines(events: readonly SessionEvent[], packChunks: boolean): string {
|
||||
const records: readonly StorageRecord[] = packChunks ? packChunkRuns(events) : events
|
||||
return records.map(record => JSON.stringify(record)).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSONL log buffer into its preserved event prefix (the header is line
|
||||
* 0). Fully written events in an interrupted final turn remain part of the
|
||||
* 0). Event lines pass through verbatim; packed chunk rows expand back into
|
||||
* their events, so callers see one contiguous event list regardless of layout.
|
||||
* Fully written events in an interrupted final turn remain part of the
|
||||
* prefix. The first unparsable record or seq gap after the last `turn/end`
|
||||
* marks a tolerated torn tail; the same hole in the committed region rejects.
|
||||
*
|
||||
@@ -200,46 +210,60 @@ export function scanLog(buffer: Buffer): { meta: SessionHeader; events: SessionE
|
||||
}
|
||||
const headerLine = parsedHeader
|
||||
|
||||
// Parse every complete record first so the last valid `turn/end` determines
|
||||
// whether an earlier hole is committed corruption or an uncommitted tail.
|
||||
interface Parsed { ok: boolean; event?: SessionEvent; endByte: number }
|
||||
// Parse and decode every complete line first so the last valid `turn/end`
|
||||
// determines whether an earlier hole is committed corruption or an
|
||||
// uncommitted tail. One line yields one event, or a whole run for a packed
|
||||
// chunk row; a row-tagged line that fails row validation is a hole, exactly
|
||||
// like unparsable JSON.
|
||||
interface Parsed { ok: boolean; events?: SessionEvent[]; endByte: number }
|
||||
const parsed: Parsed[] = eventEntries.map((entry) => {
|
||||
try {
|
||||
return { ok: true, event: JSON.parse(entry.text) as SessionEvent, endByte: entry.endByte }
|
||||
return { ok: true, events: decodeStorageRecord(JSON.parse(entry.text)), endByte: entry.endByte }
|
||||
} catch {
|
||||
return { ok: false, endByte: entry.endByte }
|
||||
}
|
||||
})
|
||||
|
||||
// The last index (into eventEntries) that is a valid `turn/end` — the last
|
||||
// fully-committed boundary (the loop flushes only at turn/end).
|
||||
// The last index (into eventEntries) that ends in a valid `turn/end` — the
|
||||
// last fully-committed boundary (the loop flushes only at turn/end). A packed
|
||||
// row never stores a turn/end, so only single-event lines can match.
|
||||
let lastTurnEnd = -1
|
||||
for (let i = parsed.length - 1; i >= 0; i--) {
|
||||
const p = parsed[i]
|
||||
if (p?.ok && p.event?.type === 'turn/end') { lastTurnEnd = i; break }
|
||||
if (p?.ok && p.events?.some(e => e.type === 'turn/end')) { lastTurnEnd = i; break }
|
||||
}
|
||||
|
||||
// Preserve the contiguous prefix, including a complete interrupted turn;
|
||||
// holes through the last committed boundary throw, while later holes stop.
|
||||
// Contiguity is a cursor over seqs (not the line index): a packed row
|
||||
// advances the cursor by its whole run.
|
||||
const preserved: SessionEvent[] = []
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
let lastPreservedLine = -1
|
||||
scan: for (let i = 0; i < parsed.length; i++) {
|
||||
const p = parsed[i]
|
||||
if (!p?.ok || p.event === undefined) {
|
||||
if (!p?.ok || p.events === undefined) {
|
||||
if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at line ${i + 1}`)
|
||||
break // torn tail fragment after the last turn/end — stop, tolerate
|
||||
}
|
||||
if (p.event.seq !== i) {
|
||||
if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region at line ${i + 1} (expected ${i}, got ${p.event.seq})`)
|
||||
break // gap after the last turn/end — torn tail, stop
|
||||
for (const event of p.events) {
|
||||
if (event.seq !== preserved.length) {
|
||||
if (i <= lastTurnEnd) {
|
||||
throw new Error(`corrupt session log: seq gap in committed region at line ${i + 1} (expected ${preserved.length}, got ${event.seq})`)
|
||||
}
|
||||
break scan // gap after the last turn/end — torn tail, stop
|
||||
}
|
||||
preserved.push(event)
|
||||
}
|
||||
preserved.push(p.event)
|
||||
lastPreservedLine = i
|
||||
}
|
||||
|
||||
// committedBytes = end of the last PRESERVED line (header if none): the next
|
||||
// append truncates any torn bytes past this point before writing the
|
||||
// synthetic closers + new events.
|
||||
const lastPreserved = parsed[preserved.length - 1]
|
||||
const committedBytes = preserved.length > 0 && lastPreserved ? lastPreserved.endByte : headerEntry.endByte
|
||||
// committedBytes = end of the last FULLY preserved line (header if none): the
|
||||
// next append truncates any torn bytes past this point before writing the
|
||||
// synthetic closers + new events. A line is preserved whole or not at all —
|
||||
// a mid-row seq gap discards the whole row, keeping the truncation offset on
|
||||
// a line boundary.
|
||||
const lastPreserved = parsed[lastPreservedLine]
|
||||
const committedBytes = lastPreserved !== undefined ? lastPreserved.endByte : headerEntry.endByte
|
||||
return { meta: fromHeaderLine(headerLine), events: preserved, committedBytes }
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
encodeSegment, eventLine, logPath, logSuffix, parseHeaderMeta, scanLog, sessionDir, toHeaderLine,
|
||||
encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, scanLog, sessionDir, toHeaderLine,
|
||||
type JsonlCompression,
|
||||
} from './format.ts'
|
||||
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from './zstd.ts'
|
||||
@@ -33,7 +33,7 @@ export const JsonlCompressionSchema: z<JsonlCompression> = z.union([
|
||||
z.const('none'),
|
||||
]).default(DEFAULT_COMPRESSION)
|
||||
|
||||
/** Plugin config: where the JSONL backend keeps its session logs (`root` is required — no default). */
|
||||
/** Plugin config: where the JSONL backend keeps its session logs, and the packed-row write switch. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Root directory for all session files. Required (no default): a default of
|
||||
@@ -41,6 +41,15 @@ export interface Config {
|
||||
* (bash calls, subprocesses). Sessions group under per-cwd subdirectories.
|
||||
*/
|
||||
root: string
|
||||
/**
|
||||
* Write runs of consecutive `assistant/chunk` delta events as packed
|
||||
* `text-chunks`/`reasoning-chunks`/`tool-call-chunks` rows (lossless,
|
||||
* ~60% smaller logs measured on a real session). Off by default while
|
||||
* snapshot fixtures stay in the one-event-per-line layout: recording with
|
||||
* packing on rewrites every golden `session.jsonl`. READING packed rows is
|
||||
* unconditional — a log's layout never depends on this switch.
|
||||
*/
|
||||
packChunks?: boolean
|
||||
/** Physical encoding; defaults to checksummed Zstandard frames. */
|
||||
compression?: JsonlCompression
|
||||
}
|
||||
@@ -67,6 +76,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
root: z.string().required(),
|
||||
packChunks: z.boolean().default(false),
|
||||
compression: JsonlCompressionSchema,
|
||||
})
|
||||
|
||||
@@ -78,6 +88,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
override readonly name = 'session-persistence-jsonl'
|
||||
|
||||
private root: string
|
||||
private packChunks: boolean
|
||||
private compression: JsonlCompression
|
||||
private coordinator: PersistenceCoordinator<JsonlTornMarker>
|
||||
private rootEncodingCheck: Promise<void> | undefined
|
||||
@@ -86,6 +97,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
super(ctx)
|
||||
// Resolve once so later process.cwd() changes cannot split one backend across roots.
|
||||
this.root = resolve(config.root)
|
||||
// schemastery (static Config) applied the default before construction;
|
||||
// the cast records that runtime fact for exactOptionalPropertyTypes.
|
||||
this.packChunks = (config as Required<Config>).packChunks
|
||||
this.compression = config.compression ?? DEFAULT_COMPRESSION
|
||||
this.coordinator = new PersistenceCoordinator<JsonlTornMarker>(this.ctx, this)
|
||||
}
|
||||
@@ -354,7 +368,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
/** Encode the header and first batch without combining their frame boundaries. */
|
||||
private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise<Buffer | string> {
|
||||
const header = JSON.stringify(toHeaderLine(meta)) + '\n'
|
||||
const body = events.map(eventLine).join('\n') + '\n'
|
||||
const body = eventLines(events, this.packChunks) + '\n'
|
||||
if (this.compression === 'none') return header + body
|
||||
const headerFrame = await compressZstdFrame(header)
|
||||
const eventFrame = await compressZstdFrame(body)
|
||||
@@ -363,7 +377,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
|
||||
/** Encode one durable append batch in the configured physical representation. */
|
||||
private async encodeEventBatch(events: readonly SessionEvent[]): Promise<Buffer | string> {
|
||||
const body = events.map(eventLine).join('\n') + '\n'
|
||||
const body = eventLines(events, this.packChunks) + '\n'
|
||||
return this.compression === 'zstd' ? compressZstdFrame(body) : body
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { encodeSegment, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts'
|
||||
import { encodeSegment, eventLines, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
|
||||
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
|
||||
|
||||
@@ -554,6 +554,121 @@ describe('SessionPersistenceJsonl: scanLog unit', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () => {
|
||||
let ctx: Context
|
||||
beforeEach(async () => {
|
||||
root = await freshRoot()
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
// compression: 'none' — these tests assert the textual storage-record layout
|
||||
// (row tags per line); packing is orthogonal to the physical encoding.
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root, packChunks: true, compression: 'none' })
|
||||
})
|
||||
afterEach(async () => { await ctx.fiber.dispose() })
|
||||
|
||||
/** A one-turn log whose step streams a five-member text-delta run. */
|
||||
function chunkRunLog(): SessionEvent[] {
|
||||
const deltas: SessionEvent[] = Array.from({ length: 5 }, (_, k) => ({
|
||||
type: 'assistant/chunk',
|
||||
seq: 2 + k,
|
||||
time: 3 + k,
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: `t${k}` } },
|
||||
}))
|
||||
return [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
...deltas,
|
||||
{ type: 'assistant/message', seq: 7, time: 8, data: { turn: 1, step: 1, content: [{ type: 'text', text: 't0t1t2t3t4' }], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append', sourceEventSeqs: [2, 3, 4, 5, 6] },
|
||||
{ type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
}
|
||||
|
||||
it('writes a delta run as one text-chunks row and loads back identical events', async () => {
|
||||
const m = meta('packed', '/work')
|
||||
const log = chunkRunLog()
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, log)
|
||||
|
||||
const raw = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean)
|
||||
const tags = raw.slice(1).map(line => (JSON.parse(line) as { type: string }).type)
|
||||
expect(tags).toEqual(['turn/start', 'step/start', 'text-chunks', 'assistant/message', 'step/end', 'turn/end'])
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(m.id)
|
||||
expect(loaded.events).toEqual(log)
|
||||
})
|
||||
|
||||
it('loads a mixed file: verbatim lines from an unpacked writer, then packed appends', async () => {
|
||||
const m = meta('mixed', '/work')
|
||||
const log = chunkRunLog()
|
||||
// First turn written line-per-event by an unpacked-config writer (an old
|
||||
// file, hand-planted so this packed-config backend adopts it on load).
|
||||
await mkdir(sessionDir(root, '/work'), { recursive: true })
|
||||
await writeFile(rawLogPath(root, '/work', m.id), [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'mixed', createdAt: 1000, cwd: '/work', delegationDepth: 0 }),
|
||||
...log.map(e => JSON.stringify(e)),
|
||||
].join('\n') + '\n')
|
||||
// Adopt the stored log (cursor = stored length), then append a second turn
|
||||
// through THIS packed-config backend.
|
||||
expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(log)
|
||||
const secondTurn: SessionEvent[] = JSON.parse(JSON.stringify(log)) as SessionEvent[]
|
||||
for (const [k, e] of secondTurn.entries()) {
|
||||
;(e as { seq: number }).seq = 10 + k
|
||||
;(e.data as { turn: number }).turn = 2
|
||||
}
|
||||
await ctx.sessionPersistence.append(m.id, secondTurn)
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(m.id)
|
||||
expect(loaded.events).toEqual([...log, ...secondTurn])
|
||||
// The packed append really packed: the file's tail carries a text-chunks row.
|
||||
const tags = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean)
|
||||
.map(line => (JSON.parse(line) as { type: string }).type)
|
||||
expect(tags.filter(t => t === 'text-chunks')).toHaveLength(1)
|
||||
expect(tags.filter(t => t === 'assistant/chunk')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('scanLog: a packed row advances the seq cursor by its whole run', () => {
|
||||
const logText = [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'rows', createdAt: 1, delegationDepth: 0 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
].join('\n') + '\n'
|
||||
const { events } = scanLog(Buffer.from(logText))
|
||||
expect(events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4])
|
||||
expect(events[2]).toEqual({ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } } })
|
||||
})
|
||||
|
||||
it('scanLog: a malformed packed row in the committed region rejects like corrupt JSON', () => {
|
||||
const logText = [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'bad-row', createdAt: 1, delegationDepth: 0 }),
|
||||
// dt arity mismatch — row validation throws, so the line is a committed hole.
|
||||
JSON.stringify({ type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] } }),
|
||||
JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
|
||||
].join('\n') + '\n'
|
||||
expect(() => scanLog(Buffer.from(logText))).toThrow(/unparsable committed event/)
|
||||
})
|
||||
|
||||
it('scanLog: a packed row with a mid-run seq gap after the last turn/end drops the whole row', () => {
|
||||
const logText = [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'row-gap', createdAt: 1, delegationDepth: 0 }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
// seq0 skips 1 — the run's first member is already a gap; no turn/end follows.
|
||||
JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
|
||||
].join('\n') + '\n'
|
||||
const scanned = scanLog(Buffer.from(logText))
|
||||
expect(scanned.events.map(e => e.seq)).toEqual([0])
|
||||
// committedBytes stays on the line boundary BEFORE the dropped row.
|
||||
const headerAndTurn = logText.split('\n').slice(0, 2).join('\n') + '\n'
|
||||
expect(scanned.committedBytes).toBe(Buffer.byteLength(headerAndTurn, 'utf8'))
|
||||
})
|
||||
|
||||
it('eventLines(packChunks: false) is byte-identical to the pre-packing layout', () => {
|
||||
const log = chunkRunLog()
|
||||
expect(eventLines(log, false)).toBe(log.map(e => JSON.stringify(e)).join('\n'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
let ctx: Context
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { join } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { eventLine, logPath, scanLog, sessionDir, toHeaderLine, type JsonlCompression } from '../src/format.ts'
|
||||
import { logPath, scanLog, sessionDir, toHeaderLine, type JsonlCompression } from '../src/format.ts'
|
||||
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../src/zstd.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
|
||||
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
|
||||
@@ -217,7 +217,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
const plaintext = await decodeCompleteFrames(buffer)
|
||||
expect(plaintext.toString()).toBe([
|
||||
JSON.stringify(toHeaderLine(header)),
|
||||
...oneTurnLog().map(eventLine),
|
||||
...oneTurnLog().map(e => JSON.stringify(e)),
|
||||
'',
|
||||
].join('\n'))
|
||||
expect((await ctx.sessionPersistence.load(header.id)).events).toEqual(oneTurnLog())
|
||||
@@ -288,7 +288,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/chunk', seq: 8, time: 9, data: { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: deterministicNoise(300_000) } } },
|
||||
] as SessionEvent[]
|
||||
const plaintext = openTurn.map(eventLine).join('\n') + '\n'
|
||||
const plaintext = openTurn.map(e => JSON.stringify(e)).join('\n') + '\n'
|
||||
const partial = await tornFrame(plaintext, (decoded) => {
|
||||
const newlines = decoded.match(/\n/g)?.length ?? 0
|
||||
return newlines >= 2 && !decoded.endsWith('\n')
|
||||
@@ -334,7 +334,7 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
] as SessionEvent[]
|
||||
const frame = await compressZstdFrame(secondTurn.map(eventLine).join('\n') + '\n')
|
||||
const frame = await compressZstdFrame(secondTurn.map(e => JSON.stringify(e)).join('\n') + '\n')
|
||||
await appendFile(path, frame.subarray(0, -1))
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(header.id)
|
||||
@@ -456,7 +456,7 @@ describe('SessionPersistenceJsonl: encoding selection', () => {
|
||||
await mkdir(sessionDir(root, loadHeader.cwd), { recursive: true })
|
||||
await writeFile(logPath(root, loadHeader.cwd, loadHeader.id, 'none'), [
|
||||
JSON.stringify(toHeaderLine(loadHeader)),
|
||||
...oneTurnLog().map(eventLine),
|
||||
...oneTurnLog().map(e => JSON.stringify(e)),
|
||||
'',
|
||||
].join('\n'))
|
||||
await expect(ctx.sessionPersistence.load(loadHeader.id)).rejects.toThrow(/uses \.jsonl/)
|
||||
@@ -474,7 +474,7 @@ describe('SessionPersistenceJsonl: encoding selection', () => {
|
||||
await mkdir(sessionDir(root, header.cwd), { recursive: true })
|
||||
await writeFile(logPath(root, header.cwd, header.id, 'none'), [
|
||||
JSON.stringify(toHeaderLine(header)),
|
||||
...oneTurnLog().map(eventLine),
|
||||
...oneTurnLog().map(e => JSON.stringify(e)),
|
||||
'',
|
||||
].join('\n'))
|
||||
await expect(ctx.sessionPersistence.append(header.id, oneTurnLog())).rejects.toThrow(/uses \.jsonl/)
|
||||
|
||||
@@ -39,7 +39,7 @@ Like the JSONL backend, the plugin also installs the `session/event` → buffer
|
||||
|
||||
#### What the model sees
|
||||
|
||||
SQLite storage contributes no live prompt or schema. Loading restores the same surface history as JSONL and preserves prior headers for reconstruction; the new loop composes its current envelope. Each unanswered call in interrupted rows is balanced with the exact error text `Tool call interrupted by a crash; no result was recorded.` Row metadata and raw chunks are not messages.
|
||||
SQLite storage contributes no live prompt or schema. Loading restores the same surface history as JSONL and preserves prior headers for reconstruction; the new loop composes its current envelope. Recovery balances an assistant request without a durable call with `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, which tells the model to retry only read-only or idempotent work and to verify possible side effects or ask the user. Row metadata and raw chunks are not messages.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -166,8 +166,8 @@ export function scanRows(rows: readonly EventRow[]): { preserved: SessionEvent[]
|
||||
}
|
||||
})
|
||||
|
||||
// The last index that is a valid `turn/end` — the last fully-committed
|
||||
// boundary (the loop flushes only at turn/end).
|
||||
// The last index that is a valid `turn/end` — holes through a closed turn
|
||||
// are always committed corruption.
|
||||
let lastTurnEnd = -1
|
||||
for (let i = parsed.length - 1; i >= 0; i--) {
|
||||
if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break }
|
||||
|
||||
@@ -16,7 +16,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
|
||||
## Invariants every backend must honor
|
||||
|
||||
- **Append-only; a crashed turn is closed, not truncated.** Committed events (at or below a flushed `turn/end`) are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded.
|
||||
- **Append-only; a crashed turn is closed, not truncated.** Flushed events are never rewritten. A crash can leave an unclosed final turn whose events are real and possibly large; `load` preserves them and durably appends synthetic closers (a risk-classified error `tool/result` per unanswered assistant call, then `step/end?`+`turn/end {interrupted}`) to balance the log and keep the rehydrated history a valid provider transcript. Only a never-fully-written torn tail fragment is discarded.
|
||||
- **Contiguous seq.** `load` rejects a `seq` gap/parse error in the MIDDLE of the log; `append`'s first `seq` must equal the stored next-seq.
|
||||
- **JSON-serializable data.** `append` materializes each direct/replay batch through the shared one-pass lossless-JSON boundary. Live `Session` events are already deep-frozen, but the write coordinator still copies each event into a persistence-owned buffer.
|
||||
- **Durability.** `append` returns only once the batch is durable.
|
||||
@@ -25,6 +25,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
|
||||
`PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event` → `session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
|
||||
|
||||
The coordinator implements durability at checkpoints but does not select their schedule. Persisted deployments explicitly compose [`dsh-session-checkpoint-policy`](../session-checkpoint-policy) when they want request-, tool-dispatch-, and completed-step recovery boundaries; omitting it leaves the loop's coarser checkpoints intact.
|
||||
|
||||
When a live session emits `session/disposed`, the coordinator waits for its initialization, serializes a final buffer drain, then releases every map entry owned by that exact `Session` object. A failed final drain keeps the pending buffer for backend teardown to retry. Backend teardown stops event admission first, awaits all in-flight session retirements and remaining per-id operations, drains any retained buffers, and only then closes the storage handle.
|
||||
|
||||
The side-effect-free `locate` query remains backend-owned because it describes storage topology rather than write orchestration.
|
||||
@@ -59,7 +61,7 @@ Re-exported from `dsh-session`: `SessionHeader` (immutable session metadata: `ve
|
||||
|
||||
#### What the model sees
|
||||
|
||||
This seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair inserts exactly `Tool call interrupted by a crash; no result was recorded.` as the error result for each unanswered tool call.
|
||||
This seam adds no prompt or schema. Resume restores stored surface events as message history; stored request headers reconstruct earlier calls, while the new loop composes the current system prompt, tools, and session prefix for its next request. Crash repair marks an assistant request without a durable call as `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, whose text lets the model retry read-only or idempotent work but directs it to verify side effects or ask the user instead of retrying blindly.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionPersistence } from '../src/index.ts'
|
||||
|
||||
@@ -122,7 +122,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
}
|
||||
})
|
||||
|
||||
it('crash recovery: an interrupted tool call gets a synthetic error result so resume is a valid transcript', async () => {
|
||||
it('crash recovery: an unstarted assistant tool request gets a retryable synthetic result', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('interrupted-toolcall')
|
||||
@@ -149,7 +149,7 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
])
|
||||
const synthetic = loaded.events.find(e => e.type === 'tool/result')
|
||||
expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({
|
||||
callId: CallId('call-x'), isError: true, error: { code: 'interrupted' },
|
||||
callId: CallId('call-x'), isError: true, error: { code: TOOL_NOT_STARTED },
|
||||
})
|
||||
// The synthetic result carries the SAME callId as the orphaned tool-call,
|
||||
// so deriveMessages() pairs them — no provider-invalid dangling call.
|
||||
@@ -162,6 +162,40 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
}
|
||||
})
|
||||
|
||||
it('crash recovery: a recorded tool call with no result tells the model to assess retry risk', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const m = meta('unknown-tool-outcome')
|
||||
await persistence.create(m)
|
||||
await persistence.append(m.id, [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, content: [
|
||||
{ type: 'tool-call', id: CallId('call-risk'), name: 'write', arguments: '{}' },
|
||||
], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' },
|
||||
{ type: 'tool/call', seq: 3, time: 4, data: { turn: 1, step: 1, callId: CallId('call-risk'), name: 'write', arguments: '{}' } },
|
||||
])
|
||||
|
||||
const loaded = await persistence.load(m.id)
|
||||
const synthetic = loaded.events.find(e => e.type === 'tool/result')
|
||||
expect(synthetic?.type === 'tool/result' && synthetic.data.error).toEqual({
|
||||
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
|
||||
})
|
||||
if (synthetic?.type !== 'tool/result' || synthetic.data.content[0]?.type !== 'text') {
|
||||
throw new Error('expected a text tool result')
|
||||
}
|
||||
expect(synthetic.data.content[0].text).toContain('retry only if the operation is read-only or idempotent')
|
||||
expect(synthetic.data.content[0].text).toContain('if it may have side effects, first verify external state or ask the user')
|
||||
const resumed = new Session(m.id, loaded.events, loaded.meta)
|
||||
const resumedResult = resumed.deriveMessages().find(message => message.content.some(block => block.type === 'tool-result'))
|
||||
expect(resumedResult?.content[0]).toMatchObject({
|
||||
type: 'tool-result', toolCallId: CallId('call-risk'), isError: true,
|
||||
})
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('list() excludes a created-but-never-appended (zero-event) session', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
|
||||
@@ -7,7 +7,7 @@ Four layers, importable separately:
|
||||
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
|
||||
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic.
|
||||
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt` → `{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
|
||||
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh preserves existing volatile fields by event position and gives a newly inserted `session/title` its preceding event's time, so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
|
||||
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh expands packed timing envelopes before aligning existing volatile event times, so switching between packed and unpacked layouts cannot shift later records; fresh chunk-fragment arrays remain authoritative. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
|
||||
|
||||
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { existsSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, dirname, join, delimiter } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
PROTOCOL_VERSION,
|
||||
@@ -34,6 +35,9 @@ import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } fr
|
||||
|
||||
export type { AgentUnderTest } from './launcher.ts'
|
||||
|
||||
const DEFAULT_WAIT_TIMEOUT_MS = 10_000
|
||||
const WAIT_POLL_INTERVAL_MS = 10
|
||||
|
||||
/**
|
||||
* One step of a scenario's deterministic input script (`input.json`). The
|
||||
* harness interprets these in order. `newSession` captures the server-issued
|
||||
@@ -42,10 +46,13 @@ export type { AgentUnderTest } from './launcher.ts'
|
||||
*
|
||||
* `promptAndCancel` starts a prompt without awaiting completion, waits until
|
||||
* the client observes the selected update (`agent_message_chunk` by default),
|
||||
* then cancels and awaits completion. A named `waitForToolCallUpdate` keeps the
|
||||
* step open for a terminal tool update that may follow the prompt response.
|
||||
* then cancels and awaits completion. An optional `waitForFile` first observes
|
||||
* a cwd-relative readiness marker, and a named `waitForToolCallUpdate` keeps
|
||||
* the step open for a terminal tool update that may follow the prompt response.
|
||||
* `promptAndWaitForAgentMessage` arms an exact text-chunk waiter before sending
|
||||
* the prompt, then keeps the application live until that later update arrives.
|
||||
* `waitForTurnEnd` holds the subprocess open until the selected session's latest
|
||||
* complete raw-JSONL turn boundary is `turn/end`; its timeout defaults to 10s.
|
||||
*/
|
||||
export type InputStep =
|
||||
| { op: 'initialize'; terminalOutput?: boolean }
|
||||
@@ -58,8 +65,10 @@ export type InputStep =
|
||||
op: 'promptAndCancel'
|
||||
text: string
|
||||
afterUpdate?: 'agent_message_chunk' | 'tool_call'
|
||||
waitForFile?: { path: string; timeoutMs?: number }
|
||||
waitForToolCallUpdate?: string
|
||||
}
|
||||
| { op: 'waitForTurnEnd'; timeoutMs?: number }
|
||||
| { op: 'cancel' }
|
||||
| { op: 'setMode'; modeId: string }
|
||||
| { op: 'setModeExpectError'; modeId: string }
|
||||
@@ -295,7 +304,15 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
|
||||
const { client } = active
|
||||
|
||||
for (const step of input.steps) {
|
||||
await runStep(client, step, cwd, match => active.waitForUpdate(match), () => sessionId, (id) => { sessionId = id })
|
||||
await runStep(
|
||||
client,
|
||||
step,
|
||||
cwd,
|
||||
match => active.waitForUpdate(match),
|
||||
() => sessionId,
|
||||
(id) => { sessionId = id },
|
||||
(id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs),
|
||||
)
|
||||
// A permission exchange happens while a step's request is in flight, so
|
||||
// by the time the step settles any script bug it exposed is captured —
|
||||
// fail the run HERE, as a harness error, rather than hoping the agent's
|
||||
@@ -365,6 +382,7 @@ async function runStep(
|
||||
waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise<SessionNotification['update']>,
|
||||
getSessionId: () => string | undefined,
|
||||
setSessionId: (id: string) => void,
|
||||
waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
switch (step.op) {
|
||||
case 'initialize':
|
||||
@@ -429,6 +447,9 @@ async function runStep(
|
||||
const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
|
||||
const afterUpdate = step.afterUpdate ?? 'agent_message_chunk'
|
||||
await waitForUpdate(u => u.sessionUpdate === afterUpdate)
|
||||
if (step.waitForFile !== undefined) {
|
||||
await waitForWorkspaceFile(cwd, step.waitForFile.path, step.waitForFile.timeoutMs)
|
||||
}
|
||||
// Arm this before cancellation so a fast tool drain cannot outrun the waiter.
|
||||
const toolCallUpdateDone = step.waitForToolCallUpdate === undefined
|
||||
? undefined
|
||||
@@ -438,6 +459,12 @@ async function runStep(
|
||||
if (toolCallUpdateDone !== undefined) await toolCallUpdateDone
|
||||
return
|
||||
}
|
||||
case 'waitForTurnEnd': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: waitForTurnEnd before newSession')
|
||||
await waitForTurnEnd(sessionId, step.timeoutMs)
|
||||
return
|
||||
}
|
||||
case 'cancel': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: cancel before newSession')
|
||||
@@ -485,6 +512,51 @@ async function runStep(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the raw JSONL backend exposes one complete closing turn boundary.
|
||||
* The ACP cancel notification settles its prompt before the agent necessarily
|
||||
* reaches quiescence, so cancellation snapshots use this external boundary to
|
||||
* keep subprocess disposal from changing an `aborted` turn into `disposed`.
|
||||
*/
|
||||
async function waitForPersistedTurnEnd(
|
||||
root: string,
|
||||
sessionId: string,
|
||||
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (true) {
|
||||
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
|
||||
if (log !== undefined && latestTurnIsClosed(log.content)) return
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`)
|
||||
}
|
||||
await delay(WAIT_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for a cwd-relative marker proving an external action reached readiness. */
|
||||
async function waitForWorkspaceFile(
|
||||
cwd: string,
|
||||
path: string,
|
||||
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
const target = join(cwd, path)
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!existsSync(target)) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`)
|
||||
}
|
||||
await delay(WAIT_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Return whether the last complete raw-JSONL turn boundary closes its turn. */
|
||||
function latestTurnIsClosed(content: string): boolean {
|
||||
const complete = content.slice(0, content.lastIndexOf('\n') + 1)
|
||||
return complete.lastIndexOf('\n{"type":"turn/end",')
|
||||
> complete.lastIndexOf('\n{"type":"turn/start",')
|
||||
}
|
||||
|
||||
/**
|
||||
* Harvest EVERY persisted `.jsonl` session log under a sessions root, parse each
|
||||
* header line, and return them ordered primary-first: the top-level session (no
|
||||
|
||||
@@ -135,8 +135,10 @@ export function normalizeStdout(
|
||||
* Normalize a session JSONL log into a stable expected output: the header line's
|
||||
* volatile fields (`createdAt`, `id`, `cwd`) and every event's `time` are
|
||||
* zeroed/scrubbed, all volatile strings scrubbed, and `seq` is LEFT INTACT
|
||||
* (deterministic by contract). Output is JSONL in the same shape as the input —
|
||||
* one compact record per line.
|
||||
* (deterministic by contract). A packed chunk row's timing (`time0`, the `dt`
|
||||
* gaps) zeroes just like an event `time`; its `seq0` stays, like `seq`.
|
||||
* Output is JSONL in the same shape as the input — one compact record per
|
||||
* line.
|
||||
*
|
||||
* @param rawLog The raw session `.jsonl` content.
|
||||
* @param ctx The run's volatile values to scrub.
|
||||
@@ -155,6 +157,13 @@ export function normalizeSessionLog(
|
||||
// Header line: { type: 'session', createdAt, id, cwd, … }.
|
||||
if (record.type === 'session') {
|
||||
if ('createdAt' in record) record.createdAt = 0
|
||||
} else if ('time0' in record) {
|
||||
// Packed chunk row: zero the anchor timestamp and every member gap.
|
||||
record.time0 = 0
|
||||
const data = record.data
|
||||
if (data !== null && typeof data === 'object' && Array.isArray((data as { dt?: unknown }).dt)) {
|
||||
(data as { dt: unknown[] }).dt = (data as { dt: unknown[] }).dt.map(() => 0)
|
||||
}
|
||||
} else if ('time' in record) {
|
||||
// Event line: zero the epoch-ms timestamp; keep seq (deterministic).
|
||||
record.time = 0
|
||||
|
||||
@@ -42,6 +42,8 @@ const WINDOWS_STDOUT_SNAPSHOT = 'stdout.expected.windows.jsonl'
|
||||
/** Stable session-log token standing in for the sidecar's initial schemas. */
|
||||
const TOOLS_TOKEN = '{{tools}}'
|
||||
|
||||
const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
|
||||
|
||||
/** A snapshot scenario and how its fixtures are produced. */
|
||||
export interface Scenario {
|
||||
name: string
|
||||
@@ -404,6 +406,23 @@ function parseJsonlRecords(text: string): Record<string, unknown>[] {
|
||||
.map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** One packed row's member times, or `undefined` for an ordinary record. */
|
||||
function packedTimes(record: Record<string, unknown>): number[] | undefined {
|
||||
if (!PACKED_CHUNK_ROW_TYPES.has(record.type as string)) return undefined
|
||||
const row = record as unknown as { time0: number; data: { dt: number[] } }
|
||||
const times = [row.time0]
|
||||
for (const gap of row.data.dt) times.push((times[times.length - 1] as number) + gap)
|
||||
return times
|
||||
}
|
||||
|
||||
/** Expand packed timing envelopes so refresh alignment follows logical events, not physical lines. */
|
||||
function logicalRecords(records: Record<string, unknown>[]): Record<string, unknown>[] {
|
||||
return records.flatMap((record) => {
|
||||
const times = packedTimes(record)
|
||||
return times === undefined ? [record] : times.map(time => ({ type: 'assistant/chunk', time }))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tool calls whose structured result reports `UNKNOWN_TOOL`.
|
||||
*
|
||||
@@ -469,11 +488,32 @@ function preserveFixtureVolatiles(record: Record<string, unknown>, existing: Rec
|
||||
}
|
||||
}
|
||||
|
||||
/** Carry logical member times into a fresh packed row while leaving its fragment arrays untouched. */
|
||||
function preservePackedMemberTimes(
|
||||
record: Record<string, unknown>,
|
||||
existingMembers: Record<string, unknown>[],
|
||||
): void {
|
||||
if (!PACKED_CHUNK_ROW_TYPES.has(record.type as string)) return
|
||||
const row = record as unknown as { time0: number; data: { dt: number[] } }
|
||||
const firstTime = existingMembers[0]?.time
|
||||
if (!Number.isSafeInteger(firstTime)) return
|
||||
row.time0 = firstTime as number
|
||||
if (existingMembers.length !== row.data.dt.length + 1) return
|
||||
const times = existingMembers.map(member => Number.isSafeInteger(member.time) ? member.time as number : undefined)
|
||||
if (times.some(time => time === undefined)) return
|
||||
const memberTimes = times as number[]
|
||||
const gaps = memberTimes.slice(1).map((time, index) => time - (memberTimes[index] as number))
|
||||
if (gaps.some(gap => !Number.isSafeInteger(gap))) return
|
||||
row.data.dt = gaps
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a fresh replay-produced log so repeated refreshes do not churn
|
||||
* volatile fixture fields. Meaningful event payloads come from `fresh`; the
|
||||
* existing fixture lends session ids, cwd, creation times, event times, and
|
||||
* hook durations where the record shape still matches.
|
||||
* existing fixture lends session ids, cwd, creation times, logical event
|
||||
* times, and hook durations where the record shape still matches. Packed
|
||||
* timing envelopes expand for alignment, so packing does not shift later
|
||||
* records; fresh fragment arrays remain authoritative.
|
||||
*
|
||||
* @param fresh The newly harvested session JSONL.
|
||||
* @param existing The committed fixture JSONL being refreshed.
|
||||
@@ -483,21 +523,23 @@ function preserveFixtureVolatiles(record: Record<string, unknown>, existing: Rec
|
||||
export function stabilizeRefreshLog(fresh: string, existing: string, replacements: FixtureReplacement[]): string {
|
||||
let stable = fresh
|
||||
for (const { from, to } of replacements) stable = stable.split(from).join(to)
|
||||
const existingRecords = parseJsonlRecords(existing)
|
||||
const existingRecords = logicalRecords(parseJsonlRecords(existing))
|
||||
const records = parseJsonlRecords(stable)
|
||||
let existingIndex = 0
|
||||
let previousEventTime: unknown
|
||||
for (let i = 0; i < records.length; i++) {
|
||||
const record = records[i] as Record<string, unknown>
|
||||
const existingRecord = existingRecords[existingIndex]
|
||||
const memberCount = packedTimes(record)?.length ?? 1
|
||||
const insertedTitle = record.type === 'session/title' && existingRecord?.type !== 'session/title'
|
||||
if (insertedTitle) {
|
||||
/* v8 ignore next -- a title is turn-enclosed, so a preceding event time exists in every valid fixture. */
|
||||
if (typeof previousEventTime !== 'number') throw new Error('acp-snapshot: inserted title has no preceding event time')
|
||||
record.time = previousEventTime
|
||||
} else {
|
||||
preservePackedMemberTimes(record, existingRecords.slice(existingIndex, existingIndex + memberCount))
|
||||
preserveFixtureVolatiles(record, existingRecord)
|
||||
existingIndex += 1
|
||||
existingIndex += memberCount
|
||||
}
|
||||
if (typeof record.time === 'number') previousEventTime = record.time
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ interface Behavior {
|
||||
cancelAtToolCall?: boolean
|
||||
/** Emit the parked tool call's terminal update after answering cancellation. */
|
||||
cancelToolCallUpdate?: boolean
|
||||
/** Persist the scripted logs while handling cancellation, before stdin EOF. */
|
||||
persistLogsOnCancel?: boolean
|
||||
/** Before responding to a prompt, send a `session/request_permission` request and echo its outcome as a chunk. */
|
||||
permissionProbe?: boolean
|
||||
/** Before responding to a prompt, send an `elicitation/create` request and echo its response as a chunk. */
|
||||
@@ -63,7 +65,7 @@ interface Behavior {
|
||||
stderrNote?: string
|
||||
/** Let a short-lived descendant retain stdio and emit one final ACP update plus stderr line after this parent exits. */
|
||||
lateInheritedOutput?: boolean
|
||||
/** Session logs to persist on stdin EOF. */
|
||||
/** Session logs to persist on stdin EOF and, when selected, on cancellation. */
|
||||
logs?: ScriptedLog[]
|
||||
/** Leave a stray FILE directly under the sessions root (harvest must skip it). */
|
||||
strayRootFile?: boolean
|
||||
@@ -304,6 +306,7 @@ function handleFrame(frame: Record<string, unknown>): void {
|
||||
},
|
||||
})
|
||||
}
|
||||
if (behavior.persistLogsOnCancel === true) writeLogs()
|
||||
}
|
||||
return
|
||||
default:
|
||||
@@ -313,12 +316,16 @@ function handleFrame(frame: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function flushLogsAndExit(): void {
|
||||
function writeLogs(): void {
|
||||
for (const log of behavior.logs ?? []) {
|
||||
const target = join(sessionsRoot, log.file)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
writeFileSync(target, log.lines.map(l => JSON.stringify(instantiate(l))).join('\n') + '\n')
|
||||
}
|
||||
}
|
||||
|
||||
function flushLogsAndExit(): void {
|
||||
writeLogs()
|
||||
if (behavior.strayRootFile === true) writeFileSync(join(sessionsRoot, 'stray.txt'), 'not a bucket\n')
|
||||
if (behavior.strayBucketFile === true) {
|
||||
mkdirSync(join(sessionsRoot, 'bucket-noise'), { recursive: true })
|
||||
|
||||
@@ -493,6 +493,37 @@ describe('runScenario', () => {
|
||||
expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled'))
|
||||
})
|
||||
|
||||
it('promptAndCancel can wait for cwd-relative readiness before cancelling', { timeout: 20_000 }, async () => {
|
||||
const { dir, fixtureFile } = await scenario({ prompt: 'hang-until-cancel' })
|
||||
const workspaceDir = join(dir, 'workspace')
|
||||
const { mkdir } = await import('node:fs/promises')
|
||||
await mkdir(workspaceDir, { recursive: true })
|
||||
await writeFile(join(workspaceDir, 'started.txt'), 'started')
|
||||
const result = await runScenario(
|
||||
{
|
||||
steps: [...boot, {
|
||||
op: 'promptAndCancel',
|
||||
text: 'hang',
|
||||
waitForFile: { path: 'started.txt' },
|
||||
}],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile, workspaceDir },
|
||||
)
|
||||
expect(result.rawStdout).toContain('"stopReason":"cancelled"')
|
||||
|
||||
const missing = await scenario({ prompt: 'hang-until-cancel' })
|
||||
await expect(runScenario(
|
||||
{
|
||||
steps: [...boot, {
|
||||
op: 'promptAndCancel',
|
||||
text: 'hang',
|
||||
waitForFile: { path: 'never.txt', timeoutMs: 20 },
|
||||
}],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
|
||||
)).rejects.toThrow(/workspace file "never\.txt" did not appear within 20ms/)
|
||||
})
|
||||
|
||||
it('promptAndWaitForAgentMessage keeps the app live through a matching later update', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ prompt: 'respond' })
|
||||
const result = await runScenario(
|
||||
@@ -530,6 +561,55 @@ describe('runScenario', () => {
|
||||
expect(result.rawStdout.indexOf('cancelled')).toBeLessThan(result.rawStdout.indexOf('"sessionUpdate":"tool_call_update"'))
|
||||
})
|
||||
|
||||
it('waitForTurnEnd holds cancellation open through the persisted closing boundary', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [{
|
||||
file: 'bucket/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const result = await runScenario(
|
||||
{ steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForTurnEnd' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)
|
||||
expect(result.sessionLogs[0]?.content).toContain('"type":"turn/end"')
|
||||
})
|
||||
|
||||
it('waitForTurnEnd times out for a missing log and an open logged turn', { timeout: 20_000 }, async () => {
|
||||
const missing = await scenario({})
|
||||
await expect(runScenario(
|
||||
{ steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 20 }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
|
||||
)).rejects.toThrow(/did not persist turn\/end within 20ms/)
|
||||
|
||||
const open = await scenario({
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [{
|
||||
file: 'bucket/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
],
|
||||
}],
|
||||
})
|
||||
await expect(runScenario(
|
||||
{
|
||||
steps: [
|
||||
...boot,
|
||||
{ op: 'promptAndCancel', text: 'hang' },
|
||||
{ op: 'waitForTurnEnd', timeoutMs: 20 },
|
||||
],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile: open.fixtureFile },
|
||||
)).rejects.toThrow(/did not persist turn\/end within 20ms/)
|
||||
})
|
||||
|
||||
it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ prompt: 'error' })
|
||||
const result = await runScenario(
|
||||
@@ -620,6 +700,7 @@ describe('runScenario', () => {
|
||||
[{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/],
|
||||
[{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/],
|
||||
[{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/],
|
||||
[{ op: 'waitForTurnEnd' }, /waitForTurnEnd before newSession/],
|
||||
[{ op: 'cancel' }, /cancel before newSession/],
|
||||
[{ op: 'setConfigOption', configId: 'sandbox-mode', value: 'read-only' }, /setConfigOption before newSession/],
|
||||
[{ op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'yolo' }, /setConfigOptionExpectError before newSession/],
|
||||
|
||||
@@ -265,6 +265,27 @@ describe('normalizeSessionLog', () => {
|
||||
expect(out).toContain('"decision":"block"') // the decision is the behavior — kept
|
||||
})
|
||||
|
||||
it('zeroes a packed chunk row\'s time0 and dt gaps but keeps seq0 and payload', () => {
|
||||
const row = JSON.stringify({
|
||||
type: 'text-chunks', seq0: 7, time0: 999,
|
||||
data: { turn: 1, step: 1, index: 0, dt: [212, 27, 0], texts: ['a', 'b', 'c', 'd'] },
|
||||
})
|
||||
const out = normalizeSessionLog(`${header({})}\n${row}\n`, ctx)
|
||||
expect(out).toContain('"time0":0')
|
||||
expect(out).toContain('"dt":[0,0,0]')
|
||||
expect(out).toContain('"seq0":7') // seq0 is deterministic, like seq — NOT scrubbed
|
||||
expect(out).toContain('"texts":["a","b","c","d"]')
|
||||
expect(out).not.toContain('999')
|
||||
expect(out).not.toContain('212')
|
||||
})
|
||||
|
||||
it('zeroes time0 even when a malformed row carries no dt array', () => {
|
||||
const row = JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 999, data: 'not-an-object' })
|
||||
const out = normalizeSessionLog(`${header({})}\n${row}\n`, ctx)
|
||||
expect(out).toContain('"time0":0')
|
||||
expect(out).not.toContain('999')
|
||||
})
|
||||
|
||||
it('leaves a non-hook event durationMs untouched (only hook/result is scrubbed)', () => {
|
||||
const ev = JSON.stringify({ type: 'tool/result', seq: 2, time: 5, data: { durationMs: 88 } })
|
||||
const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
|
||||
|
||||
@@ -458,6 +458,76 @@ describe('refreshFixtureReplacements', () => {
|
||||
})
|
||||
|
||||
describe('stabilizeRefreshLog', () => {
|
||||
it('preserves unpacked member times when refresh first packs a chunk run', () => {
|
||||
const fresh = [
|
||||
'{"type":"session","id":"same","createdAt":200}',
|
||||
'{"type":"reasoning-chunks","seq0":2,"time0":200,"data":{"turn":1,"step":1,"index":0,"dt":[5,7],"texts":["new",""," split"]}}',
|
||||
'{"type":"assistant/message","seq":5,"time":220,"data":{}}',
|
||||
'',
|
||||
].join('\n')
|
||||
const existing = [
|
||||
'{"type":"session","id":"same","createdAt":100}',
|
||||
'{"type":"assistant/chunk","seq":2,"time":100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"old"}}}',
|
||||
'{"type":"assistant/chunk","seq":3,"time":101,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"chunk"}}}',
|
||||
'{"type":"assistant/chunk","seq":4,"time":103,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"shape"}}}',
|
||||
'{"type":"assistant/message","seq":5,"time":104,"data":{}}',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
expect(stabilizeRefreshLog(fresh, existing, [])).toBe([
|
||||
'{"type":"session","id":"same","createdAt":100}',
|
||||
'{"type":"reasoning-chunks","seq0":2,"time0":100,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["new",""," split"]}}',
|
||||
'{"type":"assistant/message","seq":5,"time":104,"data":{}}',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('preserves packed member times without flattening fresh chunk boundaries', () => {
|
||||
const fresh = [
|
||||
'{"type":"session","id":"same","createdAt":200}',
|
||||
'{"type":"text-chunks","seq0":2,"time0":200,"data":{"turn":1,"step":1,"index":0,"dt":[5,7],"texts":["new",""," split"]}}',
|
||||
'',
|
||||
].join('\n')
|
||||
const existing = [
|
||||
'{"type":"session","id":"same","createdAt":100}',
|
||||
'{"type":"text-chunks","seq0":2,"time0":100,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["old","chunk","shape"]}}',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
expect(stabilizeRefreshLog(fresh, existing, [])).toBe([
|
||||
'{"type":"session","id":"same","createdAt":100}',
|
||||
'{"type":"text-chunks","seq0":2,"time0":100,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["new",""," split"]}}',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the old run is absent', [], 200],
|
||||
['the old run is shorter', [100, 101], 100],
|
||||
['a later old time is invalid', [100, 'invalid', 103], 100],
|
||||
['an old gap is not exactly representable', [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER - 1, Number.MAX_SAFE_INTEGER - 1], Number.MIN_SAFE_INTEGER],
|
||||
])('keeps fresh packed gaps when %s', (_case, existingTimes, expectedTime0) => {
|
||||
const freshRow = {
|
||||
type: 'reasoning-chunks',
|
||||
seq0: 2,
|
||||
time0: 200,
|
||||
data: { turn: 1, step: 1, index: 0, dt: [5, 7], texts: ['new', '', ' split'] },
|
||||
}
|
||||
const existingRows = existingTimes.map((time, index) => ({
|
||||
type: 'assistant/chunk',
|
||||
seq: index + 2,
|
||||
time,
|
||||
data: {},
|
||||
}))
|
||||
const output = stabilizeRefreshLog(
|
||||
`${JSON.stringify({ type: 'session', id: 'same', createdAt: 200 })}\n${JSON.stringify(freshRow)}\n`,
|
||||
`${JSON.stringify({ type: 'session', id: 'same', createdAt: 100 })}\n${existingRows.map(row => JSON.stringify(row)).join('\n')}\n`,
|
||||
[],
|
||||
).trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
|
||||
|
||||
expect(output[1]).toStrictEqual({ ...freshRow, time0: expectedTime0 })
|
||||
})
|
||||
|
||||
it('aligns volatile times across a newly inserted log event', () => {
|
||||
const fresh = [
|
||||
'{"type":"session","id":"same","createdAt":200}',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { delimiter as pathDelimiter } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { GenerateOptions, LlmModelContext, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
@@ -96,7 +97,9 @@ export interface SessionScript {
|
||||
/**
|
||||
* Parse a session `.jsonl` buffer into its event list. Line 0 is the session
|
||||
* header (a `{type:'session',…}` record), every subsequent non-empty line is a
|
||||
* {@link SessionEvent}. The header is skipped; malformed lines fail loud.
|
||||
* {@link SessionEvent} or a packed chunk row (expanded back into its events, so
|
||||
* a fixture recorded with `packChunks` on derives the same script). The header
|
||||
* is skipped; malformed lines fail loud.
|
||||
* @param text - the raw `.jsonl` file contents.
|
||||
* @returns every event after the header, in log order.
|
||||
*/
|
||||
@@ -105,8 +108,7 @@ export function parseSessionLog(text: string): SessionEvent[] {
|
||||
const events: SessionEvent[] = []
|
||||
// The JSONL backend guarantees line 0 is the session header.
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const parsed: unknown = JSON.parse(lines[i] as string)
|
||||
events.push(parsed as SessionEvent)
|
||||
events.push(...decodeStorageRecord(JSON.parse(lines[i] as string)))
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
@@ -90,6 +90,19 @@ describe('parseSessionLog', () => {
|
||||
const ev = chunkEvent(1, 1, 1, TEXT_CHUNKS[0] as StreamChunk)
|
||||
expect(parseSessionLog(`${header}\n\n${JSON.stringify(ev)}\n\n`)).toEqual([ev])
|
||||
})
|
||||
|
||||
it('expands a packed chunk row into its events (a fixture recorded with packChunks on)', () => {
|
||||
const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 })
|
||||
const row = JSON.stringify({
|
||||
type: 'text-chunks', seq0: 1, time0: 0,
|
||||
data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] },
|
||||
})
|
||||
expect(parseSessionLog(`${header}\n${row}\n`)).toEqual([
|
||||
chunkEvent(1, 1, 1, { type: 'text-delta', index: 0, text: 'a' }),
|
||||
chunkEvent(2, 1, 1, { type: 'text-delta', index: 0, text: 'b' }),
|
||||
chunkEvent(3, 1, 1, { type: 'text-delta', index: 0, text: 'c' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveReplayScript', () => {
|
||||
|
||||
Generated
+54
@@ -264,6 +264,9 @@ importers:
|
||||
'@deepseek-ai/dsh-sandbox-policy':
|
||||
specifier: workspace:^
|
||||
version: link:../packages/sandbox/sandbox-policy
|
||||
'@deepseek-ai/dsh-session-checkpoint-policy':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/session-persistence/session-checkpoint-policy
|
||||
'@deepseek-ai/dsh-session-persistence-jsonl':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/session-persistence/session-persistence-jsonl
|
||||
@@ -1186,6 +1189,9 @@ importers:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-session-checkpoint-policy':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-persistence/session-checkpoint-policy
|
||||
'@deepseek-ai/dsh-session-persistence-jsonl':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-persistence/session-persistence-jsonl
|
||||
@@ -1334,6 +1340,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-checkpoint-policy':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-persistence/session-checkpoint-policy
|
||||
'@deepseek-ai/dsh-session-persistence-jsonl':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-persistence/session-persistence-jsonl
|
||||
@@ -1401,6 +1410,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-checkpoint-policy':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-persistence/session-checkpoint-policy
|
||||
'@deepseek-ai/dsh-session-persistence-jsonl':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-persistence/session-persistence-jsonl
|
||||
@@ -2487,6 +2499,45 @@ importers:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
|
||||
|
||||
packages/session-persistence/session-checkpoint-policy:
|
||||
devDependencies:
|
||||
'@cordisjs/plugin-loader':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/loader
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-agent-loop':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent-loop
|
||||
'@deepseek-ai/dsh-agent-loop-testkit':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/agent-loop-testkit
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-persistence':
|
||||
specifier: workspace:^
|
||||
version: link:../session-persistence
|
||||
'@deepseek-ai/dsh-session-persistence-jsonl':
|
||||
specifier: workspace:^
|
||||
version: link:../session-persistence-jsonl
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.7
|
||||
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
|
||||
|
||||
packages/session-persistence/session-persistence:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
@@ -4036,6 +4087,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/core/session
|
||||
'@deepseek-ai/dsh-session-checkpoint-policy':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/session-persistence/session-checkpoint-policy
|
||||
'@deepseek-ai/dsh-session-persistence':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/session-persistence/session-persistence
|
||||
|
||||
@@ -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
|
||||
README.md: cdf38d4474a0e0148a4804e14c12971c76b38e27
|
||||
README.zh.md: 99d57c6f900371a46b94c5ea80b2a1665fd5e8d1
|
||||
README.md: f2ccd8939e497d10359aafe8b1bd8b364875ed98
|
||||
README.zh.md: 30bdf46fee03c38a1f4b6e8b2b39d87e8174a3e0
|
||||
@@ -26,4 +26,4 @@ Each wheel contains exactly one executable. The fixed tags are `py3-none-manylin
|
||||
|
||||
## Zero-config design
|
||||
|
||||
The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, local bash, and a local filesystem provider for bounded workspace-instruction loading. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime.
|
||||
The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` with the JSON-RPC serving entry, agent core, a preloaded DeepSeek adapter, JSONL persistence, the explicitly composed semantic checkpoint policy, local bash, and a local filesystem provider for bounded workspace-instruction loading. The persistence backend owns durable storage while the separate policy selects request-, tool-dispatch-, and completed-step checkpoints. The adapter reads `DEEPSEEK_API_KEY` and `DEEPSEEK_BASE_URL`, while persistence, bash, and the filesystem provider use `DSH_SESSION_ROOT` and `DSH_CWD` with manual-run fallbacks. When the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime.
|
||||
@@ -26,4 +26,4 @@ exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deep
|
||||
|
||||
## 零配置设计
|
||||
|
||||
运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。`bin`(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent 就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、`agent-core`、预载的 DeepSeek 适配器、JSONL 持久化、本地 bash,以及用于有界加载工作区指令的本地文件系统 provider。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统 provider 则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。
|
||||
运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。`bin`(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent 就没有对外通道。本包检入的 `runtime/cordis.yml` 包含 JSON-RPC 服务条目、`agent-core`、预载的 DeepSeek 适配器、JSONL 持久化、显式组合的语义检查点策略、本地 bash,以及用于有界加载工作区指令的本地文件系统 provider。持久化后端负责持久存储,独立的策略则选择请求、工具分发和已完成步骤的检查点。DeepSeek 适配器读取 `DEEPSEEK_API_KEY` 与 `DEEPSEEK_BASE_URL`,持久化、bash 和文件系统 provider 则使用 `DSH_SESSION_ROOT` 和 `DSH_CWD`,并为手动运行提供回退值。调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。
|
||||
@@ -47,6 +47,7 @@
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
config:
|
||||
root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions'
|
||||
|
||||
# Persistence owns durable storage; this separate policy explicitly selects
|
||||
# the request, tool-dispatch, and completed-step durability checkpoints.
|
||||
- id: session-checkpoints
|
||||
name: '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
|
||||
# Local bash executor; $DSH_CWD wins over the process cwd.
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
@@ -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
|
||||
README.md: 5fd1bc7cd89152a28d3da17100fd62eed4f8cb14
|
||||
README.zh.md: 247a2ca5ea5c1c3afc19335a6bbcba356c823211
|
||||
README.md: 23d15d617b3d295a6cc2d8d20c6d03abc226834b
|
||||
README.zh.md: 4f6aef13833af937babc2e5a92bfd14c12170534
|
||||
@@ -19,7 +19,7 @@ with DeepSeekHarness() as harness:
|
||||
|
||||
`DeepSeekHarness` keeps its lazily started runtime subprocess for reuse across calls. Use it as a context manager, as above, or call `close()` explicitly when finished.
|
||||
|
||||
By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-jsonrpc` entry in the config and pass the Cordis config path.
|
||||
By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence with an explicitly composed semantic checkpoint policy, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-jsonrpc` entry in the config and pass the Cordis config path.
|
||||
|
||||
```py
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
@@ -15,7 +15,7 @@ with DeepSeekHarness() as harness:
|
||||
|
||||
`DeepSeekHarness` 会保留延迟启动的运行时子进程,以供多次调用复用。请像上例一样将其用作上下文管理器,或在用完后显式调用 `close()`。
|
||||
|
||||
默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、`agent-core`、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash)。要运行自己的插件组合,请在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。
|
||||
默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、`agent-core`、预载的 DeepSeek 适配器、配有显式组合语义检查点策略的 JSONL 会话持久化、本地 bash)。要运行自己的插件组合,请在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。
|
||||
|
||||
```py
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
@@ -28,6 +28,8 @@ _CORDIS_YML = """\
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: './sessions'
|
||||
- id: session-checkpoints
|
||||
name: '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
|
||||
@@ -15,7 +15,10 @@ from deepseek_harness_runtime import (
|
||||
def test_default_config_is_shipped_with_the_package() -> None:
|
||||
path = bundled_default_config_path()
|
||||
assert path == bundled_package_dir() / "runtime" / "cordis.yml"
|
||||
assert "@deepseek-ai/dsh-agent-spine-demo" in path.read_text()
|
||||
config = path.read_text()
|
||||
assert "@deepseek-ai/dsh-agent-spine-demo" in config
|
||||
assert "@deepseek-ai/dsh-session-persistence-jsonl" in config
|
||||
assert "@deepseek-ai/dsh-session-checkpoint-policy" in config
|
||||
|
||||
|
||||
def test_unknown_explicit_mode_fails_loud() -> None:
|
||||
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in New Issue
Block a user