From 5aeb06bb11f9f825f15ed401029f0eb861091c7a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 11 Aug 2026 17:43:26 +0200 Subject: [PATCH] feat(agent): establish durable harness type contracts --- packages/agent/CHANGELOG.md | 4 + packages/agent/docs/harness.md | 15 +- packages/agent/docs/telemetry-schema.md | 18 +- packages/agent/package.json | 4 - packages/agent/src/harness/agent-harness.ts | 850 ++++++++----- .../compaction/branch-summarization.ts | 32 +- .../src/harness/compaction/compaction.ts | 22 +- packages/agent/src/harness/events.ts | 102 -- packages/agent/src/harness/reducer.ts | 667 ---------- packages/agent/src/harness/session/context.ts | 73 +- packages/agent/src/harness/session/index.ts | 11 - packages/agent/src/harness/session/jsonl.ts | 9 - .../agent/src/harness/session/jsonl/codec.ts | 240 ---- .../agent/src/harness/session/jsonl/errors.ts | 27 - .../agent/src/harness/session/jsonl/repo.ts | 247 ---- .../src/harness/session/jsonl/storage.ts | 277 ---- .../agent/src/harness/session/jsonl/types.ts | 57 - packages/agent/src/harness/session/memory.ts | 192 --- packages/agent/src/harness/session/session.ts | 299 ----- packages/agent/src/harness/session/state.ts | 344 ----- .../harness/session/testing/conformance.ts | 1016 --------------- .../src/harness/session/testing/index.ts | 6 - .../src/harness/session/testing/types.ts | 16 - packages/agent/src/harness/session/types.ts | 685 ++++++---- packages/agent/src/harness/telemetry.ts | 51 +- packages/agent/src/harness/types.ts | 2 + packages/agent/src/index.ts | 7 - packages/agent/src/search/index.ts | 47 +- packages/agent/src/search/scanning.ts | 176 --- packages/agent/src/types.ts | 2 + .../harness/agent-harness-scaffold.test.ts | 199 --- .../test/harness/branch-summarization.test.ts | 57 +- .../agent/test/harness/compaction.test.ts | 48 +- packages/agent/test/harness/events.test.ts | 65 - packages/agent/test/harness/reducer.test.ts | 1127 ----------------- .../test/harness/session/context.test.ts | 124 -- .../test/harness/session/jsonl-codec.test.ts | 182 --- .../harness/session/jsonl-storage.test.ts | 497 -------- .../agent/test/harness/session/jsonl.test.ts | 766 ----------- .../agent/test/harness/session/memory.test.ts | 38 - .../agent/test/harness/session/search.test.ts | 125 -- packages/agent/test/harness/telemetry.test.ts | 13 + packages/agent/test/harness/types.test.ts | 73 ++ .../coding-agent/src/server/create-harness.ts | 161 --- .../test/server/create-harness.test.ts | 352 ----- .../session-backends/sqlite-node/CHANGELOG.md | 4 + .../session-backends/sqlite-node/package.json | 2 +- .../sqlite-node/scripts/prepare-dist.mjs | 35 - .../sqlite-node/src/sqlite/branch-cache.ts | 101 -- .../sqlite-node/src/sqlite/index.ts | 11 - .../sqlite-node/src/sqlite/migrations.ts | 49 - .../src/sqlite/migrations/001_initial.sql | 122 -- .../sqlite-node/src/sqlite/repo.ts | 953 -------------- .../sqlite-node/src/sqlite/search-backend.ts | 194 --- .../src/sqlite/storage/branch-entries.ts | 174 --- .../src/sqlite/storage/branch-tips.ts | 35 - .../sqlite-node/src/sqlite/storage/entries.ts | 78 -- .../sqlite-node/src/sqlite/storage/facts.ts | 64 - .../sqlite-node/src/sqlite/storage/lanes.ts | 124 -- .../sqlite-node/src/sqlite/storage/records.ts | 95 -- .../src/sqlite/storage/session-sequences.ts | 29 - .../src/sqlite/storage/session-stats.ts | 54 - .../src/sqlite/storage/sessions.ts | 131 -- .../src/sqlite/storage/writer-leases.ts | 58 - .../sqlite-node/src/sqlite/types.ts | 28 +- .../sqlite-node/test/branch-cache.test.ts | 220 ---- .../sqlite-node/test/branch-query.test.ts | 139 -- .../sqlite-node/test/conformance.test.ts | 54 - .../sqlite-node/test/facts-query.test.ts | 31 - .../sqlite-node/test/log-query.test.ts | 35 - .../sqlite-node/test/migrations.test.ts | 61 - .../sqlite-node/test/repository.test.ts | 496 -------- .../sqlite-node/test/search.test.ts | 314 ----- .../sqlite-node/test/test-utils.ts | 144 --- .../sqlite-node/test/writer-leases.test.ts | 217 ---- .../sqlite-node/vitest.config.ts | 2 - scripts/browser-smoke-entry.ts | 3 - tsconfig.json | 3 - 78 files changed, 1211 insertions(+), 12174 deletions(-) delete mode 100644 packages/agent/src/harness/events.ts delete mode 100644 packages/agent/src/harness/reducer.ts delete mode 100644 packages/agent/src/harness/session/jsonl.ts delete mode 100644 packages/agent/src/harness/session/jsonl/codec.ts delete mode 100644 packages/agent/src/harness/session/jsonl/errors.ts delete mode 100644 packages/agent/src/harness/session/jsonl/repo.ts delete mode 100644 packages/agent/src/harness/session/jsonl/storage.ts delete mode 100644 packages/agent/src/harness/session/jsonl/types.ts delete mode 100644 packages/agent/src/harness/session/memory.ts delete mode 100644 packages/agent/src/harness/session/session.ts delete mode 100644 packages/agent/src/harness/session/state.ts delete mode 100644 packages/agent/src/harness/session/testing/conformance.ts delete mode 100644 packages/agent/src/harness/session/testing/index.ts delete mode 100644 packages/agent/src/harness/session/testing/types.ts delete mode 100644 packages/agent/src/search/scanning.ts delete mode 100644 packages/agent/test/harness/agent-harness-scaffold.test.ts delete mode 100644 packages/agent/test/harness/events.test.ts delete mode 100644 packages/agent/test/harness/reducer.test.ts delete mode 100644 packages/agent/test/harness/session/context.test.ts delete mode 100644 packages/agent/test/harness/session/jsonl-codec.test.ts delete mode 100644 packages/agent/test/harness/session/jsonl-storage.test.ts delete mode 100644 packages/agent/test/harness/session/jsonl.test.ts delete mode 100644 packages/agent/test/harness/session/memory.test.ts delete mode 100644 packages/agent/test/harness/session/search.test.ts create mode 100644 packages/agent/test/harness/types.test.ts delete mode 100644 packages/coding-agent/src/server/create-harness.ts delete mode 100644 packages/coding-agent/test/server/create-harness.test.ts delete mode 100644 packages/session-backends/sqlite-node/scripts/prepare-dist.mjs delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/branch-cache.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/migrations.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/repo.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/search-backend.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/storage/branch-tips.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/storage/records.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/storage/session-sequences.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/storage/session-stats.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts delete mode 100644 packages/session-backends/sqlite-node/src/sqlite/storage/writer-leases.ts delete mode 100644 packages/session-backends/sqlite-node/test/branch-cache.test.ts delete mode 100644 packages/session-backends/sqlite-node/test/branch-query.test.ts delete mode 100644 packages/session-backends/sqlite-node/test/conformance.test.ts delete mode 100644 packages/session-backends/sqlite-node/test/facts-query.test.ts delete mode 100644 packages/session-backends/sqlite-node/test/log-query.test.ts delete mode 100644 packages/session-backends/sqlite-node/test/migrations.test.ts delete mode 100644 packages/session-backends/sqlite-node/test/repository.test.ts delete mode 100644 packages/session-backends/sqlite-node/test/search.test.ts delete mode 100644 packages/session-backends/sqlite-node/test/test-utils.ts delete mode 100644 packages/session-backends/sqlite-node/test/writer-leases.test.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 81e894d8c..40a094e5e 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Replaced the unfinished harness runtime and record-log session model with the shared entry/register/usage, operation-state, session, search, and public harness contracts specified for the durable harness. + ### Fixed - Fixed `streamProxy()` dropping finalized tool-call metadata such as OpenAI Responses namespaces ([#7709](https://github.com/earendil-works/pi/issues/7709)). diff --git a/packages/agent/docs/harness.md b/packages/agent/docs/harness.md index 9e38c1fab..22945ecf7 100644 --- a/packages/agent/docs/harness.md +++ b/packages/agent/docs/harness.md @@ -707,14 +707,16 @@ interface LaneConfiguration { - A lane's leaf moves in exactly two ways: the lane appends an entry (leaf becomes that entry), or the lane navigates (leaf jumps to an existing entry). - `LaneConfiguration` is **total**. A setter overwrites the whole register; it is never a patch and never a tree entry. +- `Session.createLane(name, at, configuration)` is the primitive that creates a lane. It validates the permanent lane name, rejects an existing lane or unknown non-null anchor, and commits all three registers atomically. The supplied configuration is a total seed; it is not inferred from the anchor or another lane. The method returns the new lane's `SessionTree` view. - Creating a lane copies no tree content, no history, and no configuration from its anchor: ``` -TX[ upsert lane.config/{name} = , +TX[ upsert lane.config/{name} = , upsert lane.leaf/{name} = anchorEntryId, upsert lane.state/{name} = { currentOperationId: null, pendingNextRun: [] } ] ``` +- `AgentHarness.createLane()` invokes this primitive on the lane mutation line with the harness's immutable captured seed, then publishes the harness event. Session-level callers use the same primitive directly when they need tree and lane management without runtime orchestration. - Lanes are never deleted or renamed. Names are permanent application keys. - `main` exists in every session. - Two lanes at the same leaf simply diverge on their next append. @@ -848,6 +850,9 @@ interface Session extends SessionTr /** Mints UUIDv7 ids; a supplied timestamp mints a follower id (§1.2). */ readonly idGenerator: { next(timestampMs?: number): string }; view(lane: string): SessionTree; + /** Atomically creates a configured lane and returns its lane-bound tree view (§2.3). */ + createLane(name: string, at: string | null, + configuration: LaneConfiguration): Promise; /** Package-internal harness storage surface; validates before delegating to Storage. */ commit(tx: Transaction): Promise; @@ -861,7 +866,7 @@ interface Session extends SessionTr } ``` -Repository constructors accept `SessionCodecOptions`. Every declaration-merged custom `AgentMessage` must have a string `role` and a registered runtime schema; unknown custom roles are rejected before persistence and on decode. A new repository session creates `main` with null leaf and an empty `LaneState`, but no configuration; first harness attachment writes its seed configuration. +Repository constructors accept `SessionCodecOptions`. Every declaration-merged custom `AgentMessage` must have a string `role` and a registered runtime schema; unknown custom roles are rejected before persistence and on decode. A new repository session creates `main` with null leaf and an empty `LaneState`, but no configuration; first harness attachment writes its seed configuration. Every additional lane is created through `Session.createLane()` with a total configuration. `open()` compares the stored `storageVersion` with the binary's: equal proceeds; older runs chained migrations under the writer lease before returning (Part 7); newer refuses to open. Old coding-agent v3 JSONL sessions open through the same repository and normalize on load (Appendix B — "v3" there names the legacy JSONL session format, not this document). @@ -1734,7 +1739,7 @@ A fresh operation drive starts with zero deferred permits; `resume()` starts wit ## 4.2 The effects boundary -Every operation-procedure commit, provider request, tool invocation, hook call, and timer crosses exactly one injected `Effects` (`fx`) method. Procedures receive `fx`, their telemetry context, and a read-only runtime view — never `Session`, `Models`, the tool registry, or the hook runner directly. Ungated lane-surface commits—acceptance, queue/configuration calls, facts, lane creation, and idle writes—use the same lane mutation line and typed `Session` transaction API directly. +Every operation-procedure commit, provider request, tool invocation, hook call, and timer crosses exactly one injected `Effects` (`fx`) method. Procedures receive `fx`, their telemetry context, and a read-only runtime view — never `Session`, `Models`, the tool registry, or the hook runner directly. Ungated lane-surface commits—acceptance, queue/configuration calls, facts, and idle writes—use the same lane mutation line and typed `Session` transaction API directly. Harness lane creation runs on that line and delegates its atomic register creation to `Session.createLane()`. ```ts type SummaryRequestOutput = @@ -2220,7 +2225,7 @@ type EntryProjector = (entry: CustomEntry) => `create()` copies the three seed fields into one immutable `LaneConfiguration`, storing the model as `{ provider, modelId }`. Before restore, it commits that seed as the first `lane.config` for a fresh or normalized-v3 `main`. Existing lanes use only their current config; the seed never overrides them. A configuration-less lane in a format-4 session is corrupt. -`createLane(name, at)` atomically writes its registers and the original captured seed, regardless of later changes. Setters replace only their lane's register value. Reopen options can seed new lanes but cannot alter existing ones without a setter. Applications opt into deferred generation through `setStreamOptions({ deferred: ... })` or initial `streamOptions`; `before_request` may patch the same curated field per attempt. +`createLane(name, at)` calls `Session.createLane(name, at, capturedSeed)` on the lane mutation line, regardless of later changes to existing lanes. It maps session validation failures to `LaneExists`, `InvalidLane`, or `UnknownTarget`, and emits `lane_created` only after the session transaction commits. Setters replace only their lane's register value. Reopen options can seed new lanes but cannot alter existing ones without a setter. Applications opt into deferred generation through `setStreamOptions({ deferred: ... })` or initial `streamOptions`; `before_request` may patch the same curated field per attempt. Initial, replacement, and hook-patched stream options are normalized to detached JSON-safe values before publication because ready states persist them. Functions, symbols, bigint values, cycles, non-finite numbers, and unsupported prototypes in metadata reject construction/the setter without changing settings; an invalid hook patch is isolated as `handler_error` and ignored without changing operation state. Patch deletion semantics are applied before this validation. @@ -2788,7 +2793,7 @@ Each slice implements its named behavior end to end and adds focused tests for i | # | Slice | Implement | Required focused tests | |---|---|---|---| | 1 | **Types** | The complete shared type surface, behavior-free: `Entry`/`Register`/`UsageRow` and `RegisterValues` including the full Part 3 state tree, `Write`/`Transaction`/`Storage`/`Session`/`SessionTree`/`SessionRepo`, scans, the id-generator and `SessionSearchService` interfaces, `storageVersion`, and the Part 5 surface types (results, errors, events, snapshots, hooks). Delete `packages/agent/src/harness/**` and its tests outright; patch remaining consumers. The repo may not compile mid-slice; it compiles again — `npm run check` clean — at the end. | Type-level only; no behavior. | -| 2 | **Session layer, Memory, conformance** | Entry materialization with inline payloads, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`/views, codec plus runtime entry/register/custom-message schemas, UUIDv7 generator with follower minting, stats projection, the Memory backend with repository lifecycle/forks and the `storageVersion` gate at open, the backend conformance suite, and the instrumented-storage decorator (Part 9). | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, fact deletion vs JSON `null`, schema validation, unknown custom roles, immutable reads, stats-equals-ledger, follower minting, placement, divergence, filters/cursors/stops, custom entries with and without data, context projection, fork before first attachment, configured fork snapshots/facts/zero ledger, close. | +| 2 | **Session layer, Memory, conformance** | Entry materialization with inline payloads, atomic configured lane creation, lane/config/state registers, facts, branch/global queries, context projection, `SessionTree`/views, codec plus runtime entry/register/custom-message schemas, UUIDv7 generator with follower minting, stats projection, the Memory backend with repository lifecycle/forks and the `storageVersion` gate at open, the backend conformance suite, and the instrumented-storage decorator (Part 9). | Rollback, sequence order, duplicate ids, register set/delete/recreate, delete-of-absent-key no-op, atomic lane creation and duplicate/name/anchor rejection, fact deletion vs JSON `null`, schema validation, unknown custom roles, immutable reads, stats-equals-ledger, follower minting, placement, divergence, filters/cursors/stops, custom entries with and without data, context projection, fork before first attachment, configured fork snapshots/facts/zero ledger, close. | | S1 | **JSONL** | Format 4: single-item/array transaction lines, register set/delete replay, header `storageVersion`, torn-tail handling, snapshot compaction (GC keep-predicate), the file-based repository, format-3 read normalization and first-write temp/rename conversion with id re-minting (Appendix B). Replace the unfinished current v4 without migration. | Backend conformance, corrupt interior/final lines, whole-array tear, compaction logical-equivalence, every format-3 rule including id re-minting and reference remapping, resolved/unresolved parent paths, aggregate imported usage adjustment. | | S2 | **SQLite** | One database file per session: entries/registers/usage-ledger tables, one-row session/lease rows, transactions, `storageVersion`, the file-based repository, segmented branch cache, `VACUUM INTO`-based rewrite/fork, and explicit repair. No values table, no `slot_history`, no `getLog`, no search projection, no migration. | Shared conformance, `BEGIN IMMEDIATE`, fencing, query plans, segment-chain soundness, register upsert/delete, forks/stats/repair. | | S3 | **Search** | The standalone `SessionSearchService` (§2.8): durable per-session cursors, `sync()` enumeration and catch-up, debounced `notify()`, `remove()`/reconciliation, `(sessionId, storeGeneration)` cursor keys, and the reference SQLite FTS5 implementation working over any backend's repository. | Cursor catch-up from empty against existing sessions, idempotent re-index after crash mid-batch, notify/sweep equivalence, sessions-vs-entries queries and ranking, removal and reconciliation, shared-index multi-process discipline. | diff --git a/packages/agent/docs/telemetry-schema.md b/packages/agent/docs/telemetry-schema.md index 9eb8e6e4e..a8b46758e 100644 --- a/packages/agent/docs/telemetry-schema.md +++ b/packages/agent/docs/telemetry-schema.md @@ -301,7 +301,7 @@ No declared span events. One retry delay -- Parents: `pi.harness.step`, `pi.harness.run` +- Parents: `pi.harness.run`, `pi.harness.compaction`, `pi.harness.navigation`, `pi.harness.turn`, `pi.harness.checkpoint` - Default status: `ok` - Error when: Sleep work throws @@ -353,20 +353,21 @@ No declared span events. ### `pi.session.write` -One committed session mutation +One committed session transaction - Parents: root or any caller span - Default status: `ok` -- Error when: Storage rejects the mutation +- Error when: Storage rejects the transaction #### Start attributes | Name | Type | Required | Values | Notes | Description | |---|---|---:|---|---|---| -| `pi.lane.name` | `string` | yes | | high cardinality | Lane name | -| `pi.operation.id` | `string` | no | | high cardinality | Durable operation id when accepted | -| `pi.session.mutation` | `string` | yes | entry, record, lane, fact | | Session mutation kind | -| `pi.session.item_type` | `string` | no | | | Entry, record, lane, or fact subtype | +| `pi.session.id` | `string` | yes | | high cardinality | Session id | +| `pi.lane.name` | `string` | no | | high cardinality | Lane name when supplied by the caller | +| `pi.operation.id` | `string` | no | | high cardinality | Durable operation id when supplied by the caller | +| `pi.session.item_count` | `number` | yes | | | Number of writes in the transaction | +| `pi.session.item_kinds` | `string[]` | yes | elements: entry, usage, register | | Distinct write kinds in the transaction | #### End attributes @@ -374,7 +375,8 @@ All end attributes are optional completion enrichment. | Name | Type | Values | Notes | Description | |---|---|---|---|---| -| `pi.session.seq` | `number` | | | Committed session sequence when exposed | +| `pi.session.first_seq` | `number` | | | First committed sequence in the transaction | +| `pi.session.last_seq` | `number` | | | Last committed sequence in the transaction | #### Events diff --git a/packages/agent/package.json b/packages/agent/package.json index 4c011eaf7..7fb9bde12 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -14,10 +14,6 @@ "types": "./dist/node.d.ts", "import": "./dist/node.js" }, - "./session/testing": { - "types": "./dist/harness/session/testing/index.d.ts", - "import": "./dist/harness/session/testing/index.js" - }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 3802900db..0419a788d 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -1,29 +1,45 @@ import type { Api, AssistantMessage, + AssistantMessageEvent, DeferredHandle, ImageContent, Message, Model, Models, RetryPolicy, - SimpleStreamOptions, + ToolResultMessage, Usage, } from "@earendil-works/pi-ai"; -import type { AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../types.ts"; -import type { CompactionSettings } from "./compaction/compaction.ts"; -import { type Result as ResultValue, TaggedError } from "./result.ts"; +import type { TelemetryContext } from "@earendil-works/pi-telemetry"; +import type { TSchema } from "typebox"; +import type { AgentEventSink } from "../agent-loop.ts"; +import type { AgentMessage, AgentTool, AgentToolCall, AgentToolResult, QueueMode, ThinkingLevel } from "../types.ts"; +import type { BranchPreparation, BranchSummaryResult } from "./compaction/branch-summarization.ts"; +import type { CompactionPreparation, CompactionSettings, CompactResult } from "./compaction/compaction.ts"; +import { type Result, TaggedError } from "./result.ts"; import type { BranchSummaryEntry, CompactionEntry, + CustomEntry, Entry, + EntryType, JsonValue, - ProvisionedEntry, + LaneLastResult, + OperationError, Session, SessionTree, -} from "./session/index.ts"; -import type { TelemetryContext } from "./telemetry.ts"; -import type { AgentHarnessResources, PromptTemplate, Skill } from "./types.ts"; + SettledAssistantMessage, + UsageRow, +} from "./session/types.ts"; +import type { + AgentHarnessResources, + AgentHarnessStreamOptions, + AgentHarnessStreamOptionsPatch, + AgentHarnessTool, + PromptTemplate, + Skill, +} from "./types.ts"; export class LaneBusy extends TaggedError("LaneBusy")<{ lane: string; @@ -40,18 +56,26 @@ export class MissingIdentities extends TaggedError("MissingIdentities")<{ export class NoActiveRun extends TaggedError("NoActiveRun")<{ lane: string; message: string }> {} export class NoActiveOperation extends TaggedError("NoActiveOperation")<{ lane: string; message: string }> {} export class NothingToResume extends TaggedError("NothingToResume")<{ lane: string; message: string }> {} -export class InvalidMessage extends TaggedError("InvalidMessage")<{ lane: string; reason: string; message: string }> {} +export class NothingToCompact extends TaggedError("NothingToCompact")<{ lane: string; message: string }> {} +export class InvalidMessage extends TaggedError("InvalidMessage")<{ + lane: string; + reason: string; + message: string; +}> {} +export class InvalidNavigation extends TaggedError("InvalidNavigation")<{ + lane: string; + reason: string; + message: string; +}> {} export class UnknownSkill extends TaggedError("UnknownSkill")<{ name: string; message: string }> {} export class UnknownTemplate extends TaggedError("UnknownTemplate")<{ name: string; message: string }> {} export class UnknownTarget extends TaggedError("UnknownTarget")<{ targetId: string; message: string }> {} -export class UnknownQueueItem extends TaggedError("UnknownQueueItem")<{ +export class LaneExists extends TaggedError("LaneExists")<{ lane: string; message: string }> {} +export class InvalidLane extends TaggedError("InvalidLane")<{ lane: string; - entryId: string; + reason: string; message: string; }> {} -export class LaneExists extends TaggedError("LaneExists")<{ lane: string; message: string }> {} -export class InvalidLane extends TaggedError("InvalidLane")<{ lane: string; reason: string; message: string }> {} -export class NothingToCompact extends TaggedError("NothingToCompact")<{ lane: string; message: string }> {} export class Closed extends TaggedError("Closed")<{ message: string }> {} export class HarnessFault extends Error { @@ -71,82 +95,90 @@ export class HarnessClosed extends Error { } } -export class HarnessNotImplemented extends Error { - readonly operation: string; +export type OptionalFinalAssistant = + | { finalEntryId: string; finalMessage: AssistantMessage } + | { finalEntryId?: never; finalMessage?: never }; - constructor(operation: string) { - super(`AgentHarness.${operation} is not implemented yet`); - this.name = "HarnessNotImplemented"; - this.operation = operation; - } -} - -export interface OperationError { - code: string; - message: string; -} +export type MissingIdentitySuspension = { + kind: "suspended"; + reason: "missing_identities"; + missing: { tools: string[]; models: string[] }; +}; export type RunOutcome = - | { kind: "completed"; leafId: string; finalEntryId: string; finalMessage: AssistantMessage } - | { kind: "aborted"; leafId: string; finalEntryId: string; finalMessage: AssistantMessage } - | { kind: "failed"; leafId: string; error: OperationError; finalEntryId?: string; finalMessage?: AssistantMessage } - | { kind: "suspended"; leafId: string; finalEntryId: string; deferred: DeferredHandle }; + | ({ kind: "completed"; leafId: string } & OptionalFinalAssistant) + | ({ kind: "aborted"; leafId: string } & OptionalFinalAssistant) + | ({ kind: "failed"; leafId: string; error: OperationError } & OptionalFinalAssistant) + | { + kind: "suspended"; + reason: "deferred"; + leafId: string; + finalEntryId: string; + deferred: DeferredHandle; + } + | (MissingIdentitySuspension & { leafId: string }); export type CompactionOutcome = | { kind: "completed"; leafId: string; entry: CompactionEntry } | { kind: "declined" | "aborted"; leafId: string } - | { kind: "failed"; leafId: string; error: OperationError }; + | { kind: "failed"; leafId: string; error: OperationError } + | (MissingIdentitySuspension & { leafId: string }); export type NavigationOutcome = - | { kind: "completed"; newLeafId: string | null; summaryEntry?: BranchSummaryEntry } + | { + kind: "completed"; + oldLeafId: string | null; + newLeafId: string | null; + summaryEntry?: BranchSummaryEntry; + } | { kind: "declined" | "aborted"; leafId: string | null } - | { kind: "failed"; leafId: string | null; error: OperationError }; - -export type RunRejected = LaneBusy | InvalidMessage | UnknownSkill | UnknownTemplate | Closed; -export type CompactionRejected = LaneBusy | NothingToCompact | Closed; -export type NavigationRejected = LaneBusy | UnknownTarget | Closed; -export type ResumeRejected = LaneBusy | NothingToResume | MissingIdentities | Closed; -export type QueueRejected = NoActiveRun | InvalidMessage | Closed; -export type CancelQueuedRejected = UnknownQueueItem | Closed; -export type AbortRejected = NoActiveOperation | Closed; - -export type RunResult = ResultValue<{ runId: string } & RunOutcome, RunRejected>; -export type CompactionResult = ResultValue<{ runId: string } & CompactionOutcome, CompactionRejected>; -export type NavigationResult = ResultValue<{ runId: string } & NavigationOutcome, NavigationRejected>; -export type QueueResult = ResultValue<{ entryId: string }, QueueRejected>; -export type CancelQueuedResult = ResultValue< - { outcome: "cancelled" | "already_consumed" | "already_cleared" }, - CancelQueuedRejected ->; -export type RecordUsageResult = ResultValue; -export type AbortResult = ResultValue< - { runId: string; steer: AgentMessage[]; followUp: AgentMessage[] }, - AbortRejected ->; + | { kind: "failed"; leafId: string | null; error: OperationError } + | (MissingIdentitySuspension & { leafId: string | null }); export type ResumeOutcome = | ({ operation: "run"; runId: string } & RunOutcome) | ({ operation: "compaction"; runId: string } & CompactionOutcome) | ({ operation: "navigation"; runId: string } & NavigationOutcome); -export type ResumeResult = ResultValue; -export type CreateLaneResult = ResultValue; + +export type RunResult = Result< + { runId: string } & RunOutcome, + LaneBusy | MissingIdentities | InvalidMessage | UnknownSkill | UnknownTemplate | Closed +>; +export type CompactionResult = Result< + { runId: string } & CompactionOutcome, + LaneBusy | MissingIdentities | NothingToCompact | Closed +>; +export type NavigationResult = Result< + { runId: string } & NavigationOutcome, + LaneBusy | MissingIdentities | InvalidNavigation | UnknownTarget | Closed +>; +export type ResumeResult = Result; +export type QueueResult = Result<{ entryId: string }, NoActiveRun | InvalidMessage | Closed>; +export type NextRunResult = Result<{ entryId: string }, InvalidMessage | Closed>; +export type CancelQueuedResult = Result<{ kind: "cancelled" | "already_consumed" | "not_found" }, Closed>; +export type AbortResult = Result< + { runId: string; steer: AgentMessage[]; followUp: AgentMessage[] }, + NoActiveOperation | Closed +>; +export type RecordUsageResult = Result<{ usageId: string }, Closed>; +export type CreateLaneResult = Result; export interface NavigateOptions { summarize?: boolean; - customInstructions?: string; label?: string; + customInstructions?: string; } -export interface SuspendedOperation { - lane: string; - kind: "run" | "compaction" | "navigation"; - id: string; - startedAt: number; - reason: "crash" | "deferred"; - prompt?: AgentMessage[]; - deferred?: DeferredHandle; - aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] }; - missing: { tools: string[]; models: string[] }; +export interface ActionInfo { + kind: string; + description: string; + details?: JsonValue; +} + +export interface WatchHandle { + snapshot: T; + start(listener: EventListener): void; + unsubscribe(): void; } export interface LaneInfo { @@ -159,6 +191,18 @@ export interface LaneInfo { }; } +export interface SuspendedOperation { + lane: string; + operationId: string; + kind: "run" | "compaction" | "navigation"; + reason: "crash" | "deferred" | "missing_identities"; + startedAt: number; + prompt?: AgentMessage[]; + deferred?: DeferredHandle; + aborting?: { steer: AgentMessage[]; followUp: AgentMessage[] }; + missing: { tools: string[]; models: string[] }; +} + export interface QueuedItem { entryId: string; message: AgentMessage; @@ -168,9 +212,29 @@ export interface LaneSnapshot { lane: string; transcript: Entry[]; leafId: string | null; - operation: LaneInfo["operation"]; + operation: null | { + id: string; + kind: "run" | "compaction" | "navigation"; + status: "running" | "suspended" | "aborting"; + startedAt: number; + suspended?: SuspendedOperation; + streamingMessage?: AssistantMessage; + runningTools: { + toolCallId: string; + toolName: string; + args: unknown; + partialResult?: AgentToolResult; + }[]; + retry?: { attempt: number; maxAttempts: number; nextAttemptAt: number }; + }; queues: { steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] }; - pendingWrites: { id: string; entry: ProvisionedEntry }[]; + pendingWrites: { + entryId: string; + type: EntryType; + customType?: string; + message?: AgentMessage; + data?: JsonValue; + }[]; faulted: boolean; } @@ -179,78 +243,279 @@ export interface SessionSnapshot { faulted: boolean; } -export type ActionInfo = - | { kind: "append_entry"; entryType: Entry["type"]; entryId: string } - | { kind: "append_record"; recordType: string } - | { kind: "move_lane"; to: string | null } - | { kind: "set_fact"; fact: "name" | "label" } - | { kind: "try_finish_run"; outcome: "completed" | "failed" } - | { kind: "finish_operation"; outcome: "completed" | "declined" | "failed" | "aborted" } - | { kind: "commit_follow_up" } - | { kind: "consume_queue_item"; queue: "steer" | "followUp"; entryId: string } - | { kind: "apply_pending_write"; entryId: string } - | { kind: "stream_assistant"; step: "assistant" | "compaction" | "branch_summary"; attempt: number } - | { kind: "execute_tool"; toolCallId: string; toolName: string } - | { kind: "fetch_deferred" | "cancel_deferred"; provider: string; id: string } - | { kind: "hook"; name: HookName } - | { kind: "sleep"; delayMs: number }; +export type HarnessEventPayload = + | { type: "run_start"; runId: string } + | { type: "run_resume"; runId: string } + | { type: "run_suspend"; runId: string; reason: "deferred"; deferred: DeferredHandle } + | { + type: "run_suspend"; + runId: string; + reason: "missing_identities"; + missing: { tools: string[]; models: string[] }; + } + | { type: "run_abort"; runId: string; steer: AgentMessage[]; followUp: AgentMessage[] } + | ({ type: "run_end"; runId: string; leafId: string | null } & ( + | ({ outcome: "completed" | "aborted" } & OptionalFinalAssistant) + | ({ outcome: "failed"; error: OperationError } & OptionalFinalAssistant) + )) + | { type: "fault"; code: string; message: string } + | ({ type: "handler_error"; error: string; stack?: string } & ( + | { kind: "hook"; hook: string } + | { kind: "event"; event: string } + )) + | { type: "turn_start"; runId: string; turnId: string } + | { + type: "turn_end"; + runId: string; + turnId: string; + message: AssistantMessage; + toolResults: ToolResultMessage[]; + } + | { + type: "retry_scheduled"; + runId: string; + step: string; + attempt: number; + maxAttempts: number; + delayMs: number; + errorMessage: string; + } + | { type: "retry_start"; runId: string; step: string; attempt: number } + | { + type: "retry_end"; + runId: string; + step: string; + attempt: number; + success: boolean; + finalError?: string; + } + | { type: "message_start"; runId?: string; message: AgentMessage } + | { + type: "message_update"; + runId: string; + message: AgentMessage; + event: AssistantMessageEvent; + } + | { type: "message_end"; runId?: string; message: AgentMessage; entryId?: string } + | { + type: "tool_start"; + runId: string; + turnId: string; + toolCallId: string; + toolName: string; + args: unknown; + } + | { + type: "tool_update"; + runId: string; + turnId: string; + toolCallId: string; + toolName: string; + partialResult: AgentToolResult; + } + | { + type: "tool_end"; + runId: string; + turnId: string; + toolCallId: string; + toolName: string; + result: AgentToolResult; + isError: boolean; + terminate: boolean; + } + | { type: "entry_added"; entry: Entry } + | { type: "write_pending"; runId: string; entryId: string; entryType: EntryType } + | { type: "queue_update"; steer: QueuedItem[]; followUp: QueuedItem[]; nextRun: QueuedItem[] } + | ({ type: "fact_update" } & ( + | { fact: "name"; name: string | undefined } + | { fact: "label"; targetId: string; label: string | undefined } + | { fact: "custom"; key: string; value: JsonValue | undefined } + )) + | ({ type: "config_update" } & ( + | { + property: "model"; + value: { provider: string; modelId: string }; + previous: unknown; + } + | { property: "thinkingLevel"; value: ThinkingLevel; previous: ThinkingLevel } + | { property: "activeTools"; value: string[]; previous: string[] } + | { + property: + | "tools" + | "resources" + | "streamOptions" + | "retryPolicy" + | "compactionSettings" + | "steeringMode" + | "followUpMode"; + } + )) + | { type: "compaction_start"; runId: string; reason: "manual" | "threshold" | "overflow" } + | ({ type: "compaction_end"; runId: string; reason: "manual" | "threshold" | "overflow" } & ( + | { outcome: "completed"; entry: CompactionEntry; fromHook: boolean } + | { outcome: "declined" | "aborted" } + | { outcome: "failed"; error: OperationError } + )) + | { type: "navigation_start"; runId: string; targetId: string | null } + | ({ + type: "navigation_end"; + runId: string; + oldLeafId: string | null; + newLeafId: string | null; + } & ( + | { outcome: "completed"; summaryEntry?: BranchSummaryEntry } + | { outcome: "declined" | "aborted"; summaryEntry?: never; error?: never } + | { outcome: "failed"; error: OperationError; summaryEntry?: never } + )) + | { type: "lane_created"; at: string | null } + | { type: "usage"; lane: string; row: UsageRow; totals: Usage }; -export type HookName = - | "before_run" - | "before_resume" - | "before_run_end" - | "transform_context" - | "before_request" - | "before_payload" - | "after_response" - | "before_tool" - | "after_tool" - | "before_compaction" - | "before_navigation"; +export type SpecialEventPayload = Extract< + HarnessEventPayload, + { type: "fault" | "fact_update" | "usage" | "config_update" | "handler_error" } +>; +export type LaneEventPayload = Exclude; +export type ConfigEventPayload = Extract; +export type LaneConfigEventPayload = Extract< + ConfigEventPayload, + { property: "model" | "thinkingLevel" | "activeTools" } +>; +export type GlobalConfigEventPayload = Exclude; +export type HandlerErrorPayload = Extract; -export interface Hooks { - on(name: HookName, handler: (event: unknown) => unknown | Promise, options?: { id?: string }): () => void; -} +export type HarnessEvent = + | (LaneEventPayload & { lane: string; recovery?: true }) + | (LaneConfigEventPayload & { lane: string; recovery?: true }) + | (Extract & { + lane?: never; + recovery?: never; + }) + | (Extract & { recovery?: never }) + | (GlobalConfigEventPayload & { lane?: never; recovery?: never }) + | (HandlerErrorPayload & ({ lane: string; recovery?: true } | { lane?: never; recovery?: never })); + +export type HarnessEventType = HarnessEvent["type"]; +export type EventListener = (event: TEvent) => void | Promise; export interface Events { - on(type: string, listener: (event: unknown) => void | Promise): () => void; + on( + type: TType, + listener: EventListener>, + ): () => void; } -class UnavailableRegistry implements Hooks, Events { - private readonly operation: string; - private readonly isClosed: () => boolean; - - constructor(operation: string, isClosed: () => boolean) { - this.operation = operation; - this.isClosed = isClosed; - } - - on( - _name: HookName | string, - _handler: (event: unknown) => unknown | Promise, - _options?: { id?: string }, - ): () => void { - throw this.isClosed() ? new HarnessClosed() : new HarnessNotImplemented(this.operation); - } -} - -export type HarnessTool = AgentTool & { replay?: "never" | "safe" }; export type Resources = AgentHarnessResources; -export type StreamOptions = SimpleStreamOptions; -export type StreamOptionsPatch = Partial; -export type EntryProjector = (entry: Entry) => AgentMessage[] | Promise; -export interface AgentHarnessOptions { +export type BeforeResumePrepared = + | { kind: "run"; prompt: AgentMessage[]; systemPromptOverride?: string } + | { kind: "compaction"; sourceLeafId: string | null; customInstructions?: string } + | { + kind: "navigation"; + sourceLeafId: string | null; + targetId: string | null; + summarize: boolean; + label?: string; + customInstructions?: string; + }; + +export interface HookMap { + before_run: { + event: { prompt: AgentMessage[]; systemPrompt: string; resources: Resources }; + result: { messages?: AgentMessage[]; systemPrompt?: string; resumeData?: JsonValue } | undefined; + }; + before_resume: { + event: BeforeResumePrepared & { resumeData?: JsonValue }; + result: undefined; + }; + before_run_end: { + event: { runId: string; messages: AgentMessage[] }; + result: { followUp?: string } | undefined; + }; + transform_context: { + event: { messages: AgentMessage[] }; + result: { messages: AgentMessage[] } | undefined; + }; + before_request: { + event: { + model: Model; + step: "assistant" | "deferred" | "compaction" | "branch_summary"; + attempt: number; + streamOptions: AgentHarnessStreamOptions; + }; + result: { streamOptions?: AgentHarnessStreamOptionsPatch } | undefined; + }; + before_payload: { + event: { model: Model; payload: unknown }; + result: { payload: unknown } | undefined; + }; + after_response: { + event: { status?: number; headers?: Record; message: SettledAssistantMessage }; + result: { message?: SettledAssistantMessage } | undefined; + }; + before_tool: { + event: { toolCallId: string; toolName: string; args: Record }; + result: { args?: Record; block?: { reason: string; terminate?: boolean } } | undefined; + }; + after_tool: { + event: { + toolCallId: string; + toolName: string; + args: Record; + content: AgentToolResult["content"]; + details?: JsonValue; + isError: boolean; + usage?: Usage; + }; + result: + | { + content?: AgentToolResult["content"]; + details?: JsonValue; + isError?: boolean; + usage?: Usage; + terminate?: boolean; + } + | undefined; + }; + before_compaction: { + event: { + reason: "manual" | "threshold" | "overflow"; + preparation: CompactionPreparation; + customInstructions?: string; + }; + result: { decline?: boolean; compaction?: CompactResult } | undefined; + }; + before_navigation: { + event: { targetId: string; preparation: BranchPreparation; customInstructions?: string }; + result: { decline?: boolean; summary?: BranchSummaryResult } | undefined; + }; +} + +export type HookName = keyof HookMap; +export type HookInvocation = HookMap[TName]["event"] & { + lane: string; + runId: string; +}; +export type HookHandler = TName extends "before_resume" + ? (event: HookInvocation) => void | Promise + : (event: HookInvocation) => Promise | HookMap[TName]["result"]; + +export interface Hooks { + on(name: TName, handler: HookHandler, options?: { id?: string }): () => void; +} + +export type EntryProjector = (entry: CustomEntry) => AgentMessage[] | undefined | Promise; + +export interface AgentHarnessOptions { session: Session; models: Models; model: Model; thinkingLevel?: ThinkingLevel; activeToolNames?: string[]; - tools?: HarnessTool[]; - toolContext?: object | (() => object | Promise); - systemPrompt?: string | (() => string | Promise); + tools?: AgentHarnessTool[]; + toolContext?: TContext | (() => TContext | Promise); + systemPrompt?: string | ((context: TContext) => string | Promise); resources?: Resources; - streamOptions?: StreamOptions; + streamOptions?: AgentHarnessStreamOptions; retry?: RetryPolicy; compaction?: CompactionSettings; steeringMode?: QueueMode; @@ -259,18 +524,13 @@ export interface AgentHarnessOptions { drive?: "automatic" | "manual"; toProviderMessages?: (messages: AgentMessage[]) => Message[] | Promise; entryProjectors?: Record; - context?: TelemetryContext; -} - -export interface WatchHandle { - snapshot: TSnapshot; - start(listener: (event: unknown) => void): void; - unsubscribe(): void; + telemetryContext?: TelemetryContext; } export interface AgentLane { readonly name: string; getLeafId(): Promise; + getLastResult(): Promise; prompt(text: string, images?: ImageContent[]): Promise; prompt(message: AgentMessage | AgentMessage[]): Promise; skill(name: string, additionalInstructions?: string): Promise; @@ -279,12 +539,9 @@ export interface AgentLane { navigateTree(targetId: string | null, options?: NavigateOptions): Promise; resume(): Promise; abort(): Promise; - steer(text: string, images?: ImageContent[]): Promise; - steer(message: AgentMessage): Promise; - followUp(text: string, images?: ImageContent[]): Promise; - followUp(message: AgentMessage): Promise; - nextRun(text: string, images?: ImageContent[]): Promise; - nextRun(message: AgentMessage): Promise; + steer(message: string | AgentMessage, images?: ImageContent[]): Promise; + followUp(message: string | AgentMessage, images?: ImageContent[]): Promise; + nextRun(message: string | AgentMessage, images?: ImageContent[]): Promise; cancelQueued(entryId: string): Promise; recordUsage(usage: Usage, options?: { entryId?: string; details?: JsonValue }): Promise; waitForIdle(): Promise; @@ -292,7 +549,7 @@ export interface AgentLane { peekAction(): Promise; executeAction(): Promise; runToCompletion(): Promise; - getModel(): Promise>; + getModel(): Promise | undefined>; setModel(model: Model): Promise; getThinkingLevel(): Promise; setThinkingLevel(level: ThinkingLevel): Promise; @@ -302,207 +559,112 @@ export interface AgentLane { watch(): Promise>; } -export class AgentHarness implements AgentLane { - readonly name = "main"; - readonly session: SessionTree; +export interface AgentHarness extends AgentLane { + lane(name: string): Promise; + createLane(name: string, at: string | null): Promise; + lanes(): Promise; + getTools(): Promise[]>; + setTools(tools: AgentHarnessTool[]): Promise; + getResources(): Promise; + setResources(resources: Resources): Promise; + getStreamOptions(): Promise; + setStreamOptions(options: AgentHarnessStreamOptions): Promise; + getRetryPolicy(): Promise; + setRetryPolicy(policy: RetryPolicy): Promise; + getCompactionSettings(): Promise; + setCompactionSettings(settings: CompactionSettings): Promise; + getSteeringMode(): Promise; + setSteeringMode(mode: QueueMode): Promise; + getFollowUpMode(): Promise; + setFollowUpMode(mode: QueueMode): Promise; + watchSession(): Promise>; readonly hooks: Hooks; readonly events: Events; - private readonly durableSession: Session; - private model: Model; - private thinkingLevel: ThinkingLevel; - private activeToolNames: string[]; - private tools: HarnessTool[]; - private resources: Resources; - private streamOptions: StreamOptions; - private retryPolicy: RetryPolicy; - private compactionSettings: CompactionSettings; - private steeringMode: QueueMode; - private followUpMode: QueueMode; - private closed = false; - - private constructor(options: AgentHarnessOptions) { - this.durableSession = options.session; - this.session = options.session; - this.hooks = new UnavailableRegistry("hooks.on", () => this.closed); - this.events = new UnavailableRegistry("events.on", () => this.closed); - this.model = options.model; - this.thinkingLevel = options.thinkingLevel ?? "off"; - this.activeToolNames = [...(options.activeToolNames ?? options.tools?.map((tool) => tool.name) ?? [])]; - this.tools = [...(options.tools ?? [])]; - this.resources = { - skills: options.resources?.skills ? [...options.resources.skills] : undefined, - promptTemplates: options.resources?.promptTemplates ? [...options.resources.promptTemplates] : undefined, - }; - this.streamOptions = { ...(options.streamOptions ?? {}) }; - this.retryPolicy = options.retry ?? { enabled: false, maxRetries: 0, baseDelayMs: 1000 }; - this.compactionSettings = options.compaction ?? { - enabled: true, - reserveTokens: 16384, - keepRecentTokens: 20000, - }; - this.steeringMode = options.steeringMode ?? "one-at-a-time"; - this.followUpMode = options.followUpMode ?? "one-at-a-time"; - } - - static async create( - options: AgentHarnessOptions, - ): Promise<{ harness: AgentHarness; suspended: SuspendedOperation[] }> { - const [record] = await options.session.findRecords({ limit: 1 }); - if (record !== undefined) throw new HarnessNotImplemented("create.restore"); - return { harness: new AgentHarness(options), suspended: [] }; - } - - private unavailable(operation: string): Promise { - return Promise.reject(this.closed ? new HarnessClosed() : new HarnessNotImplemented(operation)); - } - - async getLeafId(): Promise { - return this.durableSession.getLeafId(); - } - - async prompt(_text: string, _images?: ImageContent[]): Promise; - async prompt(_message: AgentMessage | AgentMessage[]): Promise; - async prompt(_input: string | AgentMessage | AgentMessage[], _images?: ImageContent[]): Promise { - return this.unavailable("prompt"); - } - async skill(_name: string, _additionalInstructions?: string): Promise { - return this.unavailable("skill"); - } - async promptFromTemplate(_name: string, _args?: string[]): Promise { - return this.unavailable("promptFromTemplate"); - } - async compact(_options?: { customInstructions?: string }): Promise { - return this.unavailable("compact"); - } - async navigateTree(_targetId: string | null, _options?: NavigateOptions): Promise { - return this.unavailable("navigateTree"); - } - async resume(): Promise { - return this.unavailable("resume"); - } - async abort(): Promise { - return this.unavailable("abort"); - } - async steer(_text: string, _images?: ImageContent[]): Promise; - async steer(_message: AgentMessage): Promise; - async steer(_input: string | AgentMessage, _images?: ImageContent[]): Promise { - return this.unavailable("steer"); - } - async followUp(_text: string, _images?: ImageContent[]): Promise; - async followUp(_message: AgentMessage): Promise; - async followUp(_input: string | AgentMessage, _images?: ImageContent[]): Promise { - return this.unavailable("followUp"); - } - async nextRun(_text: string, _images?: ImageContent[]): Promise; - async nextRun(_message: AgentMessage): Promise; - async nextRun(_input: string | AgentMessage, _images?: ImageContent[]): Promise { - return this.unavailable("nextRun"); - } - async cancelQueued(_entryId: string): Promise { - return this.unavailable("cancelQueued"); - } - async recordUsage(_usage: Usage, _options?: { entryId?: string; details?: JsonValue }): Promise { - return this.unavailable("recordUsage"); - } - async waitForIdle(): Promise { - return this.unavailable("waitForIdle"); - } - async runWhenIdle(_callback: () => void | Promise): Promise { - return this.unavailable("runWhenIdle"); - } - async peekAction(): Promise { - return this.unavailable("peekAction"); - } - async executeAction(): Promise { - return this.unavailable("executeAction"); - } - async runToCompletion(): Promise { - return this.unavailable("runToCompletion"); - } - async getModel(): Promise> { - return this.model; - } - async setModel(model: Model): Promise { - this.model = model; - } - async getThinkingLevel(): Promise { - return this.thinkingLevel; - } - async setThinkingLevel(level: ThinkingLevel): Promise { - this.thinkingLevel = level; - } - async getActiveTools(): Promise { - return [...this.activeToolNames]; - } - async setActiveTools(names: string[]): Promise { - this.activeToolNames = [...names]; - } - async watch(): Promise> { - return this.unavailable("watch"); - } - - async lane(_name: string): Promise { - return this.unavailable("lane"); - } - async createLane(_name: string, _at: string | null): Promise { - return this.unavailable("createLane"); - } - async lanes(): Promise { - return this.unavailable("lanes"); - } - async getTools(): Promise { - return [...this.tools]; - } - async setTools(tools: HarnessTool[], activeNames?: string[]): Promise { - this.tools = [...tools]; - this.activeToolNames = [...(activeNames ?? tools.map((tool) => tool.name))]; - } - async getResources(): Promise { - return { - skills: this.resources.skills ? [...this.resources.skills] : undefined, - promptTemplates: this.resources.promptTemplates ? [...this.resources.promptTemplates] : undefined, - }; - } - async setResources(resources: Resources): Promise { - this.resources = { - skills: resources.skills ? [...resources.skills] : undefined, - promptTemplates: resources.promptTemplates ? [...resources.promptTemplates] : undefined, - }; - } - async getStreamOptions(): Promise { - return { ...this.streamOptions }; - } - async setStreamOptions(options: StreamOptions): Promise { - this.streamOptions = { ...options }; - } - async getRetryPolicy(): Promise { - return { ...this.retryPolicy }; - } - async setRetryPolicy(policy: RetryPolicy): Promise { - this.retryPolicy = { ...policy }; - } - async getCompactionSettings(): Promise { - return { ...this.compactionSettings }; - } - async setCompactionSettings(settings: CompactionSettings): Promise { - this.compactionSettings = { ...settings }; - } - async getSteeringMode(): Promise { - return this.steeringMode; - } - async setSteeringMode(mode: QueueMode): Promise { - this.steeringMode = mode; - } - async getFollowUpMode(): Promise { - return this.followUpMode; - } - async setFollowUpMode(mode: QueueMode): Promise { - this.followUpMode = mode; - } - async watchSession(): Promise> { - return this.unavailable("watchSession"); - } - async close(): Promise { - this.closed = true; - } + close(): Promise; } + +export interface AgentHarnessConstructor { + create( + options: AgentHarnessOptions, + ): Promise<{ harness: AgentHarness; suspended: SuspendedOperation[] }>; +} + +export interface StreamAssistantConfig { + model: Model; + thinkingLevel: ThinkingLevel; + systemPrompt?: string; + tools?: AgentTool[]; + transformContext?: (messages: AgentMessage[], signal: AbortSignal) => Promise; + toProviderMessages: (messages: AgentMessage[]) => Message[] | Promise; + models: Models; + streamOptions?: AgentHarnessStreamOptions; + transformPayload?: (payload: unknown, model: Model) => unknown | undefined | Promise; + transformResponse?: ( + message: SettledAssistantMessage, + metadata: { status?: number; headers?: Record }, + ) => Promise; + telemetryContext: TelemetryContext; + signal: AbortSignal; +} + +export type StreamAssistant = ( + messages: AgentMessage[], + config: StreamAssistantConfig, + emit: AgentEventSink, +) => Promise; + +export type PreparedToolCall = { + kind: "prepared"; + toolCall: AgentToolCall; + tool: AgentTool; + args: Record; +}; +export type ImmediateOutcome = { + kind: "immediate"; + result: AgentToolResult; + isError: true; + terminate: boolean; +}; +export type FinalizedToolCall = { + toolCall: AgentToolCall; + result: AgentToolResult; + isError: boolean; + terminate: boolean; +}; + +export interface ToolCallbacks { + beforeToolCall?(call: AgentToolCall, args: Record): Promise; + afterToolCall?( + call: AgentToolCall, + args: Record, + result: AgentToolResult, + isError: boolean, + ): Promise; + executeTool?(call: PreparedToolCall): Promise<{ result: AgentToolResult; isError: boolean }>; + onToolStart?(call: AgentToolCall, effectiveArgs: Record): Promise; + onToolResult?(call: AgentToolCall, message: ToolResultMessage, terminate: boolean): Promise; +} + +/** Type-only signatures for the R4 agent-loop phases. */ +export type PrepareToolCall = ( + call: AgentToolCall, + tools: AgentTool[], + callbacks: ToolCallbacks, + telemetry: TelemetryContext, + signal: AbortSignal, +) => Promise; +export type ExecuteToolCall = ( + call: PreparedToolCall, + emit: AgentEventSink, + telemetry: TelemetryContext, + signal: AbortSignal, +) => Promise<{ result: AgentToolResult; isError: boolean }>; +export type FinalizeToolCall = ( + call: PreparedToolCall, + executed: { result: AgentToolResult; isError: boolean }, + callbacks: ToolCallbacks, + telemetry: TelemetryContext, + signal: AbortSignal, +) => Promise; + +export type CustomMessageSchemas = Record; diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index 64c088f70..fc4db8df6 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -10,7 +10,7 @@ import { import type { AgentMessage } from "../../types.ts"; import { convertToLlm, createBranchSummaryMessage, createCompactionSummaryMessage } from "../messages.ts"; -import { type Entry, type Session, SessionError } from "../session/index.ts"; +import type { Entry, SessionTree } from "../session/index.ts"; import { BranchSummaryError, err, ok, type Result } from "../types.ts"; import { completeSimpleWithRetries, estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts"; import { @@ -80,7 +80,7 @@ export interface GenerateBranchSummaryOptions { /** Collect entries that should be summarized before navigating to a different session tree entry. */ export async function collectEntriesForBranchSummary( - session: Session, + session: Pick, oldLeafId: string | null, targetId: string, ): Promise { @@ -101,7 +101,7 @@ export async function collectEntriesForBranchSummary( while (current && current !== commonAncestorId) { const entry = await session.getEntry(current); - if (!entry) throw new SessionError("invalid_entry", `Entry ${current} not found`); + if (!entry) throw new Error(`Corrupt session: entry ${current} not found`); entries.push(entry); current = entry.parentId; } @@ -120,9 +120,6 @@ function getMessageFromEntry(entry: Entry): AgentMessage | undefined { case "compaction": return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); - case "thinking_level_change": - case "model_change": - case "active_tools_change": case "custom": return undefined; } @@ -134,15 +131,22 @@ export function prepareBranchEntries(entries: Entry[], tokenBudget: number = 0): const fileOps = createFileOps(); let totalTokens = 0; for (const entry of entries) { - if (entry.type === "branch_summary" && entry.details) { - const details = entry.details as BranchSummaryDetails; - if (Array.isArray(details.readFiles)) { - for (const f of details.readFiles) fileOps.read.add(f); + if ( + entry.type !== "branch_summary" || + typeof entry.details !== "object" || + entry.details === null || + Array.isArray(entry.details) + ) { + continue; + } + if (Array.isArray(entry.details.readFiles)) { + for (const path of entry.details.readFiles) { + if (typeof path === "string") fileOps.read.add(path); } - if (Array.isArray(details.modifiedFiles)) { - for (const f of details.modifiedFiles) { - fileOps.edited.add(f); - } + } + if (Array.isArray(entry.details.modifiedFiles)) { + for (const path of entry.details.modifiedFiles) { + if (typeof path === "string") fileOps.edited.add(path); } } } diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index 06ae8afb1..92d6bb797 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -49,13 +49,20 @@ function extractFileOperations( const fileOps = createFileOps(); if (prevCompactionIndex >= 0) { const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; - if (prevCompaction.details) { - const details = prevCompaction.details as CompactionDetails; - if (Array.isArray(details.readFiles)) { - for (const f of details.readFiles) fileOps.read.add(f); + if ( + typeof prevCompaction.details === "object" && + prevCompaction.details !== null && + !Array.isArray(prevCompaction.details) + ) { + if (Array.isArray(prevCompaction.details.readFiles)) { + for (const path of prevCompaction.details.readFiles) { + if (typeof path === "string") fileOps.read.add(path); + } } - if (Array.isArray(details.modifiedFiles)) { - for (const f of details.modifiedFiles) fileOps.edited.add(f); + if (Array.isArray(prevCompaction.details.modifiedFiles)) { + for (const path of prevCompaction.details.modifiedFiles) { + if (typeof path === "string") fileOps.edited.add(path); + } } } } @@ -330,9 +337,6 @@ function findValidCutPoints(entries: Entry[], startIndex: number, endIndex: numb } break; } - case "thinking_level_change": - case "model_change": - case "active_tools_change": case "compaction": case "branch_summary": case "custom": diff --git a/packages/agent/src/harness/events.ts b/packages/agent/src/harness/events.ts deleted file mode 100644 index a457b2b01..000000000 --- a/packages/agent/src/harness/events.ts +++ /dev/null @@ -1,102 +0,0 @@ -export interface RunStartEvent { - type: "run_start"; - lane: string; - runId: string; -} - -export interface RunEndEvent { - type: "run_end"; - lane: string; - runId: string; - outcome: "completed" | "aborted" | "failed"; - leafId: string; -} - -export type HarnessEvent = RunStartEvent | RunEndEvent; -export type HarnessEventType = HarnessEvent["type"]; -export type HarnessEventOfType = Extract; -export type HarnessEventListener = (event: TEvent) => void | Promise; - -export interface Events { - /** - * Register a passive listener for future events and return its unsubscribe function. - * Earlier events are not replayed and no current-state snapshot is provided; use a lane or session watch for both. - */ - on( - type: TType, - listener: HarnessEventListener>, - ): () => void; -} - -export interface WatchHandle { - snapshot: TSnapshot; - start(listener: HarnessEventListener): void; - unsubscribe(): void; -} - -export class HarnessEventBus implements Events { - private readonly listeners = new Map>(); - private readonly watchListeners = new Set<(event: HarnessEvent) => void>(); - - /** - * Register a listener for future events of one type and return its unsubscribe function. - * Earlier events are not replayed, and no snapshot or event buffer is provided. - */ - on( - type: TType, - listener: HarnessEventListener>, - ): () => void { - // Reuse this event type's listener set, or create its first set. - const listeners = this.listeners.get(type) ?? new Set(); - this.listeners.set(type, listeners); - - // Wrap this event-specific callback so it can be stored as a general HarnessEvent listener. - // Keep the wrapper reference so unsubscribe can remove that exact function from the set. - const receive: HarnessEventListener = (event) => { - if (event.type === type) return listener(event as HarnessEventOfType); - }; - listeners.add(receive); - return () => { - listeners.delete(receive); - if (listeners.size === 0) this.listeners.delete(type); - }; - } - - /** Publish an event to current event subscriptions and watch subscriptions. */ - emit(event: HarnessEvent): void { - // Deliver only to direct listeners registered for this event type. - // Async results are not awaited because emit() is synchronous. - for (const listener of this.listeners.get(event.type) ?? []) void listener(event); - - // Deliver every event to each watcher; watch() handles buffering until start(). - for (const listener of this.watchListeners) listener(event); - } - - watch(captureSnapshot: () => TSnapshot): WatchHandle { - let listener: HarnessEventListener | undefined; - let buffered: HarnessEvent[] = []; - const receive = (event: HarnessEvent): void => { - if (listener) void listener(event); - else buffered.push(event); - }; - this.watchListeners.add(receive); - const snapshot = captureSnapshot(); - - return { - snapshot, - start: (nextListener) => { - // Stay in buffering mode while flushing so reentrant emissions preserve order. - while (buffered.length > 0) { - const pending = buffered; - buffered = []; - for (const event of pending) void nextListener(event); - } - listener = nextListener; - }, - unsubscribe: () => { - this.watchListeners.delete(receive); - buffered = []; - }, - }; - } -} diff --git a/packages/agent/src/harness/reducer.ts b/packages/agent/src/harness/reducer.ts deleted file mode 100644 index 68595b245..000000000 --- a/packages/agent/src/harness/reducer.ts +++ /dev/null @@ -1,667 +0,0 @@ -import type { AssistantMessage, DeferredHandle, StopReason } from "@earendil-works/pi-ai"; -import { Guard } from "typebox/guard"; -import type { AgentMessage, AgentToolCall, ThinkingLevel } from "../types.ts"; -import type { - Entry, - LaneRecord, - OperationStartedRecord, - ProvisionedEntry, - QueueEnqueuedRecord, - StepAttemptRecord, - ToolStartedRecord, - WriteDeferredRecord, -} from "./session/types.ts"; - -/** - * Machine-readable category for a contradiction in a lane's durable recovery - * slice. These indicate states the single-writer record protocol cannot - * produce, not ordinary operation failures or incomplete-but-recoverable - * intent/result prefixes. Restore must reject such states rather than repair or - * continue it; the accompanying error message supplies human-readable detail. - */ -export type RecordLogCorruptionReason = - | "multiple_open_operations" - | "unknown_operation" - | "record_after_finish" - | "non_consecutive_attempt" - | "invalid_compaction_reason" - | "queue_after_abort" - | "invalid_queue_cancellation" - | "inconsistent_step" - | "tool_call_mismatch" - | "duplicate_tool_invocation" - | "provisioned_entry_mismatch" - | "invalid_deferred_handle"; - -export class RecordLogCorruption extends Error { - readonly reason: RecordLogCorruptionReason; - - constructor(reason: RecordLogCorruptionReason, message: string) { - super(message); - this.name = "RecordLogCorruption"; - this.reason = reason; - } -} - -export interface RecordLogSlice { - lane: string; - openOperations: readonly OperationStartedRecord[]; - records: readonly LaneRecord[]; - /** Operation-owned entries plus entries fetched directly by provisioned or referenced ids. */ - entries: readonly Entry[]; -} - -export interface EffectiveLaneConfiguration { - model: { provider: string; modelId: string }; - thinkingLevel: ThinkingLevel; - activeToolNames: string[]; -} - -export interface TerminalFailureState { - entryId: string; - source: "step" | "deferred_fetch"; - message: AssistantMessage; -} - -export interface ToolBatchState { - assistantEntryId: string; - calls: { - toolIndex: number; - toolCall: AgentToolCall; - started?: ToolStartedRecord; - resultExists: boolean; - terminate?: boolean; - }[]; - truncated: boolean; - unresolved: boolean; -} - -export interface LaneState { - lane: string; - leafId: string | null; - operation: null | { - id: string; - kind: "run" | "compaction" | "navigation"; - intent: OperationStartedRecord["intent"]; - aborting: boolean; - step: null | { - kind: "assistant" | "compaction" | "branch_summary"; - attempts: number; - resultEntryId: string; - compactionReason?: "manual" | "threshold" | "overflow"; - }; - toolBatch: ToolBatchState | null; - missingInitialMessages: ProvisionedEntry[]; - pendingSteer: ProvisionedEntry[]; - pendingFollowUp: ProvisionedEntry[]; - pendingWrites: ProvisionedEntry[]; - deferred: DeferredHandle | null; - overflowRecoveryUsed: boolean; - newestOwn: null | { - entryId: string; - type: Entry["type"]; - role?: AgentMessage["role"]; - stopReason?: StopReason; - }; - targets: { result?: boolean; summary?: boolean }; - }; - pendingNextRun: ProvisionedEntry[]; -} - -export interface LaneReductionInput extends RecordLogSlice { - leafId: string | null; - /** Entries appended by the open operation, oldest first. Empty when idle. */ - ownEntries: readonly Entry[]; - /** Bounded effective-state lookups at the operation anchor or idle leaf, oldest first. */ - configurationEntries: readonly Entry[]; - /** Harness option fallbacks used when no persisted value exists. */ - defaults: EffectiveLaneConfiguration; -} - -export interface LaneReductionResult { - laneState: LaneState; - effectiveConfiguration: EffectiveLaneConfiguration; - terminalFailure: TerminalFailureState | null; -} - -interface AttemptSeries { - record: StepAttemptRecord; -} - -function corrupt(reason: RecordLogCorruptionReason, message: string): never { - throw new RecordLogCorruption(reason, message); -} - -function hasRunId(record: LaneRecord): record is Exclude & { runId: string } { - return "runId" in record && typeof record.runId === "string"; -} - -function matchesProvisionedEntry(entry: Entry, target: ProvisionedEntry): boolean { - const { parentId: _parentId, seq: _seq, timestamp: _timestamp, ...payload } = entry; - return Guard.IsDeepEqual(payload, target); -} - -function validateExactProvisionedEntry(entriesById: ReadonlyMap, target: ProvisionedEntry): void { - const entry = entriesById.get(target.id); - if (entry && !matchesProvisionedEntry(entry, target)) { - corrupt( - "provisioned_entry_mismatch", - `Provisioned entry ${target.id} exists with content different from its intent`, - ); - } -} - -function validateResultEntry( - entriesById: ReadonlyMap, - resultEntryId: string, - matches: (entry: Entry) => boolean, - description: string, -): void { - const entry = entriesById.get(resultEntryId); - if (entry && !matches(entry)) { - corrupt( - "provisioned_entry_mismatch", - `Provisioned ${description} entry ${resultEntryId} exists with different content`, - ); - } -} - -function validateAttemptReason(record: StepAttemptRecord): void { - const reason = (record as { compactionReason?: unknown }).compactionReason; - if (record.step === "compaction") { - if (reason !== "manual" && reason !== "threshold" && reason !== "overflow") { - corrupt("invalid_compaction_reason", `Compaction attempt ${record.id} has no valid compaction reason`); - } - } else if (reason !== undefined) { - corrupt("invalid_compaction_reason", `${record.step} attempt ${record.id} has a compaction reason`); - } -} - -function validateAttemptSequence( - record: StepAttemptRecord, - previous: AttemptSeries | undefined, - entriesById: ReadonlyMap, -): void { - const previousRecord = previous?.record; - const previousResult = previousRecord ? entriesById.get(previousRecord.resultEntryId) : undefined; - const continuesSeries = - previousRecord !== undefined && - previousRecord.step === record.step && - (previousResult === undefined || previousResult.seq >= record.seq); - const expectedAttempt = continuesSeries ? previousRecord.attempt + 1 : 1; - if (record.attempt !== expectedAttempt) { - corrupt( - "non_consecutive_attempt", - `${record.step} attempt ${record.id} is ${record.attempt}; expected ${expectedAttempt}`, - ); - } - if (!continuesSeries || record.step === "assistant" || previousRecord === undefined) return; - if (record.resultEntryId !== previousRecord.resultEntryId) { - corrupt("inconsistent_step", `${record.step} attempts disagree on their result entry id`); - } - if (record.compactionReason !== previousRecord.compactionReason) { - corrupt("inconsistent_step", `${record.step} attempts disagree on their compaction reason`); - } -} - -function validateAttemptResult(entriesById: ReadonlyMap, record: StepAttemptRecord): void { - switch (record.step) { - case "assistant": - validateResultEntry( - entriesById, - record.resultEntryId, - (entry) => entry.type === "message" && entry.message.role === "assistant", - "assistant result", - ); - break; - case "compaction": - validateResultEntry( - entriesById, - record.resultEntryId, - (entry) => entry.type === "compaction", - "compaction result", - ); - break; - case "branch_summary": - validateResultEntry( - entriesById, - record.resultEntryId, - (entry) => entry.type === "branch_summary", - "branch-summary result", - ); - break; - } -} - -function validateToolStart( - record: Extract, - entriesById: ReadonlyMap, - invocations: Set, -): void { - const invocation = `${record.assistantEntryId}\u0000${record.toolIndex}`; - if (invocations.has(invocation)) { - corrupt( - "duplicate_tool_invocation", - `Tool invocation ${record.assistantEntryId}:${record.toolIndex} is duplicated`, - ); - } - invocations.add(invocation); - - const assistantEntry = entriesById.get(record.assistantEntryId); - if (!assistantEntry || assistantEntry.type !== "message" || assistantEntry.message.role !== "assistant") { - corrupt("tool_call_mismatch", `Tool start ${record.id} does not reference an assistant entry`); - } - const toolCalls = assistantEntry.message.content.filter((content) => content.type === "toolCall"); - const toolCall = toolCalls[record.toolIndex]; - if (!toolCall || toolCall.id !== record.toolCallId || toolCall.name !== record.toolName) { - corrupt("tool_call_mismatch", `Tool start ${record.id} does not match its assistant tool-call ordinal`); - } - - validateResultEntry( - entriesById, - record.resultEntryId, - (entry) => - entry.type === "message" && - entry.message.role === "toolResult" && - entry.message.toolCallId === record.toolCallId && - entry.message.toolName === record.toolName, - "tool result", - ); -} - -function validateDeferredHandles(entries: Iterable): void { - for (const entry of entries) { - if ( - entry.type === "message" && - entry.message.role === "assistant" && - entry.message.stopReason === "deferred" && - !entry.message.deferred - ) { - corrupt("invalid_deferred_handle", `Deferred assistant entry ${entry.id} does not carry a handle`); - } - } -} - -function validateOperationResult(entriesById: ReadonlyMap, record: OperationStartedRecord): void { - switch (record.intent.kind) { - case "run": - for (const target of record.intent.initialMessages) validateExactProvisionedEntry(entriesById, target); - break; - case "compaction": - validateResultEntry( - entriesById, - record.intent.resultEntryId, - (entry) => entry.type === "compaction", - "manual compaction", - ); - break; - case "navigation": - if (record.intent.summaryEntryId) { - validateResultEntry( - entriesById, - record.intent.summaryEntryId, - (entry) => entry.type === "branch_summary", - "navigation summary", - ); - } - break; - } -} - -/** Validates a bounded lane recovery slice without reading or mutating session state. */ -export function validateRecordLog(input: RecordLogSlice): void { - if (input.openOperations.length > 1) { - corrupt("multiple_open_operations", `Lane ${input.lane} has at least two open operations`); - } - - const entriesById = new Map(input.entries.map((entry) => [entry.id, entry])); - validateDeferredHandles(entriesById.values()); - const starts = new Map(); - const finishedAt = new Map(); - const abortedAt = new Map(); - const queueEnqueues = new Map>(); - const latestAttempt = new Map(); - const toolInvocations = new Set(); - const records = [...input.records].sort((left, right) => left.seq - right.seq); - - for (const record of records) { - if (record.type === "operation_started") { - starts.set(record.id, record); - validateOperationResult(entriesById, record); - continue; - } - - if (hasRunId(record)) { - if (!starts.has(record.runId)) { - corrupt("unknown_operation", `Record ${record.id} references unknown operation ${record.runId}`); - } - const finishSeq = finishedAt.get(record.runId); - if (finishSeq !== undefined && record.seq > finishSeq) { - corrupt("record_after_finish", `Record ${record.id} follows the finish of operation ${record.runId}`); - } - } - - switch (record.type) { - case "operation_finished": - finishedAt.set(record.runId, record.seq); - break; - case "abort_requested": - abortedAt.set(record.runId, record.seq); - break; - case "step_attempt": - validateAttemptReason(record); - validateAttemptSequence(record, latestAttempt.get(record.runId), entriesById); - validateAttemptResult(entriesById, record); - latestAttempt.set(record.runId, { record }); - break; - case "tool_started": - validateToolStart(record, entriesById, toolInvocations); - break; - case "queue_enqueued": - if ( - record.queue !== "nextRun" && - abortedAt.get(record.runId) !== undefined && - record.seq > abortedAt.get(record.runId)! - ) { - corrupt("queue_after_abort", `${record.queue} item ${record.target.id} was enqueued after abort`); - } - queueEnqueues.set(record.target.id, record); - validateExactProvisionedEntry(entriesById, record.target); - break; - case "queue_cancelled": { - const enqueue = queueEnqueues.get(record.entryId); - if ( - !enqueue || - enqueue.seq >= record.seq || - enqueue.runId !== record.runId || - entriesById.has(record.entryId) - ) { - corrupt("invalid_queue_cancellation", `Queue cancellation ${record.id} has no pending matching enqueue`); - } - break; - } - case "write_deferred": - validateExactProvisionedEntry(entriesById, record.target); - break; - case "usage": - break; - } - } -} - -function clone(value: T): T { - return structuredClone(value); -} - -function bySequence(values: readonly T[]): T[] { - return [...values].sort((left, right) => left.seq - right.seq); -} - -function deriveEffectiveConfiguration(input: LaneReductionInput): EffectiveLaneConfiguration { - let configuration = clone(input.defaults); - const entriesById = new Map(); - for (const entry of [...input.configurationEntries, ...input.ownEntries]) entriesById.set(entry.id, entry); - - for (const entry of bySequence([...entriesById.values()])) { - switch (entry.type) { - case "model_change": - configuration = { ...configuration, model: { provider: entry.provider, modelId: entry.modelId } }; - break; - case "thinking_level_change": - configuration = { ...configuration, thinkingLevel: entry.thinkingLevel as ThinkingLevel }; - break; - case "active_tools_change": - configuration = { ...configuration, activeToolNames: [...entry.activeToolNames] }; - break; - case "message": - if (entry.message.role === "assistant") { - configuration = { - ...configuration, - model: { provider: entry.message.provider, modelId: entry.message.model }, - }; - } - break; - } - } - return configuration; -} - -function deriveNewestOwn( - entry: Entry | undefined, -): NonNullable["newestOwn"]> | null { - if (!entry) return null; - if (entry.type !== "message") return { entryId: entry.id, type: entry.type }; - if (entry.message.role !== "assistant") { - return { entryId: entry.id, type: entry.type, role: entry.message.role }; - } - return { - entryId: entry.id, - type: entry.type, - role: entry.message.role, - stopReason: entry.message.stopReason, - }; -} - -function deriveToolBatch( - operationId: string, - records: readonly LaneRecord[], - ownEntries: readonly Entry[], - entriesById: ReadonlyMap, - deferredWriteIds: ReadonlySet, -): ToolBatchState | null { - const assistantEntry = [...ownEntries] - .reverse() - .find( - (entry) => - entry.type === "message" && - entry.message.role === "assistant" && - entry.message.content.some((content) => content.type === "toolCall"), - ); - if (!assistantEntry || assistantEntry.type !== "message" || assistantEntry.message.role !== "assistant") return null; - - const toolCalls = assistantEntry.message.content.filter( - (content): content is AgentToolCall => content.type === "toolCall", - ); - const starts = new Map(); - for (const record of records) { - if ( - record.type === "tool_started" && - record.runId === operationId && - record.assistantEntryId === assistantEntry.id - ) { - starts.set(record.toolIndex, record); - } - } - - const calls = toolCalls.map((toolCall, toolIndex) => { - const started = starts.get(toolIndex); - const startedResult = started ? entriesById.get(started.resultEntryId) : undefined; - const blockedResult = ownEntries.find( - (entry) => - entry.seq > assistantEntry.seq && - !deferredWriteIds.has(entry.id) && - entry.type === "message" && - entry.message.role === "toolResult" && - entry.message.toolCallId === toolCall.id, - ); - const result = startedResult ?? blockedResult; - return { - toolIndex, - toolCall: clone(toolCall), - ...(started ? { started: clone(started) } : {}), - resultExists: result !== undefined, - ...(result?.type === "message" && result.terminate === true ? { terminate: true } : {}), - }; - }); - - return { - assistantEntryId: assistantEntry.id, - calls, - truncated: assistantEntry.message.stopReason === "length", - unresolved: calls.some((call) => !call.resultExists), - }; -} - -/** Purely reconstructs one lane's orchestration state from its bounded recovery inputs. */ -export function reduceLaneState(input: LaneReductionInput): LaneReductionResult { - validateRecordLog(input); - - const records = bySequence(input.records); - const ownEntries = bySequence(input.ownEntries); - const entriesById = new Map(); - for (const entry of [...input.entries, ...ownEntries]) entriesById.set(entry.id, entry); - const cancelledQueueIds = new Set( - records.filter((record) => record.type === "queue_cancelled").map((record) => record.entryId), - ); - const pendingQueueRecords = records.filter( - (record): record is QueueEnqueuedRecord => - record.type === "queue_enqueued" && - !entriesById.has(record.target.id) && - !cancelledQueueIds.has(record.target.id), - ); - const started = input.openOperations[0]; - const capturedInitialMessageIds = new Set( - started?.intent.kind === "run" ? started.intent.initialMessages.map((target) => target.id) : [], - ); - const pendingNextRun = pendingQueueRecords - .filter((record) => record.queue === "nextRun" && !capturedInitialMessageIds.has(record.target.id)) - .map((record) => clone(record.target)); - const effectiveConfiguration = deriveEffectiveConfiguration(input); - - if (!started) { - return { - laneState: { lane: input.lane, leafId: input.leafId, operation: null, pendingNextRun }, - effectiveConfiguration, - terminalFailure: null, - }; - } - - const operationRecords = records.filter((record) => - record.type === "operation_started" ? record.id === started.id : "runId" in record && record.runId === started.id, - ); - const aborting = operationRecords.some((record) => record.type === "abort_requested"); - const pendingSteer = aborting - ? [] - : pendingQueueRecords - .filter((record) => record.queue === "steer" && record.runId === started.id) - .map((record) => clone(record.target)); - const pendingFollowUp = aborting - ? [] - : pendingQueueRecords - .filter((record) => record.queue === "followUp" && record.runId === started.id) - .map((record) => clone(record.target)); - const pendingWrites = operationRecords - .filter( - (record): record is WriteDeferredRecord => - record.type === "write_deferred" && !entriesById.has(record.target.id), - ) - .map((record) => clone(record.target)); - const missingInitialMessages = - started.intent.kind === "run" - ? started.intent.initialMessages.filter((target) => !entriesById.has(target.id)).map(clone) - : []; - - const newestAttempt = operationRecords.filter((record) => record.type === "step_attempt").at(-1); - const step = - newestAttempt && !entriesById.has(newestAttempt.resultEntryId) - ? { - kind: newestAttempt.step, - attempts: newestAttempt.attempt, - resultEntryId: newestAttempt.resultEntryId, - ...(newestAttempt.step === "compaction" ? { compactionReason: newestAttempt.compactionReason } : {}), - } - : null; - - const consumedInputIds = new Set(); - if (started.intent.kind === "run") { - for (const target of started.intent.initialMessages) consumedInputIds.add(target.id); - } - for (const record of operationRecords) { - if (record.type === "queue_enqueued" && record.queue !== "nextRun") consumedInputIds.add(record.target.id); - } - let newestConsumedInputSequence = Number.NEGATIVE_INFINITY; - for (const id of consumedInputIds) { - const entry = entriesById.get(id); - if (entry?.type === "message") newestConsumedInputSequence = Math.max(newestConsumedInputSequence, entry.seq); - } - const overflowRecoveryUsed = operationRecords.some( - (record) => - record.type === "step_attempt" && - record.step === "compaction" && - record.compactionReason === "overflow" && - record.seq > newestConsumedInputSequence, - ); - - const newestOwnEntry = ownEntries.at(-1); - const newestOwn = deriveNewestOwn(newestOwnEntry); - const deferred = - newestOwnEntry?.type === "message" && - newestOwnEntry.message.role === "assistant" && - newestOwnEntry.message.stopReason === "deferred" && - newestOwnEntry.message.deferred - ? clone(newestOwnEntry.message.deferred) - : null; - const targets: { result?: boolean; summary?: boolean } = {}; - if (started.intent.kind === "compaction") { - targets.result = entriesById.has(started.intent.resultEntryId); - } else if (started.intent.kind === "navigation" && started.intent.summaryEntryId) { - targets.summary = entriesById.has(started.intent.summaryEntryId); - } - - const deferredWriteIds = new Set( - operationRecords.filter((record) => record.type === "write_deferred").map((record) => record.target.id), - ); - let terminalFailure: TerminalFailureState | null = null; - if ( - newestOwnEntry?.type === "message" && - newestOwnEntry.message.role === "assistant" && - newestOwnEntry.message.stopReason === "error" && - !deferredWriteIds.has(newestOwnEntry.id) - ) { - const producedByStep = operationRecords.some( - (record) => record.type === "step_attempt" && record.resultEntryId === newestOwnEntry.id, - ); - const previousOwnEntry = ownEntries.at(-2); - const producedByDeferredFetch = - operationRecords.some( - (record) => - record.type === "usage" && record.cause === "deferred_fetch" && record.entryId === newestOwnEntry.id, - ) || - (previousOwnEntry?.type === "message" && - previousOwnEntry.message.role === "assistant" && - previousOwnEntry.message.stopReason === "deferred"); - if (producedByStep || producedByDeferredFetch) { - terminalFailure = { - entryId: newestOwnEntry.id, - source: producedByStep ? "step" : "deferred_fetch", - message: clone(newestOwnEntry.message), - }; - } - } - - return { - laneState: { - lane: input.lane, - leafId: input.leafId, - operation: { - id: started.id, - kind: started.intent.kind, - intent: clone(started.intent), - aborting, - step, - toolBatch: deriveToolBatch(started.id, operationRecords, ownEntries, entriesById, deferredWriteIds), - missingInitialMessages, - pendingSteer, - pendingFollowUp, - pendingWrites, - deferred, - overflowRecoveryUsed, - newestOwn, - targets, - }, - pendingNextRun, - }, - effectiveConfiguration, - terminalFailure, - }; -} diff --git a/packages/agent/src/harness/session/context.ts b/packages/agent/src/harness/session/context.ts index d219b541a..6fe5566c0 100644 --- a/packages/agent/src/harness/session/context.ts +++ b/packages/agent/src/harness/session/context.ts @@ -4,9 +4,6 @@ import type { CompactionEntry, CustomEntry, Entry } from "./types.ts"; export interface SessionContext { messages: AgentMessage[]; - thinkingLevel: string; - model: { provider: string; modelId: string } | null; - activeToolNames: string[] | null; } export type ContextEntryTransform = (entries: readonly Entry[]) => readonly Entry[]; @@ -22,32 +19,12 @@ export interface SessionContextBuildOptions { entryProjectors?: Readonly>; } -function deriveSessionContextState(pathEntries: readonly Entry[]): Omit { - let thinkingLevel = "off"; - let model: { provider: string; modelId: string } | null = null; - let activeToolNames: string[] | null = null; - - for (const entry of pathEntries) { - if (entry.type === "thinking_level_change") { - thinkingLevel = entry.thinkingLevel; - } else if (entry.type === "model_change") { - model = { provider: entry.provider, modelId: entry.modelId }; - } else if (entry.type === "message" && entry.message.role === "assistant") { - model = { provider: entry.message.provider, modelId: entry.message.model }; - } else if (entry.type === "active_tools_change") { - activeToolNames = [...entry.activeToolNames]; - } - } - - return { thinkingLevel, model, activeToolNames }; -} - export function defaultContextEntryTransform(pathEntries: readonly Entry[]): Entry[] { let compaction: CompactionEntry | undefined; let compactionIndex = -1; for (let index = pathEntries.length - 1; index >= 0; index--) { - const entry = pathEntries[index]!; - if (entry.type === "compaction") { + const entry = pathEntries[index]; + if (entry?.type === "compaction") { compaction = entry; compactionIndex = index; break; @@ -68,33 +45,37 @@ export function sessionEntryToContextMessages( entries: readonly Entry[], options: SessionContextBuildOptions = {}, ): AgentMessage[] { - if (entry.type === "message") { - if (entry.message.role === "assistant" && entry.message.stopReason === "deferred") return []; - return [entry.message]; + switch (entry.type) { + case "message": + if ( + entry.message.role === "assistant" && + (entry.message.stopReason === "error" || + entry.message.stopReason === "aborted" || + entry.message.stopReason === "deferred") + ) { + return []; + } + return [entry.message]; + case "compaction": + return [ + createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp), + ...entry.retainedTail, + ]; + case "branch_summary": + return entry.summary ? [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)] : []; + case "custom": + return [...(options.entryProjectors?.[entry.customType]?.(entry, index, entries) ?? [])]; } - if (entry.type === "compaction") { - return [ - createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp), - ...entry.retainedTail, - ]; - } - if (entry.type === "branch_summary" && entry.summary) { - return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)]; - } - if (entry.type === "custom") { - return [...(options.entryProjectors?.[entry.customType]?.(entry, index, entries) ?? [])]; - } - return []; } export function buildSessionContext( pathEntries: readonly Entry[], options: SessionContextBuildOptions = {}, ): SessionContext { - const state = deriveSessionContextState(pathEntries); const contextEntries = buildContextEntries(pathEntries, options); - const messages = contextEntries.flatMap((entry, index) => - sessionEntryToContextMessages(entry, index, contextEntries, options), - ); - return { ...state, messages }; + return { + messages: contextEntries.flatMap((entry, index) => + sessionEntryToContextMessages(entry, index, contextEntries, options), + ), + }; } diff --git a/packages/agent/src/harness/session/index.ts b/packages/agent/src/harness/session/index.ts index 198da4dde..69e8833da 100644 --- a/packages/agent/src/harness/session/index.ts +++ b/packages/agent/src/harness/session/index.ts @@ -1,13 +1,2 @@ export * from "./context.ts"; -export type { - JsonlSessionCreateOptions, - JsonlSessionListOptions, - JsonlSessionMetadata, - JsonlSessionRepoFileSystem, - JsonlSessionRepoOptions, - JsonlV4Header, -} from "./jsonl.ts"; -export { JsonlSessionRepo } from "./jsonl.ts"; -export * from "./memory.ts"; -export * from "./session.ts"; export * from "./types.ts"; diff --git a/packages/agent/src/harness/session/jsonl.ts b/packages/agent/src/harness/session/jsonl.ts deleted file mode 100644 index 3548ac0c0..000000000 --- a/packages/agent/src/harness/session/jsonl.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { JsonlSessionRepo } from "./jsonl/repo.ts"; -export type { - JsonlSessionCreateOptions, - JsonlSessionListOptions, - JsonlSessionMetadata, - JsonlSessionRepoFileSystem, - JsonlSessionRepoOptions, - JsonlV4Header, -} from "./jsonl/types.ts"; diff --git a/packages/agent/src/harness/session/jsonl/codec.ts b/packages/agent/src/harness/session/jsonl/codec.ts deleted file mode 100644 index 84dbeeda0..000000000 --- a/packages/agent/src/harness/session/jsonl/codec.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { err, ok, type Result } from "../../types.ts"; -import type { SessionMutation } from "../state.ts"; -import type { Entry, LaneRecord } from "../types.ts"; -import { JsonlDecodeError } from "./errors.ts"; -import type { JsonlSessionMetadata, JsonlV4Header } from "./types.ts"; - -const ENTRY_TYPES = new Set([ - "message", - "model_change", - "thinking_level_change", - "active_tools_change", - "compaction", - "branch_summary", - "custom", -]); -const RECORD_TYPES = new Set([ - "operation_started", - "abort_requested", - "operation_finished", - "step_attempt", - "tool_started", - "queue_enqueued", - "queue_cancelled", - "write_deferred", - "usage", -]); -const OPERATION_KINDS = new Set(["run", "compaction", "navigation"]); - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function parseObject(line: string): Record { - let value: unknown; - try { - value = JSON.parse(line); - } catch (error) { - throw new JsonlDecodeError("syntax", "is not valid JSON", error instanceof Error ? error : undefined); - } - if (!isObject(value)) throw new JsonlDecodeError("schema", "is not a JSON object"); - return value; -} - -function requireString(value: unknown, field: string): string { - if (typeof value !== "string") throw new JsonlDecodeError("schema", `has invalid ${field}`); - return value; -} - -function requireSequence(value: unknown): number { - if (!Number.isSafeInteger(value) || (value as number) <= 0) { - throw new JsonlDecodeError("schema", "has invalid seq"); - } - return value as number; -} - -function requireTimestamp(value: unknown): number { - if (!Number.isSafeInteger(value) || (value as number) < 0) { - throw new JsonlDecodeError("schema", "has invalid timestamp"); - } - return value as number; -} - -function requireNullableId(value: unknown, field: string): string | null { - if (value !== null && typeof value !== "string") { - throw new JsonlDecodeError("schema", `has invalid ${field}`); - } - return value as string | null; -} - -function decodeHeader(line: string): JsonlV4Header { - const value = parseObject(line); - if (value.kind !== "header") throw new JsonlDecodeError("schema", "is not a header"); - if (value.version !== 4) throw new JsonlDecodeError("schema", "has unsupported session version"); - const parentSessionId = value.parentSessionId; - if (parentSessionId !== undefined && typeof parentSessionId !== "string") { - throw new JsonlDecodeError("schema", "has invalid parentSessionId"); - } - const legacyParentSessionPath = value.legacyParentSessionPath; - if (legacyParentSessionPath !== undefined && typeof legacyParentSessionPath !== "string") { - throw new JsonlDecodeError("schema", "has invalid legacyParentSessionPath"); - } - if (parentSessionId !== undefined && legacyParentSessionPath !== undefined) { - throw new JsonlDecodeError("schema", "has both parentSessionId and legacyParentSessionPath"); - } - const metadataValue = value.metadata; - if (metadataValue !== undefined && !isObject(metadataValue)) { - throw new JsonlDecodeError("schema", "has invalid metadata"); - } - const metadata = metadataValue as JsonlV4Header["metadata"]; - return { - kind: "header", - version: 4, - id: requireString(value.id, "id"), - createdAt: requireTimestamp(value.createdAt), - cwd: requireString(value.cwd, "cwd"), - parentSessionId, - legacyParentSessionPath, - metadata, - }; -} - -export function parseHeader(line: string): Result { - try { - return ok(decodeHeader(line)); - } catch (error) { - if (error instanceof JsonlDecodeError) return err(error); - throw error; - } -} - -export function encodeHeader(header: JsonlV4Header): string { - return `${JSON.stringify(header)}\n`; -} - -export function metadataFromHeader(header: JsonlV4Header, path: string, modifiedAt: number): JsonlSessionMetadata { - return { - id: header.id, - createdAt: header.createdAt, - cwd: header.cwd, - path, - modifiedAt, - sourceFormat: 4, - ...(header.parentSessionId === undefined ? {} : { parentSessionId: header.parentSessionId }), - ...(header.legacyParentSessionPath === undefined - ? {} - : { legacyParentSessionPath: header.legacyParentSessionPath }), - ...(header.metadata === undefined ? {} : { metadata: header.metadata }), - }; -} - -function parseEntryMutation(value: Record, seq: number): Extract { - const lane = value.lane === undefined ? undefined : requireString(value.lane, "lane"); - const id = requireString(value.id, "id"); - const type = requireString(value.type, "entry type"); - if (!ENTRY_TYPES.has(type as Entry["type"])) { - throw new JsonlDecodeError("schema", `has unknown entry type ${type}`); - } - const parentId = requireNullableId(value.parentId, "parentId"); - const timestamp = requireTimestamp(value.timestamp); - if (type === "custom") requireString(value.customType, "customType"); - const { kind: _kind, lane: _lane, ...entryFields } = value; - const entry = { ...entryFields, id, type, parentId, seq, timestamp } as unknown as Entry; - return lane === undefined ? { kind: "entry", entry } : { kind: "entry", lane, entry }; -} - -function parseRecordMutation( - value: Record, - seq: number, -): Extract { - const id = requireString(value.id, "id"); - const lane = requireString(value.lane, "lane"); - const type = requireString(value.type, "record type"); - if (!RECORD_TYPES.has(type as LaneRecord["type"])) { - throw new JsonlDecodeError("schema", `has unknown record type ${type}`); - } - const timestamp = requireTimestamp(value.timestamp); - if (type === "operation_started") { - if (!isObject(value.intent)) throw new JsonlDecodeError("schema", "has invalid intent"); - const operationKind = requireString(value.intent.kind, "operation kind"); - if (!OPERATION_KINDS.has(operationKind)) { - throw new JsonlDecodeError("schema", `has unknown operation kind ${operationKind}`); - } - } - if (type === "operation_finished") requireString(value.runId, "runId"); - const { kind: _kind, ...recordFields } = value; - return { - kind: "record", - record: { ...recordFields, id, lane, type, seq, timestamp } as unknown as LaneRecord, - }; -} - -function parseLaneMutation(value: Record, seq: number): Extract { - return { - kind: "lane", - seq, - lane: requireString(value.lane, "lane"), - leafId: requireNullableId(value.leafId, "leafId"), - }; -} - -function parseFactMutation(value: Record, seq: number): Extract { - if (value.fact === "name") { - if (value.name !== undefined && typeof value.name !== "string") { - throw new JsonlDecodeError("schema", "has invalid name"); - } - return { kind: "fact", seq, fact: "name", name: value.name }; - } - if (value.fact === "label") { - if (value.label !== undefined && typeof value.label !== "string") { - throw new JsonlDecodeError("schema", "has invalid label"); - } - return { - kind: "fact", - seq, - fact: "label", - targetId: requireString(value.targetId, "targetId"), - label: value.label, - }; - } - throw new JsonlDecodeError("schema", "has unknown fact type"); -} - -function decodeMutation(line: string): SessionMutation { - const value = parseObject(line); - const seq = requireSequence(value.seq); - switch (value.kind) { - case "entry": - return parseEntryMutation(value, seq); - case "record": - return parseRecordMutation(value, seq); - case "lane": - return parseLaneMutation(value, seq); - case "fact": - return parseFactMutation(value, seq); - default: - throw new JsonlDecodeError("schema", "has unknown mutation kind"); - } -} - -export function parseMutation(line: string): Result { - try { - return ok(decodeMutation(line)); - } catch (error) { - if (error instanceof JsonlDecodeError) return err(error); - throw error; - } -} - -export function encodeMutation(mutation: SessionMutation): string { - switch (mutation.kind) { - case "entry": - return `${JSON.stringify({ kind: "entry", lane: mutation.lane, ...mutation.entry })}\n`; - case "record": - return `${JSON.stringify({ kind: "record", ...mutation.record })}\n`; - case "lane": - return `${JSON.stringify(mutation)}\n`; - case "fact": - return `${JSON.stringify(mutation)}\n`; - } -} diff --git a/packages/agent/src/harness/session/jsonl/errors.ts b/packages/agent/src/harness/session/jsonl/errors.ts deleted file mode 100644 index bc1915541..000000000 --- a/packages/agent/src/harness/session/jsonl/errors.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { FileError, Result } from "../../types.ts"; -import { SessionError } from "../types.ts"; - -export class JsonlDecodeError extends Error { - readonly kind: "syntax" | "schema"; - - constructor(kind: "syntax" | "schema", message: string, cause?: Error) { - super(message, cause === undefined ? undefined : { cause }); - this.name = "JsonlDecodeError"; - this.kind = kind; - } -} - -export function fileResult(result: Result, message: string): T { - if (!result.ok) { - throw new SessionError( - result.error.code === "not_found" ? "not_found" : "storage", - `${message}: ${result.error.message}`, - result.error, - ); - } - return result.value; -} - -export function invalidFile(path: string, line: number, cause: Error): SessionError { - return new SessionError("invalid_entry", `Invalid JSONL v4 session ${path}: line ${line} ${cause.message}`, cause); -} diff --git a/packages/agent/src/harness/session/jsonl/repo.ts b/packages/agent/src/harness/session/jsonl/repo.ts deleted file mode 100644 index bf8bb4874..000000000 --- a/packages/agent/src/harness/session/jsonl/repo.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { uuidv7 } from "@earendil-works/pi-ai"; -import { assertJsonSerializable, Session } from "../session.ts"; -import { type ForkOptions, SessionError, type SessionRepo } from "../types.ts"; -import { metadataFromHeader, parseHeader } from "./codec.ts"; -import { fileResult } from "./errors.ts"; -import { JsonlSessionStorage } from "./storage.ts"; -import type { - JsonlSessionCreateOptions, - JsonlSessionListOptions, - JsonlSessionMetadata, - JsonlSessionRepoFileSystem, - JsonlSessionRepoOptions, - JsonlV4Header, -} from "./types.ts"; - -const SESSION_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/; - -function validateSessionId(id: string): void { - if (!SESSION_ID_PATTERN.test(id)) { - throw new SessionError( - "invalid_payload", - "Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character", - ); - } -} - -function jsonlSessionDirectoryName(cwd: string): string { - return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; -} - -async function jsonlSessionsRoot(options: JsonlSessionRepoOptions): Promise { - return fileResult( - await options.fs.absolutePath(options.sessionsRoot), - `Failed to resolve sessions root ${options.sessionsRoot}`, - ); -} - -async function jsonlSessionDirectory( - fs: JsonlSessionRepoFileSystem, - sessionsRoot: string, - cwd: string, -): Promise { - return fileResult( - await fs.joinPath([sessionsRoot, jsonlSessionDirectoryName(cwd)]), - `Failed to resolve sessions directory for ${cwd}`, - ); -} - -async function jsonlSessionDirectories(options: JsonlSessionRepoOptions, cwd?: string): Promise { - const sessionsRoot = await jsonlSessionsRoot(options); - if (cwd !== undefined) { - const resolvedCwd = fileResult(await options.fs.absolutePath(cwd), `Failed to resolve session cwd ${cwd}`); - const directory = await jsonlSessionDirectory(options.fs, sessionsRoot, resolvedCwd); - return fileResult(await options.fs.exists(directory), `Failed to check sessions directory ${directory}`) - ? [directory] - : []; - } - if (!fileResult(await options.fs.exists(sessionsRoot), `Failed to check sessions directory ${sessionsRoot}`)) - return []; - return fileResult(await options.fs.listDir(sessionsRoot), `Failed to list sessions directory ${sessionsRoot}`) - .filter((entry) => entry.kind === "directory" || entry.kind === "symlink") - .map((entry) => entry.path); -} - -export async function listJsonlSessionMetadata( - options: JsonlSessionRepoOptions, - query: JsonlSessionListOptions = {}, -): Promise { - const metadata: JsonlSessionMetadata[] = []; - for (const directory of await jsonlSessionDirectories(options, query.cwd)) { - const files = fileResult( - await options.fs.listDir(directory), - `Failed to list sessions directory ${directory}`, - ).filter((entry) => entry.kind !== "directory" && entry.name.endsWith(".jsonl")); - for (const file of files) { - const [firstLine] = fileResult( - await options.fs.readTextLines(file.path, { maxLines: 1 }), - `Failed to read session header ${file.path}`, - ); - if (!firstLine) continue; - const headerResult = parseHeader(firstLine); - if (!headerResult.ok) continue; - metadata.push(metadataFromHeader(headerResult.value, file.path, file.mtimeMs)); - } - } - return metadata.sort((left, right) => right.modifiedAt - left.modifiedAt); -} - -export async function loadJsonlSessionStorage( - options: JsonlSessionRepoOptions, - metadata: JsonlSessionMetadata, -): Promise { - if (!fileResult(await options.fs.exists(metadata.path), `Failed to check session ${metadata.path}`)) { - throw new SessionError("not_found", `Session not found: ${metadata.id}`); - } - const storage = await JsonlSessionStorage.load(options.fs, metadata.path); - const loadedMetadata = await storage.getMetadata(); - if (loadedMetadata.id !== metadata.id) { - throw new SessionError("invalid_entry", `Session id does not match header: ${metadata.id}`); - } - return storage; -} - -function sessionFileName(createdAt: number, id: string): string { - const timestamp = new Date(createdAt).toISOString().replace(/[:.]/g, "-"); - return `${timestamp}_${id}.jsonl`; -} - -export class JsonlSessionRepo - implements SessionRepo -{ - private readonly fs: JsonlSessionRepoFileSystem; - private readonly sessionsRootInput: string; - private readonly activeCreateDestinations = new Set(); - private rootPromise: Promise | undefined; - - constructor(options: JsonlSessionRepoOptions) { - this.fs = options.fs; - this.sessionsRootInput = options.sessionsRoot; - } - - async create(options: JsonlSessionCreateOptions): Promise> { - const destination = await this.resolveCreateDestination(options); - return this.claimCreateDestination(destination, async () => { - const { header, path } = await this.prepareCreate(destination, options); - return new Session(await JsonlSessionStorage.create(this.fs, path, header)); - }); - } - - async open(metadata: JsonlSessionMetadata): Promise> { - return new Session(await this.loadStorage(metadata)); - } - - async list(options: JsonlSessionListOptions = {}): Promise { - return this.listDirect(options); - } - - async delete(metadata: JsonlSessionMetadata): Promise { - fileResult(await this.fs.remove(metadata.path, { force: true }), `Failed to delete session ${metadata.path}`); - } - - async fork( - source: JsonlSessionMetadata, - options: ForkOptions & JsonlSessionCreateOptions, - ): Promise> { - const sourceStorage = await this.loadStorage(source); - const createOptions = { - ...options, - parentSessionId: options.parentSessionId ?? source.id, - }; - const destination = await this.resolveCreateDestination(createOptions); - return this.claimCreateDestination(destination, async () => { - const { header, path } = await this.prepareCreate(destination, createOptions); - return new Session(await sourceStorage.fork(path, header, options)); - }); - } - - private async loadStorage(metadata: JsonlSessionMetadata): Promise { - return loadJsonlSessionStorage({ fs: this.fs, sessionsRoot: this.sessionsRootInput }, metadata); - } - - private async resolveCreateDestination(options: JsonlSessionCreateOptions): Promise<{ id: string; cwd: string }> { - const id = options.id ?? uuidv7(); - validateSessionId(id); - const cwd = fileResult(await this.fs.absolutePath(options.cwd), `Failed to resolve session cwd ${options.cwd}`); - return { id, cwd }; - } - - /** - * Prevent same-process create/fork races for one logical destination. The durable filename includes a - * timestamp, so the async filesystem existence check alone can let two concurrent calls both decide the - * same {cwd, id} is free and publish duplicate sessions. - */ - private async claimCreateDestination( - destination: { id: string; cwd: string }, - operation: () => Promise, - ): Promise { - const key = `${destination.cwd}\0${destination.id}`; - if (this.activeCreateDestinations.has(key)) { - throw new SessionError("already_exists", `Session already exists: ${destination.id}`); - } - this.activeCreateDestinations.add(key); - try { - return await operation(); - } finally { - this.activeCreateDestinations.delete(key); - } - } - - private async prepareCreate( - destination: { id: string; cwd: string }, - options: JsonlSessionCreateOptions, - ): Promise<{ - header: JsonlV4Header; - path: string; - }> { - const { id, cwd } = destination; - if (await this.sessionIdExists(id, cwd)) { - throw new SessionError("already_exists", `Session already exists: ${id}`); - } - - const createdAt = Date.now(); - const sessionDirectory = await this.sessionDirectory(cwd); - const path = fileResult( - await this.fs.joinPath([sessionDirectory, sessionFileName(createdAt, id)]), - `Failed to resolve path for session ${id}`, - ); - if (options.metadata !== undefined) assertJsonSerializable(options.metadata); - const header: JsonlV4Header = { - kind: "header", - version: 4, - id, - createdAt, - cwd, - parentSessionId: options.parentSessionId, - metadata: options.metadata, - }; - fileResult(await this.fs.createDir(sessionDirectory, { recursive: true }), `Failed to create sessions directory`); - return { header, path }; - } - - private async listDirect(options: JsonlSessionListOptions): Promise { - return listJsonlSessionMetadata({ fs: this.fs, sessionsRoot: this.sessionsRootInput }, options); - } - - private async sessionIdExists(id: string, cwd: string): Promise { - const suffix = `_${id}.jsonl`; - const directory = await this.sessionDirectory(cwd); - if (!fileResult(await this.fs.exists(directory), `Failed to check sessions directory ${directory}`)) return false; - const files = fileResult(await this.fs.listDir(directory), `Failed to list sessions directory ${directory}`); - return files.some((entry) => entry.kind !== "directory" && entry.name.endsWith(suffix)); - } - - private async sessionDirectory(cwd: string): Promise { - return fileResult( - await this.fs.joinPath([await this.root(), jsonlSessionDirectoryName(cwd)]), - `Failed to resolve sessions directory for ${cwd}`, - ); - } - - private root(): Promise { - this.rootPromise ??= this.fs - .absolutePath(this.sessionsRootInput) - .then((result) => fileResult(result, `Failed to resolve sessions root ${this.sessionsRootInput}`)); - return this.rootPromise; - } -} diff --git a/packages/agent/src/harness/session/jsonl/storage.ts b/packages/agent/src/harness/session/jsonl/storage.ts deleted file mode 100644 index 3d76cf92e..000000000 --- a/packages/agent/src/harness/session/jsonl/storage.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { type SessionMutation, SessionState } from "../state.ts"; -import { - type BranchBounds, - type Entry, - type EntryQuery, - type ForkOptions, - type LanePointer, - type LaneRecord, - type LogItem, - type LogOptions, - type NewRecord, - type OperationStartedRecord, - type ProvisionedEntry, - type RecordQuery, - SessionError, - type SessionStats, - type SessionStorage, -} from "../types.ts"; -import { encodeHeader, encodeMutation, metadataFromHeader, parseHeader, parseMutation } from "./codec.ts"; -import { fileResult, invalidFile, JsonlDecodeError } from "./errors.ts"; -import type { JsonlSessionMetadata, JsonlSessionRepoFileSystem, JsonlV4Header } from "./types.ts"; - -/** - * Build a complete sibling temporary file, then atomically rename it over the destination. - * The populate callback must create or overwrite `tempPath` with the complete file. The - * destination is untouched until the rename commits, so a process crash while populating - * can leave only the ignored `.tmp` file behind. - * - * Rejects when population or rename fails. On rejection, temporary-file removal is - * best-effort and the original error is preserved. Callers must serialize publications to - * the same destination because they share its deterministic `.tmp` path. - */ -async function publishFileAtomically( - fs: JsonlSessionRepoFileSystem, - destinationPath: string, - populate: (tempPath: string) => Promise, -): Promise { - const tempPath = `${destinationPath}.tmp`; - try { - await populate(tempPath); - fileResult(await fs.renameFile(tempPath, destinationPath), `Failed to publish staged file ${destinationPath}`); - } catch (error) { - await fs.remove(tempPath, { force: true }); - throw error; - } -} - -export class JsonlSessionStorage implements SessionStorage { - private readonly fs: JsonlSessionRepoFileSystem; - private readonly metadata: JsonlSessionMetadata; - private readonly state = new SessionState(); - private tail: Promise = Promise.resolve(); - - constructor(fs: JsonlSessionRepoFileSystem, metadata: JsonlSessionMetadata) { - this.fs = fs; - this.metadata = structuredClone(metadata); - } - - static async create( - fs: JsonlSessionRepoFileSystem, - path: string, - header: JsonlV4Header, - ): Promise { - fileResult(await fs.writeFile(path, encodeHeader(header)), `Failed to initialize session ${path}`); - const fileInfo = fileResult(await fs.fileInfo(path), `Failed to read session metadata ${path}`); - return new JsonlSessionStorage(fs, metadataFromHeader(header, path, fileInfo.mtimeMs)); - } - - static async load(fs: JsonlSessionRepoFileSystem, path: string): Promise { - const content = fileResult(await fs.readTextFile(path), `Failed to read session ${path}`); - const physicalLines = content.split("\n"); - if (physicalLines.at(-1) === "") physicalLines.pop(); - if (physicalLines.length === 0 || !physicalLines[0]) { - throw invalidFile(path, 1, new JsonlDecodeError("schema", "is missing a header")); - } - const headerResult = parseHeader(physicalLines[0]); - if (!headerResult.ok) throw invalidFile(path, 1, headerResult.error); - const fileInfo = fileResult(await fs.fileInfo(path), `Failed to read session metadata ${path}`); - const storage = new JsonlSessionStorage(fs, metadataFromHeader(headerResult.value, path, fileInfo.mtimeMs)); - for (let index = 1; index < physicalLines.length; index++) { - const line = physicalLines[index]!; - const mutationResult = parseMutation(line); - if (!mutationResult.ok) { - const isTornTail = index === physicalLines.length - 1 && mutationResult.error.kind === "syntax"; - if (isTornTail) { - // Drop the unacknowledged partial append by atomically publishing the valid prefix. - const validPrefix = `${physicalLines.slice(0, index).join("\n")}\n`; - await publishFileAtomically(fs, path, async (tempPath) => { - fileResult(await fs.writeFile(tempPath, validPrefix), `Failed to stage torn-tail repair ${path}`); - }); - return storage; - } - throw invalidFile(path, index + 1, mutationResult.error); - } - try { - storage.applyMutation(mutationResult.value); - } catch (error) { - if (error instanceof SessionError && error.code === "invalid_entry") { - throw invalidFile(path, index + 1, error); - } - throw error; - } - } - if (!content.endsWith("\n")) { - fileResult(await fs.appendFile(path, "\n"), `Failed to repair unterminated session tail ${path}`); - } - return storage; - } - - async fork(path: string, header: JsonlV4Header, options: ForkOptions): Promise { - const mutations = this.state.createForkMutations(options); - await publishFileAtomically(this.fs, path, async (tempPath) => { - const targetStorage = await JsonlSessionStorage.create(this.fs, tempPath, header); - for (const mutation of mutations) { - await targetStorage.appendMutation(mutation); - targetStorage.applyMutation(mutation); - } - }); - return JsonlSessionStorage.load(this.fs, path); - } - - async drain(): Promise { - await this.tail; - } - - async getMetadata(): Promise { - return structuredClone(this.metadata); - } - - async getLanes(): Promise { - return this.state.getLanes(); - } - - createLane(lane: string, at: string | null): Promise { - return this.enqueue(async () => { - this.state.validateNewLane(lane); - this.state.validateTarget(at); - const mutation: SessionMutation = { kind: "lane", seq: this.state.nextSequence, lane, leafId: at }; - await this.appendMutation(mutation); - this.applyMutation(mutation); - }); - } - - moveLane(lane: string, to: string | null): Promise { - return this.enqueue(async () => { - this.state.requireLane(lane); - this.state.validateTarget(to); - const mutation: SessionMutation = { kind: "lane", seq: this.state.nextSequence, lane, leafId: to }; - await this.appendMutation(mutation); - this.applyMutation(mutation); - }); - } - - appendEntry(newEntry: ProvisionedEntry, lane: string): Promise { - return this.enqueue(async () => { - const parentId = this.state.requireLane(lane); - this.state.validateUnusedId(newEntry.id); - const entry = { - ...structuredClone(newEntry), - parentId, - seq: this.state.nextSequence, - timestamp: Date.now(), - } as unknown as TEntry; - const mutation: SessionMutation = { kind: "entry", lane, entry }; - await this.appendMutation(mutation); - this.applyMutation(mutation); - return structuredClone(entry); - }); - } - - appendRecord(newRecord: NewRecord): Promise { - return this.enqueue(async () => { - this.state.requireLane(newRecord.lane); - this.state.validateUnusedId(newRecord.id); - const currentOpenOperationId = this.state.findOpenOperations(newRecord.lane, { limit: 1 })[0]?.id; - if (newRecord.type === "operation_started" && currentOpenOperationId !== undefined) { - throw new SessionError( - "storage", - `Lane ${newRecord.lane} already has an open operation ${currentOpenOperationId}`, - ); - } - const record = { - ...structuredClone(newRecord), - seq: this.state.nextSequence, - timestamp: Date.now(), - } as unknown as TRecord; - const mutation: SessionMutation = { kind: "record", record }; - await this.appendMutation(mutation); - this.applyMutation(mutation); - return structuredClone(record); - }); - } - - async getEntry(id: string): Promise { - const entry = this.state.getEntry(id); - return entry === undefined ? undefined : structuredClone(entry); - } - - async findEntries(query: EntryQuery = {}): Promise { - return structuredClone(this.state.findEntries(query)); - } - - async findEntriesOnBranch(query: EntryQuery & BranchBounds & { start: string }): Promise { - return structuredClone(this.state.findEntriesOnBranch(query)); - } - - async findRecords( - query: RecordQuery & { type: K }, - ): Promise[]>; - async findRecords(query?: RecordQuery): Promise; - async findRecords(query: RecordQuery = {}): Promise { - return structuredClone(this.state.findRecords(query)); - } - - async findOpenOperations(lane: string, options?: { limit?: number }): Promise { - return structuredClone(this.state.findOpenOperations(lane, options)); - } - - async getLog(options: LogOptions = {}): Promise { - return structuredClone(this.state.getLog(options)); - } - - async getName(): Promise { - return this.state.getName(); - } - - setName(name: string | undefined): Promise { - return this.enqueue(async () => { - const mutation: SessionMutation = { kind: "fact", seq: this.state.nextSequence, fact: "name", name }; - await this.appendMutation(mutation); - this.applyMutation(mutation); - }); - } - - async getLabel(id: string): Promise { - return this.state.getLabel(id); - } - - setLabel(id: string, label: string | undefined): Promise { - return this.enqueue(async () => { - this.state.validateTarget(id); - const mutation: SessionMutation = { - kind: "fact", - seq: this.state.nextSequence, - fact: "label", - targetId: id, - label, - }; - await this.appendMutation(mutation); - this.applyMutation(mutation); - }); - } - - async getStats(): Promise { - return structuredClone(this.state.getStats()); - } - - private enqueue(operation: () => Promise): Promise { - const result = this.tail.then(operation); - this.tail = result.then( - () => undefined, - () => undefined, - ); - return result; - } - - private async appendMutation(mutation: SessionMutation): Promise { - fileResult( - await this.fs.appendFile(this.metadata.path, encodeMutation(mutation)), - `Failed to append session ${this.metadata.path}`, - ); - } - - private applyMutation(mutation: SessionMutation): void { - this.state.applyMutation(mutation); - } -} diff --git a/packages/agent/src/harness/session/jsonl/types.ts b/packages/agent/src/harness/session/jsonl/types.ts deleted file mode 100644 index f838a90f3..000000000 --- a/packages/agent/src/harness/session/jsonl/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { FileSystem } from "../../types.ts"; -import type { JsonValue, SessionCreateOptions, SessionMetadata } from "../types.ts"; - -export type JsonlSessionRepoFileSystem = Pick< - FileSystem, - | "absolutePath" - | "joinPath" - | "readTextFile" - | "readTextLines" - | "writeFile" - | "appendFile" - | "renameFile" - | "fileInfo" - | "listDir" - | "exists" - | "createDir" - | "remove" ->; - -export interface JsonlSessionRepoOptions { - fs: JsonlSessionRepoFileSystem; - /** Root containing coding-agent-compatible cwd-encoded session directories. */ - sessionsRoot: string; -} - -export interface JsonlSessionMetadata extends SessionMetadata { - cwd: string; - path: string; - /** Filesystem modification time as milliseconds since Unix epoch. */ - modifiedAt: number; - sourceFormat: 3 | 4; - /** Present only when a v3 parent path could not be resolved to a session id. */ - legacyParentSessionPath?: string; - /** Opaque application-owned metadata. */ - metadata?: Record; -} - -export interface JsonlSessionCreateOptions extends SessionCreateOptions { - cwd: string; - metadata?: Record; -} - -export interface JsonlSessionListOptions { - cwd?: string; -} - -export interface JsonlV4Header { - kind: "header"; - version: 4; - id: string; - createdAt: number; - cwd: string; - parentSessionId?: string; - /** Preserved only when a v3 parent path could not be resolved to a session id. */ - legacyParentSessionPath?: string; - metadata?: Record; -} diff --git a/packages/agent/src/harness/session/memory.ts b/packages/agent/src/harness/session/memory.ts deleted file mode 100644 index cccbd8209..000000000 --- a/packages/agent/src/harness/session/memory.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { uuidv7 } from "@earendil-works/pi-ai"; -import { Session } from "./session.ts"; -import { SessionState } from "./state.ts"; -import { - type BranchBounds, - type Entry, - type EntryQuery, - type ForkOptions, - type LanePointer, - type LaneRecord, - type LogItem, - type LogOptions, - type NewRecord, - type OperationStartedRecord, - type ProvisionedEntry, - type RecordQuery, - type SessionCreateOptions, - SessionError, - type SessionMetadata, - type SessionRepo, - type SessionStats, - type SessionStorage, -} from "./types.ts"; - -export class InMemorySessionStorage implements SessionStorage { - private readonly metadata: SessionMetadata; - private readonly state = new SessionState(); - - constructor(metadata: SessionMetadata) { - this.metadata = structuredClone(metadata); - } - - fork(metadata: SessionMetadata, options: ForkOptions & SessionCreateOptions): InMemorySessionStorage { - const storage = new InMemorySessionStorage(metadata); - for (const mutation of this.state.createForkMutations(options)) storage.state.applyMutation(mutation); - return storage; - } - - async getMetadata(): Promise { - return structuredClone(this.metadata); - } - - async getLanes(): Promise { - return this.state.getLanes(); - } - - async createLane(lane: string, at: string | null): Promise { - this.state.validateNewLane(lane); - this.state.validateTarget(at); - this.state.applyMutation({ kind: "lane", seq: this.state.nextSequence, lane, leafId: at }); - } - - async moveLane(lane: string, to: string | null): Promise { - this.state.requireLane(lane); - this.state.validateTarget(to); - this.state.applyMutation({ kind: "lane", seq: this.state.nextSequence, lane, leafId: to }); - } - - async appendEntry(newEntry: ProvisionedEntry, lane: string): Promise { - const parentId = this.state.requireLane(lane); - this.state.validateUnusedId(newEntry.id); - const entry = { - ...structuredClone(newEntry), - parentId, - seq: this.state.nextSequence, - timestamp: Date.now(), - } as unknown as TEntry; - this.state.applyMutation({ kind: "entry", lane, entry }); - return structuredClone(entry); - } - - async appendRecord(newRecord: NewRecord): Promise { - this.state.requireLane(newRecord.lane); - this.state.validateUnusedId(newRecord.id); - const currentOpenOperationId = this.state.findOpenOperations(newRecord.lane, { limit: 1 })[0]?.id; - if (newRecord.type === "operation_started" && currentOpenOperationId !== undefined) { - throw new SessionError( - "storage", - `Lane ${newRecord.lane} already has an open operation ${currentOpenOperationId}`, - ); - } - const record = { - ...structuredClone(newRecord), - seq: this.state.nextSequence, - timestamp: Date.now(), - } as unknown as TRecord; - this.state.applyMutation({ kind: "record", record }); - return structuredClone(record); - } - - async getEntry(id: string): Promise { - const entry = this.state.getEntry(id); - return entry === undefined ? undefined : structuredClone(entry); - } - - async findEntries(query: EntryQuery = {}): Promise { - return structuredClone(this.state.findEntries(query)); - } - - async findEntriesOnBranch(query: EntryQuery & BranchBounds & { start: string }): Promise { - return structuredClone(this.state.findEntriesOnBranch(query)); - } - - async findRecords( - query: RecordQuery & { type: K }, - ): Promise[]>; - async findRecords(query?: RecordQuery): Promise; - async findRecords(query: RecordQuery = {}): Promise { - return structuredClone(this.state.findRecords(query)); - } - - async findOpenOperations(lane: string, options?: { limit?: number }): Promise { - return structuredClone(this.state.findOpenOperations(lane, options)); - } - - async getLog(options: LogOptions = {}): Promise { - return structuredClone(this.state.getLog(options)); - } - - async getName(): Promise { - return this.state.getName(); - } - - async setName(name: string | undefined): Promise { - this.state.applyMutation({ kind: "fact", seq: this.state.nextSequence, fact: "name", name }); - } - - async getLabel(id: string): Promise { - return this.state.getLabel(id); - } - - async setLabel(id: string, label: string | undefined): Promise { - this.state.validateTarget(id); - this.state.applyMutation({ - kind: "fact", - seq: this.state.nextSequence, - fact: "label", - targetId: id, - label, - }); - } - - async getStats(): Promise { - return structuredClone(this.state.getStats()); - } -} - -export class InMemorySessionRepo implements SessionRepo { - private readonly sessions = new Map(); - - async create(options: SessionCreateOptions = {}): Promise { - const id = options.id ?? uuidv7(); - if (this.sessions.has(id)) throw new SessionError("already_exists", `Session already exists: ${id}`); - const storage = new InMemorySessionStorage({ - id, - createdAt: Date.now(), - parentSessionId: options.parentSessionId, - }); - this.sessions.set(id, storage); - return new Session(storage); - } - - async open(metadata: SessionMetadata): Promise { - return new Session(this.requireStorage(metadata.id)); - } - - async list(): Promise { - return Promise.all([...this.sessions.values()].map((storage) => storage.getMetadata())); - } - - async delete(metadata: SessionMetadata): Promise { - this.sessions.delete(metadata.id); - } - - async fork(source: SessionMetadata, options: ForkOptions & SessionCreateOptions = {}): Promise { - const sourceStorage = this.requireStorage(source.id); - const id = options.id ?? uuidv7(); - if (this.sessions.has(id)) throw new SessionError("already_exists", `Session already exists: ${id}`); - const storage = sourceStorage.fork( - { id, createdAt: Date.now(), parentSessionId: options.parentSessionId ?? source.id }, - options, - ); - this.sessions.set(id, storage); - return new Session(storage); - } - - private requireStorage(id: string): InMemorySessionStorage { - const storage = this.sessions.get(id); - if (!storage) throw new SessionError("not_found", `Session not found: ${id}`); - return storage; - } -} diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts deleted file mode 100644 index 8027475dd..000000000 --- a/packages/agent/src/harness/session/session.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { uuidv7 } from "@earendil-works/pi-ai"; -import type { AgentMessage } from "../../types.ts"; -import type { - BranchBounds, - Entry, - EntryQuery, - IdGenerator, - LanePointer, - LaneRecord, - LogItem, - LogOptions, - NewRecord, - OperationStartedRecord, - ProvisionedEntry, - RecordBase, - RecordQuery, - SessionMetadata, - SessionStats, - SessionStorage, - SessionTree, -} from "./types.ts"; -import { SessionError } from "./types.ts"; - -type JsonValidationFrame = { value: unknown } | { exit: object }; - -function invalidPayload(reason: string): never { - throw new SessionError("invalid_payload", `Durable payload ${reason}`); -} - -function assertValidLimit(limit: number | undefined): void { - if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) { - throw new SessionError("invalid_query", "limit must be a positive integer"); - } -} - -function assertValidCursor(afterSeq: number | undefined): void { - if (afterSeq !== undefined && (!Number.isInteger(afterSeq) || afterSeq < 0)) { - throw new SessionError("invalid_query", "cursor sequence must be a non-negative integer"); - } -} - -export function assertJsonSerializable(value: unknown): void { - const active = new WeakSet(); - const stack: JsonValidationFrame[] = [{ value }]; - while (stack.length > 0) { - const frame = stack.pop()!; - if ("exit" in frame) { - active.delete(frame.exit); - continue; - } - const candidate = frame.value; - if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") { - continue; - } - if (typeof candidate === "number") { - if (!Number.isFinite(candidate)) invalidPayload("contains a non-finite number"); - continue; - } - if (typeof candidate !== "object") invalidPayload(`contains ${typeof candidate}`); - if (active.has(candidate)) invalidPayload("contains a cycle"); - active.add(candidate); - stack.push({ exit: candidate }); - - if (Array.isArray(candidate)) { - if (Object.getPrototypeOf(candidate) !== Array.prototype) { - invalidPayload("contains a non-standard array"); - } - if ( - Object.getOwnPropertySymbols(candidate).length > 0 || - Object.getOwnPropertyNames(candidate).length !== candidate.length + 1 - ) { - invalidPayload("contains an array with unsupported properties"); - } - for (let index = candidate.length - 1; index >= 0; index--) { - if (!Object.hasOwn(candidate, index)) invalidPayload("contains a sparse array"); - const descriptor = Object.getOwnPropertyDescriptor(candidate, index)!; - if (!("value" in descriptor)) invalidPayload("contains an array accessor"); - stack.push({ value: descriptor.value }); - } - continue; - } - - const prototype = Object.getPrototypeOf(candidate); - if (prototype !== Object.prototype && prototype !== null) { - invalidPayload("contains a non-plain object"); - } - if (Object.getOwnPropertySymbols(candidate).length > 0) { - invalidPayload("contains a symbol-keyed property"); - } - const keys = Object.keys(candidate); - if (Object.getOwnPropertyNames(candidate).length !== keys.length) { - invalidPayload("contains a non-enumerable property"); - } - for (let index = keys.length - 1; index >= 0; index--) { - const descriptor = Object.getOwnPropertyDescriptor(candidate, keys[index]!)!; - if (!("value" in descriptor)) invalidPayload("contains an accessor"); - stack.push({ value: descriptor.value }); - } - } -} - -export class Session implements SessionTree { - private readonly storage: SessionStorage; - readonly idGenerator: IdGenerator; - - constructor(storage: SessionStorage, options: { idGenerator?: IdGenerator } = {}) { - this.storage = storage; - this.idGenerator = options.idGenerator ?? { next: () => uuidv7() }; - } - - async getMetadata(): Promise { - return this.storage.getMetadata(); - } - - view(lane: string): SessionTree { - if (lane === "main") return this; - return { - getLeafId: () => this.getLeafIdForLane(lane), - getEntry: (id) => this.getEntry(id), - getStats: () => this.getStats(), - getName: () => this.getName(), - setName: (name) => this.setName(name), - getLabel: (targetId) => this.getLabel(targetId), - setLabel: (targetId, label) => this.setLabel(targetId, label), - findEntries: (query) => this.queryEntries(query), - findEntry: async (query = {}) => (await this.queryEntries(query, 1))[0], - findEntriesOnBranch: (query) => this.queryBranchEntries(lane, query), - findEntryOnBranch: async (query = {}) => (await this.queryBranchEntries(lane, query, 1))[0], - appendMessage: (message) => this.appendMessageToLane(lane, message), - appendCustomEntry: (customType, data) => this.appendCustomEntryToLane(lane, customType, data), - }; - } - - async getLeafId(): Promise { - return this.getLeafIdForLane("main"); - } - - async getEntry(id: string): Promise { - return this.storage.getEntry(id); - } - - async getStats(): Promise { - return this.storage.getStats(); - } - - async getName(): Promise { - return this.storage.getName(); - } - - async setName(name: string | undefined): Promise { - await this.storage.setName(name); - } - - async getLabel(targetId: string): Promise { - return this.storage.getLabel(targetId); - } - - async setLabel(targetId: string, label: string | undefined): Promise { - await this.storage.setLabel(targetId, label); - } - - async findEntries(query?: EntryQuery): Promise { - return this.queryEntries(query); - } - - async findEntry(query: EntryQuery = {}): Promise { - return (await this.queryEntries(query, 1))[0]; - } - - async findEntriesOnBranch(query?: EntryQuery & BranchBounds): Promise { - return this.queryBranchEntries("main", query); - } - - async findEntryOnBranch(query: EntryQuery & BranchBounds = {}): Promise { - return (await this.queryBranchEntries("main", query, 1))[0]; - } - - async appendMessage(message: AgentMessage): Promise { - return this.appendMessageToLane("main", message); - } - - async appendCustomEntry(customType: string, data?: unknown): Promise { - return this.appendCustomEntryToLane("main", customType, data); - } - - async getLanes(): Promise { - return this.storage.getLanes(); - } - - async createLane(lane: string, at: string | null): Promise { - await this.storage.createLane(lane, at); - } - - async moveLane(lane: string, to: string | null): Promise { - await this.storage.moveLane(lane, to); - } - - async appendEntry(entry: ProvisionedEntry, lane: string): Promise { - return this.commitEntry(entry, lane); - } - - async appendRecord( - record: TNewRecord, - ): Promise>; - async appendRecord(record: NewRecord): Promise { - return this.commitRecord(record); - } - - async findRecords( - query: RecordQuery & { type: K }, - ): Promise[]>; - async findRecords(query?: RecordQuery): Promise; - async findRecords(query?: RecordQuery): Promise { - return this.queryRecords(query); - } - - async findOpenOperations(lane: string, options?: { limit?: number }): Promise { - assertValidLimit(options?.limit); - return this.storage.findOpenOperations(lane, options); - } - - async getLog(options?: LogOptions): Promise { - return this.queryLog(options); - } - - /** Returns the lane's current leaf, or null when empty. Throws when the lane does not exist. */ - private async getLeafIdForLane(lane: string): Promise { - const pointer = (await this.getLanes()).find((candidate) => candidate.lane === lane); - if (!pointer) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); - return pointer.leafId; - } - - private async queryEntries(query: EntryQuery = {}, resultLimit = query.limit): Promise { - assertValidLimit(query.limit); - assertValidCursor(query.cursor?.afterSeq); - return this.storage.findEntries(resultLimit === query.limit ? query : { ...query, limit: resultLimit }); - } - - /** - * Queries from `query.start` toward the root, defaulting to the lane's current leaf. - * `resultLimit` lets single-entry queries cap results without changing the caller's query. - */ - private async queryBranchEntries( - defaultLane: string, - query: EntryQuery & BranchBounds = {}, - resultLimit = query.limit, - ): Promise { - assertValidLimit(query.limit); - assertValidCursor(query.cursor?.afterSeq); - const start = query.start ?? (await this.getLeafIdForLane(defaultLane)); - if (start === null) return []; - const storageQuery = resultLimit === query.limit ? query : { ...query, limit: resultLimit }; - return this.storage.findEntriesOnBranch({ ...storageQuery, start }); - } - - private async queryRecords(query: RecordQuery = {}): Promise { - assertValidLimit(query.limit); - assertValidCursor(query.afterSeq); - if (query.operationKind !== undefined && query.type !== "operation_started") { - throw new SessionError("invalid_query", 'operationKind requires type "operation_started"'); - } - return this.storage.findRecords(query); - } - - private async queryLog(options: LogOptions = {}): Promise { - assertValidLimit(options.limit); - assertValidCursor(options.afterSeq); - return this.storage.getLog(options); - } - - private async appendMessageToLane(lane: string, message: AgentMessage): Promise { - const entry = await this.commitEntry({ type: "message", id: this.idGenerator.next(), message }, lane); - return entry.id; - } - - private async appendCustomEntryToLane(lane: string, customType: string, data?: unknown): Promise { - const entry = await this.commitEntry( - data === undefined - ? { type: "custom", id: this.idGenerator.next(), customType } - : { type: "custom", id: this.idGenerator.next(), customType, data }, - lane, - ); - return entry.id; - } - - private async commitEntry(entry: ProvisionedEntry, lane: string): Promise { - assertJsonSerializable(entry); - return this.storage.appendEntry(entry, lane); - } - - private async commitRecord( - record: TNewRecord, - ): Promise> { - assertJsonSerializable(record); - return this.storage.appendRecord(record) as unknown as Promise< - TNewRecord & Pick - >; - } -} diff --git a/packages/agent/src/harness/session/state.ts b/packages/agent/src/harness/session/state.ts deleted file mode 100644 index c63bd5b93..000000000 --- a/packages/agent/src/harness/session/state.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { - type BranchBounds, - type Entry, - type EntryOrder, - type EntryQuery, - type ForkOptions, - type LanePointer, - type LaneRecord, - type LogItem, - type LogOptions, - type OperationStartedRecord, - type RecordQuery, - SessionError, - type SessionStats, -} from "./types.ts"; - -export type SessionMutation = - | { kind: "entry"; lane?: string; entry: Entry } - | { kind: "record"; record: LaneRecord } - | { kind: "lane"; seq: number; lane: string; leafId: string | null } - | { kind: "fact"; seq: number; fact: "name"; name: string | undefined } - | { kind: "fact"; seq: number; fact: "label"; targetId: string; label: string | undefined }; - -type InvalidMutation = (message: string) => never; - -function invalidMutation(message: string): never { - throw new SessionError("invalid_entry", `Invalid session mutation: ${message}`); -} - -function assertValidLimit(limit: number | undefined): void { - if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) { - throw new SessionError("invalid_query", "limit must be a positive integer"); - } -} - -function assertValidCursor(afterSeq: number | undefined): void { - if (afterSeq !== undefined && (!Number.isInteger(afterSeq) || afterSeq < 0)) { - throw new SessionError("invalid_query", "cursor sequence must be a non-negative integer"); - } -} - -function* ordered(items: readonly T[], order: EntryOrder | undefined): IterableIterator { - if (order === "oldestFirst") { - yield* items; - return; - } - for (let index = items.length - 1; index >= 0; index--) yield items[index]!; -} - -export class SessionState { - private sequence = 0; - private readonly usedIds = new Set(); - private readonly entries: Entry[] = []; - private readonly entriesById = new Map(); - private readonly records: LaneRecord[] = []; - private readonly openOperationsByLane = new Map>(); - private readonly lanes = new Map([["main", null]]); - private readonly log: LogItem[] = []; - private readonly stats: SessionStats = { - messageCount: 0, - cachedTokens: 0, - uncachedTokens: 0, - totalTokens: 0, - costTotal: 0, - }; - private name: string | undefined; - private readonly labels = new Map(); - - get nextSequence(): number { - return this.sequence + 1; - } - - getLanes(): LanePointer[] { - return [...this.lanes].map(([lane, leafId]) => ({ lane, leafId })); - } - - requireLane(lane: string): string | null { - const leafId = this.lanes.get(lane); - if (leafId === undefined) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); - return leafId; - } - - validateNewLane(lane: string): void { - if (this.lanes.has(lane)) throw new SessionError("already_exists", `Lane already exists: ${lane}`); - } - - validateTarget(targetId: string | null): void { - if (targetId !== null && !this.entriesById.has(targetId)) { - throw new SessionError("not_found", `Entry not found: ${targetId}`); - } - } - - validateUnusedId(id: string): void { - if (this.usedIds.has(id)) throw new SessionError("already_exists", `Session id already exists: ${id}`); - } - - applyMutation(mutation: SessionMutation, invalid: InvalidMutation = invalidMutation): void { - const seq = - mutation.kind === "entry" - ? mutation.entry.seq - : mutation.kind === "record" - ? mutation.record.seq - : mutation.seq; - if (seq !== this.sequence + 1) invalid(`has non-consecutive seq ${seq}`); - - switch (mutation.kind) { - case "entry": { - if (this.usedIds.has(mutation.entry.id)) invalid(`contains duplicate id ${mutation.entry.id}`); - if (mutation.lane !== undefined) { - const leafId = this.lanes.get(mutation.lane); - if (leafId === undefined) invalid(`references missing lane ${mutation.lane}`); - if (mutation.entry.parentId !== leafId) invalid("does not chain to the lane leaf"); - } - if (mutation.entry.parentId !== null && !this.entriesById.has(mutation.entry.parentId)) { - invalid(`references missing parent ${mutation.entry.parentId}`); - } - this.sequence = seq; - this.usedIds.add(mutation.entry.id); - this.entries.push(mutation.entry); - this.entriesById.set(mutation.entry.id, mutation.entry); - if (mutation.lane !== undefined) this.lanes.set(mutation.lane, mutation.entry.id); - this.log.push({ kind: "entry", seq, entry: mutation.entry }); - if (mutation.entry.type === "message") this.stats.messageCount += 1; - break; - } - case "record": { - if (!this.lanes.has(mutation.record.lane)) invalid(`references missing lane ${mutation.record.lane}`); - if (this.usedIds.has(mutation.record.id)) invalid(`contains duplicate id ${mutation.record.id}`); - this.sequence = seq; - this.usedIds.add(mutation.record.id); - this.records.push(mutation.record); - if (mutation.record.type === "operation_started") { - let openOperations = this.openOperationsByLane.get(mutation.record.lane); - if (!openOperations) { - openOperations = new Map(); - this.openOperationsByLane.set(mutation.record.lane, openOperations); - } - openOperations.set(mutation.record.id, mutation.record); - } else if (mutation.record.type === "operation_finished") { - this.openOperationsByLane.get(mutation.record.lane)?.delete(mutation.record.runId); - } - this.log.push({ kind: "record", seq, record: mutation.record }); - if (mutation.record.type === "usage") { - this.stats.cachedTokens += mutation.record.usage.cacheRead; - this.stats.uncachedTokens += mutation.record.usage.input + mutation.record.usage.cacheWrite; - this.stats.totalTokens += mutation.record.usage.totalTokens; - this.stats.costTotal += mutation.record.usage.cost.total; - } - break; - } - case "lane": - if (mutation.leafId !== null && !this.entriesById.has(mutation.leafId)) { - invalid(`references missing lane target ${mutation.leafId}`); - } - this.sequence = seq; - this.lanes.set(mutation.lane, mutation.leafId); - this.log.push({ kind: "lane", seq, lane: mutation.lane, leafId: mutation.leafId }); - break; - case "fact": - if (mutation.fact === "label" && !this.entriesById.has(mutation.targetId)) { - invalid(`references missing label target ${mutation.targetId}`); - } - this.sequence = seq; - if (mutation.fact === "name") { - this.name = mutation.name; - this.log.push({ kind: "fact", seq, fact: "name", name: mutation.name }); - } else { - if (mutation.label === undefined) this.labels.delete(mutation.targetId); - else this.labels.set(mutation.targetId, mutation.label); - this.log.push({ - kind: "fact", - seq, - fact: "label", - targetId: mutation.targetId, - label: mutation.label, - }); - } - break; - } - } - - getEntry(id: string): Entry | undefined { - return this.entriesById.get(id); - } - - findEntries(query: EntryQuery = {}): Entry[] { - assertValidLimit(query.limit); - assertValidCursor(query.cursor?.afterSeq); - const results: Entry[] = []; - for (const entry of ordered(this.entries, query.order)) { - if (!this.matchesEntryQuery(entry, query)) continue; - results.push(entry); - if (results.length === query.limit) break; - } - return results; - } - - findEntriesOnBranch(query: EntryQuery & BranchBounds & { start: string }): Entry[] { - assertValidLimit(query.limit); - assertValidCursor(query.cursor?.afterSeq); - const results: Entry[] = []; - if (query.order === "oldestFirst") { - for (const entry of [...this.walkToRoot(query.start)].reverse()) { - const reachedBound = entry.id === query.stopAtId || entry.type === query.stopAtType; - if (this.matchesEntryQuery(entry, query)) results.push(entry); - if (reachedBound || results.length === query.limit) break; - } - } else { - for (const entry of this.walkToRoot(query.start, query)) { - if (this.matchesEntryQuery(entry, query)) results.push(entry); - if (results.length === query.limit) break; - } - } - return results; - } - - findRecords(query: RecordQuery = {}): LaneRecord[] { - assertValidLimit(query.limit); - assertValidCursor(query.afterSeq); - const results: LaneRecord[] = []; - for (const record of ordered(this.records, query.order)) { - if (!this.matchesRecordQuery(record, query)) continue; - results.push(record); - if (results.length === query.limit) break; - } - return results; - } - - findOpenOperations(lane: string, options?: { limit?: number }): OperationStartedRecord[] { - assertValidLimit(options?.limit); - const openOperationsById = this.openOperationsByLane.get(lane); - const openOperations = openOperationsById ? [...openOperationsById.values()].reverse() : []; - return options?.limit === undefined ? openOperations : openOperations.slice(0, options.limit); - } - - getLog(options: LogOptions = {}): LogItem[] { - assertValidLimit(options.limit); - assertValidCursor(options.afterSeq); - const results: LogItem[] = []; - for (const item of this.log) { - if (options.afterSeq !== undefined && item.seq <= options.afterSeq) continue; - results.push(item); - if (results.length === options.limit) break; - } - return results; - } - - getName(): string | undefined { - return this.name; - } - - getLabel(id: string): string | undefined { - return this.labels.get(id); - } - - getStats(): SessionStats { - return this.stats; - } - - createForkMutations(options: ForkOptions): SessionMutation[] { - let copiedEntries: Entry[]; - let forkLanes: LanePointer[]; - if (options.scope === "tree") { - copiedEntries = this.findEntries({ order: "oldestFirst" }); - forkLanes = this.getLanes(); - } else { - const selectedEntryId = options.entryId ?? this.requireLane("main"); - let targetId: string | null = null; - if (selectedEntryId !== null) { - const entry = this.getEntry(selectedEntryId); - if (!entry || entry.type !== "message") { - throw new SessionError("invalid_fork_target", `Fork target is not a message entry: ${selectedEntryId}`); - } - const position = options.position ?? (options.entryId === undefined ? "at" : "before"); - targetId = position === "at" ? entry.id : entry.parentId; - } - copiedEntries = targetId === null ? [] : this.findEntriesOnBranch({ start: targetId, order: "oldestFirst" }); - forkLanes = [{ lane: "main", leafId: targetId }]; - } - - const mutations: SessionMutation[] = []; - let sequence = 1; - for (const sourceEntry of copiedEntries) { - mutations.push({ kind: "entry", entry: { ...structuredClone(sourceEntry), seq: sequence++ } }); - } - for (const pointer of forkLanes) { - mutations.push({ kind: "lane", seq: sequence++, lane: pointer.lane, leafId: pointer.leafId }); - } - if (this.name !== undefined) { - mutations.push({ kind: "fact", seq: sequence++, fact: "name", name: this.name }); - } - for (const entry of copiedEntries) { - const label = this.labels.get(entry.id); - if (label !== undefined) { - mutations.push({ kind: "fact", seq: sequence++, fact: "label", targetId: entry.id, label }); - } - } - return mutations; - } - - private *walkToRoot( - start: string | null, - bounds?: Pick, - ): IterableIterator { - if (start === null) return; - const visited = new Set(); - let current = this.entriesById.get(start); - if (!current) throw new SessionError("not_found", `Entry not found: ${start}`); - while (current) { - if (visited.has(current.id)) { - throw new SessionError("invalid_entry", `Session branch contains a cycle at ${current.id}`); - } - visited.add(current.id); - yield current; - if (current.id === bounds?.stopAtId || current.type === bounds?.stopAtType || current.parentId === null) break; - const parentId: string = current.parentId; - current = this.entriesById.get(parentId); - if (!current) throw new SessionError("invalid_entry", `Entry not found: ${parentId}`); - } - } - - private matchesEntryQuery(entry: Entry, query: EntryQuery): boolean { - return ( - (query.type === undefined || entry.type === query.type) && - (query.customType === undefined || (entry.type === "custom" && entry.customType === query.customType)) && - (query.cursor === undefined || - (query.order === "oldestFirst" ? entry.seq > query.cursor.afterSeq : entry.seq < query.cursor.afterSeq)) - ); - } - - private matchesRecordQuery(record: LaneRecord, query: RecordQuery): boolean { - return ( - (query.lane === undefined || record.lane === query.lane) && - (query.type === undefined || record.type === query.type) && - (query.runId === undefined || - (record.type === "operation_started" - ? record.id === query.runId - : "runId" in record && record.runId === query.runId)) && - (query.operationKind === undefined || - (record.type === "operation_started" && record.intent.kind === query.operationKind)) && - (query.afterSeq === undefined || record.seq > query.afterSeq) - ); - } -} diff --git a/packages/agent/src/harness/session/testing/conformance.ts b/packages/agent/src/harness/session/testing/conformance.ts deleted file mode 100644 index 9adc10bb9..000000000 --- a/packages/agent/src/harness/session/testing/conformance.ts +++ /dev/null @@ -1,1016 +0,0 @@ -import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; -import type { AgentMessage } from "../../../types.ts"; -import type { - CustomEntry, - Entry, - MessageEntry, - NewRecord, - OperationStartedRecord, - SessionErrorCode, - SessionRepo, -} from "../types.ts"; -import type { SessionBackendConformanceCase, SessionBackendFixtureFactory } from "./types.ts"; - -function createUserMessage(text: string): AgentMessage { - return { - role: "user", - content: [{ type: "text", text }], - timestamp: 1, - }; -} - -function createAssistantMessage(text: string): AgentMessage { - return { - role: "assistant", - content: [{ type: "text", text }], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: 1, - }; -} - -function operationStarted( - id: string, - { lane, kind }: { lane: string; kind: OperationStartedRecord["intent"]["kind"] }, -): NewRecord { - let intent: OperationStartedRecord["intent"]; - switch (kind) { - case "run": - intent = { kind, originalPrompt: [], initialMessages: [] }; - break; - case "compaction": - intent = { kind, resultEntryId: `${id}-result` }; - break; - case "navigation": - intent = { kind, targetId: null, summarize: false }; - break; - } - return { type: "operation_started", id, lane, sourceLeafId: null, intent }; -} - -async function entryIds(entries: Promise): Promise { - return (await entries).map((entry) => entry.id); -} - -async function rejectsWithCode(operation: Promise, code: SessionErrorCode): Promise { - await rejects( - operation, - (error: unknown) => typeof error === "object" && error !== null && "code" in error && error.code === code, - `Expected SessionError with code ${code}`, - ); -} - -type ConformanceTest = (repository: SessionRepo) => Promise; - -function createCase( - factory: SessionBackendFixtureFactory, - group: string, - name: string, - test: ConformanceTest, -): SessionBackendConformanceCase { - return { - group, - name, - async run() { - await using fixture = await factory(); - await test(fixture.repository); - }, - }; -} - -/** Creates the session backend conformance cases. Each case creates and disposes its own fixture. */ -export function createSessionBackendConformance( - factory: SessionBackendFixtureFactory, -): readonly SessionBackendConformanceCase[] { - return [ - createCase( - factory, - "entries and lanes", - "assigns parents and one sequence across every mutation", - async (repository) => { - const session = await repository.create({ id: "session" }); - const root = await session.appendEntry( - { type: "message", id: "root", message: createUserMessage("root") }, - "main", - ); - await session.createLane("thread", root.id); - const child = await session.appendEntry( - { type: "custom", id: "child", customType: "note", data: { value: 1 } }, - "thread", - ); - const record = await session.appendRecord(operationStarted("run", { lane: "thread", kind: "run" })); - await session.setName("Example"); - await session.setLabel(root.id, "checkpoint"); - await session.moveLane("main", child.id); - - deepStrictEqual({ parentId: root.parentId, seq: root.seq }, { parentId: null, seq: 1 }); - deepStrictEqual({ parentId: child.parentId, seq: child.seq }, { parentId: "root", seq: 3 }); - strictEqual(record.seq, 4); - for (const timestamp of [root.timestamp, child.timestamp, record.timestamp]) { - ok( - Number.isSafeInteger(timestamp) && timestamp >= 0, - "storage-assigned timestamps must be Unix milliseconds", - ); - } - deepStrictEqual( - (await session.getLog()).map((item) => [item.kind, item.seq]), - [ - ["entry", 1], - ["lane", 2], - ["entry", 3], - ["record", 4], - ["fact", 5], - ["fact", 6], - ["lane", 7], - ], - ); - deepStrictEqual(await session.getLanes(), [ - { lane: "main", leafId: "child" }, - { lane: "thread", leafId: "child" }, - ]); - }, - ), - - createCase( - factory, - "records and log", - "commits records and lane moves as separate mutations", - async (repository) => { - const session = await repository.create({ id: "session" }); - const root = await session.appendEntry( - { type: "message", id: "root", message: createUserMessage("root") }, - "main", - ); - const finished = await session.appendRecord({ - type: "operation_finished", - id: "finish", - lane: "main", - runId: "run", - outcome: "completed", - }); - - strictEqual(finished.seq, 2); - deepStrictEqual(await session.getLanes(), [{ lane: "main", leafId: "root" }]); - await session.moveLane("main", null); - deepStrictEqual(await session.getLanes(), [{ lane: "main", leafId: null }]); - deepStrictEqual(await session.getLog(), [ - { kind: "entry", seq: 1, entry: root }, - { kind: "record", seq: 2, record: finished }, - { kind: "lane", seq: 3, lane: "main", leafId: null }, - ]); - - await rejectsWithCode(session.moveLane("main", "missing"), "not_found"); - strictEqual((await session.findRecords()).length, 1); - deepStrictEqual( - (await session.getLog()).map((item) => item.seq), - [1, 2, 3], - ); - }, - ), - - createCase(factory, "entries and lanes", "rejects duplicate ids without changing state", async (repository) => { - const session = await repository.create({ id: "session" }); - await session.appendEntry( - { type: "message", id: "shared", message: createUserMessage("root") }, - "main", - ); - await rejectsWithCode( - session.appendRecord(operationStarted("shared", { lane: "main", kind: "run" })), - "already_exists", - ); - await session.appendRecord(operationStarted("run", { lane: "main", kind: "run" })); - await rejectsWithCode( - session.appendEntry({ type: "custom", id: "run", customType: "note" }, "main"), - "already_exists", - ); - deepStrictEqual( - (await session.getLog()).map((item) => item.seq), - [1, 2], - ); - }), - - createCase(factory, "entries and lanes", "isolates lanes while sharing the tree", async (repository) => { - const session = await repository.create({ id: "session" }); - await session.appendEntry( - { type: "message", id: "root", message: createUserMessage("root") }, - "main", - ); - await session.createLane("thread", "root"); - await session.appendEntry( - { type: "message", id: "main-child", message: createUserMessage("main") }, - "main", - ); - await session.appendEntry( - { type: "message", id: "thread-child", message: createUserMessage("thread") }, - "thread", - ); - - deepStrictEqual(await session.getLanes(), [ - { lane: "main", leafId: "main-child" }, - { lane: "thread", leafId: "thread-child" }, - ]); - deepStrictEqual(await entryIds(session.findEntriesOnBranch({ start: "main-child", order: "oldestFirst" })), [ - "root", - "main-child", - ]); - deepStrictEqual(await entryIds(session.findEntriesOnBranch({ start: "thread-child", order: "oldestFirst" })), [ - "root", - "thread-child", - ]); - }), - - createCase(factory, "queries and facts", "rejects invalid queries before empty reads", async (repository) => { - const session = await repository.create({ id: "invalid-queries" }); - await session.createLane("thread", null); - const thread = session.view("thread"); - - await rejectsWithCode(session.findEntries({ limit: 0 }), "invalid_query"); - await rejectsWithCode(session.findEntry({ limit: 0 }), "invalid_query"); - await rejectsWithCode(session.findEntriesOnBranch({ limit: 0 }), "invalid_query"); - await rejectsWithCode(thread.findEntriesOnBranch({ cursor: { afterSeq: -1 } }), "invalid_query"); - await rejectsWithCode(thread.findEntryOnBranch({ limit: 0 }), "invalid_query"); - await rejectsWithCode(session.findRecords({ limit: 0 }), "invalid_query"); - await rejectsWithCode(session.findRecords({ operationKind: "run" }), "invalid_query"); - await rejectsWithCode(session.findRecords({ type: "step_attempt", operationKind: "run" }), "invalid_query"); - await rejectsWithCode(session.findOpenOperations("main", { limit: 0 }), "invalid_query"); - await rejectsWithCode(session.findOpenOperations("main", { limit: -1 }), "invalid_query"); - await rejectsWithCode(session.getLog({ afterSeq: -1 }), "invalid_query"); - }), - - createCase( - factory, - "queries and facts", - "supports bounded filtered and cursor-based queries", - async (repository) => { - const session = await repository.create({ id: "session" }); - await session.appendEntry( - { type: "message", id: "root", message: createUserMessage("root") }, - "main", - ); - await session.appendEntry( - { type: "custom", id: "old-note", customType: "note", data: 1 }, - "main", - ); - await session.appendEntry( - { type: "compaction", id: "compact", summary: "summary", retainedTail: [], tokensBefore: 10 }, - "main", - ); - await session.appendEntry( - { type: "custom", id: "new-note", customType: "note", data: 2 }, - "main", - ); - await session.appendEntry( - { type: "message", id: "tail", message: createAssistantMessage("tail") }, - "main", - ); - - deepStrictEqual(await entryIds(session.findEntries()), ["tail", "new-note", "compact", "old-note", "root"]); - deepStrictEqual( - await entryIds(session.findEntries({ order: "oldestFirst", cursor: { afterSeq: 2 }, limit: 2 })), - ["compact", "new-note"], - ); - deepStrictEqual(await entryIds(session.findEntries({ customType: "note" })), ["new-note", "old-note"]); - deepStrictEqual( - await entryIds(session.findEntriesOnBranch({ start: "tail", customType: "note", limit: 1 })), - ["new-note"], - ); - deepStrictEqual( - await entryIds( - session.findEntriesOnBranch({ start: "tail", stopAtType: "compaction", type: "message" }), - ), - ["tail"], - ); - deepStrictEqual( - await entryIds(session.findEntriesOnBranch({ start: "tail", stopAtId: "tail", type: "custom" })), - [], - ); - deepStrictEqual( - await entryIds( - session.findEntriesOnBranch({ start: "tail", stopAtType: "custom", order: "oldestFirst" }), - ), - ["root", "old-note"], - ); - await rejectsWithCode(session.findEntries({ limit: 0 }), "invalid_query"); - await rejectsWithCode(session.findEntriesOnBranch({ start: "missing" }), "not_found"); - }, - ), - - createCase( - factory, - "records and log", - "keeps lane names permanent with their recovery records", - async (repository) => { - const session = await repository.create({ id: "session" }); - await session.createLane("thread", null); - await session.appendRecord(operationStarted("old-run", { lane: "thread", kind: "run" })); - await session.appendRecord({ - type: "queue_enqueued", - id: "old-next-run", - lane: "thread", - queue: "nextRun", - target: { type: "message", id: "queued-message", message: createUserMessage("queued") }, - }); - - deepStrictEqual( - (await session.findRecords({ lane: "thread" })).map((record) => record.id), - ["old-next-run", "old-run"], - ); - deepStrictEqual( - (await session.getLog()).flatMap((item) => (item.kind === "record" ? [item.record.id] : [])), - ["old-run", "old-next-run"], - ); - await rejectsWithCode(session.createLane("thread", null), "already_exists"); - }, - ), - - createCase( - factory, - "records and log", - "persists queue cancellation without consuming its target", - async (repository) => { - const session = await repository.create({ id: "session" }); - const enqueued = await session.appendRecord({ - type: "queue_enqueued", - id: "enqueue", - lane: "main", - queue: "nextRun", - target: { type: "message", id: "queued-message", message: createUserMessage("queued") }, - }); - const cancelled = await session.appendRecord({ - type: "queue_cancelled", - id: "cancel", - lane: "main", - entryId: "queued-message", - }); - deepStrictEqual({ seq: cancelled.seq, entryId: cancelled.entryId }, { seq: 2, entryId: "queued-message" }); - strictEqual("runId" in cancelled, false); - strictEqual(await session.getEntry("queued-message"), undefined); - const cancellations = await session.findRecords({ type: "queue_cancelled" }); - strictEqual(cancellations[0]?.entryId, "queued-message"); - deepStrictEqual(cancellations, [cancelled]); - deepStrictEqual(await session.getLog(), [ - { kind: "record", seq: enqueued.seq, record: enqueued }, - { kind: "record", seq: cancelled.seq, record: cancelled }, - ]); - }, - ), - - createCase( - factory, - "records and log", - "filters records by lane type run sequence and order", - async (repository) => { - const session = await repository.create({ id: "session" }); - await session.appendRecord(operationStarted("run-1", { lane: "main", kind: "run" })); - await session.appendRecord({ - type: "step_attempt", - id: "attempt-1", - lane: "main", - runId: "run-1", - step: "assistant", - attempt: 1, - resultEntryId: "assistant-1", - }); - await session.createLane("thread", null); - await session.appendRecord(operationStarted("run-2", { lane: "thread", kind: "run" })); - await session.appendRecord({ - type: "step_attempt", - id: "attempt-2", - lane: "thread", - runId: "run-2", - step: "assistant", - attempt: 1, - resultEntryId: "assistant-2", - }); - - deepStrictEqual( - (await session.findRecords({ lane: "thread" })).map((record) => record.id), - ["attempt-2", "run-2"], - ); - deepStrictEqual( - (await session.findRecords({ type: "step_attempt", order: "oldestFirst" })).map((record) => record.id), - ["attempt-1", "attempt-2"], - ); - deepStrictEqual( - (await session.findRecords({ runId: "run-1", afterSeq: 1 })).map((record) => record.id), - ["attempt-1"], - ); - deepStrictEqual( - (await session.findRecords({ limit: 1 })).map((record) => record.id), - ["attempt-2"], - ); - }, - ), - - createCase(factory, "records and log", "filters operation starts by operation kind", async (repository) => { - const session = await repository.create({ id: "session" }); - await session.appendRecord(operationStarted("run-old", { lane: "main", kind: "run" })); - await session.appendRecord({ - type: "operation_finished", - id: "run-old-finished", - lane: "main", - runId: "run-old", - outcome: "completed", - }); - await session.appendRecord(operationStarted("compaction", { lane: "main", kind: "compaction" })); - await session.appendRecord({ - type: "operation_finished", - id: "compaction-finished", - lane: "main", - runId: "compaction", - outcome: "completed", - }); - await session.appendRecord(operationStarted("navigation", { lane: "main", kind: "navigation" })); - await session.appendRecord({ - type: "operation_finished", - id: "navigation-finished", - lane: "main", - runId: "navigation", - outcome: "completed", - }); - await session.appendRecord(operationStarted("run-new", { lane: "main", kind: "run" })); - - deepStrictEqual( - ( - await session.findRecords({ - type: "operation_started", - operationKind: "run", - order: "oldestFirst", - }) - ).map((record) => record.id), - ["run-old", "run-new"], - ); - deepStrictEqual( - ( - await session.findRecords({ - type: "operation_started", - operationKind: "compaction", - }) - ).map((record) => record.id), - ["compaction"], - ); - deepStrictEqual( - ( - await session.findRecords({ - type: "operation_started", - operationKind: "navigation", - }) - ).map((record) => record.id), - ["navigation"], - ); - deepStrictEqual( - ( - await session.findRecords({ - type: "operation_started", - operationKind: "run", - limit: 1, - }) - ).map((record) => record.id), - ["run-new"], - ); - }), - - createCase(factory, "records and log", "tracks and enforces one open operation per lane", async (repository) => { - const session = await repository.create({ id: "session" }); - deepStrictEqual(await session.findOpenOperations("main", { limit: 2 }), []); - - const first = await session.appendRecord(operationStarted("first", { lane: "main", kind: "run" })); - deepStrictEqual(await session.findOpenOperations("main", { limit: 2 }), [first]); - await rejectsWithCode( - session.appendRecord(operationStarted("second", { lane: "main", kind: "run" })), - "storage", - ); - deepStrictEqual(await session.findOpenOperations("main", { limit: 2 }), [first]); - - await session.appendRecord({ - type: "operation_finished", - id: "finish-first", - lane: "main", - runId: first.id, - outcome: "completed", - }); - deepStrictEqual(await session.findOpenOperations("main", { limit: 2 }), []); - }), - - createCase( - factory, - "records and log", - "does not let an earlier finish close a later start", - async (repository) => { - const session = await repository.create({ id: "session" }); - await session.appendRecord({ - type: "operation_finished", - id: "finish-before-start", - lane: "main", - runId: "run", - outcome: "completed", - }); - const started = await session.appendRecord(operationStarted("run", { lane: "main", kind: "run" })); - deepStrictEqual(await session.findOpenOperations("main", { limit: 2 }), [started]); - }, - ), - - createCase(factory, "records and log", "scopes open operations by lane and limit", async (repository) => { - const session = await repository.create({ id: "session" }); - await session.createLane("thread", null); - const mainRun = await session.appendRecord(operationStarted("main-run", { lane: "main", kind: "run" })); - const threadNavigation = await session.appendRecord( - operationStarted("thread-navigation", { lane: "thread", kind: "navigation" }), - ); - - deepStrictEqual(await session.findOpenOperations("main"), [mainRun]); - deepStrictEqual(await session.findOpenOperations("main", { limit: 1 }), [mainRun]); - deepStrictEqual(await session.findOpenOperations("thread", { limit: 2 }), [threadNavigation]); - }), - - createCase( - factory, - "validation and immutability", - "returns immutable open-operation records", - async (repository) => { - const session = await repository.create({ id: "session" }); - const committed = await session.appendRecord(operationStarted("run", { lane: "main", kind: "run" })); - const [read] = await session.findOpenOperations("main"); - if (read?.intent.kind !== "run") throw new Error("Expected an open run operation"); - read.intent.originalPrompt.push(createUserMessage("mutated")); - - deepStrictEqual(await session.findOpenOperations("main"), [committed]); - }, - ), - - createCase( - factory, - "queries and facts", - "keeps latest-value facts and computes ledger statistics across lanes", - async (repository) => { - const session = await repository.create({ id: "session" }); - const assistant = createAssistantMessage("answer"); - if (assistant.role !== "assistant") throw new Error("Expected assistant message"); - assistant.usage = { - input: 10, - output: 5, - cacheRead: 3, - cacheWrite: 2, - totalTokens: 20, - cost: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, total: 10 }, - }; - await session.appendEntry( - { type: "message", id: "user", message: createUserMessage("question") }, - "main", - ); - await session.appendEntry({ type: "message", id: "assistant", message: assistant }, "main"); - await session.appendRecord({ - type: "usage", - id: "assistant-usage", - lane: "main", - cause: "assistant", - runId: "run", - entryId: "assistant", - attempt: 1, - stopReason: "stop", - usage: assistant.usage, - }); - await session.appendRecord({ - type: "usage", - id: "deferred-usage", - lane: "main", - cause: "deferred_fetch", - runId: "run", - entryId: "deferred-result", - attempt: 1, - stopReason: "deferred", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }); - await session.createLane("thread", "assistant"); - await session.appendRecord({ - type: "usage", - id: "correction", - lane: "thread", - cause: "adjustment", - details: { reason: "provider correction" }, - usage: { - input: -2, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: -2, - cost: { input: -0.5, output: 0, cacheRead: 0, cacheWrite: 0, total: -0.5 }, - }, - }); - await session.setName("First"); - await session.setName("Second"); - await session.setLabel("user", "keep"); - await session.setLabel("user", undefined); - await rejectsWithCode(session.setLabel("missing", "checkpoint"), "not_found"); - - strictEqual(await session.getName(), "Second"); - strictEqual(await session.getLabel("user"), undefined); - const usageRecords = await session.findRecords({ type: "usage", order: "oldestFirst" }); - deepStrictEqual( - usageRecords.map((record) => record.cause), - ["assistant", "deferred_fetch", "adjustment"], - ); - const deferredUsage = usageRecords.find((record) => record.cause === "deferred_fetch"); - if (deferredUsage?.cause !== "deferred_fetch") throw new Error("Expected deferred usage record"); - strictEqual(deferredUsage.stopReason, "deferred"); - deepStrictEqual(await session.getStats(), { - messageCount: 2, - cachedTokens: 3, - uncachedTokens: 10, - totalTokens: 18, - costTotal: 9.5, - }); - }, - ), - - createCase(factory, "queries and facts", "clears session names durably", async (repository) => { - const session = await repository.create({ id: "session" }); - await session.setName("Temporary"); - await session.setName(undefined); - - strictEqual(await session.getName(), undefined); - deepStrictEqual(await session.getLog(), [ - { kind: "fact", seq: 1, fact: "name", name: "Temporary" }, - { kind: "fact", seq: 2, fact: "name", name: undefined }, - ]); - - const metadata = await session.getMetadata(); - const reopened = await repository.open(metadata); - strictEqual(await reopened.getName(), undefined); - deepStrictEqual(await reopened.getLog(), [ - { kind: "fact", seq: 1, fact: "name", name: "Temporary" }, - { kind: "fact", seq: 2, fact: "name", name: undefined }, - ]); - - const fork = await repository.fork(metadata, { id: "fork" }); - strictEqual(await fork.getName(), undefined); - }), - - createCase(factory, "validation and immutability", "returns immutable copies from reads", async (repository) => { - const session = await repository.create({ id: "immutable" }); - const metadata = await session.getMetadata(); - const data = { nested: { value: 1 } }; - await session.appendEntry({ type: "custom", id: "custom", customType: "note", data }, "main"); - data.nested.value = 50; - const read = await session.getEntry("custom"); - if (read?.type !== "custom") throw new Error("Expected custom entry"); - (read.data as { nested: { value: number } }).nested.value = 99; - const readMetadata = await session.getMetadata(); - readMetadata.id = "changed"; - const log = await session.getLog(); - if (log[0]?.kind !== "entry" || log[0].entry.type !== "custom") throw new Error("Expected entry log"); - (log[0].entry.data as { nested: { value: number } }).nested.value = 100; - - deepStrictEqual(await session.getMetadata(), metadata); - deepStrictEqual(await session.getEntry("custom"), { - type: "custom", - id: "custom", - customType: "note", - data: { nested: { value: 1 } }, - parentId: null, - seq: 1, - timestamp: read.timestamp, - }); - }), - - createCase(factory, "entries and lanes", "validates lane lifecycle and targets", async (repository) => { - const session = await repository.create({ id: "session" }); - await rejectsWithCode(session.createLane("main", null), "already_exists"); - await rejectsWithCode(session.createLane("thread", "missing"), "not_found"); - await rejectsWithCode(session.moveLane("missing", null), "invalid_lane"); - }), - - createCase(factory, "entries and lanes", "binds lane views without caching leaves", async (repository) => { - const session = await repository.create({ id: "session" }); - const root = await session.appendMessage(createUserMessage("root")); - await session.createLane("thread", root); - const thread = session.view("thread"); - const [mainChild, threadChild] = await Promise.all([ - session.appendMessage(createUserMessage("main")), - thread.appendMessage(createUserMessage("thread")), - ]); - - strictEqual(await session.getLeafId(), mainChild); - strictEqual(await thread.getLeafId(), threadChild); - deepStrictEqual(await entryIds(session.findEntriesOnBranch({ order: "oldestFirst" })), [root, mainChild]); - deepStrictEqual(await entryIds(thread.findEntriesOnBranch({ order: "oldestFirst" })), [root, threadChild]); - const empty = await repository.create({ id: "empty" }); - deepStrictEqual(await empty.findEntriesOnBranch(), []); - }), - - createCase( - factory, - "entries and lanes", - "appends provisioned entries with their existing ids", - async (repository) => { - const session = await repository.create({ id: "session" }); - const entry = await session.appendEntry( - { type: "custom", id: "provisioned", customType: "note", data: { value: 1 } }, - "main", - ); - - strictEqual(entry.customType, "note"); - deepStrictEqual( - { id: entry.id, parentId: entry.parentId, seq: entry.seq }, - { id: "provisioned", parentId: null, seq: 1 }, - ); - strictEqual(await session.getLeafId(), "provisioned"); - }, - ), - - createCase(factory, "entries and lanes", "persists tool-result termination decisions", async (repository) => { - const session = await repository.create({ id: "session" }); - const entry = await session.appendEntry( - { - type: "message", - id: "tool-result", - message: { - role: "toolResult", - toolCallId: "call-1", - toolName: "example", - content: [{ type: "text", text: "done" }], - isError: false, - timestamp: 1, - }, - terminate: true, - }, - "main", - ); - - strictEqual(entry.terminate, true); - const stored = await session.getEntry(entry.id); - if (stored?.type !== "message") throw new Error("Expected message entry"); - strictEqual(stored.terminate, true); - deepStrictEqual(await session.findEntries(), [entry]); - deepStrictEqual(await session.getLog(), [{ kind: "entry", seq: entry.seq, entry }]); - }), - - createCase( - factory, - "validation and immutability", - "rejects non-JSON entries before storage mutation", - async (repository) => { - const session = await repository.create({ id: "session" }); - const cyclic: { self?: unknown } = {}; - cyclic.self = cyclic; - - for (const data of [ - { value: undefined }, - [undefined], - { value: 1n }, - { value: Number.NaN }, - { value: new Map() }, - cyclic, - ]) { - await rejectsWithCode(session.appendCustomEntry("invalid", data), "invalid_payload"); - } - - strictEqual(await session.getLeafId(), null); - deepStrictEqual(await session.findEntries(), []); - deepStrictEqual(await session.getLog(), []); - const validId = await session.appendCustomEntry("valid", { value: 1 }); - strictEqual((await session.getEntry(validId))?.seq, 1); - }, - ), - - createCase( - factory, - "validation and immutability", - "rejects non-JSON records before storage mutation", - async (repository) => { - const session = await repository.create({ id: "session" }); - for (const [id, value] of [ - ["undefined-record", undefined], - ["bigint-record", 1n], - ] as const) { - await rejectsWithCode( - session.appendRecord({ - type: "tool_started", - id, - lane: "main", - runId: "run", - assistantEntryId: "assistant", - toolIndex: 0, - toolCallId: "call", - toolName: "example", - effectiveArgs: { value }, - resultEntryId: "result", - replay: "never", - }), - "invalid_payload", - ); - } - - deepStrictEqual(await session.findRecords(), []); - deepStrictEqual(await session.getLog(), []); - strictEqual( - (await session.appendRecord(operationStarted("valid-record", { lane: "main", kind: "run" }))).seq, - 1, - ); - }, - ), - - createCase(factory, "entries and lanes", "linearizes concurrent writes across two lanes", async (repository) => { - const session = await repository.create({ id: "session" }); - await session.appendEntry( - { type: "message", id: "root", message: createUserMessage("root") }, - "main", - ); - await session.createLane("thread", "root"); - const completionOrder: string[] = []; - const writes = [ - session.appendEntry({ type: "custom", id: "main-1", customType: "note" }, "main"), - session.appendEntry({ type: "custom", id: "thread-1", customType: "note" }, "thread"), - session.appendEntry({ type: "custom", id: "main-2", customType: "note" }, "main"), - session.appendEntry({ type: "custom", id: "thread-2", customType: "note" }, "thread"), - ].map((write) => - write.then((entry) => { - completionOrder.push(entry.id); - return entry; - }), - ); - const entries = await Promise.all(writes); - const commitOrder = [...entries].sort((left, right) => left.seq - right.seq).map((entry) => entry.id); - - strictEqual(new Set(entries.map((entry) => entry.seq)).size, entries.length); - deepStrictEqual(completionOrder, commitOrder); - const concurrentIds = new Set(entries.map((entry) => entry.id)); - deepStrictEqual( - (await session.getLog()).flatMap((item) => - item.kind === "entry" && concurrentIds.has(item.entry.id) ? [item.entry.id] : [], - ), - commitOrder, - ); - const sequences = (await session.getLog()).map((item) => item.seq); - deepStrictEqual( - sequences, - [...sequences].sort((left, right) => left - right), - ); - }), - - createCase(factory, "repository and forks", "creates lists and opens sessions", async (repository) => { - const session = await repository.create({ id: "one" }); - const entryId = await session.appendMessage(createUserMessage("persisted")); - const metadata = await session.getMetadata(); - - const listed = await repository.list(); - strictEqual(listed.length, 1); - strictEqual(listed[0]?.id, metadata.id); - strictEqual(listed[0]?.createdAt, metadata.createdAt); - strictEqual(listed[0]?.parentSessionId, metadata.parentSessionId); - deepStrictEqual(await entryIds((await repository.open(metadata)).findEntries()), [entryId]); - await rejectsWithCode(repository.create({ id: "one" }), "already_exists"); - }), - - createCase(factory, "repository and forks", "deletes sessions idempotently", async (repository) => { - const session = await repository.create({ id: "one" }); - const metadata = await session.getMetadata(); - - await repository.delete(metadata); - await rejectsWithCode(repository.open(metadata), "not_found"); - await repository.delete(metadata); - }), - - createCase( - factory, - "repository and forks", - "forks one branch with selected facts and no records", - async (repository) => { - const source = await repository.create({ id: "source" }); - const root = await source.appendMessage(createUserMessage("root")); - const shared = await source.appendMessage(createAssistantMessage("shared")); - await source.createLane("thread", shared); - const threadChild = await source.view("thread").appendMessage(createUserMessage("thread")); - const mainChild = await source.appendMessage(createUserMessage("main")); - await source.setName("Source"); - await source.setLabel(shared, "copied"); - await source.setLabel(threadChild, "excluded"); - await source.appendRecord(operationStarted("run", { lane: "main", kind: "run" })); - await source.appendRecord({ - type: "usage", - id: "source-usage", - lane: "main", - cause: "adjustment", - usage: { - input: 10, - output: 5, - cacheRead: 3, - cacheWrite: 2, - totalTokens: 20, - cost: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4, total: 10 }, - }, - }); - - const fork = await repository.fork(await source.getMetadata(), { - scope: "branch", - entryId: mainChild, - position: "at", - id: "branch-fork", - }); - - deepStrictEqual(await entryIds(fork.findEntries({ order: "oldestFirst" })), [root, shared, mainChild]); - deepStrictEqual(await fork.getLanes(), [{ lane: "main", leafId: mainChild }]); - strictEqual(await fork.getName(), "Source"); - strictEqual(await fork.getLabel(shared), "copied"); - strictEqual(await fork.getLabel(threadChild), undefined); - deepStrictEqual(await fork.findRecords(), []); - deepStrictEqual(await fork.getStats(), { - messageCount: 3, - cachedTokens: 0, - uncachedTokens: 0, - totalTokens: 0, - costTotal: 0, - }); - await fork.appendMessage(createUserMessage("after fork")); - strictEqual((await fork.getStats()).messageCount, 4); - const metadata = await fork.getMetadata(); - deepStrictEqual( - { id: metadata.id, parentSessionId: metadata.parentSessionId }, - { id: "branch-fork", parentSessionId: "source" }, - ); - }, - ), - - createCase(factory, "repository and forks", "forks a complete tree with lanes and facts", async (repository) => { - const source = await repository.create({ id: "source" }); - const root = await source.appendMessage(createUserMessage("root")); - await source.createLane("thread", root); - const mainChild = await source.appendMessage(createUserMessage("main")); - const threadChild = await source.view("thread").appendMessage(createUserMessage("thread")); - await source.setLabel(threadChild, "thread-tip"); - - const fork = await repository.fork(await source.getMetadata(), { scope: "tree", id: "tree-fork" }); - deepStrictEqual(await entryIds(fork.findEntries({ order: "oldestFirst" })), [root, mainChild, threadChild]); - deepStrictEqual(await fork.getLanes(), [ - { lane: "main", leafId: mainChild }, - { lane: "thread", leafId: threadChild }, - ]); - strictEqual(await fork.getLabel(threadChild), "thread-tip"); - strictEqual((await fork.getStats()).messageCount, 3); - deepStrictEqual( - (await fork.getLog()).filter((item) => item.kind === "lane"), - [ - { kind: "lane", seq: 4, lane: "main", leafId: mainChild }, - { kind: "lane", seq: 5, lane: "thread", leafId: threadChild }, - ], - ); - }), - - createCase( - factory, - "repository and forks", - "forks before an entry without modifying the source", - async (repository) => { - const source = await repository.create({ id: "source" }); - const root = await source.appendMessage(createUserMessage("root")); - const tail = await source.appendMessage(createUserMessage("tail")); - const fork = await repository.fork(await source.getMetadata(), { entryId: tail, id: "fork" }); - - deepStrictEqual(await entryIds(fork.findEntries({ order: "oldestFirst" })), [root]); - strictEqual(await fork.getLeafId(), root); - strictEqual(await source.getLeafId(), tail); - const beforeDefaultTarget = await repository.fork(await source.getMetadata(), { - position: "before", - id: "before-default-target", - }); - deepStrictEqual(await entryIds(beforeDefaultTarget.findEntries({ order: "oldestFirst" })), [root]); - strictEqual(await beforeDefaultTarget.getLeafId(), root); - - const atDefaultTarget = await repository.fork(await source.getMetadata(), { - position: "at", - id: "at-default-target", - }); - deepStrictEqual(await entryIds(atDefaultTarget.findEntries({ order: "oldestFirst" })), [root, tail]); - strictEqual(await atDefaultTarget.getLeafId(), tail); - await rejectsWithCode( - repository.fork(await source.getMetadata(), { entryId: "missing" }), - "invalid_fork_target", - ); - }, - ), - - createCase(factory, "repository and forks", "validates the default fork target", async (repository) => { - const source = await repository.create({ id: "source-with-custom-leaf" }); - await source.appendCustomEntry("not-a-message"); - - await rejectsWithCode(repository.fork(await source.getMetadata(), { id: "fork" }), "invalid_fork_target"); - }), - ]; -} diff --git a/packages/agent/src/harness/session/testing/index.ts b/packages/agent/src/harness/session/testing/index.ts deleted file mode 100644 index 8812edc63..000000000 --- a/packages/agent/src/harness/session/testing/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { createSessionBackendConformance } from "./conformance.ts"; -export type { - SessionBackendConformanceCase, - SessionBackendFixture, - SessionBackendFixtureFactory, -} from "./types.ts"; diff --git a/packages/agent/src/harness/session/testing/types.ts b/packages/agent/src/harness/session/testing/types.ts deleted file mode 100644 index 83e8d091f..000000000 --- a/packages/agent/src/harness/session/testing/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { SessionRepo } from "../types.ts"; - -/** A fresh backend instance owned by one conformance case. */ -export interface SessionBackendFixture extends AsyncDisposable { - readonly repository: SessionRepo; -} - -/** Creates an isolated fixture for one conformance case. */ -export type SessionBackendFixtureFactory = () => Promise; - -/** A runner-independent conformance case that can be registered with any test framework. */ -export interface SessionBackendConformanceCase { - readonly group: string; - readonly name: string; - run(): Promise; -} diff --git a/packages/agent/src/harness/session/types.ts b/packages/agent/src/harness/session/types.ts index 1164698ff..d958e0820 100644 --- a/packages/agent/src/harness/session/types.ts +++ b/packages/agent/src/harness/session/types.ts @@ -1,22 +1,25 @@ -import type { StopReason, Usage } from "@earendil-works/pi-ai"; -import "../messages.ts"; -import type { AgentMessage } from "../../types.ts"; -import type { Session } from "./session.ts"; +import type { AssistantMessage, StopReason, Usage } from "@earendil-works/pi-ai"; +import type { TSchema } from "typebox"; +import type { AgentMessage, QueueMode, ThinkingLevel } from "../../types.ts"; +import type { BranchPreparation } from "../compaction/branch-summarization.ts"; +import type { CompactionPreparation, CompactionSettings } from "../compaction/compaction.ts"; +import type { AgentHarnessStreamOptions } from "../types.ts"; export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; -export type SessionStopReason = Exclude | "deferred"; +export type SettledAssistantMessage = AssistantMessage & { + stopReason: Exclude; +}; -export interface IdGenerator { - next(): string; -} +export type EntryType = "message" | "compaction" | "branch_summary" | "custom"; export interface EntryBase { - type: string; id: string; - seq: number; // shared sequence; read-side, storage-assigned - parentId: string | null; // storage-assigned: the appending lane's leaf - timestamp: number; // Unix ms, storage-assigned + parentId: string | null; + seq: number; + timestamp: number; + type: EntryType; + customType?: string; } export interface MessageEntry extends EntryBase { @@ -25,330 +28,498 @@ export interface MessageEntry extends EntryBase { terminate?: true; } -export interface ModelChangeEntry extends EntryBase { - type: "model_change"; - provider: string; - modelId: string; -} - -export interface ThinkingLevelEntry extends EntryBase { - type: "thinking_level_change"; - thinkingLevel: string; -} - -export interface ActiveToolsEntry extends EntryBase { - type: "active_tools_change"; - activeToolNames: string[]; -} - export interface CompactionEntry extends EntryBase { type: "compaction"; summary: string; retainedTail: AgentMessage[]; tokensBefore: number; - details?: unknown; + details?: JsonValue; usage?: Usage; + fromHook: boolean; } export interface BranchSummaryEntry extends EntryBase { type: "branch_summary"; fromId: string; summary: string; - details?: unknown; + details?: JsonValue; usage?: Usage; + fromHook: boolean; } export interface CustomEntry extends EntryBase { type: "custom"; customType: string; - data?: unknown; + data?: JsonValue; } -export type Entry = - | MessageEntry - | ModelChangeEntry - | ThinkingLevelEntry - | ActiveToolsEntry - | CompactionEntry - | BranchSummaryEntry - | CustomEntry; +export type Entry = MessageEntry | CompactionEntry | BranchSummaryEntry | CustomEntry; -export type ProvisionedEntry = TEntry extends Entry - ? Omit - : never; +/** Entry supplied to a transaction before storage assigns sequence and timestamp. */ +export type NewEntry = TEntry extends Entry ? Omit : never; -export interface RecordBase { - id: string; - seq: number; +export interface LaneConfiguration { + model: { provider: string; modelId: string }; + thinkingLevel: ThinkingLevel; + activeToolNames: string[]; +} + +export interface Operation { + operationId: string; lane: string; - timestamp: number; -} - -export interface OperationStartedRecord extends RecordBase { - type: "operation_started"; sourceLeafId: string | null; + startedAt: number; intent: | { kind: "run"; - /** Normalized caller input before before_run; kept for suspended operations and before_resume. */ - originalPrompt: AgentMessage[]; - /** Captured nextRun items, then the prompt, then before_run injections. */ - initialMessages: ProvisionedEntry[]; + promptEntryIds: string[]; systemPromptOverride?: string; - resumeData?: { [extensionId: string]: JsonValue }; - } - | { - kind: "compaction"; - customInstructions?: string; - resultEntryId: string; + resumeData?: Record; } + | { kind: "compaction"; customInstructions?: string } | { kind: "navigation"; targetId: string | null; summarize: boolean; - customInstructions?: string; label?: string; - summaryEntryId?: string; + customInstructions?: string; }; } -export interface AbortRequestedRecord extends RecordBase { - type: "abort_requested"; - runId: string; +export type Control = + | { status: "running" } + | { + status: "cancel_requested"; + requestedAt: number; + drainedSteer: string[]; + drainedFollowUp: string[]; + }; + +export interface OperationError { + code: string; + message: string; + details?: JsonValue; } -export interface OperationFinishedRecord extends RecordBase { - type: "operation_finished"; - runId: string; - outcome: "completed" | "aborted" | "failed" | "declined"; - error?: { code: string; message: string }; +export type Continuation = + | { kind: "need_assistant"; overflowRecoveryUsed: boolean } + | { kind: "may_finish"; includeFinalAssistant: boolean }; + +export interface CheckpointPhase { + kind: "checkpoint"; + continuation: Continuation; + triggerEntryId: string; + thresholdCheckedTriggerEntryId?: string; + skipInboxOnce?: boolean; } -export type CompactionReason = "manual" | "threshold" | "overflow"; +export interface Inbox { + steer: string[]; + followUp: string[]; + writes: string[]; +} -export type StepAttemptRecord = RecordBase & - ( - | { - type: "step_attempt"; - runId: string; - step: "assistant" | "branch_summary"; - attempt: number; - resultEntryId: string; - compactionReason?: never; - } - | { - type: "step_attempt"; - runId: string; - step: "compaction"; - attempt: number; - resultEntryId: string; - /** Persists why compaction summary generation started so recovery resumes the same work. */ - compactionReason: CompactionReason; - } - ); +export interface NormalizedRetryPolicy { + maxAttempts: number; + baseDelayMs: number; +} -export interface ToolStartedRecord extends RecordBase { - type: "tool_started"; - runId: string; +export interface GenerationContext { + stepId: string; + triggerEntryId: string; + configuration: LaneConfiguration; + streamOptions: AgentHarnessStreamOptions; + retryPolicy: NormalizedRetryPolicy; + overflowRecoveryUsed: boolean; +} + +export type Generation = + | { status: "ready"; context: GenerationContext; nextAttempt: number } + | { + status: "effect_pending"; + context: GenerationContext; + attempt: number; + responseEntryId: string; + usageId: string; + intendedOutputLimit: number; + contextWindow: number; + } + | { + status: "retry_wait"; + context: GenerationContext; + nextAttempt: number; + notBefore: number; + errorMessage: string; + }; + +export type ToolCall = + | { status: "planned"; sourceIndex: number; resultEntryId: string } + | { + status: "effect_pending"; + sourceIndex: number; + resultEntryId: string; + replay: "never" | "safe"; + } + | { + status: "completed"; + sourceIndex: number; + resultEntryId: string; + terminate: boolean; + }; + +export interface ToolBatch { assistantEntryId: string; - toolIndex: number; - toolCallId: string; - toolName: string; - effectiveArgs: { [key: string]: unknown }; + configuration: LaneConfiguration; + turnId: string; + calls: ToolCall[]; +} + +export type Deferred = + | { + status: "suspended"; + stepId: string; + sourceEntryId: string; + poll: number; + configuration: LaneConfiguration; + streamOptions: AgentHarnessStreamOptions; + } + | { + status: "effect_pending"; + stepId: string; + sourceEntryId: string; + poll: number; + responseEntryId: string; + usageId: string; + configuration: LaneConfiguration; + streamOptions: AgentHarnessStreamOptions; + }; + +export interface SummaryContext { + taskId: string; resultEntryId: string; - replay: "never" | "safe"; + kind: "compaction" | "branch_summary"; + configuration: LaneConfiguration; + streamOptions: AgentHarnessStreamOptions; + retryPolicy: NormalizedRetryPolicy; + reason?: "manual" | "threshold" | "overflow"; } -export type QueueEnqueuedRecord = RecordBase & - ( - | { - type: "queue_enqueued"; - queue: "steer" | "followUp"; - runId: string; - target: ProvisionedEntry; - } - | { - type: "queue_enqueued"; - queue: "nextRun"; - runId?: never; - target: ProvisionedEntry; - } - ); +export type SummaryGeneration = + | { status: "ready"; context: SummaryContext; nextAttempt: number } + | { + status: "effect_pending"; + context: SummaryContext; + attempt: number; + request?: { index: number; usageId: string }; + usageIds: string[]; + } + | { + status: "retry_wait"; + context: SummaryContext; + nextAttempt: number; + notBefore: number; + errorMessage: string; + }; -export interface QueueCancelledRecord extends RecordBase { - type: "queue_cancelled"; - runId?: string; - entryId: string; +export type StructuralDecision = { taskId: string } & ( + | { status: "deciding" } + | { status: "generating"; generation: SummaryGeneration } +); + +export type RunPhase = + | CheckpointPhase + | { kind: "assistant"; generation: Generation } + | { kind: "tools"; batch: ToolBatch } + | { + kind: "compaction"; + reason: "threshold" | "overflow"; + structural: StructuralDecision; + resumeAfter: CheckpointPhase; + } + | { kind: "deferred"; deferred: Deferred } + | { + kind: "failure_drain"; + error: OperationError; + provenance: { kind: "response"; entryId: string } | { kind: "structural"; taskId: string }; + }; + +export interface RunState { + kind: "run"; + control: Control; + settings: { + compaction: CompactionSettings; + steeringMode: QueueMode; + followUpMode: QueueMode; + toolExecution: "sequential" | "parallel"; + }; + phase: RunPhase; + inbox: Inbox; + latestAssistantEntryId: string | null; } -export interface WriteDeferredRecord extends RecordBase { - type: "write_deferred"; - runId: string; - target: ProvisionedEntry; +export interface CompactionState { + kind: "compaction"; + control: Control; + customInstructions?: string; + structural: StructuralDecision; } -export type UsageRecord = RecordBase & { type: "usage"; usage: Usage } & ( - | { - cause: "assistant" | "compaction" | "branch_summary" | "deferred_fetch"; - runId: string; - entryId: string; - attempt: number; - stopReason: SessionStopReason; - } - | { cause: "tool"; runId: string; entryId: string; toolCallId: string } - | { cause: "hook"; runId: string; entryId: string } - | { cause: "adjustment"; runId?: string; entryId?: string; details?: JsonValue } - ); +export type NavigationState = + | { + kind: "navigation"; + control: Control; + targetId: string | null; + label?: string; + summarize: false; + phase: { kind: "ready_to_commit" }; + } + | { + kind: "navigation"; + control: Control; + targetId: string; + label?: string; + customInstructions?: string; + summarize: true; + phase: { kind: "summary"; structural: StructuralDecision }; + }; -export type LaneRecord = - | OperationStartedRecord - | AbortRequestedRecord - | OperationFinishedRecord - | StepAttemptRecord - | ToolStartedRecord - | QueueEnqueuedRecord - | QueueCancelledRecord - | WriteDeferredRecord - | UsageRecord; -export type NewRecord = TRecord extends LaneRecord - ? Omit - : never; +export type OperationState = RunState | CompactionState | NavigationState; -export type EntryOrder = "newestFirst" | "oldestFirst"; +export interface LaneState { + currentOperationId: string | null; + pendingNextRun: string[]; +} + +export type LaneLastResult = { + operationId: string; + kind: "run" | "compaction" | "navigation"; + leafId: string | null; + finalAssistantEntryId?: string; +} & ( + | { outcome: "failed"; error: OperationError; runCompletion?: never } + | { + outcome: "completed"; + error?: never; + runCompletion?: "assistant" | "terminated_tools"; + } + | { outcome: "declined" | "aborted"; error?: never; runCompletion?: never } +); + +export interface PendingEntry { + type: "message" | "custom"; + customType?: string; + payload?: JsonValue; +} + +export interface DurableFileOperations { + read: string[]; + written: string[]; + edited: string[]; +} + +export type DurableStructuralPreparation = + | { + kind: "compaction"; + messagesToSummarize: CompactionPreparation["messagesToSummarize"]; + turnPrefixMessages: CompactionPreparation["turnPrefixMessages"]; + retainedTail: CompactionPreparation["retainedTail"]; + isSplitTurn: boolean; + tokensBefore: number; + previousSummary?: string; + fileOps: DurableFileOperations; + settings: CompactionSettings; + } + | { + kind: "branch_summary"; + messages: BranchPreparation["messages"]; + fileOps: DurableFileOperations; + totalTokens: number; + }; + +export interface RegisterValues { + "lane.leaf": string | null; + "lane.config": LaneConfiguration; + "lane.state": LaneState; + "lane.lastResult": LaneLastResult; + "op.meta": Operation; + "op.state": OperationState; + "op.tool_args": Record; + "op.preparation": DurableStructuralPreparation; + "pending.entry": PendingEntry; + "fact.name": string; + "fact.label": string; + "fact.custom": JsonValue; +} + +export type RegisterNamespace = keyof RegisterValues; + +export interface Register { + namespace: TNamespace; + key: string; + value: RegisterValues[TNamespace]; + seq: number; +} + +export interface UsageRow { + id: string; + seq: number; + usage: Usage; + entryId?: string; + adjustment: boolean; + details?: JsonValue; +} + +export type RegisterSetWrite = { + [TNamespace in RegisterNamespace]: { + kind: "register"; + op: "set"; + namespace: TNamespace; + key: string; + value: RegisterValues[TNamespace]; + }; +}[RegisterNamespace]; + +export type Write = + | { kind: "entry"; entry: NewEntry } + | { kind: "usage"; row: Omit } + | RegisterSetWrite + | { kind: "register"; op: "delete"; namespace: RegisterNamespace; key: string }; + +export interface Transaction { + writes: Write[]; +} + +export interface CommitResult { + firstSeq: number; + seqs: number[]; + timestamp: number; +} + +export interface EntryStructure { + id: string; + parentId: string | null; + seq: number; + timestamp: number; + type: EntryType; + customType?: string; +} export interface EntryCursor { - afterSeq: number; + seq: number; } -export interface EntryQuery { - type?: Entry["type"]; - customType?: string; // for type "custom" - order?: EntryOrder; // default newestFirst +export interface BranchScan { + start?: string; + stopAtType?: EntryType; + stopAtId?: string; + type?: EntryType; + customType?: string; + order?: "newestFirst" | "oldestFirst"; limit?: number; cursor?: EntryCursor; } -/** Bounds of a branch scan. Default: the whole path, leaf to root. */ -export interface BranchBounds { - start?: string; // default: the view's lane leaf - stopAtType?: Entry["type"]; // scan ends after the first match, inclusive - stopAtId?: string; +export type StorageBranchScan = BranchScan & { start: string }; + +export interface EntryScan { + type?: EntryType; + customType?: string; + fromSeq?: number; + toSeq?: number; + order?: "asc" | "desc"; + limit?: number; } -export interface RecordQuery { - /** Exact lane match. Omit to query every lane. */ - lane?: string; - /** Exact record discriminant match. Omit to query every record type. */ - type?: LaneRecord["type"]; - /** - * Operation identity. Matches OperationStartedRecord.id and the runId - * property of operation-owned records. Records without an operation - * identity do not match. - */ - runId?: string; - /** Exact operation intent kind. Valid only with type "operation_started". */ - operationKind?: OperationStartedRecord["intent"]["kind"]; - /** Exclusive chronological lower bound: seq > afterSeq, regardless of order. */ - afterSeq?: number; - /** Sequence order. Default: "newestFirst". */ - order?: EntryOrder; - /** Positive maximum number of matching records. */ +export interface UsageScan { + fromSeq?: number; + toSeq?: number; + order?: "asc" | "desc"; limit?: number; } +export interface SessionStats { + messageCount: number; + usage: Usage; +} + +export interface Storage { + commit(transaction: Transaction): Promise; + getEntries(ids: string[]): Promise>; + getRegister( + namespace: TNamespace, + key: string, + ): Promise | undefined>; + listRegisters( + namespace: TNamespace, + keyPrefix?: string, + ): Promise[]>; + scanBranch(query: StorageBranchScan): Promise; + scanBranchStructure(query: StorageBranchScan): Promise; + scanEntries(query: EntryScan): Promise; + scanUsage(query: UsageScan): Promise; + getStats(): Promise; + close(): Promise; +} + export interface SessionMetadata { id: string; createdAt: number; + storageVersion: number; + cwd?: string; parentSessionId?: string; + legacyParentSessionPath?: string; } -export interface SessionStats { - messageCount: number; - cachedTokens: number; - uncachedTokens: number; - totalTokens: number; - costTotal: number; +export interface SessionCodecOptions { + customMessageSchemas?: Record; } -export interface LanePointer { - lane: string; - leafId: string | null; +export interface IdGenerator { + next(timestampMs?: number): string; } -export type LogItem = - | { kind: "entry"; seq: number; entry: Entry } - | { kind: "record"; seq: number; record: LaneRecord } - | { kind: "lane"; seq: number; lane: string; leafId: string | null } - | { kind: "fact"; seq: number; fact: "name"; name: string | undefined } - | { kind: "fact"; seq: number; fact: "label"; targetId: string; label: string | undefined }; - -export interface LogOptions { - afterSeq?: number; +export interface EntryQuery { + type?: EntryType; + customType?: string; + order?: "asc" | "desc"; limit?: number; -} - -export interface SessionStorage { - getMetadata(): Promise; - - // Lanes - getLanes(): Promise<{ lane: string; leafId: string | null }[]>; - createLane(lane: string, at: string | null): Promise; - moveLane(lane: string, to: string | null): Promise; - - // Entries and Records - appendEntry(entry: ProvisionedEntry, lane: string): Promise; - appendRecord(record: NewRecord): Promise; - - // Reads - getEntry(id: string): Promise; - findEntries(query?: EntryQuery): Promise; - /** start is mandatory here (as opposed to SessionTree's findEntriesOnBranch); defaulting to a lane's leaf is view sugar. */ - findEntriesOnBranch(query: EntryQuery & BranchBounds & { start: string }): Promise; - findRecords( - query: RecordQuery & { type: K }, - ): Promise[]>; - findRecords(query?: RecordQuery): Promise; - /** - * Returns unfinished operation starts newest first. Recovery uses `limit: 2`: - * zero results mean the lane is idle, one means it is suspended, and two - * mean at least two operations are open, which is corruption. Further - * results provide no additional recovery state. - */ - findOpenOperations(lane: string, options?: { limit?: number }): Promise; - getLog(options?: { afterSeq?: number; limit?: number }): Promise; - - // Global facts - getName(): Promise; - setName(name: string | undefined): Promise; - getLabel(id: string): Promise; - setLabel(id: string, label: string | undefined): Promise; - getStats(): Promise; + cursor?: EntryCursor; } export interface SessionTree { getLeafId(): Promise; getEntry(id: string): Promise; getStats(): Promise; - - // Global facts. Latest wins; not branch-scoped. "set", not "append": - // append vocabulary is reserved for tree writes. getName(): Promise; setName(name: string | undefined): Promise; getLabel(targetId: string): Promise; setLabel(targetId: string, label: string | undefined): Promise; - - /** Session-wide, all branches, sequence order. */ + getCustomFact(key: string): Promise; + setCustomFact(key: string, value: JsonValue | undefined): Promise; findEntries(query?: EntryQuery): Promise; findEntry(query?: EntryQuery): Promise; - - /** Branch-scoped: the path from start toward root. */ - findEntriesOnBranch(query?: EntryQuery & BranchBounds): Promise; - findEntryOnBranch(query?: EntryQuery & BranchBounds): Promise; - - // Writes. Resolve on durable acceptance; the returned id is the entry's - // id (provisioned when the write defers). + findEntriesOnBranch(query?: BranchScan): Promise; + findEntryOnBranch(query?: BranchScan): Promise; appendMessage(message: AgentMessage): Promise; - appendCustomEntry(customType: string, data?: unknown): Promise; + appendCustomEntry(customType: string, data?: JsonValue): Promise; +} + +export interface Session extends SessionTree { + readonly metadata: TMetadata; + readonly idGenerator: IdGenerator; + view(lane: string): SessionTree; + createLane(name: string, at: string | null, configuration: LaneConfiguration): Promise; + commit(transaction: Transaction): Promise; + getEntries(ids: string[]): Promise>; + getRegister( + namespace: TNamespace, + key: string, + ): Promise | undefined>; + listRegisters( + namespace: TNamespace, + keyPrefix?: string, + ): Promise[]>; + close(): Promise; } export interface SessionCreateOptions { @@ -360,34 +531,12 @@ export type ForkOptions = { scope?: "branch"; entryId?: string; position?: "befo export interface SessionRepo< TMetadata extends SessionMetadata = SessionMetadata, - TCreateOptions extends SessionCreateOptions = SessionCreateOptions, + TCreateOptions extends { id?: string; parentSessionId?: string } = SessionCreateOptions, TListOptions = void, > { create(options: TCreateOptions): Promise>; - /** Opens the session for writing and acquires any backend writer claim. */ open(metadata: TMetadata): Promise>; - /** Lists session metadata without opening sessions or acquiring writer claims. */ list(options?: TListOptions): Promise; delete(metadata: TMetadata): Promise; fork(source: TMetadata, options: ForkOptions & TCreateOptions): Promise>; } - -export type SessionErrorCode = - | "not_found" - | "already_exists" - | "invalid_entry" - | "invalid_payload" - | "invalid_lane" - | "invalid_query" - | "invalid_fork_target" - | "storage"; - -export class SessionError extends Error { - readonly code: SessionErrorCode; - - constructor(code: SessionErrorCode, message: string, cause?: Error) { - super(message, cause === undefined ? undefined : { cause }); - this.name = "SessionError"; - this.code = code; - } -} diff --git a/packages/agent/src/harness/telemetry.ts b/packages/agent/src/harness/telemetry.ts index c7ba25c57..8711ef680 100644 --- a/packages/agent/src/harness/telemetry.ts +++ b/packages/agent/src/harness/telemetry.ts @@ -488,7 +488,16 @@ export const HARNESS_TELEMETRY_SCHEMA = { }, "pi.harness.sleep": { description: "One retry delay", - parents: { kind: "spans", spans: ["pi.harness.step", "pi.harness.run"] }, + parents: { + kind: "spans", + spans: [ + "pi.harness.run", + "pi.harness.compaction", + "pi.harness.navigation", + "pi.harness.turn", + "pi.harness.checkpoint", + ], + }, startAttributes: { "pi.operation.id": { type: "string", @@ -533,40 +542,50 @@ export const HARNESS_TELEMETRY_SCHEMA = { status: { default: "ok", errorWhen: "The listener throws" }, }, "pi.session.write": { - description: "One committed session mutation", + description: "One committed session transaction", parents: { kind: "any" }, startAttributes: { - "pi.lane.name": { + "pi.session.id": { type: "string", required: true, cardinality: "high", - description: "Lane name", + description: "Session id", + }, + "pi.lane.name": { + type: "string", + required: false, + cardinality: "high", + description: "Lane name when supplied by the caller", }, "pi.operation.id": { type: "string", required: false, cardinality: "high", - description: "Durable operation id when accepted", + description: "Durable operation id when supplied by the caller", }, - "pi.session.mutation": { - type: "string", + "pi.session.item_count": { + type: "number", required: true, - values: ["entry", "record", "lane", "fact"], - description: "Session mutation kind", + description: "Number of writes in the transaction", }, - "pi.session.item_type": { - type: "string", - required: false, - description: "Entry, record, lane, or fact subtype", + "pi.session.item_kinds": { + type: "string[]", + required: true, + elementValues: ["entry", "usage", "register"], + description: "Distinct write kinds in the transaction", }, }, endAttributes: { - "pi.session.seq": { + "pi.session.first_seq": { type: "number", - description: "Committed session sequence when exposed", + description: "First committed sequence in the transaction", + }, + "pi.session.last_seq": { + type: "number", + description: "Last committed sequence in the transaction", }, }, - status: { default: "ok", errorWhen: "Storage rejects the mutation" }, + status: { default: "ok", errorWhen: "Storage rejects the transaction" }, }, }, } as const satisfies TelemetrySchemaDefinition; diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index 7fb03f3ed..e7d278b99 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -114,6 +114,8 @@ export interface AgentHarnessStreamOptions { metadata?: SimpleStreamOptions["metadata"]; /** Provider cache retention hint. */ cacheRetention?: SimpleStreamOptions["cacheRetention"]; + /** Ask a capable provider to continue generation asynchronously. */ + deferred?: boolean | { window?: "15m" | "1h" | "24h" }; } /** Per-request stream option patch returned by provider hooks. */ diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 109cb0500..2e8ef7546 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,5 +1,3 @@ -// Core Agent - export { uuidv7 } from "@earendil-works/pi-ai"; export type { AttributeValue, @@ -41,7 +39,6 @@ export { NOOP_TELEMETRY_CONTEXT, } from "@earendil-works/pi-telemetry"; export * from "./agent.ts"; -// Loop functions export * from "./agent-loop.ts"; export * from "./harness/agent-harness.ts"; export { @@ -75,7 +72,6 @@ export { } from "./harness/compaction/compaction.ts"; export * from "./harness/messages.ts"; export * from "./harness/prompt-templates.ts"; -// Harness export * from "./harness/result.ts"; export * from "./harness/session/index.ts"; export * from "./harness/skills.ts"; @@ -136,10 +132,7 @@ export { } from "./harness/types.ts"; export * from "./harness/utils/shell-output.ts"; export * from "./harness/utils/truncate.ts"; -// Proxy utilities export * from "./proxy.ts"; export * from "./search/index.ts"; -// Stream defaults export { setDefaultStreamFn } from "./stream-fn.ts"; -// Types export * from "./types.ts"; diff --git a/packages/agent/src/search/index.ts b/packages/agent/src/search/index.ts index 735847895..15add13ef 100644 --- a/packages/agent/src/search/index.ts +++ b/packages/agent/src/search/index.ts @@ -1,32 +1,27 @@ -import type { Entry } from "../harness/session/types.ts"; - -export type { - ScanningReadable, - ScanningReadableOptions, - ScanningReadableSource, - ScanningSearchTextProjector, - ScanningSessionSearchHit, - ScanningSessionSearchOptions, - SessionSearchCandidate, -} from "./scanning.ts"; -export { createScanningSessionSearch, scanningEntries } from "./scanning.ts"; - -export interface SessionSearchOptions { - /** Restrict results to specific canonical entry types. */ - readonly entryTypes?: readonly Entry["type"][]; - /** Maximum number of hits to return. */ - readonly limit?: number; - /** Abort signal for cancellation, e.g. search-as-you-type. */ - readonly signal?: AbortSignal; +export interface SearchQuery { + text: string; + limit?: number; } export interface SessionSearchHit { - /** Logical identifier of the session that owns the entry. */ - readonly sessionId: string; - /** Logical identifier of the entry within that session. */ - readonly entryId: string; + sessionId: string; + score?: number; + top?: { entryId: string; snippet?: string; timestamp: number }; } -export interface SessionSearch { - search(text: string, options?: SessionSearchOptions): AsyncIterable; +export interface EntrySearchHit { + sessionId: string; + entryId: string; + timestamp: number; + snippet?: string; + score?: number; +} + +export interface SessionSearchService { + searchSessions(query: SearchQuery): Promise; + searchEntries?(query: SearchQuery): Promise; + sync(): Promise; + notify(sessionId: string): void; + remove(sessionId: string): Promise; + close(): Promise; } diff --git a/packages/agent/src/search/scanning.ts b/packages/agent/src/search/scanning.ts deleted file mode 100644 index 14e6bfbf2..000000000 --- a/packages/agent/src/search/scanning.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { Entry, SessionMetadata, SessionStorage } from "../harness/session/types.ts"; -import type { SessionSearch, SessionSearchHit, SessionSearchOptions } from "./index.ts"; - -export interface SessionSearchCandidate { - readonly entryId: string; - readonly seq: number; - readonly type: Entry["type"]; - readonly timestamp: number; - readonly text: string; - readonly fields?: Record; -} - -export type ScanningReadable = Pick< - SessionStorage, - "getMetadata" | "findEntries" | "getLabel" ->; - -export type ScanningReadableSource = ( - options?: TOptions, -) => AsyncIterable>; - -export type ScanningSearchTextProjector = ( - metadata: TMetadata, - entry: Entry, - label: string | undefined, -) => string; - -export interface ScanningReadableOptions { - projectText?: ScanningSearchTextProjector; - pageSize?: number; -} - -export interface ScanningSessionSearchHit extends SessionSearchHit { - readonly timestamp: number; - readonly snippet: string; -} - -export interface ScanningSessionSearchOptions< - TMetadata extends SessionMetadata = SessionMetadata, - TSourceOptions = unknown, - THit extends SessionSearchHit = ScanningSessionSearchHit, -> extends ScanningReadableOptions { - sourceOptions?: (text: string, options: SessionSearchOptions) => TSourceOptions | undefined; - match?: (queryText: string, candidate: SessionSearchCandidate, metadata: TMetadata) => boolean; - createHit?: (metadata: TMetadata, candidate: SessionSearchCandidate) => THit; -} - -function defaultSearchText( - _metadata: TMetadata, - entry: Entry, - label: string | undefined, -): string { - return label === undefined ? JSON.stringify(entry) : `${JSON.stringify(entry)} ${label}`; -} - -async function* scanReadableEntries( - readable: ScanningReadable, - metadata: TMetadata, - options: ScanningReadableOptions, - query: { afterSeq?: number; limit?: number; entryTypes?: readonly Entry["type"][] } = {}, -): AsyncIterable { - const projectText = options.projectText ?? defaultSearchText; - const pageSize = query.limit ?? options.pageSize ?? 100; - let afterSeq = query.afterSeq ?? 0; - const entryTypes = query.entryTypes === undefined ? undefined : new Set(query.entryTypes); - while (true) { - const entries = await readable.findEntries({ - order: "oldestFirst", - limit: pageSize, - cursor: { afterSeq }, - type: query.entryTypes?.length === 1 ? query.entryTypes[0] : undefined, - }); - if (entries.length === 0) break; - for (const entry of entries) { - if (entryTypes !== undefined && !entryTypes.has(entry.type)) continue; - const label = await readable.getLabel(entry.id); - yield { - entryId: entry.id, - seq: entry.seq, - type: entry.type, - timestamp: entry.timestamp, - text: projectText(metadata, entry, label), - fields: label === undefined ? undefined : { label }, - }; - } - afterSeq = entries[entries.length - 1]?.seq ?? afterSeq; - if (entries.length < pageSize) break; - } -} - -export async function* scanningEntries( - readable: ScanningReadable, - options: ScanningReadableOptions = {}, -): AsyncIterable { - yield* scanReadableEntries(readable, await readable.getMetadata(), options); -} - -async function* arraySource( - readables: readonly ScanningReadable[], -): AsyncIterable> { - yield* readables; -} - -function readablesFor( - source: readonly ScanningReadable[] | ScanningReadableSource, - options: TSourceOptions | undefined, -): AsyncIterable> { - return typeof source === "function" ? source(options) : arraySource(source); -} - -function defaultMatch(queryText: string, candidate: SessionSearchCandidate): boolean { - return candidate.text.toLowerCase().includes(queryText); -} - -function throwIfAborted(signal: AbortSignal | undefined): void { - if (!signal?.aborted) return; - if (signal.reason instanceof Error) throw signal.reason; - const error = new Error("The operation was aborted"); - error.name = "AbortError"; - throw error; -} - -function createDefaultScanningHit( - metadata: TMetadata, - candidate: SessionSearchCandidate, -): ScanningSessionSearchHit { - return { - sessionId: metadata.id, - entryId: candidate.entryId, - timestamp: candidate.timestamp, - snippet: candidate.text, - }; -} - -export function createScanningSessionSearch< - TMetadata extends SessionMetadata, - TSourceOptions = unknown, - THit extends SessionSearchHit = ScanningSessionSearchHit, ->( - source: readonly ScanningReadable[] | ScanningReadableSource, - options: ScanningSessionSearchOptions = {}, -): SessionSearch { - const createHit = - options.createHit ?? - ((metadata: TMetadata, candidate: SessionSearchCandidate) => - createDefaultScanningHit(metadata, candidate) as unknown as THit); - return { - async *search(text: string, searchOptions: SessionSearchOptions = {}): AsyncIterable { - const normalizedText = text.trim().toLowerCase(); - if (!normalizedText || (searchOptions.limit !== undefined && searchOptions.limit <= 0)) return; - if (searchOptions.entryTypes?.length === 0) return; - let hitCount = 0; - const seenSessionIds = new Set(); - const entryTypes = searchOptions.entryTypes === undefined ? undefined : new Set(searchOptions.entryTypes); - const sourceOptions = options.sourceOptions?.(normalizedText, searchOptions); - for await (const readable of readablesFor(source, sourceOptions)) { - throwIfAborted(searchOptions.signal); - const metadata = await readable.getMetadata(); - if (seenSessionIds.has(metadata.id)) throw new Error(`Duplicate sessionId: ${metadata.id}`); - seenSessionIds.add(metadata.id); - for await (const candidate of scanReadableEntries(readable, metadata, options, { - entryTypes: searchOptions.entryTypes, - })) { - throwIfAborted(searchOptions.signal); - if (entryTypes !== undefined && !entryTypes.has(candidate.type)) continue; - const matches = - options.match?.(normalizedText, candidate, metadata) ?? defaultMatch(normalizedText, candidate); - if (!matches) continue; - yield createHit(metadata, candidate); - hitCount += 1; - if (searchOptions.limit !== undefined && hitCount >= searchOptions.limit) return; - } - } - }, - }; -} diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 5b20b21f4..ad9d5cc57 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -398,6 +398,8 @@ export interface AgentTool, ) => Promise>; + /** Recovery policy for an effect whose durable intent exists but whose outcome is unknown. */ + replay?: "never" | "safe"; /** * Per-tool execution mode override. * - "sequential": this tool must execute one at a time with other tool calls. diff --git a/packages/agent/test/harness/agent-harness-scaffold.test.ts b/packages/agent/test/harness/agent-harness-scaffold.test.ts deleted file mode 100644 index 4e26e9183..000000000 --- a/packages/agent/test/harness/agent-harness-scaffold.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { createModels, type Usage } from "@earendil-works/pi-ai"; -import { getModel } from "@earendil-works/pi-ai/compat"; -import { describe, expect, it } from "vitest"; -import { - AgentHarness, - HarnessClosed, - HarnessNotImplemented, - type HarnessTool, - type Resources, -} from "../../src/harness/agent-harness.ts"; -import { - InMemorySessionStorage, - type NewRecord, - type OperationStartedRecord, - Session, -} from "../../src/harness/session/index.ts"; -import type { AgentMessage } from "../../src/types.ts"; - -function createSession(id = "session"): Session { - return new Session(new InMemorySessionStorage({ id, createdAt: 1 })); -} - -function createHarness(session = createSession()): Promise { - return AgentHarness.create({ - session, - models: createModels(), - model: getModel("google", "gemini-2.5-flash"), - }).then(({ harness }) => harness); -} - -function operationStarted(id: string): NewRecord { - return { - type: "operation_started", - id, - lane: "main", - sourceLeafId: null, - intent: { kind: "run", originalPrompt: [], initialMessages: [] }, - }; -} - -const userMessage: AgentMessage = { - role: "user", - content: [{ type: "text", text: "hello" }], - timestamp: 1, -}; - -const usage: Usage = { - input: 1, - output: 2, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 3, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, -}; - -describe("AgentHarness v2 scaffold", () => { - it("opens only record-free sessions before restore is implemented", async () => { - const session = createSession(); - const { harness, suspended } = await AgentHarness.create({ - session, - models: createModels(), - model: getModel("google", "gemini-2.5-flash"), - }); - - expect(suspended).toEqual([]); - expect(harness.name).toBe("main"); - expect(harness.session).toBe(session); - expect(await harness.getLeafId()).toBeNull(); - expect(await harness.session.getLeafId()).toBeNull(); - - await expect(harness.close()).resolves.toBeUndefined(); - - const recorded = createSession("recorded"); - await recorded.appendRecord(operationStarted("run")); - await expect( - AgentHarness.create({ - session: recorded, - models: createModels(), - model: getModel("google", "gemini-2.5-flash"), - }), - ).rejects.toMatchObject({ name: "HarnessNotImplemented", operation: "create.restore" }); - }); - - it("keeps scaffold-safe configuration as defensive copies", async () => { - const harness = await createHarness(); - const model = getModel("anthropic", "claude-sonnet-4-5"); - await harness.setModel(model); - expect(await harness.getModel()).toBe(model); - - await harness.setThinkingLevel("high"); - expect(await harness.getThinkingLevel()).toBe("high"); - - const activeTools = ["one"]; - await harness.setActiveTools(activeTools); - activeTools.push("mutated"); - expect(await harness.getActiveTools()).toEqual(["one"]); - const readActiveTools = await harness.getActiveTools(); - readActiveTools.push("mutated"); - expect(await harness.getActiveTools()).toEqual(["one"]); - - const tool = { name: "tool", label: "Tool" } as HarnessTool; - const tools = [tool]; - await harness.setTools(tools); - tools.push({ name: "mutated", label: "Mutated" } as HarnessTool); - expect((await harness.getTools()).map((item) => item.name)).toEqual(["tool"]); - const readTools = await harness.getTools(); - readTools.push({ name: "mutated", label: "Mutated" } as HarnessTool); - expect((await harness.getTools()).map((item) => item.name)).toEqual(["tool"]); - - const resources: Resources = { - skills: [{ name: "skill", description: "desc", content: "body", filePath: "/tmp/SKILL.md" }], - promptTemplates: [{ name: "template", content: "body" }], - }; - await harness.setResources(resources); - resources.skills?.push({ name: "mutated", description: "desc", content: "body", filePath: "/tmp/OTHER.md" }); - expect((await harness.getResources()).skills?.map((skill) => skill.name)).toEqual(["skill"]); - const readResources = await harness.getResources(); - readResources.skills?.push({ name: "mutated", description: "desc", content: "body", filePath: "/tmp/OTHER.md" }); - expect((await harness.getResources()).skills?.map((skill) => skill.name)).toEqual(["skill"]); - - const streamOptions = { maxTokens: 10 }; - await harness.setStreamOptions(streamOptions); - streamOptions.maxTokens = 20; - expect(await harness.getStreamOptions()).toEqual({ maxTokens: 10 }); - const readStreamOptions = await harness.getStreamOptions(); - readStreamOptions.maxTokens = 30; - expect(await harness.getStreamOptions()).toEqual({ maxTokens: 10 }); - - const retryPolicy = { enabled: true, maxRetries: 2, baseDelayMs: 10 }; - await harness.setRetryPolicy(retryPolicy); - retryPolicy.maxRetries = 99; - expect(await harness.getRetryPolicy()).toEqual({ enabled: true, maxRetries: 2, baseDelayMs: 10 }); - - const compactionSettings = { enabled: false, reserveTokens: 1, keepRecentTokens: 2 }; - await harness.setCompactionSettings(compactionSettings); - compactionSettings.reserveTokens = 99; - expect(await harness.getCompactionSettings()).toEqual({ enabled: false, reserveTokens: 1, keepRecentTokens: 2 }); - - await harness.setSteeringMode("all"); - expect(await harness.getSteeringMode()).toBe("all"); - await harness.setFollowUpMode("all"); - expect(await harness.getFollowUpMode()).toBe("all"); - }); - - it("rejects every unfinished public operation explicitly", async () => { - const harness = await createHarness(); - let callbackCalled = false; - const unfinished: [string, () => unknown | Promise][] = [ - ["prompt", () => harness.prompt("hello")], - ["skill", () => harness.skill("skill")], - ["promptFromTemplate", () => harness.promptFromTemplate("template")], - ["compact", () => harness.compact()], - ["navigateTree", () => harness.navigateTree(null)], - ["resume", () => harness.resume()], - ["abort", () => harness.abort()], - ["steer", () => harness.steer(userMessage)], - ["followUp", () => harness.followUp(userMessage)], - ["nextRun", () => harness.nextRun(userMessage)], - ["cancelQueued", () => harness.cancelQueued("queued")], - ["recordUsage", () => harness.recordUsage(usage)], - ["waitForIdle", () => harness.waitForIdle()], - [ - "runWhenIdle", - () => - harness.runWhenIdle(() => { - callbackCalled = true; - }), - ], - ["peekAction", () => harness.peekAction()], - ["executeAction", () => harness.executeAction()], - ["runToCompletion", () => harness.runToCompletion()], - ["watch", () => harness.watch()], - ["lane", () => harness.lane("main")], - ["createLane", () => harness.createLane("thread", null)], - ["lanes", () => harness.lanes()], - ["watchSession", () => harness.watchSession()], - ]; - - for (const [operation, invoke] of unfinished) { - await expect(Promise.resolve().then(invoke), operation).rejects.toMatchObject({ - name: "HarnessNotImplemented", - operation, - }); - } - expect(callbackCalled).toBe(false); - expect(() => harness.hooks.on("before_run", () => {})).toThrow(HarnessNotImplemented); - expect(() => harness.events.on("event", () => {})).toThrow(HarnessNotImplemented); - }); - - it("reports HarnessClosed for unfinished operations after close", async () => { - const harness = await createHarness(); - await harness.close(); - - await expect(harness.prompt("hello")).rejects.toBeInstanceOf(HarnessClosed); - await expect(harness.waitForIdle()).rejects.toBeInstanceOf(HarnessClosed); - expect(() => harness.hooks.on("before_run", () => {})).toThrow(HarnessClosed); - expect(() => harness.events.on("event", () => {})).toThrow(HarnessClosed); - }); -}); diff --git a/packages/agent/test/harness/branch-summarization.test.ts b/packages/agent/test/harness/branch-summarization.test.ts index bb4edea50..913e582d7 100644 --- a/packages/agent/test/harness/branch-summarization.test.ts +++ b/packages/agent/test/harness/branch-summarization.test.ts @@ -1,37 +1,54 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { describe, expect, it } from "vitest"; import { collectEntriesForBranchSummary } from "../../src/harness/compaction/branch-summarization.ts"; -import { InMemorySessionStorage, Session } from "../../src/harness/session/index.ts"; +import type { Entry, MessageEntry, SessionTree } from "../../src/harness/session/index.ts"; function message(text: string): AgentMessage { return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; } +function messageEntry(id: string, parentId: string | null, text: string, seq: number): MessageEntry { + return { type: "message", id, parentId, message: message(text), seq, timestamp: seq }; +} + +function branchReader(entries: Entry[]): Pick { + const byId = new Map(entries.map((entry) => [entry.id, entry])); + return { + async getEntry(id) { + return byId.get(id); + }, + async findEntriesOnBranch(query = {}) { + const path: Entry[] = []; + let currentId = query.start ?? null; + while (currentId !== null) { + const entry = byId.get(currentId); + if (!entry) throw new Error(`Unknown entry ${currentId}`); + path.push(entry); + currentId = entry.parentId; + } + return path; + }, + }; +} + describe("v4 branch summarization", () => { it("collects the abandoned side of a branch in chronological order", async () => { - let nextId = 0; - const session = new Session(new InMemorySessionStorage({ id: "session", createdAt: 1 }), { - idGenerator: { next: () => `entry-${++nextId}` }, - }); - const rootId = await session.appendMessage(message("root")); - const commonId = await session.appendMessage(message("common")); - const abandonedIds = [ - await session.appendMessage(message("abandoned 1")), - await session.appendMessage(message("abandoned 2")), - ]; - await session.createLane("target", commonId); - const targetId = await session.view("target").appendMessage(message("target")); + const root = messageEntry("root", null, "root", 1); + const common = messageEntry("common", root.id, "common", 2); + const abandoned1 = messageEntry("abandoned-1", common.id, "abandoned 1", 3); + const abandoned2 = messageEntry("abandoned-2", abandoned1.id, "abandoned 2", 4); + const target = messageEntry("target", common.id, "target", 5); + const session = branchReader([root, common, abandoned1, abandoned2, target]); - const result = await collectEntriesForBranchSummary(session, abandonedIds[1]!, targetId); - expect(result.commonAncestorId).toBe(commonId); - expect(result.entries.map((entry) => entry.id)).toEqual(abandonedIds); - expect(result.entries.some((entry) => entry.id === rootId)).toBe(false); + const result = await collectEntriesForBranchSummary(session, abandoned2.id, target.id); + expect(result.commonAncestorId).toBe(common.id); + expect(result.entries.map((entry) => entry.id)).toEqual([abandoned1.id, abandoned2.id]); + expect(result.entries.some((entry) => entry.id === root.id)).toBe(false); }); it("returns no entries when there was no previous leaf", async () => { - const session = new Session(new InMemorySessionStorage({ id: "session", createdAt: 1 })); - const targetId = await session.appendMessage(message("target")); - expect(await collectEntriesForBranchSummary(session, null, targetId)).toEqual({ + const target = messageEntry("target", null, "target", 1); + expect(await collectEntriesForBranchSummary(branchReader([target]), null, target.id)).toEqual({ entries: [], commonAncestorId: null, }); diff --git a/packages/agent/test/harness/compaction.test.ts b/packages/agent/test/harness/compaction.test.ts index eca43dfd7..775c7b053 100644 --- a/packages/agent/test/harness/compaction.test.ts +++ b/packages/agent/test/harness/compaction.test.ts @@ -32,10 +32,9 @@ import { buildSessionContext } from "../../src/harness/session/context.ts"; import type { BranchSummaryEntry, CompactionEntry, + CustomEntry, Entry, MessageEntry, - ModelChangeEntry, - ThinkingLevelEntry, } from "../../src/harness/session/types.ts"; import { getOrThrow } from "../../src/harness/types.ts"; import type { AgentMessage } from "../../src/types.ts"; @@ -102,29 +101,18 @@ function createCompactionEntry( summary, tokensBefore: 1234, retainedTail: retainedTail ?? [], + fromHook: false, }; } -function createThinkingLevelEntry(level: string, parentId: string | null = null): ThinkingLevelEntry { +function createCustomEntry(customType: string, parentId: string | null = null): CustomEntry { return { - type: "thinking_level_change", + type: "custom", + customType, id: createId(), parentId, seq: nextId, timestamp: Date.now(), - thinkingLevel: level, - }; -} - -function createModelChangeEntry(provider: string, modelId: string, parentId: string | null = null): ModelChangeEntry { - return { - type: "model_change", - id: createId(), - parentId, - seq: nextId, - timestamp: Date.now(), - provider, - modelId, }; } @@ -199,9 +187,9 @@ describe("harness compaction", () => { }); it("covers cut-point and turn-start edge cases", () => { - const thinking = createThinkingLevelEntry("high"); - const modelChange = createModelChangeEntry("openai", "gpt-4", thinking.id); - expect(findCutPoint([thinking, modelChange], 0, 2, 1)).toEqual({ + const firstCustom = createCustomEntry("first"); + const secondCustom = createCustomEntry("second", firstCustom.id); + expect(findCutPoint([firstCustom, secondCustom], 0, 2, 1)).toEqual({ firstKeptEntryIndex: 0, turnStartIndex: -1, isSplitTurn: false, @@ -210,16 +198,17 @@ describe("harness compaction", () => { const branchSummary: BranchSummaryEntry = { type: "branch_summary", id: createId(), - parentId: modelChange.id, + parentId: secondCustom.id, seq: nextId, timestamp: Date.now(), fromId: "branch", summary: "branch summary", + fromHook: false, }; - expect(findTurnStartIndex([thinking, branchSummary], 1, 0)).toBe(1); - expect(findTurnStartIndex([thinking, modelChange], 1, 0)).toBe(-1); + expect(findTurnStartIndex([firstCustom, branchSummary], 1, 0)).toBe(1); + expect(findTurnStartIndex([firstCustom, secondCustom], 1, 0)).toBe(-1); - const result = findCutPoint([thinking, branchSummary], 0, 2, 1); + const result = findCutPoint([firstCustom, branchSummary], 0, 2, 1); expect(result.firstKeptEntryIndex).toBe(0); const toolResult = createMessageEntry({ @@ -356,14 +345,11 @@ describe("harness compaction", () => { ]); }); - it("tracks model and thinking level changes in built context", () => { + it("excludes failed assistant responses from built context", () => { const user = createMessageEntry(createUserMessage("1")); - const modelChange = createModelChangeEntry("openai", "gpt-4", user.id); - const assistant = createMessageEntry(createAssistantMessage("a"), modelChange.id); - const thinkingChange = createThinkingLevelEntry("high", assistant.id); - const loaded = buildSessionContext([user, modelChange, assistant, thinkingChange]); - expect(loaded.model).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-5" }); - expect(loaded.thinkingLevel).toBe("high"); + const failed = createMessageEntry({ ...createAssistantMessage("failed"), stopReason: "error" }, user.id); + const loaded = buildSessionContext([user, failed]); + expect(loaded.messages).toEqual([user.message]); }); it("prepares compaction using the latest compaction summary as previousSummary", () => { diff --git a/packages/agent/test/harness/events.test.ts b/packages/agent/test/harness/events.test.ts deleted file mode 100644 index 84cb5e52e..000000000 --- a/packages/agent/test/harness/events.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { type HarnessEvent, HarnessEventBus, type RunEndEvent, type RunStartEvent } from "../../src/harness/events.ts"; - -const runStartEvent: RunStartEvent = { - type: "run_start", - lane: "main", - runId: "run-1", -}; - -const runEndEvent: RunEndEvent = { - type: "run_end", - lane: "main", - runId: "run-1", - outcome: "completed", - leafId: "entry-1", -}; - -describe("HarnessEventBus", () => { - it("delivers matching events to direct listeners and watchers", () => { - const events = new HarnessEventBus(); - const direct: RunStartEvent[] = []; - const watchEvents: HarnessEvent[] = []; - const off = events.on("run_start", (event) => { - direct.push(event); - }); - const watch = events.watch(() => null); - watch.start((event) => { - watchEvents.push(event); - }); - - events.emit(runStartEvent); - events.emit(runEndEvent); - off(); - events.emit(runStartEvent); - - expect(direct).toEqual([runStartEvent]); - expect(watchEvents).toEqual([runStartEvent, runEndEvent, runStartEvent]); - }); - - it("captures a snapshot without an event gap, then flushes and delivers live events", () => { - const events = new HarnessEventBus(); - const expectedSnapshot = { leafId: null }; - const watch = events.watch(() => { - const snapshot = expectedSnapshot; - events.emit(runStartEvent); - return snapshot; - }); - const received: HarnessEvent[] = []; - - expect(watch.snapshot).toBe(expectedSnapshot); - expect(received).toEqual([]); - - watch.start((event) => { - received.push(event); - }); - expect(received).toEqual([runStartEvent]); - - events.emit(runEndEvent); - expect(received).toEqual([runStartEvent, runEndEvent]); - - watch.unsubscribe(); - events.emit(runStartEvent); - expect(received).toEqual([runStartEvent, runEndEvent]); - }); -}); diff --git a/packages/agent/test/harness/reducer.test.ts b/packages/agent/test/harness/reducer.test.ts deleted file mode 100644 index 2422cf8ff..000000000 --- a/packages/agent/test/harness/reducer.test.ts +++ /dev/null @@ -1,1127 +0,0 @@ -import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@earendil-works/pi-ai"; -import { describe, expect, it } from "vitest"; -import { - type EffectiveLaneConfiguration, - type LaneReductionInput, - RecordLogCorruption, - type RecordLogCorruptionReason, - type RecordLogSlice, - reduceLaneState, - validateRecordLog, -} from "../../src/harness/reducer.ts"; -import type { - AbortRequestedRecord, - BranchSummaryEntry, - CompactionEntry, - Entry, - LaneRecord, - MessageEntry, - OperationFinishedRecord, - OperationStartedRecord, - ProvisionedEntry, - QueueCancelledRecord, - QueueEnqueuedRecord, - SessionStopReason, - StepAttemptRecord, - ToolStartedRecord, - UsageRecord, - WriteDeferredRecord, -} from "../../src/harness/session/types.ts"; - -const usage: Usage = { - input: 1, - output: 1, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 2, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, -}; - -function userMessage(text: string): UserMessage { - return { role: "user", content: text, timestamp: 1 }; -} - -function assistantMessage( - content: AssistantMessage["content"], - stopReason: AssistantMessage["stopReason"] = "stop", -): AssistantMessage { - return { - role: "assistant", - content, - api: "openai-responses", - provider: "openai", - model: "test-model", - usage, - stopReason, - timestamp: 1, - ...(stopReason === "deferred" - ? { deferred: { provider: "openai", modelId: "test-model", api: "openai-responses", id: "deferred-1" } } - : {}), - }; -} - -function toolResultMessage(toolCallId = "call-1", toolName = "tool-1"): ToolResultMessage { - return { - role: "toolResult", - toolCallId, - toolName, - content: [{ type: "text", text: "result" }], - isError: false, - timestamp: 1, - }; -} - -function messageTarget( - id: string, - message: UserMessage | AssistantMessage | ToolResultMessage, -): ProvisionedEntry { - return { type: "message", id, message }; -} - -function persistedEntry( - target: ProvisionedEntry, - seq: number, - parentId: string | null = null, -): TEntry { - return { ...target, parentId, seq, timestamp: seq } as unknown as TEntry; -} - -function runStarted( - seq = 1, - options: { id?: string; initialMessages?: ProvisionedEntry[] } = {}, -): OperationStartedRecord { - return { - type: "operation_started", - id: options.id ?? "run-1", - lane: "main", - seq, - timestamp: seq, - sourceLeafId: null, - intent: { kind: "run", originalPrompt: [], initialMessages: options.initialMessages ?? [] }, - }; -} - -function compactionStarted(seq: number, resultEntryId = "compaction-1"): OperationStartedRecord { - return { - type: "operation_started", - id: "compact-1", - lane: "main", - seq, - timestamp: seq, - sourceLeafId: "source", - intent: { kind: "compaction", resultEntryId }, - }; -} - -function navigationStarted(seq: number, summaryEntryId = "summary-1"): OperationStartedRecord { - return { - type: "operation_started", - id: "navigate-1", - lane: "main", - seq, - timestamp: seq, - sourceLeafId: "source", - intent: { kind: "navigation", targetId: "target", summarize: true, summaryEntryId }, - }; -} - -function attempt( - seq: number, - runId: string, - step: StepAttemptRecord["step"], - attemptNumber: number, - resultEntryId: string, - compactionReason?: "manual" | "threshold" | "overflow", -): StepAttemptRecord { - const base = { - type: "step_attempt" as const, - id: `attempt-${seq}`, - lane: "main", - seq, - timestamp: seq, - runId, - attempt: attemptNumber, - resultEntryId, - }; - return step === "compaction" ? { ...base, step, compactionReason: compactionReason ?? "manual" } : { ...base, step }; -} - -function abortRequested(seq: number, runId = "run-1"): AbortRequestedRecord { - return { type: "abort_requested", id: `abort-${seq}`, lane: "main", seq, timestamp: seq, runId }; -} - -function operationFinished( - seq: number, - runId = "run-1", - outcome: OperationFinishedRecord["outcome"] = "completed", -): OperationFinishedRecord { - return { type: "operation_finished", id: `finish-${seq}`, lane: "main", seq, timestamp: seq, runId, outcome }; -} - -function toolStarted( - seq: number, - overrides: Partial< - Pick - > = {}, -): ToolStartedRecord { - return { - type: "tool_started", - id: `tool-start-${seq}`, - lane: "main", - seq, - timestamp: seq, - runId: "run-1", - assistantEntryId: overrides.assistantEntryId ?? "assistant-tools", - toolIndex: overrides.toolIndex ?? 0, - toolCallId: overrides.toolCallId ?? "call-1", - toolName: overrides.toolName ?? "tool-1", - effectiveArgs: {}, - resultEntryId: overrides.resultEntryId ?? "tool-result-1", - replay: "never", - }; -} - -function queueEnqueued( - seq: number, - target: ProvisionedEntry = messageTarget("queue-1", userMessage("queued")), - queue: QueueEnqueuedRecord["queue"] = "steer", -): QueueEnqueuedRecord { - const base = { type: "queue_enqueued" as const, id: `queue-${seq}`, lane: "main", seq, timestamp: seq, target }; - return queue === "nextRun" ? { ...base, queue } : { ...base, queue, runId: "run-1" }; -} - -function queueCancelled(seq: number, entryId = "queue-1", runId: string | null = "run-1"): QueueCancelledRecord { - return { - type: "queue_cancelled", - id: `cancel-${seq}`, - lane: "main", - seq, - timestamp: seq, - entryId, - ...(runId === null ? {} : { runId }), - }; -} - -function writeDeferred( - seq: number, - target: ProvisionedEntry = messageTarget("write-1", userMessage("deferred write")), -): WriteDeferredRecord { - return { type: "write_deferred", id: `write-${seq}`, lane: "main", seq, timestamp: seq, runId: "run-1", target }; -} - -function usageRecord( - seq: number, - resultEntryId: string, - stopReason: SessionStopReason = "error", - attemptNumber = 1, -): UsageRecord { - return { - type: "usage", - id: `usage-${seq}`, - lane: "main", - seq, - timestamp: seq, - cause: "assistant", - runId: "run-1", - entryId: resultEntryId, - attempt: attemptNumber, - stopReason, - usage, - }; -} - -function compactionEntry(id: string, seq: number): CompactionEntry { - return { - type: "compaction", - id, - parentId: null, - seq, - timestamp: seq, - summary: "summary", - retainedTail: [], - tokensBefore: 10, - }; -} - -function branchSummaryEntry(id: string, seq: number): BranchSummaryEntry { - return { - type: "branch_summary", - id, - parentId: "target", - seq, - timestamp: seq, - fromId: "source", - summary: "summary", - }; -} - -function recoverySlice(records: readonly LaneRecord[], entries: readonly Entry[] = []): RecordLogSlice { - const finished = new Set( - records - .filter((record): record is OperationFinishedRecord => record.type === "operation_finished") - .map((record) => record.runId), - ); - const openOperations = records - .filter( - (record): record is OperationStartedRecord => record.type === "operation_started" && !finished.has(record.id), - ) - .sort((left, right) => right.seq - left.seq); - return { lane: "main", openOperations, records, entries }; -} - -const defaults: EffectiveLaneConfiguration = { - model: { provider: "default-provider", modelId: "default-model" }, - thinkingLevel: "off", - activeToolNames: ["default-tool"], -}; - -function reductionInput( - records: readonly LaneRecord[], - ownEntries: readonly Entry[] = [], - options: { - entries?: readonly Entry[]; - configurationEntries?: readonly Entry[]; - leafId?: string | null; - defaults?: EffectiveLaneConfiguration; - } = {}, -): LaneReductionInput { - const slice = recoverySlice(records, [...ownEntries, ...(options.entries ?? [])]); - return { - ...slice, - leafId: options.leafId === undefined ? (ownEntries.at(-1)?.id ?? null) : options.leafId, - ownEntries, - configurationEntries: options.configurationEntries ?? [], - defaults: options.defaults ?? defaults, - }; -} - -function expectCorruption(input: RecordLogSlice, reason: RecordLogCorruptionReason): void { - try { - validateRecordLog(input); - expect.fail(`Expected ${reason}`); - } catch (error) { - expect(error).toBeInstanceOf(RecordLogCorruption); - expect(error).toMatchObject({ reason }); - } -} - -const assistantToolsEntry = persistedEntry( - messageTarget( - "assistant-tools", - assistantMessage([{ type: "toolCall", id: "call-1", name: "tool-1", arguments: {} }], "toolUse"), - ), - 3, -); - -interface CorruptionCase { - name: string; - reason: RecordLogCorruptionReason; - input: RecordLogSlice; -} - -const corruptionCases: CorruptionCase[] = [ - { - name: "multiple operations are open", - reason: "multiple_open_operations", - input: recoverySlice([runStarted(1), runStarted(2, { id: "run-2" })]), - }, - { - name: "a record references an operation that does not exist", - reason: "unknown_operation", - input: recoverySlice([abortRequested(1, "missing")]), - }, - { - name: "a record follows its operation finish", - reason: "record_after_finish", - input: recoverySlice([runStarted(1), operationFinished(2), abortRequested(3)]), - }, - { - name: "attempt numbers skip within one assistant step", - reason: "non_consecutive_attempt", - input: recoverySlice([ - runStarted(1), - attempt(2, "run-1", "assistant", 1, "assistant-1"), - attempt(3, "run-1", "assistant", 3, "assistant-2"), - ]), - }, - { - name: "a non-compaction attempt carries compactionReason", - reason: "invalid_compaction_reason", - input: recoverySlice([ - runStarted(1), - { ...attempt(2, "run-1", "assistant", 1, "assistant-1"), compactionReason: "manual" } as unknown as LaneRecord, - ]), - }, - { - name: "a compaction attempt omits compactionReason", - reason: "invalid_compaction_reason", - input: recoverySlice([ - runStarted(1), - { - ...attempt(2, "run-1", "compaction", 1, "compaction-1"), - compactionReason: undefined, - } as unknown as LaneRecord, - ]), - }, - { - name: "steering is enqueued after abort", - reason: "queue_after_abort", - input: recoverySlice([runStarted(1), abortRequested(2), queueEnqueued(3)]), - }, - { - name: "a queue cancellation has no enqueue", - reason: "invalid_queue_cancellation", - input: recoverySlice([runStarted(1), queueCancelled(2)]), - }, - { - name: "a queue cancellation targets an entry that exists", - reason: "invalid_queue_cancellation", - input: recoverySlice( - [runStarted(1), queueEnqueued(2), queueCancelled(4)], - [persistedEntry(messageTarget("queue-1", userMessage("queued")), 3)], - ), - }, - { - name: "structural attempts disagree on resultEntryId", - reason: "inconsistent_step", - input: recoverySlice([ - runStarted(1), - attempt(2, "run-1", "compaction", 1, "compaction-1", "threshold"), - attempt(3, "run-1", "compaction", 2, "compaction-2", "threshold"), - ]), - }, - { - name: "structural attempts disagree on compactionReason", - reason: "inconsistent_step", - input: recoverySlice([ - runStarted(1), - attempt(2, "run-1", "compaction", 1, "compaction-1", "threshold"), - attempt(3, "run-1", "compaction", 2, "compaction-1", "overflow"), - ]), - }, - { - name: "tool_started does not match the assistant tool call", - reason: "tool_call_mismatch", - input: recoverySlice([runStarted(1), toolStarted(4, { toolCallId: "different-call" })], [assistantToolsEntry]), - }, - { - name: "two tool_started records share an invocation identity", - reason: "duplicate_tool_invocation", - input: recoverySlice( - [ - runStarted(1), - toolStarted(4), - { ...toolStarted(5, { resultEntryId: "tool-result-2" }), id: "tool-start-duplicate" }, - ], - [assistantToolsEntry], - ), - }, - { - name: "a provisioned id exists with different content", - reason: "provisioned_entry_mismatch", - input: recoverySlice( - [runStarted(1, { initialMessages: [messageTarget("prompt-1", userMessage("expected"))] })], - [persistedEntry(messageTarget("prompt-1", userMessage("different")), 2)], - ), - }, - { - name: "a deferred assistant message has no handle", - reason: "invalid_deferred_handle", - input: recoverySlice( - [runStarted(1)], - [ - persistedEntry( - messageTarget("assistant-deferred", { ...assistantMessage([], "deferred"), deferred: undefined }), - 2, - ), - ], - ), - }, -]; - -describe("record-log validity", () => { - it.each(corruptionCases)("rejects $name", ({ input, reason }) => { - expectCorruption(input, reason); - }); - - it("does not mutate its bounded recovery inputs", () => { - const target = messageTarget("prompt-1", userMessage("hello")); - const start = Object.freeze(runStarted(1, { initialMessages: [target] })); - const entry = Object.freeze(persistedEntry(target, 2)); - const input = Object.freeze({ - lane: "main", - openOperations: Object.freeze([start]), - records: Object.freeze([start]), - entries: Object.freeze([entry]), - }); - - expect(validateRecordLog(input)).toBeUndefined(); - expect(input.records).toEqual([start]); - expect(input.entries).toEqual([entry]); - }); -}); - -type DurableAction = { record: LaneRecord } | { entry: Entry }; - -function validPrefixes(trace: string, actions: readonly DurableAction[]): { name: string; input: RecordLogSlice }[] { - return actions.map((_, index) => { - const prefix = actions.slice(0, index + 1); - return { - name: `${trace} after action ${index + 1}`, - input: recoverySlice( - prefix.flatMap((action) => ("record" in action ? [action.record] : [])), - prefix.flatMap((action) => ("entry" in action ? [action.entry] : [])), - ), - }; - }); -} - -const promptTarget = messageTarget("prompt-1", userMessage("fix the bug")); -const assistantToolTarget = messageTarget( - "assistant-tools", - assistantMessage([{ type: "toolCall", id: "call-1", name: "tool-1", arguments: {} }], "toolUse"), -); -const toolResultTarget = messageTarget("tool-result-1", toolResultMessage()); -const assistantFinalTarget = messageTarget("assistant-final", assistantMessage([{ type: "text", text: "done" }])); - -const validPrefixCases = [ - ...validPrefixes("one-tool run X1-X5", [ - { record: runStarted(1, { initialMessages: [promptTarget] }) }, - { entry: persistedEntry(promptTarget, 2) }, - { record: attempt(3, "run-1", "assistant", 1, "assistant-tools") }, - { entry: persistedEntry(assistantToolTarget, 4, "prompt-1") }, - { record: toolStarted(5) }, - { entry: persistedEntry(toolResultTarget, 6, "assistant-tools") }, - { record: attempt(7, "run-1", "assistant", 1, "assistant-final") }, - { entry: persistedEntry(assistantFinalTarget, 8, "tool-result-1") }, - { record: operationFinished(9) }, - ]), - ...validPrefixes("assistant retry", [ - { record: runStarted(1) }, - { record: attempt(2, "run-1", "assistant", 1, "assistant-attempt-1") }, - { record: usageRecord(3, "assistant-attempt-1") }, - { record: attempt(4, "run-1", "assistant", 2, "assistant-attempt-2") }, - { record: usageRecord(5, "assistant-attempt-2", "stop", 2) }, - { - entry: persistedEntry( - messageTarget("assistant-attempt-2", assistantMessage([{ type: "text", text: "ok" }])), - 6, - ), - }, - ]), - ...validPrefixes("terminal assistant failure", [ - { record: runStarted(1) }, - { record: attempt(2, "run-1", "assistant", 1, "assistant-error") }, - { - entry: persistedEntry( - messageTarget("assistant-error", { ...assistantMessage([], "error"), errorMessage: "failed" }), - 3, - ), - }, - { record: operationFinished(4, "run-1", "failed") }, - ]), - ...validPrefixes("overflow compaction and retry", [ - { record: runStarted(1) }, - { record: attempt(2, "run-1", "assistant", 1, "discarded-overflow") }, - { record: usageRecord(3, "discarded-overflow", "length") }, - { record: attempt(4, "run-1", "compaction", 1, "overflow-compaction", "overflow") }, - { entry: compactionEntry("overflow-compaction", 5) }, - { record: attempt(6, "run-1", "assistant", 1, "assistant-after-compaction") }, - { - entry: persistedEntry( - messageTarget("assistant-after-compaction", assistantMessage([{ type: "text", text: "fits" }])), - 7, - ), - }, - ]), - ...validPrefixes("steering acceptance and consumption", [ - { record: runStarted(1) }, - { record: queueEnqueued(2) }, - { entry: persistedEntry(messageTarget("queue-1", userMessage("queued")), 3) }, - ]), - ...validPrefixes("queue cancellation", [ - { record: runStarted(1) }, - { record: queueEnqueued(2) }, - { record: queueCancelled(3) }, - ]), - ...validPrefixes("deferred write acceptance and application", [ - { record: runStarted(1) }, - { record: writeDeferred(2) }, - { entry: persistedEntry(messageTarget("write-1", userMessage("deferred write")), 3) }, - ]), - ...validPrefixes("abort during a tool", [ - { record: runStarted(1) }, - { record: attempt(2, "run-1", "assistant", 1, "assistant-tools") }, - { entry: persistedEntry(assistantToolTarget, 3) }, - { record: toolStarted(4) }, - { record: abortRequested(5) }, - { - entry: persistedEntry( - messageTarget("tool-result-1", { - ...toolResultMessage(), - content: [{ type: "text", text: "interrupted" }], - isError: true, - }), - 6, - ), - }, - ]), - ...validPrefixes("threshold auto-compaction", [ - { record: runStarted(1) }, - { record: attempt(2, "run-1", "compaction", 1, "threshold-compaction", "threshold") }, - { entry: compactionEntry("threshold-compaction", 3) }, - { record: attempt(4, "run-1", "assistant", 1, "assistant-after-threshold") }, - ]), - ...validPrefixes("manual compaction", [ - { record: compactionStarted(1) }, - { record: attempt(2, "compact-1", "compaction", 1, "compaction-1", "manual") }, - { entry: compactionEntry("compaction-1", 3) }, - { record: operationFinished(4, "compact-1") }, - ]), - ...validPrefixes("move-first navigation summary", [ - { record: navigationStarted(1) }, - { record: attempt(2, "navigate-1", "branch_summary", 1, "summary-1") }, - { entry: branchSummaryEntry("summary-1", 3) }, - { record: operationFinished(4, "navigate-1") }, - ]), - ...validPrefixes("blocked tool without an intent record", [ - { record: runStarted(1) }, - { record: attempt(2, "run-1", "assistant", 1, "assistant-tools") }, - { entry: persistedEntry(assistantToolTarget, 3) }, - { - entry: persistedEntry( - messageTarget("blocked-result", { - ...toolResultMessage(), - content: [{ type: "text", text: "blocked" }], - isError: true, - }), - 4, - ), - }, - ]), - ...validPrefixes("idle next-run cancellation", [ - { record: queueEnqueued(1, messageTarget("next-1", userMessage("later")), "nextRun") }, - { record: queueCancelled(2, "next-1", null) }, - ]), - ...validPrefixes("next-run enqueue after abort", [ - { record: runStarted(1) }, - { record: abortRequested(2) }, - { record: queueEnqueued(3, messageTarget("next-1", userMessage("later")), "nextRun") }, - ]), - ...validPrefixes("deferred write applied during abort reconciliation", [ - { record: runStarted(1) }, - { record: writeDeferred(2) }, - { record: abortRequested(3) }, - { entry: persistedEntry(messageTarget("write-1", userMessage("deferred write")), 4) }, - ]), - ...validPrefixes("accepted steering killed by abort", [ - { record: runStarted(1) }, - { record: queueEnqueued(2) }, - { record: abortRequested(3) }, - ]), - ...validPrefixes("compaction retry", [ - { record: runStarted(1) }, - { record: attempt(2, "run-1", "compaction", 1, "threshold-compaction", "threshold") }, - { record: attempt(3, "run-1", "compaction", 2, "threshold-compaction", "threshold") }, - { entry: compactionEntry("threshold-compaction", 4) }, - ]), - ...validPrefixes("hook-supplied manual compaction", [ - { record: compactionStarted(1) }, - { entry: compactionEntry("compaction-1", 2) }, - { record: operationFinished(3, "compact-1") }, - ]), - ...validPrefixes("hook-supplied navigation summary", [ - { record: navigationStarted(1) }, - { entry: branchSummaryEntry("summary-1", 2) }, - { record: operationFinished(3, "navigate-1") }, - ]), - ...validPrefixes("deferred provider suspension and redemption", [ - { record: runStarted(1) }, - { record: attempt(2, "run-1", "assistant", 1, "assistant-deferred") }, - { entry: persistedEntry(messageTarget("assistant-deferred", assistantMessage([], "deferred")), 3) }, - { - entry: persistedEntry( - messageTarget("assistant-redeemed", assistantMessage([{ type: "text", text: "ready" }])), - 4, - ), - }, - ]), - ...validPrefixes("abort of a deferred provider request", [ - { record: runStarted(1) }, - { record: attempt(2, "run-1", "assistant", 1, "assistant-deferred") }, - { entry: persistedEntry(messageTarget("assistant-deferred", assistantMessage([], "deferred")), 3) }, - { record: abortRequested(4) }, - ]), -]; - -describe("valid section 6 durable prefixes", () => { - it.each(validPrefixCases)("accepts $name", ({ input }) => { - expect(validateRecordLog(input)).toBeUndefined(); - }); -}); - -describe("lane-state reduction", () => { - it("reduces an idle lane to pending next-run input and default configuration", () => { - const pending = messageTarget("next-pending", userMessage("pending")); - const cancelled = messageTarget("next-cancelled", userMessage("cancelled")); - const consumed = messageTarget("next-consumed", userMessage("consumed")); - const input = reductionInput( - [ - queueEnqueued(1, pending, "nextRun"), - queueEnqueued(2, cancelled, "nextRun"), - queueCancelled(3, cancelled.id, null), - queueEnqueued(4, consumed, "nextRun"), - ], - [], - { entries: [persistedEntry(consumed, 5)], leafId: "idle-leaf" }, - ); - - expect(reduceLaneState(input)).toEqual({ - laneState: { - lane: "main", - leafId: "idle-leaf", - operation: null, - pendingNextRun: [pending], - }, - effectiveConfiguration: defaults, - terminalFailure: null, - }); - }); - - it("folds persisted configuration over copied defaults in sequence", () => { - const configurationEntries: Entry[] = [ - { - type: "model_change", - id: "model-change", - parentId: null, - seq: 1, - timestamp: 1, - provider: "persisted-provider", - modelId: "persisted-model", - }, - { - type: "thinking_level_change", - id: "thinking-change", - parentId: "model-change", - seq: 2, - timestamp: 2, - thinkingLevel: "high", - }, - { - type: "active_tools_change", - id: "tools-change", - parentId: "thinking-change", - seq: 3, - timestamp: 3, - activeToolNames: ["persisted-tool"], - }, - ]; - const input = reductionInput([], [], { configurationEntries }); - - expect(reduceLaneState(input).effectiveConfiguration).toEqual({ - model: { provider: "persisted-provider", modelId: "persisted-model" }, - thinkingLevel: "high", - activeToolNames: ["persisted-tool"], - }); - expect(input.defaults).toEqual(defaults); - }); - - it("applies committed operation-owned configuration after the anchor", () => { - const assistant = persistedEntry( - messageTarget("assistant-config", { - ...assistantMessage([{ type: "text", text: "response" }]), - provider: "response-provider", - model: "response-model", - }), - 2, - ); - const tools: Entry = { - type: "active_tools_change", - id: "operation-tools", - parentId: assistant.id, - seq: 3, - timestamp: 3, - activeToolNames: ["operation-tool"], - }; - const result = reduceLaneState(reductionInput([runStarted(1)], [assistant, tools])); - - expect(result.effectiveConfiguration).toEqual({ - model: { provider: "response-provider", modelId: "response-model" }, - thinkingLevel: "off", - activeToolNames: ["operation-tool"], - }); - }); - - it("keeps captured next-run input with the open run instead of pending next-run", () => { - const captured = messageTarget("next-captured", userMessage("captured")); - const later = messageTarget("next-later", userMessage("later")); - const start = runStarted(2, { initialMessages: [captured] }); - - const result = reduceLaneState( - reductionInput([queueEnqueued(1, captured, "nextRun"), start, queueEnqueued(3, later, "nextRun")]), - ); - - expect(result.laneState.pendingNextRun).toEqual([later]); - expect(result.laneState.operation?.missingInitialMessages).toEqual([captured]); - }); - - it("derives missing input, queues, deferred writes, and the unfinished attempt", () => { - const missingPrompt = messageTarget("prompt-missing", userMessage("missing")); - const committedPrompt = messageTarget("prompt-committed", userMessage("committed")); - const steer = messageTarget("steer-pending", userMessage("steer")); - const consumedFollowUp = messageTarget("follow-consumed", userMessage("follow")); - const nextRun = messageTarget("next-run", userMessage("next")); - const pendingWrite = messageTarget("write-pending", userMessage("write")); - const appliedWrite = messageTarget("write-applied", userMessage("applied")); - const start = runStarted(1, { initialMessages: [missingPrompt, committedPrompt] }); - const committedPromptEntry = persistedEntry(committedPrompt, 2); - const consumedFollowUpEntry = persistedEntry(consumedFollowUp, 6, committedPrompt.id); - const appliedWriteEntry = persistedEntry(appliedWrite, 9, consumedFollowUp.id); - const input = reductionInput( - [ - start, - queueEnqueued(3, steer), - queueEnqueued(4, consumedFollowUp, "followUp"), - queueEnqueued(5, nextRun, "nextRun"), - writeDeferred(7, pendingWrite), - writeDeferred(8, appliedWrite), - attempt(10, start.id, "assistant", 1, "assistant-pending"), - ], - [committedPromptEntry, consumedFollowUpEntry, appliedWriteEntry], - ); - - const result = reduceLaneState(input); - expect(result.laneState.pendingNextRun).toEqual([nextRun]); - expect(result.laneState.operation).toMatchObject({ - id: start.id, - kind: "run", - aborting: false, - missingInitialMessages: [missingPrompt], - pendingSteer: [steer], - pendingFollowUp: [], - pendingWrites: [pendingWrite], - step: { kind: "assistant", attempts: 1, resultEntryId: "assistant-pending" }, - newestOwn: { entryId: appliedWrite.id, type: "message", role: "user" }, - }); - }); - - it("kills steer and follow-up queues on abort while preserving writes and next-run input", () => { - const steer = messageTarget("steer-aborted", userMessage("steer")); - const followUp = messageTarget("follow-aborted", userMessage("follow")); - const nextRun = messageTarget("next-after-abort", userMessage("next")); - const pendingWrite = messageTarget("write-after-abort", userMessage("write")); - const input = reductionInput([ - runStarted(1), - queueEnqueued(2, steer), - queueEnqueued(3, followUp, "followUp"), - queueEnqueued(4, nextRun, "nextRun"), - writeDeferred(5, pendingWrite), - abortRequested(6), - ]); - - const result = reduceLaneState(input); - expect(result.laneState.pendingNextRun).toEqual([nextRun]); - expect(result.laneState.operation).toMatchObject({ - aborting: true, - pendingSteer: [], - pendingFollowUp: [], - pendingWrites: [pendingWrite], - }); - }); - - it.each([ - { - name: "assistant", - record: attempt(2, "run-1", "assistant", 1, "result"), - expected: { kind: "assistant", attempts: 1, resultEntryId: "result" }, - }, - { - name: "compaction", - record: attempt(2, "run-1", "compaction", 1, "result", "overflow"), - expected: { - kind: "compaction", - attempts: 1, - resultEntryId: "result", - compactionReason: "overflow", - }, - }, - { - name: "branch summary", - record: attempt(2, "run-1", "branch_summary", 1, "result"), - expected: { kind: "branch_summary", attempts: 1, resultEntryId: "result" }, - }, - ])("reduces an unfinished $name step", ({ record, expected }) => { - const result = reduceLaneState(reductionInput([runStarted(1), record])); - expect(result.laneState.operation?.step).toEqual(expected); - }); - - it("closes the newest attempt only when its provisioned result exists", () => { - const target = messageTarget("result", assistantMessage([{ type: "text", text: "done" }])); - const result = reduceLaneState( - reductionInput([runStarted(1), attempt(2, "run-1", "assistant", 1, target.id)], [persistedEntry(target, 3)]), - ); - expect(result.laneState.operation?.step).toBeNull(); - }); - - it("ignores unfulfilled result ids from earlier attempts", () => { - const target = messageTarget("attempt-2-result", assistantMessage([{ type: "text", text: "done" }])); - const result = reduceLaneState( - reductionInput( - [ - runStarted(1), - attempt(2, "run-1", "assistant", 1, "attempt-1-result"), - attempt(3, "run-1", "assistant", 2, target.id), - ], - [persistedEntry(target, 4)], - ), - ); - expect(result.laneState.operation?.step).toBeNull(); - }); - - it.each([ - { name: "X1", records: [runStarted(1), attempt(2, "run-1", "assistant", 1, "assistant-tools")] }, - { - name: "X3", - records: [runStarted(1), attempt(2, "run-1", "assistant", 1, "assistant-tools"), toolStarted(4)], - }, - { - name: "X5", - records: [runStarted(1), attempt(2, "run-1", "assistant", 1, "assistant-tools"), toolStarted(4)], - result: { ...persistedEntry(toolResultTarget, 5, assistantToolsEntry.id), terminate: true as const }, - }, - ])("reduces tool batch state at $name", ({ records, result }) => { - const ownEntries = result ? [assistantToolsEntry, result] : [assistantToolsEntry]; - const reduction = reduceLaneState(reductionInput(records, ownEntries)); - const call = reduction.laneState.operation?.toolBatch?.calls[0]; - - expect(reduction.laneState.operation?.toolBatch).toMatchObject({ - assistantEntryId: assistantToolsEntry.id, - truncated: false, - unresolved: !result, - }); - expect(call).toMatchObject({ - toolIndex: 0, - toolCall: { id: "call-1", name: "tool-1" }, - resultExists: result !== undefined, - ...(result ? { terminate: true } : {}), - }); - expect(call?.started !== undefined).toBe(records.some((record) => record.type === "tool_started")); - }); - - it("does not resolve a tool batch from a deferred-write tool result", () => { - const assistant = persistedEntry(assistantToolTarget, 3); - const writtenResult = messageTarget("written-tool-result", toolResultMessage()); - const result = reduceLaneState( - reductionInput( - [runStarted(1), attempt(2, "run-1", "assistant", 1, assistant.id), writeDeferred(4, writtenResult)], - [assistant, persistedEntry(writtenResult, 5, assistant.id)], - ), - ); - - expect(result.laneState.operation?.toolBatch?.calls[0]).toMatchObject({ resultExists: false }); - expect(result.laneState.operation?.toolBatch?.unresolved).toBe(true); - }); - - it("matches blocked results without tool-start records and preserves source order", () => { - const assistant = persistedEntry( - messageTarget( - "assistant-two-tools", - assistantMessage( - [ - { type: "toolCall", id: "call-1", name: "tool-1", arguments: {} }, - { type: "toolCall", id: "call-2", name: "tool-2", arguments: {} }, - ], - "toolUse", - ), - ), - 3, - ); - const blocked = persistedEntry( - messageTarget("blocked-result", { - ...toolResultMessage("call-1", "tool-1"), - content: [{ type: "text", text: "blocked" }], - isError: true, - }), - 4, - assistant.id, - ); - const secondStart = toolStarted(5, { - assistantEntryId: assistant.id, - toolIndex: 1, - toolCallId: "call-2", - toolName: "tool-2", - resultEntryId: "call-2-result", - }); - const result = reduceLaneState( - reductionInput( - [runStarted(1), attempt(2, "run-1", "assistant", 1, assistant.id), secondStart], - [assistant, blocked], - ), - ); - - expect(result.laneState.operation?.toolBatch?.calls).toMatchObject([ - { toolIndex: 0, toolCall: { id: "call-1" }, resultExists: true }, - { toolIndex: 1, toolCall: { id: "call-2" }, started: secondStart, resultExists: false }, - ]); - }); - - it("marks a length-stopped tool batch as truncated without resolving it", () => { - const truncated = persistedEntry( - messageTarget( - "assistant-truncated", - assistantMessage([{ type: "toolCall", id: "call-1", name: "tool-1", arguments: {} }], "length"), - ), - 3, - ); - const result = reduceLaneState( - reductionInput([runStarted(1), attempt(2, "run-1", "assistant", 1, truncated.id)], [truncated]), - ); - expect(result.laneState.operation?.toolBatch).toMatchObject({ truncated: true, unresolved: true }); - }); - - it("detects an unredeemed deferred handle only at the operation tail", () => { - const deferredMessage = assistantMessage([], "deferred"); - const deferredEntry = persistedEntry(messageTarget("assistant-deferred", deferredMessage), 3); - const pending = reduceLaneState( - reductionInput([runStarted(1), attempt(2, "run-1", "assistant", 1, deferredEntry.id)], [deferredEntry]), - ); - expect(pending.laneState.operation?.deferred).toEqual(deferredMessage.deferred); - - const successor = persistedEntry( - messageTarget("assistant-ready", assistantMessage([{ type: "text", text: "ready" }])), - 4, - deferredEntry.id, - ); - const redeemed = reduceLaneState( - reductionInput( - [runStarted(1), attempt(2, "run-1", "assistant", 1, deferredEntry.id)], - [deferredEntry, successor], - ), - ); - expect(redeemed.laneState.operation?.deferred).toBeNull(); - }); - - it.each([ - { - name: "step", - records: [runStarted(1), attempt(2, "run-1", "assistant", 1, "assistant-error")], - ownEntries: [ - persistedEntry( - messageTarget("assistant-error", { ...assistantMessage([], "error"), errorMessage: "failed" }), - 3, - ), - ], - expectedSource: "step", - }, - { - name: "deferred fetch", - records: [runStarted(1), attempt(2, "run-1", "assistant", 1, "assistant-deferred")], - ownEntries: [ - persistedEntry(messageTarget("assistant-deferred", assistantMessage([], "deferred")), 3), - persistedEntry( - messageTarget("deferred-error", { ...assistantMessage([], "error"), errorMessage: "expired" }), - 4, - "assistant-deferred", - ), - ], - expectedSource: "deferred_fetch", - }, - { - name: "deferred fetch usage record", - records: [ - runStarted(1), - { - type: "usage", - id: "deferred-usage", - lane: "main", - seq: 3, - timestamp: 3, - cause: "deferred_fetch", - runId: "run-1", - entryId: "deferred-error", - attempt: 1, - stopReason: "error", - usage, - } satisfies UsageRecord, - ], - ownEntries: [ - persistedEntry( - messageTarget("deferred-error", { ...assistantMessage([], "error"), errorMessage: "expired" }), - 2, - ), - ], - expectedSource: "deferred_fetch", - }, - ])("derives $name terminal-failure provenance", ({ records, ownEntries, expectedSource }) => { - const result = reduceLaneState(reductionInput(records, ownEntries)); - expect(result.terminalFailure).toMatchObject({ source: expectedSource }); - }); - - it("does not classify an error-shaped deferred write as terminal failure", () => { - const target = messageTarget("written-error", { ...assistantMessage([], "error"), errorMessage: "note" }); - const entry = persistedEntry(target, 3); - const result = reduceLaneState(reductionInput([runStarted(1), writeDeferred(2, target)], [entry])); - expect(result.terminalFailure).toBeNull(); - }); - - it.each([ - { - name: "manual compaction result", - records: [compactionStarted(1)], - entries: [] as Entry[], - expected: { result: false }, - }, - { - name: "completed manual compaction result", - records: [compactionStarted(1)], - entries: [compactionEntry("compaction-1", 2)], - expected: { result: true }, - }, - { - name: "missing navigation summary", - records: [navigationStarted(1)], - entries: [] as Entry[], - expected: { summary: false }, - }, - { - name: "navigation summary", - records: [navigationStarted(1)], - entries: [branchSummaryEntry("summary-1", 2)], - expected: { summary: true }, - }, - ])("derives structural target state for $name", ({ records, entries, expected }) => { - const result = reduceLaneState(reductionInput(records, entries)); - expect(result.laneState.operation?.targets).toEqual(expected); - }); - - it("resets the overflow guard only after newer conversational input is consumed", () => { - const initial = messageTarget("initial", userMessage("initial")); - const steer = messageTarget("steer", userMessage("steer")); - const start = runStarted(1, { initialMessages: [initial] }); - const initialEntry = persistedEntry(initial, 2); - const records: LaneRecord[] = [ - start, - attempt(3, start.id, "compaction", 1, "overflow-summary", "overflow"), - queueEnqueued(5, steer), - ]; - - const used = reduceLaneState(reductionInput(records, [initialEntry])); - expect(used.laneState.operation?.overflowRecoveryUsed).toBe(true); - - const reset = reduceLaneState(reductionInput(records, [initialEntry, persistedEntry(steer, 6, initial.id)])); - expect(reset.laneState.operation?.overflowRecoveryUsed).toBe(false); - }); - - it("is deterministic and does not mutate or alias its inputs", () => { - const pending = messageTarget("next", userMessage("next")); - const input = reductionInput([queueEnqueued(1, pending, "nextRun")]); - const before = structuredClone(input); - const first = reduceLaneState(input); - const second = reduceLaneState(input); - - expect(first).toEqual(second); - expect(input).toEqual(before); - first.laneState.pendingNextRun[0]!.id = "mutated-output"; - expect(input.records[0]).toMatchObject({ type: "queue_enqueued", target: { id: "next" } }); - }); -}); diff --git a/packages/agent/test/harness/session/context.test.ts b/packages/agent/test/harness/session/context.test.ts deleted file mode 100644 index f439b29d0..000000000 --- a/packages/agent/test/harness/session/context.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage } from "@earendil-works/pi-ai"; -import { describe, expect, it } from "vitest"; -import { buildSessionContext } from "../../../src/harness/session/context.ts"; -import type { Entry } from "../../../src/harness/session/types.ts"; - -function userMessage(text: string): AgentMessage { - return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; -} - -function assistantMessage(text: string): AssistantMessage { - return { - role: "assistant", - content: [{ type: "text", text }], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: 1, - }; -} - -type EntryWithoutStorage = TEntry extends Entry - ? Omit - : never; - -function entry(value: EntryWithoutStorage, seq: number): TEntry { - return { ...value, seq, timestamp: seq } as unknown as TEntry; -} - -describe("v4 session context", () => { - it("starts at the latest compaction and materializes its retained tail", () => { - const entries: Entry[] = [ - entry({ type: "message", id: "old", parentId: null, message: userMessage("old") }, 1), - entry( - { - type: "compaction", - id: "compact", - parentId: "old", - summary: "summary", - retainedTail: [userMessage("retained"), assistantMessage("answer")], - tokensBefore: 100, - }, - 2, - ), - entry({ type: "model_change", id: "model", parentId: "compact", provider: "openai", modelId: "gpt-5" }, 3), - entry({ type: "thinking_level_change", id: "thinking", parentId: "model", thinkingLevel: "high" }, 4), - entry({ type: "message", id: "tail", parentId: "thinking", message: userMessage("tail") }, 5), - ]; - - const context = buildSessionContext(entries); - expect(context.messages.map((message) => message.role)).toEqual([ - "compactionSummary", - "user", - "assistant", - "user", - ]); - expect(context.model).toEqual({ provider: "openai", modelId: "gpt-5" }); - expect(context.thinkingLevel).toBe("high"); - }); - - it("applies caller transforms after the compaction boundary", () => { - const entries: Entry[] = [ - entry({ type: "message", id: "old", parentId: null, message: userMessage("old") }, 1), - entry( - { - type: "compaction", - id: "compact", - parentId: "old", - summary: "summary", - retainedTail: [], - tokensBefore: 100, - }, - 2, - ), - entry( - { - type: "branch_summary", - id: "branch", - parentId: "compact", - fromId: "abandoned", - summary: "branch summary", - }, - 3, - ), - entry({ type: "message", id: "tail", parentId: "branch", message: userMessage("tail") }, 4), - ]; - - const context = buildSessionContext(entries, { - entryTransforms: [(contextEntries) => contextEntries.filter((candidate) => candidate.type !== "compaction")], - }); - expect(context.messages.map((message) => message.role)).toEqual(["branchSummary", "user"]); - }); - - it("projects custom entries and omits deferred assistant handles", () => { - const deferred: AssistantMessage = { - ...assistantMessage(""), - content: [], - stopReason: "deferred", - deferred: { provider: "openai", modelId: "gpt-5", api: "openai-responses", id: "response-1" }, - }; - const entries: Entry[] = [ - entry({ type: "message", id: "user", parentId: null, message: userMessage("hello") }, 1), - entry({ type: "message", id: "deferred", parentId: "user", message: deferred }, 2), - entry({ type: "custom", id: "custom", parentId: "deferred", customType: "note", data: "project me" }, 3), - ]; - - const context = buildSessionContext(entries, { - entryProjectors: { - note: (custom) => [userMessage(`note: ${String(custom.data)}`)], - }, - }); - expect(context.messages.map((message) => message.role)).toEqual(["user", "user"]); - expect(context.messages[1]).toMatchObject({ content: [{ type: "text", text: "note: project me" }] }); - }); -}); diff --git a/packages/agent/test/harness/session/jsonl-codec.test.ts b/packages/agent/test/harness/session/jsonl-codec.test.ts deleted file mode 100644 index b81767550..000000000 --- a/packages/agent/test/harness/session/jsonl-codec.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - encodeHeader, - encodeMutation, - metadataFromHeader, - parseHeader, - parseMutation, -} from "../../../src/harness/session/jsonl/codec.ts"; -import { JsonlDecodeError } from "../../../src/harness/session/jsonl/errors.ts"; -import type { JsonlV4Header } from "../../../src/harness/session/jsonl/types.ts"; -import type { SessionMutation } from "../../../src/harness/session/state.ts"; - -function expectHeaderRoundTrip(header: JsonlV4Header): void { - const encoded = encodeHeader(header); - expect(encoded.endsWith("\n")).toBe(true); - expect(parseHeader(encoded.trimEnd())).toEqual({ ok: true, value: header }); -} - -function expectMutationRoundTrip(mutation: SessionMutation): void { - const encoded = encodeMutation(mutation); - expect(encoded.endsWith("\n")).toBe(true); - expect(parseMutation(encoded.trimEnd())).toEqual({ ok: true, value: mutation }); -} - -describe("JSONL v4 codec", () => { - describe("headers", () => { - it("round trips every header field with a resolved parent", () => { - expectHeaderRoundTrip({ - kind: "header", - version: 4, - id: "session", - createdAt: 1_700_000_000_000, - cwd: "/workspace/project", - parentSessionId: "parent", - metadata: { owner: "agent", nested: { enabled: true }, values: [1, null, "two"] }, - }); - }); - - it("round trips an unresolved legacy parent path", () => { - expectHeaderRoundTrip({ - kind: "header", - version: 4, - id: "legacy-child", - createdAt: 1_700_000_000_001, - cwd: "/workspace/project", - legacyParentSessionPath: "/sessions/missing-parent.jsonl", - }); - }); - - it("projects header and filesystem fields into metadata", () => { - const header = { - kind: "header", - version: 4, - id: "session", - createdAt: 1_700_000_000_000, - cwd: "/workspace/project", - legacyParentSessionPath: "/sessions/missing-parent.jsonl", - metadata: { owner: "agent" }, - } satisfies JsonlV4Header; - - expect(metadataFromHeader(header, "/sessions/session.jsonl", 1_700_000_000_100)).toEqual({ - id: "session", - createdAt: 1_700_000_000_000, - cwd: "/workspace/project", - path: "/sessions/session.jsonl", - modifiedAt: 1_700_000_000_100, - sourceFormat: 4, - legacyParentSessionPath: "/sessions/missing-parent.jsonl", - metadata: { owner: "agent" }, - }); - }); - }); - - describe("mutation lines", () => { - it("returns syntax and schema errors", () => { - for (const [line, kind] of [ - ["{", "syntax"], - [JSON.stringify({ kind: "unknown", seq: 1 }), "schema"], - ] as const) { - const result = parseMutation(line); - expect(result.ok).toBe(false); - if (result.ok) throw new Error(`Expected ${kind} decode error`); - expect(result.error).toBeInstanceOf(JsonlDecodeError); - expect(result.error).toMatchObject({ kind }); - } - }); - - it("round trips a lane-bound entry line", () => { - expectMutationRoundTrip({ - kind: "entry", - lane: "main", - entry: { - type: "custom", - id: "entry-1", - seq: 1, - parentId: null, - timestamp: 100, - customType: "note", - data: { text: "hello" }, - }, - }); - }); - - it("round trips an imported entry line without a lane", () => { - expectMutationRoundTrip({ - kind: "entry", - entry: { - type: "custom", - id: "entry-1", - seq: 1, - parentId: null, - timestamp: 100, - customType: "note", - }, - }); - }); - - it("round trips a record line", () => { - expectMutationRoundTrip({ - kind: "record", - record: { - type: "operation_started", - id: "run-1", - seq: 1, - lane: "main", - timestamp: 100, - sourceLeafId: null, - intent: { kind: "run", originalPrompt: [], initialMessages: [] }, - }, - }); - }); - - it("round trips a lane line", () => { - expectMutationRoundTrip({ kind: "lane", seq: 1, lane: "thread", leafId: "entry-1" }); - }); - - it("round trips fact lines, including cleared values", () => { - expectMutationRoundTrip({ kind: "fact", seq: 1, fact: "name", name: "Example" }); - expectMutationRoundTrip({ kind: "fact", seq: 2, fact: "name", name: undefined }); - expectMutationRoundTrip({ - kind: "fact", - seq: 3, - fact: "label", - targetId: "entry-1", - label: "checkpoint", - }); - }); - - it.each([ - { - name: "a custom entry without customType", - mutation: { kind: "entry", type: "custom", id: "entry", parentId: null, seq: 1, timestamp: 1 }, - }, - { - name: "an operation_started record without intent", - mutation: { - kind: "record", - type: "operation_started", - id: "run", - lane: "main", - seq: 1, - timestamp: 1, - sourceLeafId: null, - }, - }, - { - name: "an operation_finished record without runId", - mutation: { - kind: "record", - type: "operation_finished", - id: "finish", - lane: "main", - seq: 1, - timestamp: 1, - outcome: "completed", - }, - }, - ])("rejects $name", ({ mutation }) => { - expect(parseMutation(JSON.stringify(mutation))).toMatchObject({ ok: false }); - }); - }); -}); diff --git a/packages/agent/test/harness/session/jsonl-storage.test.ts b/packages/agent/test/harness/session/jsonl-storage.test.ts deleted file mode 100644 index 6b971dd6e..000000000 --- a/packages/agent/test/harness/session/jsonl-storage.test.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { Usage } from "@earendil-works/pi-ai"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { NodeExecutionEnv } from "../../../src/harness/env/nodejs.ts"; -import type { - Entry, - JsonlSessionMetadata, - LaneRecord, - MessageEntry, - NewRecord, - Session, -} from "../../../src/harness/session/index.ts"; -import { JsonlSessionRepo } from "../../../src/harness/session/index.ts"; -import { FileError } from "../../../src/harness/types.ts"; -import type { AgentMessage } from "../../../src/types.ts"; - -const tempDirs: string[] = []; - -function createTempDir(): string { - const directory = mkdtempSync(join(tmpdir(), "pi-agent-jsonl-storage-")); - tempDirs.push(directory); - return directory; -} - -function createRepository(root: string): JsonlSessionRepo { - return new JsonlSessionRepo({ - fs: new NodeExecutionEnv({ cwd: root }), - sessionsRoot: root, - }); -} - -function userMessage(text: string): AgentMessage { - return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; -} - -function createUsage(multiplier: number): Usage { - return { - input: multiplier, - output: multiplier * 2, - cacheRead: multiplier * 3, - cacheWrite: multiplier * 4, - totalTokens: multiplier * 10, - cost: { - input: multiplier * 0.1, - output: multiplier * 0.2, - cacheRead: multiplier * 0.3, - cacheWrite: multiplier * 0.4, - total: multiplier, - }, - }; -} - -async function reopen(root: string, session: Session): Promise> { - return createRepository(root).open(await session.getMetadata()); -} - -afterEach(() => { - while (tempDirs.length > 0) rmSync(tempDirs.pop()!, { recursive: true, force: true }); -}); - -describe("JSONL v4 per-session storage", () => { - it("round trips every entry type and bounded branch queries", async () => { - const root = createTempDir(); - const session = await createRepository(root).create({ id: "entries", cwd: root }); - const committed: Entry[] = []; - committed.push( - await session.appendEntry( - { type: "message", id: "message", message: userMessage("question") }, - "main", - ), - ); - committed.push( - await session.appendEntry( - { - type: "message", - id: "assistant-tool-call", - message: { - role: "assistant", - content: [ - { type: "text", text: "I'll inspect it." }, - { type: "toolCall", id: "call-1", name: "read", arguments: { path: "README.md" } }, - ], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5", - usage: createUsage(1), - stopReason: "toolUse", - timestamp: 2, - }, - }, - "main", - ), - ); - committed.push( - await session.appendEntry( - { - type: "message", - id: "tool-result", - message: { - role: "toolResult", - toolCallId: "call-1", - toolName: "read", - content: [{ type: "text", text: "contents" }], - details: { path: "README.md" }, - usage: createUsage(2), - isError: false, - timestamp: 3, - }, - terminate: true, - }, - "main", - ), - ); - committed.push( - await session.appendEntry( - { type: "model_change", id: "model", provider: "anthropic", modelId: "claude-sonnet-4-5" }, - "main", - ), - ); - committed.push( - await session.appendEntry({ type: "thinking_level_change", id: "thinking", thinkingLevel: "high" }, "main"), - ); - committed.push( - await session.appendEntry( - { type: "active_tools_change", id: "tools", activeToolNames: ["read", "bash"] }, - "main", - ), - ); - committed.push( - await session.appendEntry( - { - type: "compaction", - id: "compaction", - summary: "summary", - retainedTail: [userMessage("retained")], - tokensBefore: 123, - details: { source: "test" }, - usage: createUsage(1), - }, - "main", - ), - ); - committed.push( - await session.appendEntry( - { - type: "branch_summary", - id: "branch-summary", - fromId: "message", - summary: "branch", - details: { reason: "navigation" }, - usage: createUsage(2), - }, - "main", - ), - ); - committed.push( - await session.appendEntry( - { type: "custom", id: "custom", customType: "note", data: { nested: { value: 1 } } }, - "main", - ), - ); - - const restored = await reopen(root, session); - expect(await restored.findEntries({ order: "oldestFirst" })).toEqual(committed); - expect((await restored.findEntriesOnBranch({ stopAtType: "compaction" })).map((entry) => entry.id)).toEqual([ - "custom", - "branch-summary", - "compaction", - ]); - expect( - ( - await restored.findEntries({ - order: "oldestFirst", - cursor: { afterSeq: committed[5]!.seq }, - limit: 2, - }) - ).map((entry) => entry.id), - ).toEqual(["compaction", "branch-summary"]); - expect((await restored.findEntries({ customType: "note" })).map((entry) => entry.id)).toEqual(["custom"]); - expect(await restored.getStats()).toEqual({ - messageCount: 3, - cachedTokens: 0, - uncachedTokens: 0, - totalTokens: 0, - costTotal: 0, - }); - - const custom = await restored.getEntry("custom"); - if (custom?.type !== "custom") throw new Error("Expected custom entry"); - (custom.data as { nested: { value: number } }).nested.value = 99; - const logCustom = (await restored.getLog()).find((item) => item.kind === "entry" && item.entry.type === "custom"); - if (logCustom?.kind !== "entry" || logCustom.entry.type !== "custom") { - throw new Error("Expected custom entry in log"); - } - (logCustom.entry.data as { nested: { value: number } }).nested.value = 100; - - expect(await restored.getEntry("custom")).toEqual(committed.at(-1)); - expect(await restored.findEntries({ order: "oldestFirst" })).toEqual(committed); - }); - - it("round trips every record type, recovery projection, and ledger statistics", async () => { - const root = createTempDir(); - const session = await createRepository(root).create({ id: "records", cwd: root }); - await session.appendCustomEntry("anchor"); - const records: LaneRecord[] = []; - const append = async (record: NewRecord): Promise => { - records.push(await session.appendRecord(record)); - }; - - await append({ - type: "operation_started", - id: "run", - lane: "main", - sourceLeafId: "anchor", - intent: { - kind: "run", - originalPrompt: [userMessage("prompt")], - initialMessages: [{ type: "message", id: "initial", message: userMessage("initial") }], - systemPromptOverride: "system", - resumeData: { extension: { version: 1 } }, - }, - }); - await append({ - type: "queue_enqueued", - id: "steer", - lane: "main", - queue: "steer", - runId: "run", - target: { type: "message", id: "steer-message", message: userMessage("steer") }, - }); - await append({ - type: "queue_enqueued", - id: "follow-up", - lane: "main", - queue: "followUp", - runId: "run", - target: { type: "message", id: "follow-up-message", message: userMessage("follow up") }, - }); - await append({ - type: "step_attempt", - id: "assistant-attempt", - lane: "main", - runId: "run", - step: "assistant", - attempt: 1, - resultEntryId: "assistant-result", - }); - await append({ - type: "tool_started", - id: "tool", - lane: "main", - runId: "run", - assistantEntryId: "assistant-result", - toolIndex: 0, - toolCallId: "call-1", - toolName: "read", - effectiveArgs: { path: "README.md" }, - resultEntryId: "tool-result", - replay: "safe", - }); - await append({ - type: "write_deferred", - id: "deferred-write", - lane: "main", - runId: "run", - target: { type: "custom", id: "deferred-entry", customType: "fact", data: { value: true } }, - }); - await append({ - type: "usage", - id: "assistant-usage", - lane: "main", - cause: "assistant", - runId: "run", - entryId: "assistant-result", - attempt: 1, - stopReason: "stop", - usage: createUsage(1), - }); - await append({ - type: "usage", - id: "deferred-usage", - lane: "main", - cause: "deferred_fetch", - runId: "run", - entryId: "deferred-result", - attempt: 1, - stopReason: "deferred", - usage: createUsage(2), - }); - await append({ - type: "usage", - id: "tool-usage", - lane: "main", - cause: "tool", - runId: "run", - entryId: "tool-result", - toolCallId: "call-1", - usage: createUsage(3), - }); - await append({ - type: "usage", - id: "hook-usage", - lane: "main", - cause: "hook", - runId: "run", - entryId: "hook-result", - usage: createUsage(4), - }); - await append({ - type: "usage", - id: "adjustment", - lane: "main", - cause: "adjustment", - details: { reason: "correction" }, - usage: createUsage(5), - }); - await append({ type: "abort_requested", id: "abort", lane: "main", runId: "run" }); - await append({ - type: "operation_finished", - id: "run-finished", - lane: "main", - runId: "run", - outcome: "aborted", - }); - await append({ - type: "queue_enqueued", - id: "next-run", - lane: "main", - queue: "nextRun", - target: { type: "message", id: "next-message", message: userMessage("next") }, - }); - await append({ type: "queue_cancelled", id: "queue-cancelled", lane: "main", entryId: "next-message" }); - await append({ - type: "operation_started", - id: "compaction", - lane: "main", - sourceLeafId: "anchor", - intent: { kind: "compaction", customInstructions: "short", resultEntryId: "compaction-result" }, - }); - await append({ - type: "step_attempt", - id: "compaction-attempt", - lane: "main", - runId: "compaction", - step: "compaction", - attempt: 1, - resultEntryId: "compaction-result", - compactionReason: "manual", - }); - await append({ - type: "operation_finished", - id: "compaction-finished", - lane: "main", - runId: "compaction", - outcome: "completed", - }); - await append({ - type: "operation_started", - id: "navigation", - lane: "main", - sourceLeafId: "anchor", - intent: { - kind: "navigation", - targetId: null, - summarize: true, - customInstructions: "summarize", - label: "checkpoint", - summaryEntryId: "navigation-summary", - }, - }); - await append({ - type: "step_attempt", - id: "branch-attempt", - lane: "main", - runId: "navigation", - step: "branch_summary", - attempt: 1, - resultEntryId: "navigation-summary", - }); - - const restored = await reopen(root, session); - expect(await restored.findRecords({ order: "oldestFirst" })).toEqual(records); - expect( - ( - await restored.findRecords({ - type: "operation_started", - operationKind: "run", - limit: 1, - }) - ).map((record) => record.id), - ).toEqual(["run"]); - expect( - (await restored.findRecords({ runId: "compaction", order: "oldestFirst" })).map((record) => record.id), - ).toEqual(["compaction", "compaction-attempt", "compaction-finished"]); - expect( - (await restored.findRecords({ type: "usage", afterSeq: records[6]!.seq, limit: 2 })).map( - (record) => record.id, - ), - ).toEqual(["adjustment", "hook-usage"]); - expect((await restored.findOpenOperations("main", { limit: 2 })).map((record) => record.id)).toEqual([ - "navigation", - ]); - expect(await restored.getStats()).toEqual({ - messageCount: 0, - cachedTokens: 45, - uncachedTokens: 75, - totalTokens: 150, - costTotal: 15, - }); - - const [started] = await restored.findRecords({ type: "operation_started", operationKind: "run" }); - if (started?.intent.kind !== "run") throw new Error("Expected restored run record"); - started.intent.originalPrompt.push(userMessage("mutated")); - expect(await restored.findRecords({ order: "oldestFirst" })).toEqual(records); - }); - - it("persists concurrent cross-lane writes in shared sequence order", async () => { - const root = createTempDir(); - const session = await createRepository(root).create({ id: "concurrent", cwd: root }); - const rootEntry = await session.appendEntry({ type: "custom", id: "root", customType: "root" }, "main"); - await session.createLane("thread", rootEntry.id); - - const entries = await Promise.all([ - session.appendEntry({ type: "custom", id: "main-1", customType: "note" }, "main"), - session.appendEntry({ type: "custom", id: "thread-1", customType: "note" }, "thread"), - session.appendEntry({ type: "custom", id: "main-2", customType: "note" }, "main"), - session.appendEntry({ type: "custom", id: "thread-2", customType: "note" }, "thread"), - ]); - const commitOrder = [...entries].sort((left, right) => left.seq - right.seq).map((entry) => entry.id); - - const restored = await reopen(root, session); - const restoredConcurrentEntries = (await restored.getLog()).flatMap((item) => - item.kind === "entry" && item.entry.id !== "root" ? [item.entry] : [], - ); - expect(restoredConcurrentEntries.map((entry) => entry.id)).toEqual(commitOrder); - expect(new Set(restoredConcurrentEntries.map((entry) => entry.seq)).size).toBe(entries.length); - expect((await restored.getLog()).map((item) => item.seq)).toEqual([1, 2, 3, 4, 5, 6]); - }); - - it("rejects non-JSON payloads without changing the durable prefix", async () => { - const root = createTempDir(); - const session = await createRepository(root).create({ id: "validation", cwd: root }); - const metadata = await session.getMetadata(); - const prefix = readFileSync(metadata.path, "utf8"); - const cyclic: { self?: unknown } = {}; - cyclic.self = cyclic; - - await expect(session.appendCustomEntry("invalid", cyclic)).rejects.toMatchObject({ code: "invalid_payload" }); - // JSON.stringify would silently omit undefined, changing the effective arguments used during recovery. - await expect( - session.appendRecord({ - type: "tool_started", - id: "invalid-record", - lane: "main", - runId: "run", - assistantEntryId: "assistant", - toolIndex: 0, - toolCallId: "call", - toolName: "read", - effectiveArgs: { value: undefined }, - resultEntryId: "result", - replay: "never", - }), - ).rejects.toMatchObject({ code: "invalid_payload" }); - expect(readFileSync(metadata.path, "utf8")).toBe(prefix); - - const restored = await reopen(root, session); - expect(await restored.getLog()).toEqual([]); - const valid = await restored.appendEntry( - { type: "custom", id: "valid", customType: "note", data: { value: 1 } }, - "main", - ); - expect(valid.seq).toBe(1); - expect((await reopen(root, restored)).getEntry("valid")).resolves.toEqual(valid); - }); - - it("does not advance state or poison the write queue after an append failure", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - vi.spyOn(env, "appendFile").mockResolvedValueOnce({ - ok: false, - error: new FileError("unknown", "injected append failure"), - }); - const repository = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); - const session = await repository.create({ id: "append-failure", cwd: root }); - - await expect(session.appendCustomEntry("rejected")).rejects.toMatchObject({ code: "storage" }); - expect(await session.getLog()).toEqual([]); - const committed = await session.appendEntry({ type: "custom", id: "committed", customType: "note" }, "main"); - expect(committed.seq).toBe(1); - - const reopened = await createRepository(root).open(await session.getMetadata()); - expect(await reopened.getLog()).toEqual([{ kind: "entry", seq: 1, entry: committed }]); - }); -}); diff --git a/packages/agent/test/harness/session/jsonl.test.ts b/packages/agent/test/harness/session/jsonl.test.ts deleted file mode 100644 index 6b3a7671a..000000000 --- a/packages/agent/test/harness/session/jsonl.test.ts +++ /dev/null @@ -1,766 +0,0 @@ -import { - appendFileSync, - existsSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - statSync, - utimesSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { NodeExecutionEnv } from "../../../src/harness/env/nodejs.ts"; -import { type JsonlSessionMetadata, JsonlSessionRepo, type SessionRepo } from "../../../src/harness/session/index.ts"; -import { - createSessionBackendConformance, - type SessionBackendFixture, -} from "../../../src/harness/session/testing/index.ts"; -import { FileError } from "../../../src/harness/types.ts"; - -const tempDirs: string[] = []; - -function createTempDir(): string { - const directory = mkdtempSync(join(tmpdir(), "pi-agent-jsonl-v4-")); - tempDirs.push(directory); - return directory; -} - -function createRepository(root: string): JsonlSessionRepo { - return new JsonlSessionRepo({ - fs: new NodeExecutionEnv({ cwd: root }), - sessionsRoot: root, - }); -} - -function withDefaultSessionCwd(repository: JsonlSessionRepo, cwd: string): SessionRepo { - return { - create(options) { - const optionsWithCwd = { ...options, cwd }; - return repository.create(optionsWithCwd); - }, - open: (metadata) => repository.open(metadata), - list: () => repository.list(), - delete: (metadata) => repository.delete(metadata), - fork(source, options) { - const optionsWithCwd = { ...options, cwd }; - return repository.fork(source, optionsWithCwd); - }, - }; -} - -function expectedSessionPath(root: string, cwd: string, createdAt: number, id: string): string { - const directory = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; - const timestamp = new Date(createdAt).toISOString().replace(/[:.]/g, "-"); - return join(root, directory, `${timestamp}_${id}.jsonl`); -} - -function writeRawSession(root: string, id: string, mutations: Record[]): JsonlSessionMetadata { - const path = join(root, `${id}.jsonl`); - const createdAt = 1; - const header = { kind: "header", version: 4, id, createdAt, cwd: root }; - writeFileSync(path, `${[header, ...mutations].map((line) => JSON.stringify(line)).join("\n")}\n`); - return { - id, - createdAt, - cwd: root, - path, - modifiedAt: statSync(path).mtimeMs, - sourceFormat: 4, - }; -} - -afterEach(() => { - while (tempDirs.length > 0) rmSync(tempDirs.pop()!, { recursive: true, force: true }); -}); - -const conformance = createSessionBackendConformance(async () => { - const root = createTempDir(); - const repository = withDefaultSessionCwd(createRepository(root), root); - return { - repository, - [Symbol.asyncDispose]: () => Promise.resolve(), - } satisfies SessionBackendFixture; -}); - -describe("JsonlSessionRepo conformance", () => { - for (const group of new Set(conformance.map((testCase) => testCase.group))) { - describe(group, () => { - for (const testCase of conformance.filter((candidate) => candidate.group === group)) { - it(testCase.name, () => testCase.run()); - } - }); - } -}); - -describe("JSONL v4 persistence", () => { - it("exposes the complete metadata contract", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const cwd = join(root, "workspace", "project"); - const session = await repository.create({ - id: "metadata", - cwd, - parentSessionId: "parent", - metadata: { owner: "agent", nested: { enabled: true } }, - }); - const metadata = await session.getMetadata(); - - expect(metadata).toEqual({ - id: "metadata", - createdAt: expect.any(Number), - parentSessionId: "parent", - path: expectedSessionPath(root, metadata.cwd, metadata.createdAt, metadata.id), - cwd, - modifiedAt: statSync(metadata.path).mtimeMs, - sourceFormat: 4, - metadata: { owner: "agent", nested: { enabled: true } }, - }); - expect(await repository.list({ cwd })).toEqual([metadata]); - expect(await repository.list({ cwd: join(root, "other", "project") })).toEqual([]); - }); - - it("rejects a malformed JSON header on open and skips it when listing", async () => { - const root = createTempDir(); - const repository = createRepository(root); - await repository.create({ id: "valid", cwd: root }); - const session = await repository.create({ id: "malformed-header", cwd: root }); - const metadata = await session.getMetadata(); - const malformed = "not json\n"; - writeFileSync(metadata.path, malformed); - - await expect(repository.open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); - expect((await repository.list({ cwd: root })).map((listed) => listed.id)).toEqual(["valid"]); - expect(readFileSync(metadata.path, "utf8")).toBe(malformed); - }); - - it("rejects non-object header metadata on open and skips it when listing", async () => { - const root = createTempDir(); - const repository = createRepository(root); - await repository.create({ id: "valid", cwd: root }); - const session = await repository.create({ id: "invalid-header-metadata", cwd: root }); - const metadata = await session.getMetadata(); - const malformed = `${JSON.stringify({ - kind: "header", - version: 4, - id: metadata.id, - createdAt: metadata.createdAt, - cwd: metadata.cwd, - metadata: "invalid", - })}\n`; - writeFileSync(metadata.path, malformed); - - await expect(repository.open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); - expect((await repository.list({ cwd: root })).map((listed) => listed.id)).toEqual(["valid"]); - expect(readFileSync(metadata.path, "utf8")).toBe(malformed); - }); - - it("rejects session ids that cannot be used in coding-agent filenames", async () => { - const root = createTempDir(); - const repository = createRepository(root); - - await expect(repository.create({ id: "../escape", cwd: root })).rejects.toMatchObject({ - code: "invalid_payload", - }); - }); - - it("allows the same explicit session id in different working directories", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const firstCwd = join(root, "workspaces", "first"); - const secondCwd = join(root, "workspaces", "second"); - - const first = await repository.create({ id: "shared", cwd: firstCwd }); - const second = await repository.create({ id: "shared", cwd: secondCwd }); - - expect((await first.getMetadata()).cwd).toBe(firstCwd); - expect((await second.getMetadata()).cwd).toBe(secondCwd); - expect((await repository.list()).map((metadata) => metadata.id)).toEqual(["shared", "shared"]); - }); - - it.each([ - ["create", "create"], - ["create", "fork"], - ["fork", "fork"], - ] as const)("rejects concurrent %s and %s calls for the same destination", async (firstKind, secondKind) => { - vi.useFakeTimers({ toFake: ["Date"] }); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - try { - const root = createTempDir(); - const repository = createRepository(root); - const cwd = join(root, "workspace"); - const source = await repository.create({ id: "source", cwd }); - const sourceMetadata = await source.getMetadata(); - const run = (kind: "create" | "fork") => - kind === "create" - ? repository.create({ id: "same", cwd }) - : repository.fork(sourceMetadata, { id: "same", cwd }); - - const results = await Promise.allSettled([run(firstKind), run(secondKind)]); - const successes = results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : [])); - const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); - - expect(successes).toHaveLength(1); - expect(failures).toHaveLength(1); - expect(failures[0]).toMatchObject({ code: "already_exists" }); - expect((await repository.list({ cwd })).filter((listed) => listed.id === "same")).toHaveLength(1); - } finally { - vi.useRealTimers(); - } - }); - - it.each(["create", "fork"] as const)("releases a destination reservation after a failed %s", async (kind) => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repository = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); - const cwd = join(root, "workspace"); - const source = await repository.create({ id: "source", cwd }); - const sourceMetadata = await source.getMetadata(); - const run = () => - kind === "create" - ? repository.create({ id: "retry", cwd }) - : repository.fork(sourceMetadata, { id: "retry", cwd }); - - if (kind === "create") { - vi.spyOn(env, "writeFile").mockResolvedValueOnce({ - ok: false, - error: new FileError("unknown", "injected creation failure"), - }); - } else { - vi.spyOn(env, "renameFile").mockResolvedValueOnce({ - ok: false, - error: new FileError("unknown", "injected fork failure"), - }); - } - - await expect(run()).rejects.toMatchObject({ code: "storage" }); - await expect(run()).resolves.toBeDefined(); - expect((await repository.list({ cwd })).filter((listed) => listed.id === "retry")).toHaveLength(1); - }); - - it("sorts listed sessions by current filesystem modification time", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const newestCwd = join(root, "workspaces", "newest"); - const oldestCwd = join(root, "workspaces", "oldest"); - const newest = await repository.create({ id: "newest", cwd: newestCwd }); - const newestMetadata = await newest.getMetadata(); - const oldest = await repository.create({ id: "oldest", cwd: oldestCwd }); - const oldestMetadata = await oldest.getMetadata(); - const newestTime = new Date(1_700_000_002_000); - const oldestTime = new Date(1_700_000_001_000); - utimesSync(newestMetadata.path, newestTime, newestTime); - utimesSync(oldestMetadata.path, oldestTime, oldestTime); - - const listed = await repository.list(); - - expect(listed.map((metadata) => metadata.id)).toEqual(["newest", "oldest"]); - expect((await repository.list({ cwd: newestCwd })).map((metadata) => metadata.id)).toEqual(["newest"]); - expect(listed.map((metadata) => metadata.modifiedAt)).toEqual([ - statSync(newestMetadata.path).mtimeMs, - statSync(oldestMetadata.path).mtimeMs, - ]); - }); - - it("writes one line per mutation and restores the shared sequence", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const session = await repository.create({ id: "session", cwd: root }); - const metadata = await session.getMetadata(); - const entryId = await session.appendCustomEntry("note", { value: 1 }); - await session.createLane("thread", entryId); - await session.appendRecord({ - type: "operation_started", - id: "run", - lane: "thread", - sourceLeafId: null, - intent: { kind: "run", originalPrompt: [], initialMessages: [] }, - }); - await session.setName("Example"); - await session.setLabel(entryId, "checkpoint"); - await session.moveLane("main", null); - - const lines = readFileSync(metadata.path, "utf8") - .trimEnd() - .split("\n") - .map((line) => JSON.parse(line)); - expect(lines.map((line) => line.kind)).toEqual(["header", "entry", "lane", "record", "fact", "fact", "lane"]); - expect(lines.slice(1).map((line) => line.seq)).toEqual([1, 2, 3, 4, 5, 6]); - - const reopenedRepository = createRepository(root); - const reopened = await reopenedRepository.open(metadata); - expect(await reopened.getLanes()).toEqual([ - { lane: "main", leafId: null }, - { lane: "thread", leafId: entryId }, - ]); - expect(await reopened.getName()).toBe("Example"); - expect(await reopened.getLabel(entryId)).toBe("checkpoint"); - expect((await reopened.findRecords()).map((record) => record.id)).toEqual(["run"]); - expect( - ( - await reopened.findRecords({ - type: "operation_started", - operationKind: "run", - }) - ).map((record) => record.id), - ).toEqual(["run"]); - expect((await reopened.findOpenOperations("thread", { limit: 2 })).map((record) => record.id)).toEqual(["run"]); - expect((await reopened.getLog()).map((item) => item.seq)).toEqual([1, 2, 3, 4, 5, 6]); - expect( - ( - await reopened.appendRecord({ - type: "operation_finished", - id: "finish", - lane: "thread", - runId: "run", - outcome: "completed", - }) - ).seq, - ).toBe(7); - expect(await reopened.findOpenOperations("thread", { limit: 2 })).toEqual([]); - }); - - it("recomputes fork message counts when reopening", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const source = await repository.create({ id: "source", cwd: root }); - await source.appendMessage({ role: "user", content: [{ type: "text", text: "one" }], timestamp: 1 }); - await source.appendMessage({ role: "user", content: [{ type: "text", text: "two" }], timestamp: 2 }); - const fork = await repository.fork(await source.getMetadata(), { id: "fork", cwd: root }); - const metadata = await fork.getMetadata(); - - const reopenedRepository = createRepository(root); - const reopened = await reopenedRepository.open(metadata); - expect((await reopened.getStats()).messageCount).toBe(2); - await reopened.appendMessage({ role: "user", content: [{ type: "text", text: "three" }], timestamp: 3 }); - expect((await reopened.getStats()).messageCount).toBe(3); - - const verificationRepository = createRepository(root); - const verified = await verificationRepository.open(metadata); - expect((await verified.getStats()).messageCount).toBe(3); - }); - - it("reopens a tree fork with its lanes and facts", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const source = await repository.create({ id: "source", cwd: root }); - const rootId = await source.appendCustomEntry("root"); - await source.createLane("thread", rootId); - const mainId = await source.appendCustomEntry("main"); - const threadEntry = await source.appendEntry({ type: "custom", id: "thread", customType: "thread" }, "thread"); - const threadId = threadEntry.id; - await source.setName("Source"); - await source.setLabel(threadId, "tip"); - const fork = await repository.fork(await source.getMetadata(), { scope: "tree", id: "fork", cwd: root }); - const metadata = await fork.getMetadata(); - - const importedEntryLines = readFileSync(metadata.path, "utf8") - .trimEnd() - .split("\n") - .map((line) => JSON.parse(line)) - .filter((line) => line.kind === "entry"); - expect(importedEntryLines.map((line) => "lane" in line)).toEqual([false, false, false]); - - const reopenedRepository = createRepository(root); - const reopened = await reopenedRepository.open(metadata); - expect((await reopened.findEntries({ order: "oldestFirst" })).map((entry) => entry.id)).toEqual([ - rootId, - mainId, - threadId, - ]); - expect(await reopened.getLanes()).toEqual([ - { lane: "main", leafId: mainId }, - { lane: "thread", leafId: threadId }, - ]); - expect(await reopened.getName()).toBe("Source"); - expect(await reopened.getLabel(threadId)).toBe("tip"); - expect(await reopened.findRecords()).toEqual([]); - }); - - it("does not publish a partial fork when staging fails", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repository = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); - const source = await repository.create({ id: "source", cwd: root }); - await source.appendMessage({ role: "user", content: [{ type: "text", text: "one" }], timestamp: 1 }); - await source.appendMessage({ role: "user", content: [{ type: "text", text: "two" }], timestamp: 2 }); - const sourceMetadata = await source.getMetadata(); - const appendFile = env.appendFile.bind(env); - vi.spyOn(env, "appendFile") - .mockImplementationOnce(appendFile) - .mockResolvedValueOnce({ - ok: false, - error: new FileError("unknown", "injected staging failure"), - }); - - await expect(repository.fork(sourceMetadata, { id: "fork", cwd: root })).rejects.toMatchObject({ - code: "storage", - }); - - expect((await repository.list()).map((metadata) => metadata.id)).toEqual(["source"]); - expect(readdirSync(dirname(sourceMetadata.path)).filter((name) => name.endsWith(".tmp"))).toEqual([]); - }); - - it("does not publish a fork when atomic rename fails", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const repository = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); - const source = await repository.create({ id: "source", cwd: root }); - await source.appendMessage({ role: "user", content: [{ type: "text", text: "one" }], timestamp: 1 }); - const sourceMetadata = await source.getMetadata(); - vi.spyOn(env, "renameFile").mockResolvedValueOnce({ - ok: false, - error: new FileError("unknown", "injected rename failure"), - }); - - await expect(repository.fork(sourceMetadata, { id: "fork", cwd: root })).rejects.toMatchObject({ - code: "storage", - }); - - expect((await repository.list()).map((metadata) => metadata.id)).toEqual(["source"]); - expect(readdirSync(dirname(sourceMetadata.path)).filter((name) => name.endsWith(".tmp"))).toEqual([]); - }); - - it("repairs a valid final line missing its newline", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const session = await repository.create({ id: "session", cwd: root }); - const metadata = await session.getMetadata(); - const firstId = await session.appendCustomEntry("first"); - const unterminated = readFileSync(metadata.path, "utf8").trimEnd(); - writeFileSync(metadata.path, unterminated); - - const reopenedRepository = createRepository(root); - const reopened = await reopenedRepository.open(metadata); - expect(readFileSync(metadata.path, "utf8")).toBe(`${unterminated}\n`); - const secondId = await reopened.appendCustomEntry("second"); - - const verificationRepository = createRepository(root); - const verified = await verificationRepository.open(metadata); - expect((await verified.findEntries({ order: "oldestFirst" })).map((entry) => entry.id)).toEqual([ - firstId, - secondId, - ]); - }); - - it("fails to open when repairing a missing final newline fails", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const session = await repository.create({ id: "session", cwd: root }); - const metadata = await session.getMetadata(); - await session.appendCustomEntry("first"); - writeFileSync(metadata.path, readFileSync(metadata.path, "utf8").trimEnd()); - - const env = new NodeExecutionEnv({ cwd: root }); - vi.spyOn(env, "appendFile").mockResolvedValueOnce({ - ok: false, - error: new FileError("permission_denied", "repair denied", metadata.path), - }); - const failingRepository = new JsonlSessionRepo({ - fs: env, - sessionsRoot: root, - }); - - await expect(failingRepository.open(metadata)).rejects.toMatchObject({ - code: "storage", - cause: { code: "permission_denied" }, - }); - }); - - it("truncates a malformed final line", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const session = await repository.create({ id: "session", cwd: root }); - const metadata = await session.getMetadata(); - await session.appendCustomEntry("note", { value: "kept" }); - const validPrefix = readFileSync(metadata.path, "utf8"); - appendFileSync(metadata.path, '{"kind":"entry"'); - - const reopenedRepository = createRepository(root); - const reopened = await reopenedRepository.open(metadata); - expect((await reopened.findEntries()).map((entry) => entry.id)).toHaveLength(1); - expect(readFileSync(metadata.path, "utf8")).toBe(validPrefix); - const appendedId = await reopened.appendCustomEntry("after-recovery"); - expect((await reopened.getEntry(appendedId))?.seq).toBe(2); - }); - - it("rejects a complete invalid final mutation without modifying the file", async () => { - const root = createTempDir(); - const metadata = writeRawSession(root, "invalid-final-mutation", [{ kind: "unknown", seq: 1 }]); - const corrupted = readFileSync(metadata.path, "utf8"); - - await expect(createRepository(root).open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); - expect(readFileSync(metadata.path, "utf8")).toBe(corrupted); - }); - - it("rejects a malformed middle line without modifying the file", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const session = await repository.create({ id: "session", cwd: root }); - const metadata = await session.getMetadata(); - await session.appendCustomEntry("first"); - await session.appendCustomEntry("second"); - const lines = readFileSync(metadata.path, "utf8").trimEnd().split("\n"); - const corrupted = `${lines[0]}\n${lines[1]}\nnot-json\n${lines[2]}\n`; - writeFileSync(metadata.path, corrupted); - - const reopenedRepository = createRepository(root); - await expect(reopenedRepository.open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); - expect(readFileSync(metadata.path, "utf8")).toBe(corrupted); - }); - - it("rejects an imported entry that references a missing parent", async () => { - const root = createTempDir(); - const path = join(root, "session-missing-parent.jsonl"); - const header = { kind: "header", version: 4, id: "missing-parent", createdAt: 1, cwd: root }; - const entry = { - kind: "entry", - type: "custom", - id: "orphan", - customType: "note", - parentId: "missing", - seq: 1, - timestamp: 1, - }; - writeFileSync(path, `${JSON.stringify(header)}\n${JSON.stringify(entry)}\n`); - const metadata = { - id: header.id, - createdAt: header.createdAt, - path, - cwd: root, - modifiedAt: statSync(path).mtimeMs, - sourceFormat: 4 as const, - }; - - const repository = createRepository(root); - await expect(repository.open(metadata)).rejects.toMatchObject({ - code: "invalid_entry", - message: `Invalid JSONL v4 session ${path}: line 2 Invalid session mutation: references missing parent missing`, - }); - }); - - it("rejects a lane-bound entry that does not chain to the lane leaf", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const session = await repository.create({ id: "session", cwd: root }); - const metadata = await session.getMetadata(); - await session.appendCustomEntry("first"); - await session.appendCustomEntry("second"); - - const lines = readFileSync(metadata.path, "utf8") - .trimEnd() - .split("\n") - .map((line) => JSON.parse(line)); - lines[2].parentId = null; - writeFileSync(metadata.path, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); - - const reopenedRepository = createRepository(root); - await expect(reopenedRepository.open(metadata)).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining("does not chain to the lane leaf"), - }); - }); - - it("does not move a lane for an imported entry without lane metadata", async () => { - const root = createTempDir(); - const path = join(root, "session-import.jsonl"); - const header = { kind: "header", version: 4, id: "import", createdAt: 1, cwd: root }; - const importedEntry = { - kind: "entry", - type: "custom", - id: "imported", - customType: "note", - parentId: null, - seq: 1, - timestamp: 1, - }; - writeFileSync(path, `${JSON.stringify(header)}\n${JSON.stringify(importedEntry)}\n`); - const metadata = { - id: header.id, - createdAt: header.createdAt, - path, - cwd: root, - modifiedAt: statSync(path).mtimeMs, - sourceFormat: 4 as const, - }; - - const importedRepository = createRepository(root); - const imported = await importedRepository.open(metadata); - expect(await imported.getLeafId()).toBeNull(); - expect((await imported.findEntries()).map((entry) => entry.id)).toEqual(["imported"]); - - appendFileSync(path, `${JSON.stringify({ kind: "lane", seq: 2, lane: "main", leafId: "imported" })}\n`); - const movedRepository = createRepository(root); - const moved = await movedRepository.open(metadata); - expect(await moved.getLeafId()).toBe("imported"); - }); - - it.each([ - { - name: "a non-consecutive sequence", - message: "non-consecutive seq", - mutations: [ - { kind: "entry", type: "custom", id: "entry", customType: "note", parentId: null, seq: 2, timestamp: 1 }, - ], - }, - { - name: "a duplicate entry/record id", - message: "duplicate id", - mutations: [ - { - kind: "entry", - type: "custom", - id: "duplicate", - customType: "note", - parentId: null, - seq: 1, - timestamp: 1, - }, - { - kind: "record", - type: "operation_started", - id: "duplicate", - lane: "main", - seq: 2, - timestamp: 2, - sourceLeafId: null, - intent: { kind: "run", originalPrompt: [], initialMessages: [] }, - }, - ], - }, - { - name: "an entry with a missing parent", - message: "missing parent", - mutations: [ - { - kind: "entry", - type: "custom", - id: "entry", - customType: "note", - parentId: "missing", - seq: 1, - timestamp: 1, - }, - ], - }, - { - name: "an entry referencing a missing lane", - message: "missing lane", - mutations: [ - { - kind: "entry", - lane: "thread", - type: "custom", - id: "entry", - customType: "note", - parentId: null, - seq: 1, - timestamp: 1, - }, - ], - }, - { - name: "a record referencing a missing lane", - message: "missing lane", - mutations: [ - { - kind: "record", - type: "operation_started", - id: "run", - lane: "thread", - seq: 1, - timestamp: 1, - sourceLeafId: null, - intent: { kind: "run", originalPrompt: [], initialMessages: [] }, - }, - ], - }, - { - name: "a lane move referencing a missing entry", - message: "missing lane target", - mutations: [{ kind: "lane", lane: "thread", leafId: "missing", seq: 1 }], - }, - { - name: "a label referencing a missing entry", - message: "missing label target", - mutations: [{ kind: "fact", fact: "label", targetId: "missing", label: "checkpoint", seq: 1 }], - }, - ])("rejects $name during replay", async ({ name, message, mutations }) => { - const root = createTempDir(); - const metadata = writeRawSession(root, name.replace(/[^A-Za-z0-9._-]/g, "-"), mutations); - - await expect(createRepository(root).open(metadata)).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(message), - }); - }); - - it("rejects a complete malformed interior mutation without modifying the file", async () => { - const root = createTempDir(); - const metadata = writeRawSession(root, "malformed-interior", [ - { - kind: "record", - type: "operation_started", - id: "run", - lane: "main", - seq: 1, - timestamp: 1, - sourceLeafId: null, - }, - { kind: "fact", fact: "name", name: "after", seq: 2 }, - ]); - const corrupted = readFileSync(metadata.path, "utf8"); - - await expect(createRepository(root).open(metadata)).rejects.toMatchObject({ code: "invalid_entry" }); - expect(readFileSync(metadata.path, "utf8")).toBe(corrupted); - }); - - it("preserves the session when staging torn-tail repair fails", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const session = await repository.create({ id: "repair-failure", cwd: root }); - const metadata = await session.getMetadata(); - await session.appendCustomEntry("kept"); - appendFileSync(metadata.path, '{"kind":"entry"'); - const original = readFileSync(metadata.path, "utf8"); - - const env = new NodeExecutionEnv({ cwd: root }); - const writeFile = env.writeFile.bind(env); - vi.spyOn(env, "writeFile").mockImplementationOnce(async (path: string) => { - const damaged = await writeFile(path, ""); - if (!damaged.ok) return damaged; - return { - ok: false, - error: new FileError("unknown", "repair interrupted after truncation", path), - }; - }); - const failingRepository = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); - - await expect(failingRepository.open(metadata)).rejects.toMatchObject({ code: "storage" }); - expect(readFileSync(metadata.path, "utf8")).toBe(original); - expect(existsSync(`${metadata.path}.tmp`)).toBe(false); - }); - - it("preserves the session when torn-tail repair cannot be published", async () => { - const root = createTempDir(); - const repository = createRepository(root); - const session = await repository.create({ id: "repair-rename-failure", cwd: root }); - const metadata = await session.getMetadata(); - await session.appendCustomEntry("kept"); - appendFileSync(metadata.path, '{"kind":"entry"'); - const original = readFileSync(metadata.path, "utf8"); - - const env = new NodeExecutionEnv({ cwd: root }); - vi.spyOn(env, "renameFile").mockResolvedValueOnce({ - ok: false, - error: new FileError("unknown", "injected repair rename failure"), - }); - const failingRepository = new JsonlSessionRepo({ fs: env, sessionsRoot: root }); - - await expect(failingRepository.open(metadata)).rejects.toMatchObject({ code: "storage" }); - expect(readFileSync(metadata.path, "utf8")).toBe(original); - expect(existsSync(`${metadata.path}.tmp`)).toBe(false); - }); -}); diff --git a/packages/agent/test/harness/session/memory.test.ts b/packages/agent/test/harness/session/memory.test.ts deleted file mode 100644 index 87c13c58b..000000000 --- a/packages/agent/test/harness/session/memory.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { InMemorySessionRepo, InMemorySessionStorage, Session } from "../../../src/harness/session/index.ts"; -import { - createSessionBackendConformance, - type SessionBackendFixture, -} from "../../../src/harness/session/testing/index.ts"; - -const conformance = createSessionBackendConformance(() => - Promise.resolve({ - repository: new InMemorySessionRepo(), - [Symbol.asyncDispose]: () => Promise.resolve(), - }), -); - -describe("InMemorySessionRepo conformance", () => { - for (const group of new Set(conformance.map((testCase) => testCase.group))) { - describe(group, () => { - for (const testCase of conformance.filter((candidate) => candidate.group === group)) { - it(testCase.name, () => testCase.run()); - } - }); - } -}); - -describe("Session with in-memory storage", () => { - it("uses one injectable id generator across lane views", async () => { - let nextId = 0; - const session = new Session(new InMemorySessionStorage({ id: "session", createdAt: 1 }), { - idGenerator: { next: () => `generated-${++nextId}` }, - }); - const mainId = await session.appendCustomEntry("note"); - await session.createLane("thread", mainId); - const threadId = await session.view("thread").appendCustomEntry("note"); - - expect(mainId).toBe("generated-1"); - expect(threadId).toBe("generated-2"); - }); -}); diff --git a/packages/agent/test/harness/session/search.test.ts b/packages/agent/test/harness/session/search.test.ts deleted file mode 100644 index 883f58396..000000000 --- a/packages/agent/test/harness/session/search.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { NodeExecutionEnv } from "../../../src/harness/env/nodejs.ts"; -import { - InMemorySessionStorage, - type JsonlSessionListOptions, - JsonlSessionRepo, - type JsonlSessionRepoOptions, - Session, - type SessionMetadata, - type SessionStorage, -} from "../../../src/harness/session/index.ts"; -import { listJsonlSessionMetadata, loadJsonlSessionStorage } from "../../../src/harness/session/jsonl/repo.ts"; -import { createScanningSessionSearch } from "../../../src/search/index.ts"; -import type { AgentMessage } from "../../../src/types.ts"; - -interface WorkspaceMetadata extends SessionMetadata { - cwd: string; -} - -const tempDirs: string[] = []; - -function createTempDir(): string { - const directory = mkdtempSync(join(tmpdir(), "pi-agent-search-")); - tempDirs.push(directory); - return directory; -} - -afterEach(() => { - while (tempDirs.length > 0) rmSync(tempDirs.pop()!, { recursive: true, force: true }); -}); - -function message(text: string): AgentMessage { - return { role: "user", content: [{ type: "text", text }], timestamp: 1 }; -} - -function createMemorySession(metadata: WorkspaceMetadata): Session { - return new Session( - new InMemorySessionStorage(metadata) as unknown as SessionStorage, - ); -} - -async function collect(iterable: AsyncIterable): Promise { - const items: T[] = []; - for await (const item of iterable) items.push(item); - return items; -} - -async function* jsonlReadables(options: JsonlSessionRepoOptions, query: JsonlSessionListOptions = {}) { - for (const metadata of await listJsonlSessionMetadata(options, query)) { - yield loadJsonlSessionStorage(options, metadata); - } -} - -describe("session search", () => { - it("scans an arbitrary in-memory projected source", async () => { - const root = createMemorySession({ id: "root", createdAt: 1, cwd: "/repo" }); - await root.appendMessage(message("fix auth flow")); - const other = createMemorySession({ id: "other", createdAt: 2, cwd: "/other" }); - await other.appendMessage(message("auth in another workspace")); - const search = createScanningSessionSearch([root, other]); - - expect("apply" in search).toBe(false); - expect(await collect(search.search("auth"))).toMatchObject([{ sessionId: "root" }, { sessionId: "other" }]); - expect(await collect(search.search("missing"))).toEqual([]); - }); - - it("includes labels in memory scanning projections", async () => { - const session = createMemorySession({ id: "session", createdAt: 1, cwd: "/repo" }); - const entryId = await session.appendMessage(message("plain body")); - await session.setLabel(entryId, "important label"); - const search = createScanningSessionSearch([session]); - - expect(await collect(search.search("important"))).toMatchObject([{ sessionId: "session", entryId }]); - }); - - it("honors entry type filters and abort signals in scanning search", async () => { - const session = createMemorySession({ id: "session", createdAt: 1, cwd: "/repo" }); - const messageEntryId = await session.appendMessage(message("auth message")); - await session.appendCustomEntry("note", { text: "auth custom" }); - const search = createScanningSessionSearch([session]); - - expect(await collect(search.search("auth", { entryTypes: ["message"] }))).toMatchObject([ - { sessionId: "session", entryId: messageEntryId }, - ]); - - const controller = new AbortController(); - controller.abort(); - await expect(collect(search.search("auth", { signal: controller.signal }))).rejects.toMatchObject({ - name: "AbortError", - }); - }); - - it("scans JSONL sessions from disk through the JSONL scanning source", async () => { - const root = createTempDir(); - const options = { fs: new NodeExecutionEnv({ cwd: root }), sessionsRoot: root }; - const repository = new JsonlSessionRepo(options); - const cwd = join(root, "workspace"); - const otherCwd = join(root, "other"); - const session = await repository.create({ id: "jsonl", cwd }); - const entryId = await session.appendMessage(message("jsonl backed auth entry")); - await session.setLabel(entryId, "disk label"); - const other = await repository.create({ id: "other", cwd: otherCwd }); - const otherEntryId = await other.appendMessage(message("jsonl backed auth entry in another cwd")); - const search = createScanningSessionSearch((query?: JsonlSessionListOptions) => jsonlReadables(options, query)); - - const authHits = await collect(search.search("auth")); - expect(authHits).toHaveLength(2); - expect(authHits).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - sessionId: "jsonl", - entryId, - }), - expect.objectContaining({ - sessionId: "other", - entryId: otherEntryId, - }), - ]), - ); - expect(await collect(search.search("disk"))).toMatchObject([{ sessionId: "jsonl", entryId }]); - }); -}); diff --git a/packages/agent/test/harness/telemetry.test.ts b/packages/agent/test/harness/telemetry.test.ts index cb2b34023..cbe18b357 100644 --- a/packages/agent/test/harness/telemetry.test.ts +++ b/packages/agent/test/harness/telemetry.test.ts @@ -119,10 +119,23 @@ describe("agent telemetry schemas", () => { it("infers per-span harness literals and optional completion enrichment", async () => { type RunStart = HarnessSpanStartAttributes<"pi.harness.run">; type RunEnd = HarnessSpanEndAttributes<"pi.harness.run">; + type WriteStart = HarnessSpanStartAttributes<"pi.session.write">; + type WriteEnd = HarnessSpanEndAttributes<"pi.session.write">; expectTypeOf().toEqualTypeOf<"run">(); expectTypeOf().toEqualTypeOf< "completed" | "aborted" | "failed" | "suspended" | undefined >(); + const writeStart = { + "pi.session.id": "session", + "pi.session.item_count": 2, + "pi.session.item_kinds": ["entry", "register"], + } satisfies WriteStart; + const writeEnd = { + "pi.session.first_seq": 1, + "pi.session.last_seq": 2, + } satisfies WriteEnd; + expectTypeOf(writeStart["pi.session.item_count"]).toEqualTypeOf(); + expectTypeOf(writeEnd["pi.session.last_seq"]).toEqualTypeOf(); const telemetryContext: TelemetryContext = NOOP_TELEMETRY_CONTEXT; await startHarnessSpan( diff --git a/packages/agent/test/harness/types.test.ts b/packages/agent/test/harness/types.test.ts new file mode 100644 index 000000000..19840adb1 --- /dev/null +++ b/packages/agent/test/harness/types.test.ts @@ -0,0 +1,73 @@ +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { expectTypeOf, it } from "vitest"; +import type { + AgentHarnessStreamOptions, + HarnessEvent, + HookInvocation, + LaneConfiguration, + OperationState, + RegisterSetWrite, + Session, + SessionTree, + SettledAssistantMessage, + Write, +} from "../../src/index.ts"; + +it("exposes correlated durable and public harness contracts", () => { + const configuration = { + model: { provider: "provider", modelId: "model" }, + thinkingLevel: "off", + activeToolNames: [], + } satisfies LaneConfiguration; + const registerWrite = { + kind: "register", + op: "set", + namespace: "lane.config", + key: "main", + value: configuration, + } satisfies RegisterSetWrite; + const entryWrite = { + kind: "entry", + entry: { + id: "entry", + parentId: null, + type: "message", + message: { role: "user", content: "hello", timestamp: 1 }, + }, + } satisfies Write; + + expectTypeOf(registerWrite.namespace).toEqualTypeOf<"lane.config">(); + expectTypeOf(entryWrite.entry.type).toEqualTypeOf<"message">(); + expectTypeOf().toEqualTypeOf<"run" | "compaction" | "navigation">(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf["lane"]>().toEqualTypeOf(); + expectTypeOf["lane"]>().toEqualTypeOf(); + expectTypeOf>().toMatchTypeOf<{ + lane: string; + runId: string; + toolCallId: string; + toolName: string; + }>(); + expectTypeOf().toEqualTypeOf< + (name: string, at: string | null, laneConfiguration: LaneConfiguration) => Promise + >(); + + const compileTimeFailures = () => { + // @ts-expect-error lane.config requires a complete LaneConfiguration + const invalidRegister: RegisterSetWrite = { + kind: "register", + op: "set", + namespace: "lane.config", + key: "main", + value: "model", + }; + // @ts-expect-error callers cannot supply the harness-owned abort signal + const invalidOptions: AgentHarnessStreamOptions = { signal: new AbortController().signal }; + // @ts-expect-error pending provider messages are not settled + const unsettled: SettledAssistantMessage = { stopReason: "pending" } as AssistantMessage; + void invalidRegister; + void invalidOptions; + void unsettled; + }; + expectTypeOf(compileTimeFailures).toBeFunction(); +}); diff --git a/packages/coding-agent/src/server/create-harness.ts b/packages/coding-agent/src/server/create-harness.ts deleted file mode 100644 index 3ad0bad23..000000000 --- a/packages/coding-agent/src/server/create-harness.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { - AgentHarness, - type AgentHarnessOptions, - type AgentHarnessTool, - createBashTool, - createEditTool, - createReadTool, - createWriteTool, - type ExecutionEnv, - type ExecutionToolContext, - type HarnessTool, -} from "@earendil-works/pi-agent-core"; -import type { Static, TSchema } from "typebox"; -import { getExperimentalToolSampling } from "../core/experimental.ts"; -import { type BuildSystemPromptOptions, buildSystemPrompt } from "../core/system-prompt.ts"; -import { bashToolSystemPromptContribution } from "../core/tools/bash.ts"; -import { editToolSystemPromptContribution } from "../core/tools/edit.ts"; -import { readToolSystemPromptContribution } from "../core/tools/read.ts"; -import { writeToolSystemPromptContribution } from "../core/tools/write.ts"; - -export interface CodingAgentHarnessTool extends HarnessTool { - promptSnippet?: string; - promptGuidelines?: readonly string[]; -} - -function createCodingAgentHarnessTool( - tool: AgentHarnessTool, - context: ExecutionToolContext, - prompt: Required>, -): CodingAgentHarnessTool { - return { - ...tool, - ...prompt, - constrainedSampling: getExperimentalToolSampling(), - execute: (toolCallId, params, signal, onUpdate) => - tool.execute(toolCallId, params as Static, signal, onUpdate, context), - }; -} - -export interface CreateCodingAgentHarnessOptions extends Omit { - env: ExecutionEnv; - bashCommandPrefix?: string; - /** Path to the JSONL session file exposed to default bash commands as PI_SESSION_FILE. */ - sessionFile?: string; - tools?: CodingAgentHarnessTool[]; - systemPromptOptions?: Omit; -} - -export interface BuildCodingAgentHarnessSystemPromptOptions { - cwd: string; - tools: readonly CodingAgentHarnessTool[]; - activeToolNames: readonly string[]; - systemPromptOptions?: CreateCodingAgentHarnessOptions["systemPromptOptions"]; -} - -export function buildCodingAgentHarnessSystemPrompt(options: BuildCodingAgentHarnessSystemPromptOptions): string { - const activeTools = options.activeToolNames.flatMap((name) => { - const tool = options.tools.find((candidate) => candidate.name === name); - return tool ? [tool] : []; - }); - const toolSnippets = Object.fromEntries( - activeTools.flatMap((tool) => { - const promptSnippet = tool.promptSnippet - ?.replace(/[\r\n]+/g, " ") - .replace(/\s+/g, " ") - .trim(); - return promptSnippet ? [[tool.name, promptSnippet]] : []; - }), - ); - const promptGuidelines = activeTools.flatMap((tool) => tool.promptGuidelines ?? []); - return buildSystemPrompt({ - ...options.systemPromptOptions, - cwd: options.cwd, - selectedTools: activeTools.map((tool) => tool.name), - toolSnippets, - promptGuidelines, - }); -} - -export async function createCodingAgentHarness(options: CreateCodingAgentHarnessOptions) { - const { - env, - bashCommandPrefix, - sessionFile, - systemPromptOptions, - tools: providedTools, - activeToolNames: providedActiveToolNames, - systemPrompt: providedSystemPrompt, - ...harnessOptions - } = options; - let harness: AgentHarness | undefined; - const getHarness = (): AgentHarness => { - if (!harness) throw new Error("Coding-agent Harness callback ran before Harness initialization"); - return harness; - }; - let tools = providedTools; - if (tools === undefined) { - const metadata = await options.session.getMetadata(); - const toolContext = { env } satisfies ExecutionToolContext; - tools = [ - createCodingAgentHarnessTool(createReadTool(), toolContext, { - promptSnippet: readToolSystemPromptContribution.snippet, - promptGuidelines: readToolSystemPromptContribution.guidelines, - }), - createCodingAgentHarnessTool( - createBashTool({ - commandPrefix: bashCommandPrefix, - prepare: async (execution) => { - const currentHarness = getHarness(); - const [model, thinkingLevel] = await Promise.all([ - currentHarness.getModel(), - currentHarness.getThinkingLevel(), - ]); - execution.env.PI_SESSION_ID = metadata.id; - execution.env.PI_SESSION_FILE = sessionFile ?? ""; - execution.env.PI_PROVIDER = model.provider; - execution.env.PI_MODEL = model.id; - execution.env.PI_REASONING_LEVEL = thinkingLevel; - }, - }), - toolContext, - { - promptSnippet: bashToolSystemPromptContribution.snippet, - promptGuidelines: bashToolSystemPromptContribution.guidelines, - }, - ), - createCodingAgentHarnessTool(createEditTool(), toolContext, { - promptSnippet: editToolSystemPromptContribution.snippet, - promptGuidelines: editToolSystemPromptContribution.guidelines, - }), - createCodingAgentHarnessTool(createWriteTool(), toolContext, { - promptSnippet: writeToolSystemPromptContribution.snippet, - promptGuidelines: writeToolSystemPromptContribution.guidelines, - }), - ]; - } - const activeToolNames = [...(providedActiveToolNames ?? tools.map((tool) => tool.name))]; - const systemPrompt = - providedSystemPrompt ?? - (async () => { - const currentHarness = getHarness(); - const [currentTools, currentActiveToolNames] = await Promise.all([ - currentHarness.getTools(), - currentHarness.getActiveTools(), - ]); - return buildCodingAgentHarnessSystemPrompt({ - cwd: env.cwd, - tools: currentTools, - activeToolNames: currentActiveToolNames, - systemPromptOptions, - }); - }); - const created = await AgentHarness.create({ - ...harnessOptions, - tools, - activeToolNames, - systemPrompt, - }); - harness = created.harness; - return created; -} diff --git a/packages/coding-agent/test/server/create-harness.test.ts b/packages/coding-agent/test/server/create-harness.test.ts deleted file mode 100644 index c16f2d37c..000000000 --- a/packages/coding-agent/test/server/create-harness.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { - AgentHarness, - type AgentHarnessOptions, - type ExecutionError, - type HarnessTool, - InMemorySessionStorage, - type Result, - Session, - type ShellExecOptions, -} from "@earendil-works/pi-agent-core"; -import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; -import { createModels } from "@earendil-works/pi-ai"; -import { getModel } from "@earendil-works/pi-ai/compat"; -import { Type } from "typebox"; -import { describe, expect, test, vi } from "vitest"; -import { - buildCodingAgentHarnessSystemPrompt, - type CodingAgentHarnessTool, - createCodingAgentHarness, -} from "../../src/server/create-harness.ts"; - -class CapturingExecutionEnv extends NodeExecutionEnv { - executionOverrides: Record | undefined; - - override async exec( - command: string, - options?: ShellExecOptions, - ): Promise> { - this.executionOverrides = options?.env; - return super.exec(command, options); - } -} - -async function resolveSystemPrompt(systemPrompt: AgentHarnessOptions["systemPrompt"]): Promise { - if (typeof systemPrompt === "string") return systemPrompt; - if (systemPrompt === undefined) throw new Error("Expected a system prompt callback"); - return systemPrompt(); -} - -function createPromptTool(name: string, promptSnippet?: string, promptGuidelines?: string[]): CodingAgentHarnessTool { - return { - name, - label: name, - description: `${name} description`, - parameters: Type.Object({}), - execute: async () => ({ content: [{ type: "text", text: "ok" }], details: undefined }), - promptSnippet, - promptGuidelines, - }; -} - -const defaultPromptTools = [ - createPromptTool("read", "Read file contents", ["Use read to examine files instead of cat or sed."]), - createPromptTool("bash", "Execute bash commands (ls, grep, find, etc.)", [ - "You can inspect PI_* environment variables for current model and session details.", - ]), - createPromptTool("edit", "Edit files", ["Edit carefully."]), - createPromptTool("write", "Create or overwrite files", ["Use write only for new files or complete rewrites."]), -]; - -describe("coding-agent Harness construction", () => { - test("adds coding-agent policy to explicit Harness options", async () => { - const session = new Session(new InMemorySessionStorage({ id: "harness-session", createdAt: 1 })); - const env = new NodeExecutionEnv({ cwd: "/workspace" }); - const created = await createCodingAgentHarness({ - session, - models: createModels(), - model: getModel("google", "gemini-2.5-flash"), - thinkingLevel: "high", - env, - streamOptions: { maxTokens: 123 }, - retry: { enabled: true, maxRetries: 2, baseDelayMs: 10 }, - steeringMode: "all", - followUpMode: "all", - }); - try { - expect(created.suspended).toEqual([]); - expect(await created.harness.getActiveTools()).toEqual(["read", "bash", "edit", "write"]); - expect((await created.harness.getTools()).map((tool) => tool.name)).toEqual(["read", "bash", "edit", "write"]); - expect(await created.harness.getStreamOptions()).toEqual({ maxTokens: 123 }); - expect(await created.harness.getRetryPolicy()).toEqual({ enabled: true, maxRetries: 2, baseDelayMs: 10 }); - expect(await created.harness.getSteeringMode()).toBe("all"); - expect(await created.harness.getFollowUpMode()).toBe("all"); - } finally { - await created.harness.close(); - await env.cleanup(); - } - }); - - test("preserves coding-agent prompt snippets and guideline order", () => { - const prompt = buildCodingAgentHarnessSystemPrompt({ - cwd: "/workspace", - tools: defaultPromptTools, - activeToolNames: ["read", "bash", "edit", "write"], - }); - expect(prompt).toContain("- read: Read file contents"); - expect(prompt).toContain("- bash: Execute bash commands (ls, grep, find, etc.)"); - expect(prompt).toContain("Use read to examine files instead of cat or sed."); - expect(prompt).toContain("You can inspect PI_* environment variables for current model and session details."); - expect(prompt.indexOf("Use read to examine files")).toBeLessThan( - prompt.indexOf("You can inspect PI_* environment variables"), - ); - }); - - test("preserves caller-supplied tools and activation", async () => { - const session = new Session(new InMemorySessionStorage({ id: "custom-harness-session", createdAt: 1 })); - const env = new NodeExecutionEnv({ cwd: "/workspace" }); - const customTool: HarnessTool = { - name: "inspect", - label: "inspect", - description: "Inspect the configured service", - parameters: Type.Object({}), - execute: async () => ({ content: [{ type: "text", text: "ok" }], details: undefined }), - }; - const created = await createCodingAgentHarness({ - session, - models: createModels(), - model: getModel("google", "gemini-2.5-flash"), - env, - tools: [customTool], - activeToolNames: [], - systemPrompt: "Server-owned prompt", - }); - try { - expect((await created.harness.getTools()).map((tool) => tool.name)).toEqual(["inspect"]); - expect(await created.harness.getActiveTools()).toEqual([]); - } finally { - await created.harness.close(); - await env.cleanup(); - } - }); - - test("sets the optional session file in the default bash tool environment", async () => { - const session = new Session(new InMemorySessionStorage({ id: "session-file-harness", createdAt: 1 })); - const env = new CapturingExecutionEnv({ - cwd: process.cwd(), - shellEnv: { PI_SESSION_FILE: "/stale/parent.jsonl", PI_CODING_AGENT: "true" }, - }); - const created = await createCodingAgentHarness({ - session, - models: createModels(), - model: getModel("google", "gemini-2.5-flash"), - thinkingLevel: "high", - env, - sessionFile: "/sessions/current.jsonl", - }); - try { - const bash = (await created.harness.getTools()).find((tool) => tool.name === "bash"); - if (!bash) throw new Error("Expected the default bash tool"); - - const result = await bash.execute("bash-call", { - command: `printf '%s' "$PI_SESSION_ID|$PI_SESSION_FILE|$PI_PROVIDER|$PI_MODEL|$PI_REASONING_LEVEL|$PI_CODING_AGENT"`, - }); - - expect(env.executionOverrides).toEqual({ - PI_SESSION_ID: "session-file-harness", - PI_SESSION_FILE: "/sessions/current.jsonl", - PI_PROVIDER: "google", - PI_MODEL: "gemini-2.5-flash", - PI_REASONING_LEVEL: "high", - }); - expect(result.content).toEqual([ - { - type: "text", - text: "session-file-harness|/sessions/current.jsonl|google|gemini-2.5-flash|high|true", - }, - ]); - } finally { - await created.harness.close(); - await env.cleanup(); - } - }); - - test("keeps bash PI model variables synchronized with Harness state", async () => { - const session = new Session(new InMemorySessionStorage({ id: "dynamic-bash-session", createdAt: 1 })); - const env = new CapturingExecutionEnv({ - cwd: process.cwd(), - shellEnv: { PI_SESSION_FILE: "/stale/parent.jsonl", PI_CODING_AGENT: "true" }, - }); - const created = await createCodingAgentHarness({ - session, - models: createModels(), - model: getModel("google", "gemini-2.5-flash"), - thinkingLevel: "high", - env, - }); - try { - await created.harness.setModel(getModel("anthropic", "claude-sonnet-4-5")); - await created.harness.setThinkingLevel("low"); - const bash = (await created.harness.getTools()).find((tool) => tool.name === "bash"); - if (!bash) throw new Error("Expected the default bash tool"); - - const result = await bash.execute("bash-call", { - command: `printf '%s:%s' "\${PI_SESSION_FILE+x}" "$PI_SESSION_ID|$PI_PROVIDER|$PI_MODEL|$PI_REASONING_LEVEL|$PI_CODING_AGENT"`, - }); - - expect(env.executionOverrides).toEqual({ - PI_SESSION_ID: "dynamic-bash-session", - PI_SESSION_FILE: "", - PI_PROVIDER: "anthropic", - PI_MODEL: "claude-sonnet-4-5", - PI_REASONING_LEVEL: "low", - }); - expect(Object.hasOwn(env.executionOverrides ?? {}, "PI_SESSION_FILE")).toBe(true); - expect(env.executionOverrides?.PI_SESSION_FILE).toBe(""); - expect(result.content).toEqual([ - { - type: "text", - text: "x:dynamic-bash-session|anthropic|claude-sonnet-4-5|low|true", - }, - ]); - } finally { - await created.harness.close(); - await env.cleanup(); - } - }); - - test("builds each default system prompt from current Harness tool metadata", async () => { - const originalCreate = AgentHarness.create.bind(AgentHarness); - let configuredSystemPrompt: AgentHarnessOptions["systemPrompt"]; - const createSpy = vi.spyOn(AgentHarness, "create").mockImplementation(async (options) => { - configuredSystemPrompt = options.systemPrompt; - return originalCreate(options); - }); - const session = new Session(new InMemorySessionStorage({ id: "dynamic-prompt-session", createdAt: 1 })); - const env = new NodeExecutionEnv({ cwd: "/workspace" }); - try { - const created = await createCodingAgentHarness({ - session, - models: createModels(), - model: getModel("google", "gemini-2.5-flash"), - env, - }); - createSpy.mockRestore(); - try { - const initialPrompt = await resolveSystemPrompt(configuredSystemPrompt); - expect(initialPrompt).toContain("- read: Read file contents"); - expect(initialPrompt).toContain("- bash: Execute bash commands (ls, grep, find, etc.)"); - expect(initialPrompt).toContain("- edit: Make precise file edits with exact text replacement"); - expect(initialPrompt).toContain("- write: Create or overwrite files"); - - await created.harness.setActiveTools(["write"]); - const writePrompt = await resolveSystemPrompt(configuredSystemPrompt); - expect(writePrompt).toContain("- write: Create or overwrite files"); - expect(writePrompt).not.toContain("- read:"); - expect(writePrompt).not.toContain("- bash:"); - - const read = (await created.harness.getTools()).find((tool) => tool.name === "read"); - if (!read) throw new Error("Expected the default read tool"); - await created.harness.setTools([read]); - const readPrompt = await resolveSystemPrompt(configuredSystemPrompt); - expect(readPrompt).toContain("- read: Read file contents"); - expect(readPrompt).not.toContain("- write:"); - - const inspectTool: CodingAgentHarnessTool = { - name: "inspect", - label: "inspect", - description: "Inspect the configured service", - parameters: Type.Object({}), - execute: async () => ({ content: [{ type: "text", text: "ok" }], details: undefined }), - promptSnippet: " Inspect\nthe configured service ", - promptGuidelines: ["Use inspect for service diagnostics."], - }; - await created.harness.setTools([inspectTool]); - const inspectPrompt = await resolveSystemPrompt(configuredSystemPrompt); - expect(inspectPrompt).toContain("- inspect: Inspect the configured service"); - expect(inspectPrompt).toContain("Use inspect for service diagnostics."); - } finally { - await created.harness.close(); - await env.cleanup(); - } - } finally { - createSpy.mockRestore(); - } - }); - - test("omits active custom tools without prompt metadata from the textual tools section", () => { - const prompt = buildCodingAgentHarnessSystemPrompt({ - cwd: "/workspace", - tools: [createPromptTool("hidden")], - activeToolNames: ["hidden"], - }); - - expect(prompt).toContain("Available tools:\n(none)"); - expect(prompt).not.toContain("- hidden:"); - expect(prompt).not.toContain("hidden description"); - }); - - test.each([ - [ - "bash", - "Execute bash commands (ls, grep, find, etc.)", - "You can inspect PI_* environment variables for current model and session details.", - ], - ["read", "Read file contents", "Use read to examine files instead of cat or sed."], - [ - "edit", - "Make precise file edits with exact text replacement, including multiple disjoint edits in one call", - "Use edit for precise changes (edits[].oldText must match exactly)", - ], - ["write", "Create or overwrite files", "Use write only for new files or complete rewrites."], - ] as const)( - "does not infer prompt metadata for a caller-supplied %s replacement", - (name, builtInSnippet, builtInGuideline) => { - const prompt = buildCodingAgentHarnessSystemPrompt({ - cwd: "/workspace", - tools: [createPromptTool(name)], - activeToolNames: [name], - }); - - expect(prompt).toContain("Available tools:\n(none)"); - expect(prompt).not.toContain(builtInSnippet); - expect(prompt).not.toContain(builtInGuideline); - }, - ); - - test("builds the default prompt from active tools and resolved prompt resources", () => { - const prompt = buildCodingAgentHarnessSystemPrompt({ - cwd: "/workspace", - tools: defaultPromptTools, - activeToolNames: ["write", "read"], - systemPromptOptions: { - contextFiles: [{ path: "/workspace/AGENTS.md", content: "Follow project policy." }], - skills: [ - { - name: "review", - description: "Review server changes", - filePath: "/skills/review/SKILL.md", - baseDir: "/skills/review", - sourceInfo: { - path: "/skills/review/SKILL.md", - source: "test", - scope: "temporary", - origin: "top-level", - }, - disableModelInvocation: false, - }, - ], - }, - }); - - expect(prompt).toContain("- write: Create or overwrite files"); - expect(prompt).toContain("- read: Read file contents"); - expect(prompt).not.toContain("- bash:"); - expect(prompt).not.toContain("You can inspect PI_* environment variables"); - expect(prompt).toContain(''); - expect(prompt).toContain("review"); - expect(prompt.indexOf("Use write only for new files or complete rewrites.")).toBeLessThan( - prompt.indexOf("Use read to examine files instead of cat or sed."), - ); - }); -}); diff --git a/packages/session-backends/sqlite-node/CHANGELOG.md b/packages/session-backends/sqlite-node/CHANGELOG.md index ba9a214ff..97409302a 100644 --- a/packages/session-backends/sqlite-node/CHANGELOG.md +++ b/packages/session-backends/sqlite-node/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Removed the incompatible work-in-progress session backend while the one-file-per-session backend is rebuilt against the durable storage contract; the Node SQLite adapter and SQL helpers remain available. + ## [0.84.1] - 2026-08-07 ### Added diff --git a/packages/session-backends/sqlite-node/package.json b/packages/session-backends/sqlite-node/package.json index e54f41a08..00cfe5c0b 100644 --- a/packages/session-backends/sqlite-node/package.json +++ b/packages/session-backends/sqlite-node/package.json @@ -18,7 +18,7 @@ ], "scripts": { "clean": "node -e \"import('node:fs/promises').then(fs=>fs.rm('dist',{recursive:true,force:true}))\"", - "build": "tsgo -p tsconfig.build.json && node ./scripts/prepare-dist.mjs copy-sqlite-migrations", + "build": "tsgo -p tsconfig.build.json", "test": "vitest --run", "coverage": "vitest --run --coverage", "prepublishOnly": "npm run clean && npm run build" diff --git a/packages/session-backends/sqlite-node/scripts/prepare-dist.mjs b/packages/session-backends/sqlite-node/scripts/prepare-dist.mjs deleted file mode 100644 index 52328367f..000000000 --- a/packages/session-backends/sqlite-node/scripts/prepare-dist.mjs +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env node - -import { cp, mkdir, rm } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const packageDir = resolve(scriptDir, ".."); -const distDir = resolve(packageDir, "dist"); -const migrationSourceDir = resolve(packageDir, "src/sqlite/migrations"); -const migrationDestDir = resolve(distDir, "sqlite/migrations"); - -async function clean() { - await rm(distDir, { recursive: true, force: true }); -} - -async function copySqliteMigrations() { - await mkdir(migrationDestDir, { recursive: true }); - await cp(migrationSourceDir, migrationDestDir, { recursive: true }); -} - -const command = process.argv[2]; - -if (command === "clean") { - await clean(); - process.exit(0); -} - -if (command === "copy-sqlite-migrations") { - await copySqliteMigrations(); - process.exit(0); -} - -console.error("Usage: node scripts/prepare-dist.mjs "); -process.exit(1); diff --git a/packages/session-backends/sqlite-node/src/sqlite/branch-cache.ts b/packages/session-backends/sqlite-node/src/sqlite/branch-cache.ts deleted file mode 100644 index eaef85f58..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/branch-cache.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { SessionError } from "@earendil-works/pi-agent-core"; -import { uuidv7 } from "@earendil-works/pi-ai"; -import { sql } from "./sql.ts"; -import { - copyBranchEntriesThroughSeq, - deleteBranchEntries, - insertBranchEntriesForPath, - insertBranchEntry, - readBranchContainingEntry, -} from "./storage/branch-entries.ts"; -import { deleteBranchTips, insertBranchTip, readBranchTipBranchId, updateBranchTip } from "./storage/branch-tips.ts"; -import type { SqliteDatabase } from "./types.ts"; - -export function deleteBranchCache(db: SqliteDatabase, sessionId: string) { - deleteBranchTips(db, sessionId); - deleteBranchEntries(db, sessionId); -} - -export function rebuildBranchCache(db: SqliteDatabase, sessionId: string) { - const tips = sql`SELECT leaf.id - FROM entries AS leaf - WHERE leaf.session_id = ${sessionId} - AND NOT EXISTS ( - SELECT 1 FROM entries AS child WHERE child.session_id = leaf.session_id AND child.parent_id = leaf.id - ) - ORDER BY leaf.seq`.all<{ id: string }>(db); - deleteBranchCache(db, sessionId); - for (const tip of tips) buildCachedBranch(db, sessionId, tip.id); -} - -export function buildCachedBranch(db: SqliteDatabase, sessionId: string, leafId: string) { - sql`SAVEPOINT build_branch_cache`.exec(db); - try { - const branchId = uuidv7(); - insertBranchEntriesForPath(db, sessionId, branchId, leafId); - insertBranchTip(db, sessionId, leafId, branchId); - sql`RELEASE SAVEPOINT build_branch_cache`.exec(db); - } catch (error) { - try { - sql`ROLLBACK TO SAVEPOINT build_branch_cache`.exec(db); - sql`RELEASE SAVEPOINT build_branch_cache`.exec(db); - } catch { - // Preserve the original build failure. - } - if (error instanceof SessionError) throw error; - throw new SessionError( - "storage", - `Failed to build SQLite branch cache at entry ${leafId}`, - error instanceof Error ? error : undefined, - ); - } -} - -function extendBranch( - db: SqliteDatabase, - sessionId: string, - branchId: string, - parentId: string, - entryId: string, - entrySeq: number, - entryType: string, - customType: string | null, -) { - insertBranchEntry(db, sessionId, branchId, entryId, entrySeq, entryType, customType); - if (!updateBranchTip(db, sessionId, branchId, parentId, entryId)) { - throw new SessionError("invalid_entry", `Branch tip ${parentId} changed during append`); - } -} - -export function appendEntryToBranchCache( - db: SqliteDatabase, - sessionId: string, - entryId: string, - entrySeq: number, - entryType: string, - customType: string | null, - parentId: string | null, -) { - if (parentId === null) { - const branchId = uuidv7(); - insertBranchEntry(db, sessionId, branchId, entryId, entrySeq, entryType, customType); - insertBranchTip(db, sessionId, entryId, branchId); - return; - } - - const tipBranchId = readBranchTipBranchId(db, sessionId, parentId); - if (tipBranchId !== undefined) { - extendBranch(db, sessionId, tipBranchId, parentId, entryId, entrySeq, entryType, customType); - return; - } - - const source = readBranchContainingEntry(db, sessionId, parentId); - if (!source) { - throw new SessionError("invalid_entry", `Branch cache has no branch containing parent entry ${parentId}`); - } - - const branchId = uuidv7(); - copyBranchEntriesThroughSeq(db, sessionId, branchId, source.branchId, source.entrySeq); - insertBranchEntry(db, sessionId, branchId, entryId, entrySeq, entryType, customType); - insertBranchTip(db, sessionId, entryId, branchId); -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/index.ts b/packages/session-backends/sqlite-node/src/sqlite/index.ts index 73f6a62dd..f5e49b628 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/index.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/index.ts @@ -1,18 +1,7 @@ -export * from "./migrations.ts"; -export { - SqliteSessionRepository, - type SqliteSessionRepositoryOptions, - type SqliteWriterLeaseOptions, -} from "./repo.ts"; -export * from "./search-backend.ts"; export * from "./sql.ts"; export type { SqliteDatabase, SqliteDatabaseFactory, SqliteRunResult, - SqliteSessionCreateOptions, - SqliteSessionListOptions, - SqliteSessionMetadata, - SqliteSessionRepositoryEnv, SqliteStatement, } from "./types.ts"; diff --git a/packages/session-backends/sqlite-node/src/sqlite/migrations.ts b/packages/session-backends/sqlite-node/src/sqlite/migrations.ts deleted file mode 100644 index e171f4a01..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/migrations.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; -import { sql } from "./sql.ts"; -import type { SqliteDatabase } from "./types.ts"; - -export interface SqliteMigration { - id: string; - order: number; - sql: string; -} - -async function loadMigrationSql(relativePath: string): Promise { - return readFile(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); -} - -export async function loadMigrations(): Promise { - return [ - { - id: "001_initial.sql", - order: 1, - sql: await loadMigrationSql("./migrations/001_initial.sql"), - }, - ]; -} - -function ensureMigrationsTable(db: SqliteDatabase): void { - sql` -CREATE TABLE IF NOT EXISTS migrations ( - id TEXT PRIMARY KEY, - applied_at TEXT NOT NULL -); -`.exec(db); -} - -export async function applyMigrations(db: SqliteDatabase): Promise { - ensureMigrationsTable(db); - const migrations = await loadMigrations(); - const appliedRows = sql`SELECT id FROM migrations ORDER BY applied_at, id`.all<{ id: string }>(db); - const applied = new Set(appliedRows.map((row) => row.id)); - - for (const migration of migrations) { - if (applied.has(migration.id)) continue; - db.transaction(() => { - db.exec(migration.sql); - sql`INSERT INTO migrations (id, applied_at) VALUES (${migration.id}, ${new Date().toISOString()})`.run(db); - }); - applied.add(migration.id); - } -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql b/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql deleted file mode 100644 index fc9e277e2..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql +++ /dev/null @@ -1,122 +0,0 @@ -CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - created_at INTEGER NOT NULL, - cwd TEXT NOT NULL, - parent_session_id TEXT NULL, - metadata TEXT NULL -) WITHOUT ROWID; - -CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_sessions_cwd_created_at ON sessions(cwd, created_at DESC); - -CREATE TABLE IF NOT EXISTS entries ( - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, - id TEXT NOT NULL, - parent_id TEXT NULL, - type TEXT NOT NULL, - timestamp INTEGER NOT NULL, - payload TEXT NOT NULL, - PRIMARY KEY (session_id, id), - UNIQUE (session_id, seq) -); - -CREATE INDEX IF NOT EXISTS idx_entries_session_parent ON entries(session_id, parent_id); -CREATE INDEX IF NOT EXISTS idx_entries_session_type_seq ON entries(session_id, type, seq); - -CREATE TABLE IF NOT EXISTS session_sequences ( - session_id TEXT PRIMARY KEY, - next_seq INTEGER NOT NULL -) WITHOUT ROWID; - -CREATE TABLE IF NOT EXISTS session_stats ( - session_id TEXT PRIMARY KEY, - message_count INTEGER NOT NULL, - cached_tokens REAL NOT NULL, - uncached_tokens REAL NOT NULL, - total_tokens REAL NOT NULL, - cost_total REAL NOT NULL -) WITHOUT ROWID; - --- Derived branch cache. Parent links in entries remain canonical; this cache --- exists only to make branch scans cheap. -CREATE TABLE IF NOT EXISTS branch_entries ( - session_id TEXT NOT NULL, - branch_id TEXT NOT NULL, - entry_id TEXT NOT NULL, - entry_seq INTEGER NOT NULL, - entry_type TEXT NULL, - custom_type TEXT NULL, - PRIMARY KEY (session_id, branch_id, entry_id) -) WITHOUT ROWID; - -CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_seq ON branch_entries(session_id, branch_id, entry_seq); -CREATE INDEX IF NOT EXISTS idx_branch_entries_session_entry ON branch_entries(session_id, entry_id, branch_id, entry_seq); -CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_type_seq ON branch_entries(session_id, branch_id, entry_type, entry_seq); -CREATE INDEX IF NOT EXISTS idx_branch_entries_session_branch_custom_seq ON branch_entries(session_id, branch_id, custom_type, entry_seq); - -CREATE TABLE IF NOT EXISTS lanes ( - session_id TEXT NOT NULL, - lane TEXT NOT NULL, - leaf_id TEXT NULL, - open_operation_id TEXT NULL, - PRIMARY KEY (session_id, lane) -) WITHOUT ROWID; - -CREATE TABLE IF NOT EXISTS records ( - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, - id TEXT NOT NULL, - lane TEXT NOT NULL, - run_id TEXT NULL, - type TEXT NOT NULL, - op_kind TEXT NULL, - timestamp INTEGER NOT NULL, - payload TEXT NOT NULL, - PRIMARY KEY (session_id, id), - UNIQUE (session_id, seq) -) WITHOUT ROWID; - -CREATE INDEX IF NOT EXISTS idx_records_session_lane_seq ON records(session_id, lane, seq); -CREATE INDEX IF NOT EXISTS idx_records_session_type_seq ON records(session_id, type, seq); -CREATE INDEX IF NOT EXISTS idx_records_session_type_op_kind_seq ON records(session_id, type, op_kind, seq); -CREATE INDEX IF NOT EXISTS idx_records_session_lane_type_seq ON records(session_id, lane, type, seq); -CREATE INDEX IF NOT EXISTS idx_records_session_lane_type_op_kind_seq ON records(session_id, lane, type, op_kind, seq); -CREATE INDEX IF NOT EXISTS idx_records_session_run_id_seq ON records(session_id, run_id, seq); - -CREATE TABLE IF NOT EXISTS lane_moves ( - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, - lane TEXT NOT NULL, - leaf_id TEXT NULL, - PRIMARY KEY (session_id, seq) -) WITHOUT ROWID; - - -CREATE TABLE IF NOT EXISTS facts ( - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, - kind TEXT NOT NULL, - key TEXT NULL, - value TEXT NULL, - PRIMARY KEY (session_id, seq) -) WITHOUT ROWID; - -CREATE INDEX IF NOT EXISTS idx_facts_session_kind_key_seq ON facts(session_id, kind, key, seq); - -CREATE TABLE IF NOT EXISTS branch_tips ( - session_id TEXT NOT NULL, - branch_id TEXT NOT NULL, - tip_id TEXT NOT NULL, - PRIMARY KEY (session_id, tip_id), - UNIQUE (session_id, branch_id) -) WITHOUT ROWID; - --- Per-session writer claim. The fence prevents an expired owner from writing --- after a new owner takes over the session. -CREATE TABLE IF NOT EXISTS writer_leases ( - session_id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL, - fence INTEGER NOT NULL, - expires_at_ms INTEGER NOT NULL -) WITHOUT ROWID; diff --git a/packages/session-backends/sqlite-node/src/sqlite/repo.ts b/packages/session-backends/sqlite-node/src/sqlite/repo.ts deleted file mode 100644 index 81f86bea9..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/repo.ts +++ /dev/null @@ -1,953 +0,0 @@ -import type { FileError, Result } from "@earendil-works/pi-agent-core"; -import { - type BranchBounds, - type Entry, - type EntryQuery, - type ForkOptions, - type LaneRecord, - type LogItem, - type LogOptions, - type NewRecord, - type OperationStartedRecord, - type ProvisionedEntry, - type RecordQuery, - Session, - SessionError, - type SessionRepo as SessionRepository, - type SessionStats, - type SessionStorage, -} from "@earendil-works/pi-agent-core"; -import { uuidv7 } from "@earendil-works/pi-ai"; -import { appendEntryToBranchCache, buildCachedBranch, deleteBranchCache, rebuildBranchCache } from "./branch-cache.ts"; -import { applyMigrations } from "./migrations.ts"; -import { sql } from "./sql.ts"; -import { type CachedBranchEntryRow, queryCachedBranchRows, readCachedBranch } from "./storage/branch-entries.ts"; -import { readBranchTipIds } from "./storage/branch-tips.ts"; -import { - deleteEntryRows, - type EntryRow, - entryPayload, - idExistsInEntries, - insertEntryRow, - readEntryRow, - readEntryRows, -} from "./storage/entries.ts"; -import { appendFact, deleteFactRows, readFactRows, readLatestFact, readLatestLabelFacts } from "./storage/facts.ts"; -import { - createInitialLane, - deleteLaneRows, - finishLaneOperation, - createLane as insertLane, - readLane, - readLaneHead, - readLaneMoveRows, - readLanes, - setLaneLeaf, - startLaneOperation, - moveLane as updateLane, -} from "./storage/lanes.ts"; -import { - appendRecordRow, - deleteRecordRows, - idExistsInRecords, - readOpenOperationRows, - readRecordRows, -} from "./storage/records.ts"; -import { - advanceSequence, - createSequence, - deleteSequence, - getNextSequence, - setNextSequence, -} from "./storage/session-sequences.ts"; -import { - addUsageToStats, - createStats, - deleteStats, - incrementMessageCount, - readStats, -} from "./storage/session-stats.ts"; -import { - decodeSessionMetadata, - deleteSessionRow, - insertSessionRow, - readSessionRow, - readSessionRows, - type SessionRow, - sessionExists, -} from "./storage/sessions.ts"; -import { - acquireWriterLease, - deleteWriterLease, - releaseWriterLease, - renewWriterLease, - type WriterLease, -} from "./storage/writer-leases.ts"; -import type { - SqliteDatabase, - SqliteDatabaseFactory, - SqliteSessionCreateOptions, - SqliteSessionListOptions, - SqliteSessionMetadata, - SqliteSessionRepositoryEnv, -} from "./types.ts"; - -export interface SqliteWriterLeaseOptions { - /** Time without a successful heartbeat before another writer may take over. Default: 30 seconds. */ - ttlMs?: number; - /** Idle heartbeat cadence. Default: 10 seconds. Must be less than ttlMs. */ - heartbeatIntervalMs?: number; -} - -export interface SqliteSessionRepositoryOptions { - env: SqliteSessionRepositoryEnv; - sqlite: SqliteDatabaseFactory; - databasePath: string; - writerLease?: SqliteWriterLeaseOptions; -} - -interface ResolvedWriterLeaseOptions { - ttlMs: number; - heartbeatIntervalMs: number; -} - -function resolveWriterLeaseOptions(options: SqliteWriterLeaseOptions | undefined): ResolvedWriterLeaseOptions { - const ttlMs = options?.ttlMs ?? 30_000; - const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 10_000; - if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) throw new RangeError("writerLease.ttlMs must be positive"); - if (!Number.isSafeInteger(heartbeatIntervalMs) || heartbeatIntervalMs <= 0 || heartbeatIntervalMs >= ttlMs) { - throw new RangeError("writerLease.heartbeatIntervalMs must be positive and less than ttlMs"); - } - return { ttlMs, heartbeatIntervalMs }; -} - -function activeWriterError(sessionId: string): SessionError { - return new SessionError("storage", `SQLite session ${sessionId} already has an active writer`); -} - -function lostWriterError(sessionId: string): SessionError { - return new SessionError("storage", `SQLite session ${sessionId} writer lease was lost`); -} - -function claimWriterLease(db: SqliteDatabase, sessionId: string, options: ResolvedWriterLeaseOptions): WriterLease { - const now = Date.now(); - const lease = acquireWriterLease(db, sessionId, uuidv7(), now, now + options.ttlMs); - if (!lease) throw activeWriterError(sessionId); - return lease; -} - -class SerialOperationQueue { - private tail: Promise = Promise.resolve(); - - enqueue(operation: () => Promise | T): Promise { - const result = this.tail.then(operation); - this.tail = result.then( - () => undefined, - () => undefined, - ); - return result; - } - - async drain(): Promise { - await this.tail; - } -} - -function resultOrThrow(result: Result, message: string): T { - if (!result.ok) { - const code = result.error.code === "not_found" ? "not_found" : "storage"; - throw new SessionError(code, `${message}: ${result.error.message}`, result.error); - } - return result.value; -} - -function getParentPath(path: string): string { - const normalized = path.replace(/[\\/]+$/, ""); - const lastSlash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); - if (lastSlash < 0) return "."; - if (lastSlash === 0) return normalized.slice(0, 1); - return normalized.slice(0, lastSlash); -} - -function configureSqliteDatabase(db: SqliteDatabase): void { - sql`PRAGMA journal_mode=WAL`.exec(db); - sql`PRAGMA synchronous=FULL`.exec(db); - sql`PRAGMA busy_timeout=5000`.exec(db); -} - -function entryRowFromCached(row: CachedBranchEntryRow): EntryRow { - return { ...row, seq: row.entry_seq, type: row.type as Entry["type"] }; -} - -function readObjectPayload(row: EntryRow): Record { - const payload = JSON.parse(row.payload) as unknown; - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - throw new Error("Payload is not an object"); - } - return payload as Record; -} - -function decodeEntry(row: EntryRow): Entry { - try { - const payload = readObjectPayload(row); - const base = { id: row.id, seq: row.seq, parentId: row.parent_id, timestamp: row.timestamp }; - switch (row.type) { - case "message": - if (typeof payload.message !== "object" || payload.message === null) throw new Error("Missing message"); - return { - ...base, - type: "message", - message: payload.message as Extract["message"], - ...(payload.terminate === true ? { terminate: true as const } : {}), - }; - case "model_change": - if (typeof payload.provider !== "string" || typeof payload.modelId !== "string") { - throw new Error("Invalid model_change payload"); - } - return { ...base, type: "model_change", provider: payload.provider, modelId: payload.modelId }; - case "thinking_level_change": - if (typeof payload.thinkingLevel !== "string") throw new Error("Invalid thinking_level_change payload"); - return { ...base, type: "thinking_level_change", thinkingLevel: payload.thinkingLevel }; - case "active_tools_change": - if (!Array.isArray(payload.activeToolNames)) throw new Error("Invalid active_tools_change payload"); - if (payload.activeToolNames.some((value) => typeof value !== "string")) { - throw new Error("Invalid active_tools_change payload"); - } - return { ...base, type: "active_tools_change", activeToolNames: payload.activeToolNames }; - case "compaction": - if ( - typeof payload.summary !== "string" || - !Array.isArray(payload.retainedTail) || - typeof payload.tokensBefore !== "number" - ) { - throw new Error("Invalid compaction payload"); - } - return { - ...base, - type: "compaction", - summary: payload.summary, - retainedTail: payload.retainedTail as Extract["retainedTail"], - tokensBefore: payload.tokensBefore, - ...(Object.hasOwn(payload, "details") ? { details: payload.details } : {}), - ...(Object.hasOwn(payload, "usage") - ? { usage: payload.usage as Extract["usage"] } - : {}), - }; - case "branch_summary": - if (typeof payload.fromId !== "string" || typeof payload.summary !== "string") { - throw new Error("Invalid branch_summary payload"); - } - return { - ...base, - type: "branch_summary", - fromId: payload.fromId, - summary: payload.summary, - ...(Object.hasOwn(payload, "details") ? { details: payload.details } : {}), - ...(Object.hasOwn(payload, "usage") - ? { usage: payload.usage as Extract["usage"] } - : {}), - }; - case "custom": - if (typeof payload.customType !== "string") throw new Error("Invalid custom payload"); - return { - ...base, - type: "custom", - customType: payload.customType, - ...(Object.hasOwn(payload, "data") ? { data: payload.data } : {}), - }; - } - } catch (error) { - throw new SessionError( - "invalid_entry", - `Invalid SQLite session entry ${row.id}: failed to decode entry ${row.id}`, - error instanceof Error ? error : undefined, - ); - } -} - -function recordRunId(record: NewRecord): string | undefined { - return record.type === "operation_started" ? record.id : "runId" in record ? record.runId : undefined; -} - -function recordOpKind(record: NewRecord): string | undefined { - return record.type === "operation_started" ? record.intent.kind : undefined; -} - -function decodeRecord(row: { seq: number; timestamp: number; payload: string }): LaneRecord { - try { - return { - ...(JSON.parse(row.payload) as object), - seq: row.seq, - timestamp: row.timestamp, - } as LaneRecord; - } catch (error) { - throw new SessionError( - "storage", - `Invalid SQLite session record at sequence ${row.seq}: failed to decode payload`, - error instanceof Error ? error : undefined, - ); - } -} - -function validateCachedBranchRows(rows: readonly CachedBranchEntryRow[], query: BranchBounds & EntryQuery): void { - if (rows.length === 0 || query.type !== undefined || query.customType !== undefined) return; - const path = [...rows].sort((left, right) => left.entry_seq - right.entry_seq); - const shouldIncludeRoot = - query.stopAtId === undefined && - query.stopAtType === undefined && - query.cursor === undefined && - (query.order === "oldestFirst" || query.limit === undefined); - if (shouldIncludeRoot && path[0]?.parent_id !== null) { - throw new SessionError("invalid_entry", `Entry ${path[0]?.parent_id} not found`); - } - for (let index = 1; index < path.length; index++) { - const previous = path[index - 1]!; - const current = path[index]!; - if (current.parent_id !== previous.id) { - throw new SessionError("invalid_entry", `Entry ${current.parent_id} not found`); - } - } -} - -function matchesEntryQuery(entry: Entry, query: EntryQuery): boolean { - return ( - (query.type === undefined || entry.type === query.type) && - (query.customType === undefined || (entry.type === "custom" && entry.customType === query.customType)) && - (query.cursor === undefined || - (query.order === "oldestFirst" ? entry.seq > query.cursor.afterSeq : entry.seq < query.cursor.afterSeq)) - ); -} - -function assertUnusedId(db: SqliteDatabase, sessionId: string, id: string): void { - if (idExistsInEntries(db, sessionId, id) || idExistsInRecords(db, sessionId, id)) { - throw new SessionError("already_exists", `ID already exists: ${id}`); - } -} - -function requireSessionRow(db: SqliteDatabase, sessionId: string): SessionRow { - const row = readSessionRow(db, sessionId); - if (!row) throw new SessionError("not_found", `Session not found: ${sessionId}`); - return row; -} - -class SqliteSessionStorage implements SessionStorage { - private readonly db: SqliteDatabase; - private readonly metadata: SqliteSessionMetadata; - private readonly lease: WriterLease; - private readonly leaseOptions: ResolvedWriterLeaseOptions; - private readonly onRelease: () => void; - private readonly operations = new SerialOperationQueue(); - private heartbeatTimer: ReturnType | undefined; - private leaseError: SessionError | undefined; - private closing = false; - private releasePromise: Promise | undefined; - - constructor( - db: SqliteDatabase, - metadata: SqliteSessionMetadata, - lease: WriterLease, - leaseOptions: ResolvedWriterLeaseOptions, - onRelease: () => void, - ) { - this.db = db; - this.metadata = metadata; - this.lease = lease; - this.leaseOptions = leaseOptions; - this.onRelease = onRelease; - this.scheduleHeartbeat(); - } - - async release(): Promise { - this.releasePromise ??= this.finishRelease(); - await this.releasePromise; - } - - private async finishRelease(): Promise { - this.closing = true; - if (this.heartbeatTimer !== undefined) clearTimeout(this.heartbeatTimer); - try { - await this.operations.enqueue(() => - this.db.transaction(() => releaseWriterLease(this.db, this.metadata.id, this.lease)), - ); - } finally { - this.onRelease(); - } - } - - private enqueueWrite(operation: () => T): Promise { - if (this.closing) - return Promise.reject(new SessionError("storage", `SQLite session ${this.metadata.id} is closed`)); - return this.operations.enqueue(() => { - if (this.leaseError) throw this.leaseError; - return this.db.transaction(() => { - const now = Date.now(); - if (!renewWriterLease(this.db, this.metadata.id, this.lease, now, now + this.leaseOptions.ttlMs)) { - this.leaseError = lostWriterError(this.metadata.id); - if (this.heartbeatTimer !== undefined) clearTimeout(this.heartbeatTimer); - throw this.leaseError; - } - return operation(); - }); - }); - } - - private scheduleHeartbeat(): void { - if (this.closing || this.leaseError) return; - this.heartbeatTimer = setTimeout(async () => { - this.heartbeatTimer = undefined; - try { - await this.operations.enqueue(() => { - if (this.closing || this.leaseError) return; - this.db.transaction(() => { - const now = Date.now(); - if (!renewWriterLease(this.db, this.metadata.id, this.lease, now, now + this.leaseOptions.ttlMs)) { - this.leaseError = lostWriterError(this.metadata.id); - } - }); - }); - } catch { - // A transient heartbeat failure is retried. Every write still verifies ownership transactionally. - } finally { - this.scheduleHeartbeat(); - } - }, this.leaseOptions.heartbeatIntervalMs); - this.heartbeatTimer.unref(); - } - - async getMetadata(): Promise { - return decodeSessionMetadata(requireSessionRow(this.db, this.metadata.id), this.metadata.path); - } - - isForSession(sessionId: string): boolean { - return this.metadata.id === sessionId; - } - - async getLanes(): Promise<{ lane: string; leafId: string | null }[]> { - return readLanes(this.db, this.metadata.id).map((row) => ({ lane: row.lane, leafId: row.leaf_id })); - } - - async createLane(lane: string, at: string | null): Promise { - return this.enqueueWrite(() => { - if (readLane(this.db, this.metadata.id, lane)) { - throw new SessionError("already_exists", `Lane already exists: ${lane}`); - } - if (at !== null && !readEntryRow(this.db, this.metadata.id, at)) { - throw new SessionError("not_found", `Entry not found: ${at}`); - } - const seq = getNextSequence(this.db, this.metadata.id); - insertLane(this.db, this.metadata.id, seq, lane, at); - advanceSequence(this.db, this.metadata.id, seq); - }); - } - - async moveLane(lane: string, to: string | null): Promise { - return this.enqueueWrite(() => { - if (!readLane(this.db, this.metadata.id, lane)) - throw new SessionError("invalid_lane", `Lane not found: ${lane}`); - if (to !== null && !readEntryRow(this.db, this.metadata.id, to)) { - throw new SessionError("not_found", `Entry not found: ${to}`); - } - const seq = getNextSequence(this.db, this.metadata.id); - updateLane(this.db, this.metadata.id, seq, lane, to); - advanceSequence(this.db, this.metadata.id, seq); - }); - } - - async appendEntry(entry: ProvisionedEntry, lane: string): Promise { - return this.enqueueWrite(() => { - const parentId = readLaneHead(this.db, this.metadata.id, lane).leafId; - assertUnusedId(this.db, this.metadata.id, entry.id); - const seq = getNextSequence(this.db, this.metadata.id); - const committed = { ...entry, parentId, seq, timestamp: Date.now() } as Entry; - insertEntryRow(this.db, this.metadata.id, { - seq, - id: committed.id, - parentId: committed.parentId, - type: committed.type, - timestamp: committed.timestamp, - payload: JSON.stringify(entryPayload(committed)), - }); - setLaneLeaf(this.db, this.metadata.id, lane, committed.id); - appendEntryToBranchCache( - this.db, - this.metadata.id, - committed.id, - seq, - committed.type, - committed.type === "custom" ? committed.customType : null, - committed.parentId, - ); - if (committed.type === "message") incrementMessageCount(this.db, this.metadata.id); - advanceSequence(this.db, this.metadata.id, seq); - return structuredClone(committed as TEntry); - }); - } - - async appendRecord(record: NewRecord): Promise; - async appendRecord(record: NewRecord): Promise { - return this.enqueueWrite(() => { - if (!readLane(this.db, this.metadata.id, record.lane)) { - throw new SessionError("invalid_lane", `Lane not found: ${record.lane}`); - } - assertUnusedId(this.db, this.metadata.id, record.id); - const seq = getNextSequence(this.db, this.metadata.id); - const committed: LaneRecord = { ...record, seq, timestamp: Date.now() }; - if (record.type === "operation_started") { - startLaneOperation(this.db, this.metadata.id, record.lane, record.id); - } - appendRecordRow(this.db, this.metadata.id, { - seq, - id: record.id, - lane: record.lane, - runId: recordRunId(record), - type: record.type, - opKind: recordOpKind(record), - timestamp: committed.timestamp, - payload: JSON.stringify(record), - }); - if (record.type === "operation_finished") { - finishLaneOperation(this.db, this.metadata.id, record.lane, record.runId); - } - if (record.type === "usage") addUsageToStats(this.db, this.metadata.id, record.usage); - advanceSequence(this.db, this.metadata.id, seq); - return structuredClone(committed); - }); - } - - async getEntry(id: string): Promise { - const row = readEntryRow(this.db, this.metadata.id, id); - return row ? decodeEntry(row) : undefined; - } - - async findEntries(query: EntryQuery = {}): Promise { - const sqlType = query.type ?? (query.customType === undefined ? undefined : "custom"); - const sqlLimit = query.customType === undefined ? query.limit : undefined; - const rows = readEntryRows(this.db, this.metadata.id, { - cursor: query.cursor, - limit: sqlLimit, - order: query.order, - type: sqlType, - }); - const entries = rows.map(decodeEntry).filter((entry) => matchesEntryQuery(entry, query)); - return query.limit === undefined ? entries : entries.slice(0, query.limit); - } - - async findEntriesOnBranch(query: EntryQuery & BranchBounds & { start: string }): Promise { - const cached = readCachedBranch(this.db, this.metadata.id, query.start); - if (!cached) { - if (!readEntryRow(this.db, this.metadata.id, query.start)) - throw new SessionError("not_found", `Entry not found: ${query.start}`); - throw new SessionError("invalid_entry", `Branch cache missing entry ${query.start}`); - } - const rows = queryCachedBranchRows(this.db, this.metadata.id, cached, query); - validateCachedBranchRows(rows, query); - const entries = rows - .map(entryRowFromCached) - .map(decodeEntry) - .filter((entry) => matchesEntryQuery(entry, query)); - return query.limit === undefined ? entries : entries.slice(0, query.limit); - } - - async findRecords(query: RecordQuery = {}): Promise { - const rows = readRecordRows(this.db, this.metadata.id, query); - return rows.map(decodeRecord); - } - - async findOpenOperations(lane: string, options?: { limit?: number }): Promise { - const rows = readOpenOperationRows(this.db, this.metadata.id, lane, options); - - return rows.map((row) => { - const record = decodeRecord(row); - if (record.type !== "operation_started") { - throw new SessionError("storage", "Expected operation_started record"); - } - return record; - }); - } - - async getLog(options: LogOptions = {}): Promise { - const afterSeq = options.afterSeq ?? 0; - const limit = options.limit; - const entryRows = readEntryRows(this.db, this.metadata.id, { afterSeq, order: "oldestFirst", limit }); - const recordRows = readRecordRows(this.db, this.metadata.id, { afterSeq, order: "oldestFirst", limit }); - const laneRows = readLaneMoveRows(this.db, this.metadata.id, { afterSeq, limit }); - const factRows = readFactRows(this.db, this.metadata.id, { afterSeq, limit }); - - const logRows: { seq: number; decode: () => LogItem }[] = [ - ...entryRows.map((row) => ({ - seq: row.seq, - decode: () => ({ kind: "entry" as const, seq: row.seq, entry: decodeEntry(row) }), - })), - ...recordRows.map((row) => ({ - seq: row.seq, - decode: () => ({ kind: "record" as const, seq: row.seq, record: decodeRecord(row) }), - })), - ...laneRows.map((row) => ({ - seq: row.seq, - decode: () => ({ kind: "lane" as const, seq: row.seq, lane: row.lane, leafId: row.leaf_id }), - })), - ...factRows.map((row) => ({ - seq: row.seq, - decode: () => { - if (row.kind === "name") - return { - kind: "fact" as const, - seq: row.seq, - fact: "name" as const, - name: row.value === null ? undefined : (JSON.parse(row.value) as string), - }; - return { - kind: "fact" as const, - seq: row.seq, - fact: "label" as const, - targetId: row.key ?? "", - label: row.value === null ? undefined : (JSON.parse(row.value) as string), - }; - }, - })), - ].sort((left, right) => left.seq - right.seq); - const selectedRows = options.limit === undefined ? logRows : logRows.slice(0, options.limit); - return selectedRows.map((row) => row.decode()); - } - - async getName(): Promise { - const row = readLatestFact(this.db, this.metadata.id, "name", null); - return row?.value === undefined || row.value === null ? undefined : (JSON.parse(row.value) as string); - } - - async setName(name: string | undefined): Promise { - return this.enqueueWrite(() => { - const seq = getNextSequence(this.db, this.metadata.id); - appendFact(this.db, this.metadata.id, seq, "name", null, name === undefined ? null : JSON.stringify(name)); - advanceSequence(this.db, this.metadata.id, seq); - }); - } - - async getLabel(id: string): Promise { - const row = readLatestFact(this.db, this.metadata.id, "label", id); - return row?.value === undefined || row.value === null ? undefined : (JSON.parse(row.value) as string); - } - - async setLabel(id: string, label: string | undefined): Promise { - return this.enqueueWrite(() => { - if (!readEntryRow(this.db, this.metadata.id, id)) { - throw new SessionError("not_found", `Entry not found: ${id}`); - } - const seq = getNextSequence(this.db, this.metadata.id); - appendFact(this.db, this.metadata.id, seq, "label", id, label === undefined ? null : JSON.stringify(label)); - advanceSequence(this.db, this.metadata.id, seq); - }); - } - - async getStats(): Promise { - return readStats(this.db, this.metadata.id); - } -} - -function claimStorage( - db: SqliteDatabase, - metadata: SqliteSessionMetadata, - leaseOptions: ResolvedWriterLeaseOptions, - onRelease: () => void, -): SqliteSessionStorage { - requireSessionRow(db, metadata.id); - const claimed = db.transaction(() => { - const lease = claimWriterLease(db, metadata.id, leaseOptions); - const row = requireSessionRow(db, metadata.id); - readLanes(db, metadata.id); - return { lease, row }; - }); - return new SqliteSessionStorage( - db, - decodeSessionMetadata(claimed.row, metadata.path), - claimed.lease, - leaseOptions, - onRelease, - ); -} - -export class SqliteSessionRepository - implements - SessionRepository, - AsyncDisposable -{ - private databasePath: string | undefined; - private database: SqliteDatabase | undefined; - private databasePromise: Promise | undefined; - private readonly operations = new SerialOperationQueue(); - private readonly activeStorages = new Set(); - private readonly options: SqliteSessionRepositoryOptions; - private readonly leaseOptions: ResolvedWriterLeaseOptions; - - constructor(options: SqliteSessionRepositoryOptions) { - this.options = options; - this.leaseOptions = resolveWriterLeaseOptions(options.writerLease); - } - - private async releaseStoragesForSession(sessionId: string): Promise { - for (const storage of [...this.activeStorages]) { - if (storage.isForSession(sessionId)) await storage.release(); - } - } - - private sessionFromLease( - db: SqliteDatabase, - metadata: SqliteSessionMetadata, - lease: WriterLease, - ): Session { - let storage: SqliteSessionStorage; - storage = new SqliteSessionStorage(db, metadata, lease, this.leaseOptions, () => { - this.activeStorages.delete(storage); - }); - this.activeStorages.add(storage); - return new Session(storage); - } - - private claimSession(db: SqliteDatabase, metadata: SqliteSessionMetadata): Session { - const active = [...this.activeStorages].find((storage) => storage.isForSession(metadata.id)); - if (active) { - readLanes(db, metadata.id); - return new Session(active); - } - let storage: SqliteSessionStorage; - storage = claimStorage(db, metadata, this.leaseOptions, () => { - this.activeStorages.delete(storage); - }); - this.activeStorages.add(storage); - return new Session(storage); - } - - async create(options: SqliteSessionCreateOptions): Promise> { - return this.operations.enqueue(async () => { - const db = await this.getDatabase(); - const path = await this.getDatabasePath(); - const id = options.id ?? uuidv7(); - if (sessionExists(db, id)) throw new SessionError("already_exists", `Session already exists: ${id}`); - const createdAt = Date.now(); - const lease = db.transaction(() => { - insertSessionRow(db, { - id, - createdAt, - cwd: options.cwd, - parentSessionId: options.parentSessionId, - metadata: options.metadata, - }); - createSequence(db, id); - createStats(db, id); - createInitialLane(db, id); - return claimWriterLease(db, id, this.leaseOptions); - }); - const row = requireSessionRow(db, id); - return this.sessionFromLease(db, decodeSessionMetadata(row, path), lease); - }); - } - - async open(metadata: SqliteSessionMetadata): Promise> { - return this.operations.enqueue(async () => this.claimSession(await this.getDatabase(), metadata)); - } - - /** Rebuilds this session's private branch-read cache from canonical entry parent links. */ - async repairBranchCache(metadata: SqliteSessionMetadata): Promise { - return this.operations.enqueue(async () => { - await this.releaseStoragesForSession(metadata.id); - const db = await this.getDatabase(); - db.transaction(() => { - const lease = claimWriterLease(db, metadata.id, this.leaseOptions); - requireSessionRow(db, metadata.id); - rebuildBranchCache(db, metadata.id); - releaseWriterLease(db, metadata.id, lease); - }); - }); - } - - /** Reads the session catalog without acquiring or renewing per-session writer leases. */ - async list(options: SqliteSessionListOptions = {}): Promise { - return this.operations.enqueue(async () => { - const path = await this.getDatabasePath(); - if (!resultOrThrow(await this.options.env.exists(path), `Failed to check database ${path}`)) return []; - const db = await this.getDatabase(); - const rows = readSessionRows(db, options); - return rows.map((row) => decodeSessionMetadata(row, path)); - }); - } - - async delete(metadata: SqliteSessionMetadata): Promise { - return this.operations.enqueue(async () => { - await this.releaseStoragesForSession(metadata.id); - const db = await this.getDatabase(); - db.transaction(() => { - if (!sessionExists(db, metadata.id)) { - deleteWriterLease(db, metadata.id); - return; - } - claimWriterLease(db, metadata.id, this.leaseOptions); - deleteBranchCache(db, metadata.id); - deleteFactRows(db, metadata.id); - deleteLaneRows(db, metadata.id); - deleteRecordRows(db, metadata.id); - deleteEntryRows(db, metadata.id); - deleteWriterLease(db, metadata.id); - deleteStats(db, metadata.id); - deleteSequence(db, metadata.id); - deleteSessionRow(db, metadata.id); - }); - }); - } - - async fork( - source: SqliteSessionMetadata, - options: ForkOptions & SqliteSessionCreateOptions, - ): Promise> { - return this.operations.enqueue(async () => { - const db = await this.getDatabase(); - const path = await this.getDatabasePath(); - const sourceMetadata = decodeSessionMetadata(requireSessionRow(db, source.id), path); - const id = options.id ?? uuidv7(); - if (sessionExists(db, id)) throw new SessionError("already_exists", `Session already exists: ${id}`); - - const entries: EntryRow[] = []; - const lanes: { lane: string; leafId: string | null }[] = []; - const branchTips: string[] = []; - let branchForkTargetId: string | null = null; - - if (options.scope === "tree") { - entries.push(...readEntryRows(db, source.id, { order: "oldestFirst" })); - lanes.push(...readLanes(db, source.id).map((row) => ({ lane: row.lane, leafId: row.leaf_id }))); - branchTips.push(...readBranchTipIds(db, source.id)); - } else { - const main = readLane(db, source.id, "main"); - if (!main) throw new SessionError("invalid_lane", "Lane not found: main"); - const selectedEntryId = options.entryId ?? main.leaf_id; - if (selectedEntryId !== null) { - const target = readEntryRow(db, source.id, selectedEntryId); - if (!target || target.type !== "message") { - throw new SessionError( - "invalid_fork_target", - `Fork target is not a message entry: ${selectedEntryId}`, - ); - } - const position = options.position ?? (options.entryId === undefined ? "at" : "before"); - branchForkTargetId = position === "at" ? target.id : target.parent_id; - } - lanes.push({ lane: "main", leafId: branchForkTargetId }); - if (branchForkTargetId !== null) { - const cached = readCachedBranch(db, source.id, branchForkTargetId); - if (!cached) { - throw new SessionError( - "invalid_fork_target", - `Fork target is not on a cached branch: ${branchForkTargetId}`, - ); - } - const rows = queryCachedBranchRows(db, source.id, cached, { order: "oldestFirst" }); - entries.push(...rows.map(entryRowFromCached)); - branchTips.push(branchForkTargetId); - } - } - - const copiedIds = new Set(entries.map((entry) => entry.id)); - const latestName = readLatestFact(db, source.id, "name", null); - const latestLabels = readLatestLabelFacts(db, source.id); - const labelsToCopy = latestLabels.filter( - (row) => options.scope === "tree" || (row.key !== null && copiedIds.has(row.key)), - ); - const createdAt = Date.now(); - const metadata = options.metadata ?? sourceMetadata.metadata; - let lease: WriterLease; - - try { - lease = db.transaction(() => { - insertSessionRow(db, { - id, - createdAt, - cwd: options.cwd, - parentSessionId: options.parentSessionId ?? source.id, - metadata, - }); - createSequence(db, id); - createStats(db, id, entries.filter((entry) => entry.type === "message").length); - - let nextSeq = 1; - const allocateSeq = () => nextSeq++; - for (const entry of entries) { - insertEntryRow(db, id, { - seq: allocateSeq(), - id: entry.id, - parentId: entry.parent_id, - type: entry.type, - timestamp: entry.timestamp, - payload: entry.payload, - }); - } - - if (options.scope === "tree") { - for (const lane of lanes) insertLane(db, id, allocateSeq(), lane.lane, lane.leafId); - } else { - createInitialLane(db, id, "main", branchForkTargetId); - } - - if (latestName?.value !== undefined && latestName.value !== null) { - appendFact(db, id, allocateSeq(), "name", null, latestName.value); - } - for (const label of labelsToCopy) appendFact(db, id, allocateSeq(), "label", label.key, label.value); - - setNextSequence(db, id, nextSeq); - for (const tip of branchTips) buildCachedBranch(db, id, tip); - return claimWriterLease(db, id, this.leaseOptions); - }); - } catch (error) { - if (error instanceof SessionError) throw error; - throw new SessionError( - "storage", - `Failed to fork SQLite session ${id}`, - error instanceof Error ? error : undefined, - ); - } - - const row = requireSessionRow(db, id); - return this.sessionFromLease(db, decodeSessionMetadata(row, path), lease); - }); - } - - async close(): Promise { - await this.operations.drain(); - for (const storage of [...this.activeStorages]) await storage.release(); - if (this.database) this.database.close(); - this.database = undefined; - this.databasePromise = undefined; - } - - async [Symbol.asyncDispose](): Promise { - await this.close(); - } - - private async getDatabasePath(): Promise { - this.databasePath ??= resultOrThrow( - await this.options.env.absolutePath(this.options.databasePath), - `Failed to resolve SQLite sessions database ${this.options.databasePath}`, - ); - return this.databasePath; - } - - private async getDatabase(): Promise { - if (!this.databasePromise) this.databasePromise = this.openDatabase(); - this.database = await this.databasePromise; - return this.database; - } - - private async openDatabase(): Promise { - const path = await this.getDatabasePath(); - resultOrThrow( - await this.options.env.createDir(getParentPath(path), { recursive: true }), - `Failed to create SQLite sessions directory ${path}`, - ); - const db = await this.options.sqlite.open(path); - try { - configureSqliteDatabase(db); - await applyMigrations(db); - return db; - } catch (error) { - db.close(); - throw error; - } - } -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts b/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts deleted file mode 100644 index 6e28bfefc..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/search-backend.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type { - FileError, - Result, - SessionSearch, - SessionSearchHit, - SessionSearchOptions, -} from "@earendil-works/pi-agent-core"; -import { SessionError } from "@earendil-works/pi-agent-core"; -import { applyMigrations } from "./migrations.ts"; -import { sql } from "./sql.ts"; -import { decodeSessionMetadata, type SessionRow } from "./storage/sessions.ts"; -import type { - SqliteDatabase, - SqliteDatabaseFactory, - SqliteSessionMetadata, - SqliteSessionRepositoryEnv, -} from "./types.ts"; - -function getFileSystemResultOrThrow(result: Result, message: string): TValue { - if (!result.ok) { - const code = result.error.code === "not_found" ? "not_found" : "storage"; - throw new SessionError(code, `${message}: ${result.error.message}`, result.error); - } - return result.value; -} - -function getParentPath(path: string): string { - const normalized = path.replace(/[\\/]+$/, ""); - const lastSlash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); - if (lastSlash < 0) return "."; - if (lastSlash === 0) return normalized.slice(0, 1); - return normalized.slice(0, lastSlash); -} - -function throwIfAborted(signal: AbortSignal | undefined): void { - if (!signal?.aborted) return; - if (signal.reason instanceof Error) throw signal.reason; - const error = new Error("The operation was aborted"); - error.name = "AbortError"; - throw error; -} - -function configureSqliteDatabase(db: SqliteDatabase): void { - sql`PRAGMA journal_mode=WAL`.exec(db); - sql`PRAGMA synchronous=FULL`.exec(db); - sql`PRAGMA busy_timeout=5000`.exec(db); -} - -export interface SqliteSessionSearchOptions { - env: Pick; - sqlite: SqliteDatabaseFactory; - databasePath: string; -} - -function tableExists(db: SqliteDatabase, name: string): boolean { - return !!sql`SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ${name} LIMIT 1`.get<{ - found: number; - }>(db); -} - -function rebuildSearchIndex(db: SqliteDatabase): void { - sql`INSERT INTO session_search_fts(session_search_fts) VALUES('rebuild')`.run(db); -} - -function ensureSearchSchema(db: SqliteDatabase): void { - const ftsExists = tableExists(db, "session_search_fts"); - const entriesExist = tableExists(db, "entries"); - db.transaction(() => { - sql` -CREATE VIRTUAL TABLE IF NOT EXISTS session_search_fts USING fts5( - payload, - content = 'entries', - content_rowid = 'rowid', - tokenize = 'trigram remove_diacritics 1' -); -CREATE TRIGGER IF NOT EXISTS session_search_fts_ai AFTER INSERT ON entries BEGIN - INSERT INTO session_search_fts(rowid, payload) VALUES (new.rowid, new.payload); -END; -CREATE TRIGGER IF NOT EXISTS session_search_fts_ad AFTER DELETE ON entries BEGIN - INSERT INTO session_search_fts(session_search_fts, rowid, payload) VALUES('delete', old.rowid, old.payload); -END; -CREATE TRIGGER IF NOT EXISTS session_search_fts_au AFTER UPDATE OF payload ON entries BEGIN - INSERT INTO session_search_fts(session_search_fts, rowid, payload) VALUES('delete', old.rowid, old.payload); - INSERT INTO session_search_fts(rowid, payload) VALUES (new.rowid, new.payload); -END; -`.exec(db); - if (!ftsExists && entriesExist) rebuildSearchIndex(db); - }); -} - -export interface SqliteSessionSearchHit extends SessionSearchHit { - readonly metadata: SqliteSessionMetadata; - readonly timestamp: number; - readonly score: number; -} - -/** SQLite FTS search over a co-located canonical session database. */ -class SqliteSessionSearch implements SessionSearch { - private readonly options: SqliteSessionSearchOptions; - private databasePath: string | undefined; - - constructor(options: SqliteSessionSearchOptions) { - this.options = options; - } - - private async getDatabasePath(): Promise { - if (!this.databasePath) { - this.databasePath = getFileSystemResultOrThrow( - await this.options.env.absolutePath(this.options.databasePath), - `Failed to resolve SQLite search database ${this.options.databasePath}`, - ); - } - return this.databasePath; - } - - private async openDatabase(): Promise { - const path = await this.getDatabasePath(); - const directory = getParentPath(path); - getFileSystemResultOrThrow( - await this.options.env.createDir(directory, { recursive: true }), - `Failed to create SQLite search directory ${directory}`, - ); - const db = await this.options.sqlite.open(path); - try { - configureSqliteDatabase(db); - await applyMigrations(db); - ensureSearchSchema(db); - return db; - } catch (error) { - db.close(); - throw error; - } - } - - async *search(text: string, options: SessionSearchOptions = {}): AsyncIterable { - const queryText = text.trim(); - if (!queryText || (options.limit !== undefined && options.limit <= 0)) return; - if (options.entryTypes?.length === 0) return; - throwIfAborted(options.signal); - const db = await this.openDatabase(); - try { - const query = `"${queryText.replaceAll('"', '""')}"`; - const predicates = ["session_search_fts MATCH ?"]; - const params: unknown[] = [query]; - if (options.entryTypes !== undefined) { - predicates.push(`se.type IN (${options.entryTypes.map(() => "?").join(", ")})`); - params.push(...options.entryTypes); - } - const rows = db - .prepare( - `SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, - name_fact.seq IS NOT NULL AS has_session_name, - name_fact.value AS session_name, - se.id AS entry_id, se.timestamp, bm25(session_search_fts) AS score - FROM session_search_fts - JOIN entries AS se ON se.rowid = session_search_fts.rowid - JOIN sessions AS s ON s.id = se.session_id - LEFT JOIN facts AS name_fact - ON name_fact.session_id = s.id - AND name_fact.kind = 'name' - AND name_fact.key IS NULL - AND name_fact.seq = ( - SELECT MAX(f.seq) - FROM facts AS f - WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL - ) - WHERE ${predicates.join(" AND ")} - ORDER BY score - LIMIT ?`, - ) - .iterate( - ...params, - options.limit ?? -1, - ); - const path = await this.getDatabasePath(); - for (const row of rows) { - throwIfAborted(options.signal); - yield { - sessionId: row.id, - metadata: decodeSessionMetadata(row, path), - entryId: row.entry_id, - timestamp: row.timestamp, - score: row.score, - }; - } - } finally { - db.close(); - } - } -} - -export function createSqliteSessionSearch(options: SqliteSessionSearchOptions): SessionSearch { - return new SqliteSessionSearch(options); -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts deleted file mode 100644 index 276ca8947..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-entries.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { type Entry, SessionError } from "@earendil-works/pi-agent-core"; -import { joinSqlFragments, sql } from "../sql.ts"; -import type { SqliteDatabase } from "../types.ts"; - -/** Derived root-to-tip branch cache membership. Canonical parent links remain in entries. */ -export interface CachedBranch { - branchId: string; - leafSeq: number; -} - -export interface CachedBranchEntryRow { - session_id: string; - id: string; - entry_seq: number; - parent_id: string | null; - type: Entry["type"]; - timestamp: number; - payload: string; -} - -export interface CachedBranchQuery { - type?: Entry["type"]; - customType?: string; - stopAtType?: Entry["type"]; - stopAtId?: string; - cursor?: { afterSeq: number }; - order?: "newestFirst" | "oldestFirst"; - limit?: number; -} - -interface BranchPathEntryRow { - id: string; - seq: number; - parent_id: string | null; - type: Entry["type"]; - payload: string; -} - -export function readCachedBranch(db: SqliteDatabase, sessionId: string, leafId: string) { - const membership = sql`SELECT branch_id, entry_seq - FROM branch_entries - WHERE session_id = ${sessionId} AND entry_id = ${leafId} - ORDER BY branch_id - LIMIT 1`.get<{ branch_id: string; entry_seq: number }>(db); - if (!membership) return undefined; - return { branchId: membership.branch_id, leafSeq: membership.entry_seq }; -} - -export function queryCachedBranchRows( - db: SqliteDatabase, - sessionId: string, - branch: CachedBranch, - query: CachedBranchQuery, -) { - const oldestFirst = query.order === "oldestFirst"; - const stopPredicates: ReturnType[] = []; - if (query.stopAtType !== undefined) stopPredicates.push(sql`stop.entry_type = ${query.stopAtType}`); - if (query.stopAtId !== undefined) stopPredicates.push(sql`stop.entry_id = ${query.stopAtId}`); - - const aggregate = oldestFirst ? sql`MIN` : sql`MAX`; - const boundaryComparison = oldestFirst ? sql`<=` : sql`>=`; - const cursorComparison = oldestFirst ? sql`>` : sql`<`; - const direction = oldestFirst ? sql`ASC` : sql`DESC`; - const boundary = - stopPredicates.length === 0 - ? sql`` - : sql`SELECT ${aggregate}(stop.entry_seq) - FROM branch_entries AS stop - WHERE stop.session_id = ${sessionId} - AND stop.branch_id = ${branch.branchId} - AND stop.entry_seq <= ${branch.leafSeq} - AND (${joinSqlFragments(stopPredicates, " OR ")})`; - - const predicates = [ - sql`b.session_id = ${sessionId}`, - sql`b.branch_id = ${branch.branchId}`, - sql`b.entry_seq <= ${branch.leafSeq}`, - ]; - if (stopPredicates.length > 0) { - predicates.push( - sql`b.entry_seq ${boundaryComparison} COALESCE((${boundary}), ${oldestFirst ? branch.leafSeq : 0})`, - ); - } - if (query.cursor !== undefined) predicates.push(sql`b.entry_seq ${cursorComparison} ${query.cursor.afterSeq}`); - if (query.type !== undefined) predicates.push(sql`b.entry_type = ${query.type}`); - if (query.customType !== undefined) predicates.push(sql`b.custom_type = ${query.customType}`); - const limit = query.limit === undefined ? sql`` : sql` LIMIT ${query.limit}`; - - return sql`SELECT e.session_id, e.id, e.seq AS entry_seq, e.parent_id, e.type, e.timestamp, e.payload - FROM branch_entries AS b - JOIN entries AS e ON e.session_id = b.session_id AND e.id = b.entry_id - WHERE ${joinSqlFragments(predicates, " AND ")} - ORDER BY b.entry_seq ${direction}${limit}`.all(db); -} - -export function deleteBranchEntries(db: SqliteDatabase, sessionId: string) { - sql`DELETE FROM branch_entries WHERE session_id = ${sessionId}`.run(db); -} - -export function insertBranchEntry( - db: SqliteDatabase, - sessionId: string, - branchId: string, - entryId: string, - entrySeq: number, - entryType: string, - customType: string | null, -) { - sql`INSERT INTO branch_entries - (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) - VALUES (${sessionId}, ${branchId}, ${entryId}, ${entrySeq}, ${entryType}, ${customType})`.run(db); -} - -function customTypeFromPayload(row: BranchPathEntryRow): string | null { - if (row.type !== "custom") return null; - try { - const payload = JSON.parse(row.payload) as unknown; - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - throw new Error("Payload is not an object"); - } - const customType = (payload as { customType?: unknown }).customType; - if (typeof customType !== "string") throw new Error("Invalid custom payload"); - return customType; - } catch (error) { - throw new SessionError( - "invalid_entry", - `Invalid SQLite session entry ${row.id}: failed to decode entry ${row.id}`, - error instanceof Error ? error : undefined, - ); - } -} - -export function insertBranchEntriesForPath(db: SqliteDatabase, sessionId: string, branchId: string, leafId: string) { - const path: BranchPathEntryRow[] = []; - const seen = new Set(); - let entryId: string | null = leafId; - - while (entryId !== null) { - if (seen.has(entryId)) throw new SessionError("invalid_entry", `Entry parent cycle at ${entryId}`); - seen.add(entryId); - const row: BranchPathEntryRow | undefined = sql`SELECT id, seq, parent_id, type, payload - FROM entries - WHERE session_id = ${sessionId} AND id = ${entryId}`.get(db); - if (!row) throw new SessionError("invalid_entry", `Entry ${entryId} not found`); - path.push(row); - entryId = row.parent_id; - } - - for (const row of path.reverse()) { - insertBranchEntry(db, sessionId, branchId, row.id, row.seq, row.type, customTypeFromPayload(row)); - } -} - -export function readBranchContainingEntry(db: SqliteDatabase, sessionId: string, entryId: string) { - const row = sql`SELECT b.branch_id, b.entry_seq - FROM branch_entries AS b - WHERE b.session_id = ${sessionId} AND b.entry_id = ${entryId} - ORDER BY b.branch_id - LIMIT 1`.get<{ branch_id: string; entry_seq: number }>(db); - return row === undefined ? undefined : { branchId: row.branch_id, entrySeq: row.entry_seq }; -} - -export function copyBranchEntriesThroughSeq( - db: SqliteDatabase, - sessionId: string, - targetBranchId: string, - sourceBranchId: string, - throughSeq: number, -) { - sql`INSERT INTO branch_entries (session_id, branch_id, entry_id, entry_seq, entry_type, custom_type) - SELECT session_id, ${targetBranchId}, entry_id, entry_seq, entry_type, custom_type - FROM branch_entries - WHERE session_id = ${sessionId} AND branch_id = ${sourceBranchId} AND entry_seq <= ${throughSeq}`.run(db); -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-tips.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/branch-tips.ts deleted file mode 100644 index da593e93a..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/branch-tips.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { sql } from "../sql.ts"; -import type { SqliteDatabase } from "../types.ts"; - -export function readBranchTipIds(db: SqliteDatabase, sessionId: string) { - return sql`SELECT tip_id FROM branch_tips WHERE session_id = ${sessionId} ORDER BY tip_id` - .all<{ tip_id: string }>(db) - .map((row) => row.tip_id); -} - -export function readBranchTipBranchId(db: SqliteDatabase, sessionId: string, tipId: string) { - const tip = sql`SELECT branch_id FROM branch_tips WHERE session_id = ${sessionId} AND tip_id = ${tipId}`.get<{ - branch_id: string; - }>(db); - return tip?.branch_id; -} - -export function insertBranchTip(db: SqliteDatabase, sessionId: string, tipId: string, branchId: string) { - sql`INSERT INTO branch_tips (session_id, tip_id, branch_id) VALUES (${sessionId}, ${tipId}, ${branchId})`.run(db); -} - -export function updateBranchTip( - db: SqliteDatabase, - sessionId: string, - branchId: string, - oldTipId: string, - newTipId: string, -) { - const result = sql`UPDATE branch_tips SET tip_id = ${newTipId} - WHERE session_id = ${sessionId} AND branch_id = ${branchId} AND tip_id = ${oldTipId}`.run(db); - return result.changes === 1; -} - -export function deleteBranchTips(db: SqliteDatabase, sessionId: string) { - sql`DELETE FROM branch_tips WHERE session_id = ${sessionId}`.run(db); -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts deleted file mode 100644 index 2c5b9472c..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/entries.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Entry, EntryOrder } from "@earendil-works/pi-agent-core"; -import { sql } from "../sql.ts"; -import type { SqliteDatabase } from "../types.ts"; - -export interface EntryRow { - session_id: string; - seq: number; - id: string; - parent_id: string | null; - type: Entry["type"]; - timestamp: number; - payload: string; -} - -export interface NewEntryRow { - seq: number; - id: string; - parentId: string | null; - type: Entry["type"]; - timestamp: number; - payload: string; -} - -export function entryPayload(entry: Entry): Record { - const { type: _type, id: _id, seq: _seq, parentId: _parentId, timestamp: _timestamp, ...payload } = entry; - return payload; -} - -export function insertEntryRow(db: SqliteDatabase, sessionId: string, entry: NewEntryRow) { - sql`INSERT INTO entries (session_id, id, seq, parent_id, type, timestamp, payload) - VALUES (${sessionId}, ${entry.id}, ${entry.seq}, ${entry.parentId}, ${entry.type}, ${entry.timestamp}, ${entry.payload})`.run( - db, - ); -} - -export function readEntryRow(db: SqliteDatabase, sessionId: string, entryId: string) { - return sql`SELECT session_id, seq, id, parent_id, type, timestamp, payload - FROM entries - WHERE session_id = ${sessionId} AND id = ${entryId}`.get(db); -} - -export function readEntryRows( - db: SqliteDatabase, - sessionId: string, - options: { - afterSeq?: number; - cursor?: { afterSeq: number }; - type?: Entry["type"]; - order?: EntryOrder; - limit?: number; - } = {}, -) { - const oldestFirst = options.order === "oldestFirst"; - const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; - const cursor = - options.cursor === undefined - ? sql`` - : oldestFirst - ? sql` AND seq > ${options.cursor.afterSeq}` - : sql` AND seq < ${options.cursor.afterSeq}`; - const type = options.type === undefined ? sql`` : sql` AND type = ${options.type}`; - const direction = oldestFirst ? sql`ASC` : sql`DESC`; - const limit = options.limit === undefined ? sql`` : sql` LIMIT ${options.limit}`; - return sql`SELECT session_id, seq, id, parent_id, type, timestamp, payload - FROM entries - WHERE session_id = ${sessionId}${after}${cursor}${type} - ORDER BY seq ${direction}${limit}`.all(db); -} - -export function idExistsInEntries(db: SqliteDatabase, sessionId: string, id: string) { - return !!sql`SELECT 1 AS found FROM entries WHERE session_id = ${sessionId} AND id = ${id} LIMIT 1`.get<{ - found: number; - }>(db); -} - -export function deleteEntryRows(db: SqliteDatabase, sessionId: string) { - sql`DELETE FROM entries WHERE session_id = ${sessionId}`.run(db); -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts deleted file mode 100644 index 19fa76e75..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/facts.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { sql } from "../sql.ts"; -import type { SqliteDatabase } from "../types.ts"; - -export interface FactRow { - session_id: string; - seq: number; - kind: string; - key: string | null; - value: string | null; -} - -export function appendFact( - db: SqliteDatabase, - sessionId: string, - seq: number, - kind: string, - key: string | null, - value: string | null, -) { - sql`INSERT INTO facts (session_id, seq, kind, key, value) VALUES (${sessionId}, ${seq}, ${kind}, ${key}, ${value})`.run( - db, - ); -} - -export function readLatestFact(db: SqliteDatabase, sessionId: string, kind: string, key: string | null) { - return sql`SELECT session_id, seq, kind, key, value - FROM facts INDEXED BY idx_facts_session_kind_key_seq - WHERE session_id = ${sessionId} AND kind = ${kind} AND key IS ${key} - ORDER BY seq DESC - LIMIT 1`.get(db); -} - -export function readLatestLabelFacts(db: SqliteDatabase, sessionId: string) { - return sql`SELECT f.key, f.value - FROM facts AS f INDEXED BY idx_facts_session_kind_key_seq - WHERE f.session_id = ${sessionId} - AND f.kind = 'label' - AND f.value IS NOT NULL - AND f.seq = ( - SELECT MAX(candidate.seq) - FROM facts AS candidate INDEXED BY idx_facts_session_kind_key_seq - WHERE candidate.session_id = f.session_id - AND candidate.kind = f.kind - AND candidate.key IS f.key - ) - ORDER BY f.key`.all<{ key: string; value: string }>(db); -} - -export function readFactRows( - db: SqliteDatabase, - sessionId: string, - options: { afterSeq?: number; limit?: number } = {}, -) { - const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; - const limit = options.limit === undefined ? sql`` : sql` LIMIT ${options.limit}`; - return sql`SELECT session_id, seq, kind, key, value - FROM facts - WHERE session_id = ${sessionId}${after} - ORDER BY seq${limit}`.all(db); -} - -export function deleteFactRows(db: SqliteDatabase, sessionId: string) { - sql`DELETE FROM facts WHERE session_id = ${sessionId}`.run(db); -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts deleted file mode 100644 index 02b40d540..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/lanes.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { SessionError } from "@earendil-works/pi-agent-core"; -import { sql } from "../sql.ts"; -import type { SqliteDatabase } from "../types.ts"; - -export interface LaneRow { - session_id: string; - lane: string; - leaf_id: string | null; - open_operation_id: string | null; -} - -export interface LaneMoveRow { - session_id: string; - seq: number; - lane: string; - leaf_id: string | null; -} - -export function createInitialLane(db: SqliteDatabase, sessionId: string, lane = "main", leafId: string | null = null) { - sql`INSERT INTO lanes (session_id, lane, leaf_id, open_operation_id) - VALUES (${sessionId}, ${lane}, ${leafId}, NULL)`.run(db); -} - -export function readLanes(db: SqliteDatabase, sessionId: string) { - const rows = sql`SELECT - l.session_id, - l.lane, - l.leaf_id, - l.open_operation_id, - (l.leaf_id IS NULL OR EXISTS ( - SELECT 1 FROM entries AS e WHERE e.session_id = l.session_id AND e.id = l.leaf_id - )) AS leaf_exists - FROM lanes AS l - WHERE l.session_id = ${sessionId} - ORDER BY l.lane`.all(db); - for (const row of rows) { - if (row.leaf_exists === 0) { - throw new SessionError("storage", `Lane ${row.lane} points at missing entry ${row.leaf_id}`); - } - } - return rows.map(({ session_id, lane, leaf_id, open_operation_id }) => ({ - session_id, - lane, - leaf_id, - open_operation_id, - })); -} - -export function readLane(db: SqliteDatabase, sessionId: string, lane: string) { - return sql`SELECT session_id, lane, leaf_id, open_operation_id - FROM lanes - WHERE session_id = ${sessionId} AND lane = ${lane}`.get(db); -} - -export function readLaneHead(db: SqliteDatabase, sessionId: string, lane: string) { - const row = sql`SELECT - l.leaf_id, - (l.leaf_id IS NULL OR EXISTS ( - SELECT 1 FROM entries AS e WHERE e.session_id = l.session_id AND e.id = l.leaf_id - )) AS leaf_exists - FROM lanes AS l - WHERE l.session_id = ${sessionId} AND l.lane = ${lane}`.get<{ - leaf_id: string | null; - leaf_exists: number; - }>(db); - if (!row) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); - if (row.leaf_exists === 0) throw new SessionError("storage", `Entry ${row.leaf_id} not found`); - return { leafId: row.leaf_id }; -} - -export function createLane(db: SqliteDatabase, sessionId: string, seq: number, lane: string, leafId: string | null) { - sql`INSERT INTO lanes (session_id, lane, leaf_id, open_operation_id) - VALUES (${sessionId}, ${lane}, ${leafId}, NULL)`.run(db); - appendLaneMove(db, sessionId, seq, lane, leafId); -} - -export function moveLane(db: SqliteDatabase, sessionId: string, seq: number, lane: string, leafId: string | null) { - const result = sql`UPDATE lanes SET leaf_id = ${leafId} WHERE session_id = ${sessionId} AND lane = ${lane}`.run(db); - if (result.changes !== 1) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); - appendLaneMove(db, sessionId, seq, lane, leafId); -} - -export function setLaneLeaf(db: SqliteDatabase, sessionId: string, lane: string, leafId: string | null) { - const result = sql`UPDATE lanes SET leaf_id = ${leafId} WHERE session_id = ${sessionId} AND lane = ${lane}`.run(db); - if (result.changes !== 1) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); -} - -export function startLaneOperation(db: SqliteDatabase, sessionId: string, lane: string, runId: string) { - const result = sql`UPDATE lanes SET open_operation_id = ${runId} - WHERE session_id = ${sessionId} AND lane = ${lane} AND open_operation_id IS NULL`.run(db); - if (result.changes === 1) return; - const current = readLane(db, sessionId, lane); - if (!current) throw new SessionError("invalid_lane", `Lane not found: ${lane}`); - throw new SessionError("storage", `Lane ${lane} already has an open operation ${current.open_operation_id}`); -} - -export function finishLaneOperation(db: SqliteDatabase, sessionId: string, lane: string, runId: string) { - sql`UPDATE lanes SET open_operation_id = NULL - WHERE session_id = ${sessionId} AND lane = ${lane} AND open_operation_id = ${runId}`.run(db); -} - -export function readLaneMoveRows( - db: SqliteDatabase, - sessionId: string, - options: { afterSeq?: number; limit?: number } = {}, -) { - const after = options.afterSeq === undefined ? sql`` : sql` AND seq > ${options.afterSeq}`; - const limit = options.limit === undefined ? sql`` : sql` LIMIT ${options.limit}`; - return sql`SELECT session_id, seq, lane, leaf_id - FROM lane_moves - WHERE session_id = ${sessionId}${after} - ORDER BY seq${limit}`.all(db); -} - -export function deleteLaneRows(db: SqliteDatabase, sessionId: string) { - sql`DELETE FROM lane_moves WHERE session_id = ${sessionId}`.run(db); - sql`DELETE FROM lanes WHERE session_id = ${sessionId}`.run(db); -} - -function appendLaneMove(db: SqliteDatabase, sessionId: string, seq: number, lane: string, leafId: string | null) { - sql`INSERT INTO lane_moves (session_id, seq, lane, leaf_id) VALUES (${sessionId}, ${seq}, ${lane}, ${leafId})`.run( - db, - ); -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts deleted file mode 100644 index 7c810c2c8..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/records.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { SessionError } from "@earendil-works/pi-agent-core"; -import { joinSqlFragments, sql } from "../sql.ts"; -import type { SqliteDatabase } from "../types.ts"; - -export interface RecordRow { - session_id: string; - seq: number; - id: string; - lane: string; - run_id: string | null; - type: string; - op_kind: string | null; - timestamp: number; - payload: string; -} - -export interface NewRecordRow { - seq: number; - id: string; - lane: string; - runId?: string; - type: string; - opKind?: string; - timestamp: number; - payload: string; -} - -export function appendRecordRow(db: SqliteDatabase, sessionId: string, record: NewRecordRow) { - sql`INSERT INTO records - (session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload) - VALUES (${sessionId}, ${record.seq}, ${record.id}, ${record.lane}, ${record.runId ?? null}, ${record.type}, ${record.opKind ?? null}, ${record.timestamp}, ${record.payload})`.run( - db, - ); -} - -export function idExistsInRecords(db: SqliteDatabase, sessionId: string, id: string) { - return !!sql`SELECT 1 AS found FROM records WHERE session_id = ${sessionId} AND id = ${id} LIMIT 1`.get<{ - found: number; - }>(db); -} - -export function deleteRecordRows(db: SqliteDatabase, sessionId: string) { - sql`DELETE FROM records WHERE session_id = ${sessionId}`.run(db); -} - -export function readRecordRows( - db: SqliteDatabase, - sessionId: string, - query: { - lane?: string; - type?: string; - runId?: string; - operationKind?: string; - afterSeq?: number; - order?: "newestFirst" | "oldestFirst"; - limit?: number; - } = {}, -) { - const predicates = [sql`session_id = ${sessionId}`]; - if (query.lane !== undefined) predicates.push(sql`lane = ${query.lane}`); - if (query.type !== undefined) predicates.push(sql`type = ${query.type}`); - if (query.runId !== undefined) predicates.push(sql`run_id = ${query.runId}`); - if (query.operationKind !== undefined) predicates.push(sql`op_kind = ${query.operationKind}`); - if (query.afterSeq !== undefined) predicates.push(sql`seq > ${query.afterSeq}`); - const direction = query.order === "oldestFirst" ? sql`ASC` : sql`DESC`; - const limit = query.limit === undefined ? sql`` : sql` LIMIT ${query.limit}`; - return sql`SELECT session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload - FROM records - WHERE ${joinSqlFragments(predicates, " AND ")} - ORDER BY seq ${direction}${limit}`.all(db); -} - -export function readOpenOperationRows( - db: SqliteDatabase, - sessionId: string, - lane: string, - _options: { limit?: number } = {}, -): RecordRow[] { - const laneRow = sql`SELECT open_operation_id FROM lanes WHERE session_id = ${sessionId} AND lane = ${lane}`.get<{ - open_operation_id: string | null; - }>(db); - if (!laneRow?.open_operation_id) return []; - - const record = sql`SELECT session_id, seq, id, lane, run_id, type, op_kind, timestamp, payload - FROM records - WHERE session_id = ${sessionId} - AND id = ${laneRow.open_operation_id}`.get(db); - if (!record) { - throw new SessionError("storage", `Lane ${lane} points at missing open operation ${laneRow.open_operation_id}`); - } - if (record.lane !== lane || record.type !== "operation_started") { - throw new SessionError("storage", `Lane ${lane} points at invalid open operation ${laneRow.open_operation_id}`); - } - return [record]; -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/session-sequences.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/session-sequences.ts deleted file mode 100644 index 16f7df44c..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/session-sequences.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { SessionError } from "@earendil-works/pi-agent-core"; -import { sql } from "../sql.ts"; -import type { SqliteDatabase } from "../types.ts"; - -export function createSequence(db: SqliteDatabase, sessionId: string, nextSeq = 1) { - sql`INSERT INTO session_sequences (session_id, next_seq) VALUES (${sessionId}, ${nextSeq})`.run(db); -} - -export function getNextSequence(db: SqliteDatabase, sessionId: string) { - const sequenceRow = sql`SELECT next_seq FROM session_sequences WHERE session_id = ${sessionId}`.get<{ - next_seq: number; - }>(db); - if (!sequenceRow) { - throw new SessionError("storage", `Missing sequence row for session ${sessionId}`); - } - return sequenceRow.next_seq; -} - -export function setNextSequence(db: SqliteDatabase, sessionId: string, nextSeq: number) { - sql`UPDATE session_sequences SET next_seq = ${nextSeq} WHERE session_id = ${sessionId}`.run(db); -} - -export function advanceSequence(db: SqliteDatabase, sessionId: string, seq: number) { - setNextSequence(db, sessionId, seq + 1); -} - -export function deleteSequence(db: SqliteDatabase, sessionId: string) { - sql`DELETE FROM session_sequences WHERE session_id = ${sessionId}`.run(db); -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/session-stats.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/session-stats.ts deleted file mode 100644 index 85a3a4581..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/session-stats.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { SessionError, type SessionStats } from "@earendil-works/pi-agent-core"; -import type { Usage } from "@earendil-works/pi-ai"; -import { sql } from "../sql.ts"; -import type { SqliteDatabase } from "../types.ts"; - -export interface SessionStatsRow { - session_id: string; - message_count: number; - cached_tokens: number; - uncached_tokens: number; - total_tokens: number; - cost_total: number; -} - -export function createStats(db: SqliteDatabase, sessionId: string, messageCount = 0): void { - sql`INSERT INTO session_stats - (session_id, message_count, cached_tokens, uncached_tokens, total_tokens, cost_total) - VALUES (${sessionId}, ${messageCount}, 0, 0, 0, 0)`.run(db); -} - -export function readStats(db: SqliteDatabase, sessionId: string): SessionStats { - const row = sql`SELECT session_id, message_count, cached_tokens, uncached_tokens, total_tokens, cost_total - FROM session_stats - WHERE session_id = ${sessionId}`.get(db); - if (!row) throw new SessionError("storage", `Missing stats row for session ${sessionId}`); - return { - messageCount: row.message_count, - cachedTokens: row.cached_tokens, - uncachedTokens: row.uncached_tokens, - totalTokens: row.total_tokens, - costTotal: row.cost_total, - }; -} - -export function incrementMessageCount(db: SqliteDatabase, sessionId: string): void { - const result = sql`UPDATE session_stats SET message_count = message_count + 1 WHERE session_id = ${sessionId}`.run( - db, - ); - if (result.changes !== 1) throw new SessionError("storage", `Missing stats row for session ${sessionId}`); -} - -export function addUsageToStats(db: SqliteDatabase, sessionId: string, usage: Usage): void { - const result = sql`UPDATE session_stats - SET cached_tokens = cached_tokens + ${usage.cacheRead}, - uncached_tokens = uncached_tokens + ${usage.input + usage.cacheWrite}, - total_tokens = total_tokens + ${usage.totalTokens}, - cost_total = cost_total + ${usage.cost.total} - WHERE session_id = ${sessionId}`.run(db); - if (result.changes !== 1) throw new SessionError("storage", `Missing stats row for session ${sessionId}`); -} - -export function deleteStats(db: SqliteDatabase, sessionId: string): void { - sql`DELETE FROM session_stats WHERE session_id = ${sessionId}`.run(db); -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts deleted file mode 100644 index e954c0b38..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/sessions.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { assertJsonSerializable, SessionError } from "@earendil-works/pi-agent-core"; -import { sql } from "../sql.ts"; -import type { SqliteDatabase, SqliteSessionMetadata } from "../types.ts"; - -export interface SessionRow { - id: string; - created_at: number; - metadata: string | null; - cwd: string; - parent_session_id: string | null; - has_session_name: number; - session_name: string | null; -} - -export interface NewSessionRow { - id: string; - createdAt: number; - cwd: string; - parentSessionId?: string; - metadata?: Record; -} - -function parseMetadata(metadata: string | null, sessionId: string): Record | undefined { - if (metadata === null) return undefined; - let parsed: unknown; - try { - parsed = JSON.parse(metadata); - } catch (error) { - throw new SessionError( - "storage", - `Invalid SQLite session ${sessionId}: metadata is not valid JSON`, - error instanceof Error ? error : undefined, - ); - } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new SessionError("storage", `Invalid SQLite session ${sessionId}: metadata must be an object`); - } - return parsed as Record; -} - -export function sessionExists(db: SqliteDatabase, sessionId: string) { - return !!sql`SELECT 1 AS found FROM sessions WHERE id = ${sessionId}`.get<{ found: number }>(db); -} - -function serializeMetadata(metadata: Record | undefined): string | null { - if (metadata === undefined) return null; - if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) { - throw new SessionError("invalid_payload", "SQLite session metadata must be an object"); - } - assertJsonSerializable(metadata); - return JSON.stringify(metadata); -} - -export function insertSessionRow(db: SqliteDatabase, session: NewSessionRow) { - sql`INSERT INTO sessions (id, created_at, metadata, cwd, parent_session_id) - VALUES (${session.id}, ${session.createdAt}, ${serializeMetadata(session.metadata)}, ${session.cwd}, ${session.parentSessionId ?? null})`.run( - db, - ); -} - -export function readSessionRow(db: SqliteDatabase, sessionId: string) { - return sql`SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, - name_fact.seq IS NOT NULL AS has_session_name, - name_fact.value AS session_name - FROM sessions AS s - LEFT JOIN facts AS name_fact - ON name_fact.session_id = s.id - AND name_fact.kind = 'name' - AND name_fact.key IS NULL - AND name_fact.seq = ( - SELECT MAX(f.seq) - FROM facts AS f - WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL - ) - WHERE s.id = ${sessionId}`.get(db); -} - -export function readSessionRows(db: SqliteDatabase, options: { cwd?: string } = {}) { - const where = options.cwd === undefined ? sql`` : sql`WHERE s.cwd = ${options.cwd}`; - return sql`SELECT s.id, s.created_at, s.metadata, s.cwd, s.parent_session_id, - name_fact.seq IS NOT NULL AS has_session_name, - name_fact.value AS session_name - FROM sessions AS s - LEFT JOIN facts AS name_fact - ON name_fact.session_id = s.id - AND name_fact.kind = 'name' - AND name_fact.key IS NULL - AND name_fact.seq = ( - SELECT MAX(f.seq) - FROM facts AS f - WHERE f.session_id = s.id AND f.kind = 'name' AND f.key IS NULL - ) - ${where} - ORDER BY s.created_at DESC`.all(db); -} - -export function deleteSessionRow(db: SqliteDatabase, sessionId: string) { - sql`DELETE FROM sessions WHERE id = ${sessionId}`.run(db); -} - -function parseSessionName(value: string | null, sessionId: string): string | undefined { - if (value === null) return undefined; - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch (error) { - throw new SessionError( - "storage", - `Invalid SQLite session ${sessionId}: name is not valid JSON`, - error instanceof Error ? error : undefined, - ); - } - if (typeof parsed !== "string") { - throw new SessionError("storage", `Invalid SQLite session ${sessionId}: name must be a string`); - } - return parsed; -} - -export function decodeSessionMetadata(row: SessionRow, path: string): SqliteSessionMetadata { - const metadata = parseMetadata(row.metadata, row.id); - const name = row.has_session_name === 0 ? undefined : parseSessionName(row.session_name, row.id); - return { - id: row.id, - createdAt: row.created_at, - ...(name === undefined ? {} : { name }), - cwd: row.cwd, - path, - parentSessionId: row.parent_session_id ?? undefined, - metadata, - }; -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/storage/writer-leases.ts b/packages/session-backends/sqlite-node/src/sqlite/storage/writer-leases.ts deleted file mode 100644 index ec7fcaa91..000000000 --- a/packages/session-backends/sqlite-node/src/sqlite/storage/writer-leases.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { sql } from "../sql.ts"; -import type { SqliteDatabase } from "../types.ts"; - -export interface WriterLease { - ownerId: string; - fence: number; - expiresAtMs: number; -} - -interface WriterLeaseRow { - owner_id: string; - fence: number; - expires_at_ms: number; -} - -export function acquireWriterLease( - db: SqliteDatabase, - sessionId: string, - ownerId: string, - now: number, - expiresAtMs: number, -) { - const row = sql`INSERT INTO writer_leases (session_id, owner_id, fence, expires_at_ms) - VALUES (${sessionId}, ${ownerId}, 1, ${expiresAtMs}) - ON CONFLICT(session_id) DO UPDATE SET - owner_id = excluded.owner_id, - fence = writer_leases.fence + 1, - expires_at_ms = excluded.expires_at_ms - WHERE writer_leases.expires_at_ms <= ${now} - RETURNING owner_id, fence, expires_at_ms`.get(db); - return row === undefined ? undefined : { ownerId: row.owner_id, fence: row.fence, expiresAtMs: row.expires_at_ms }; -} - -export function renewWriterLease( - db: SqliteDatabase, - sessionId: string, - lease: WriterLease, - now: number, - expiresAtMs: number, -) { - const result = sql`UPDATE writer_leases - SET expires_at_ms = ${expiresAtMs} - WHERE session_id = ${sessionId} - AND owner_id = ${lease.ownerId} - AND fence = ${lease.fence} - AND expires_at_ms > ${now}`.run(db); - if (result.changes === 1) lease.expiresAtMs = expiresAtMs; - return result.changes === 1; -} - -export function releaseWriterLease(db: SqliteDatabase, sessionId: string, lease: WriterLease) { - sql`DELETE FROM writer_leases - WHERE session_id = ${sessionId} AND owner_id = ${lease.ownerId} AND fence = ${lease.fence}`.run(db); -} - -export function deleteWriterLease(db: SqliteDatabase, sessionId: string) { - sql`DELETE FROM writer_leases WHERE session_id = ${sessionId}`.run(db); -} diff --git a/packages/session-backends/sqlite-node/src/sqlite/types.ts b/packages/session-backends/sqlite-node/src/sqlite/types.ts index 6afadfd67..fe51a1068 100644 --- a/packages/session-backends/sqlite-node/src/sqlite/types.ts +++ b/packages/session-backends/sqlite-node/src/sqlite/types.ts @@ -1,10 +1,6 @@ -import type { FileSystem, SessionCreateOptions, SessionMetadata } from "@earendil-works/pi-agent-core"; - /** Result of a prepared SQLite statement execution. */ export interface SqliteRunResult { - /** Number of rows changed by the statement. */ changes: number; - /** Inserted row id when the backend exposes one. */ lastInsertRowid?: number; } @@ -21,32 +17,10 @@ export interface SqliteDatabase { exec(sql: string): void; prepare(sql: string): SqliteStatement; /** Runs a synchronous write transaction. The callback must not return a promise. */ - transaction(fn: () => T): T; + transaction(callback: () => T): T; close(): void; } export interface SqliteDatabaseFactory { open(path: string): Promise; } - -export interface SqliteSessionMetadata extends SessionMetadata { - cwd: string; - path: string; - parentSessionId?: string; - /** Current session name projected from SQLite global facts. */ - name?: string; - /** Opaque application-owned metadata. */ - metadata?: Record; -} - -export interface SqliteSessionCreateOptions extends SessionCreateOptions { - cwd: string; - parentSessionId?: string; - metadata?: Record; -} - -export interface SqliteSessionListOptions { - cwd?: string; -} - -export type SqliteSessionRepositoryEnv = Pick; diff --git a/packages/session-backends/sqlite-node/test/branch-cache.test.ts b/packages/session-backends/sqlite-node/test/branch-cache.test.ts deleted file mode 100644 index 7b21941e1..000000000 --- a/packages/session-backends/sqlite-node/test/branch-cache.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { join } from "node:path"; -import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; -import { describe, expect, it } from "vitest"; -import { createNodeSqliteFactory, SqliteSessionRepository } from "../src/index.ts"; -import { - appendSqliteCompaction, - createAssistantMessage, - createTempDir, - createUserMessage, - getSqliteBranch, - moveSqliteMainLane, -} from "./test-utils.ts"; - -describe("SQLite branch cache", () => { - it("collects complete root paths for branches created after compaction", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - const keptId = await session.appendMessage(createUserMessage("kept")); - const compactionId = await appendSqliteCompaction(session, "summary", 100); - await session.appendMessage(createAssistantMessage("first child")); - await moveSqliteMainLane(session, compactionId); - const branchedId = await session.appendMessage(createAssistantMessage("branched child")); - - const db = await sqlite.open(databasePath); - try { - const row = await db - .prepare("SELECT branch_id FROM branch_entries WHERE session_id = ? AND entry_id = ?") - .get<{ branch_id: string }>("session-1", branchedId); - if (!row) throw new Error("Missing branched entry cache row"); - const entries = await db - .prepare("SELECT entry_id FROM branch_entries WHERE session_id = ? AND branch_id = ? ORDER BY entry_seq") - .all<{ entry_id: string }>("session-1", row.branch_id); - expect(entries.map((entry) => entry.entry_id)).toEqual([rootId, keptId, compactionId, branchedId]); - } finally { - await db.close(); - } - }); - - it("reads only the compacted branch window from the complete cache", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const oldId = await session.appendMessage(createUserMessage("old")); - await session.appendMessage(createUserMessage("kept")); - const compactionId = await appendSqliteCompaction(session, "summary", 100); - const leafId = await session.appendMessage(createAssistantMessage("new")); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("not json", "session-1", oldId); - } finally { - await db.close(); - } - - expect((await getSqliteBranch(session)).map((entry) => entry.id)).toEqual([compactionId, leafId]); - }); - - it("preserves nested compaction boundaries when reading the cache", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - await using repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - await session.appendMessage(createUserMessage("root")); - await appendSqliteCompaction(session, "first summary", 100, undefined, undefined, []); - await session.appendMessage(createUserMessage("middle")); - const secondCompactionId = await appendSqliteCompaction(session, "second summary", 200); - const leafId = await session.appendMessage(createAssistantMessage("new")); - - expect((await getSqliteBranch(session)).map((entry) => entry.id)).toEqual([secondCompactionId, leafId]); - }); - - it("rejects reads and writes without repairing a missing private branch cache", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - await session.appendMessage(createUserMessage("root")); - await session.appendMessage(createAssistantMessage("child")); - - const db = await sqlite.open(databasePath); - try { - await db.prepare("DELETE FROM branch_tips WHERE session_id = ?").run("session-1"); - await db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run("session-1"); - } finally { - await db.close(); - } - - await expect(getSqliteBranch(session)).rejects.toMatchObject({ code: "invalid_entry" }); - await expect(session.appendMessage(createAssistantMessage("later"))).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining("has no branch containing parent entry"), - }); - - const inspection = await sqlite.open(databasePath); - try { - expect( - await inspection.prepare("SELECT entry_id FROM branch_entries WHERE session_id = ?").all("session-1"), - ).toEqual([]); - } finally { - await inspection.close(); - } - }); - - it("repairs the private branch cache explicitly", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - const childId = await session.appendMessage(createAssistantMessage("child")); - const metadata = await session.getMetadata(); - - const db = await sqlite.open(databasePath); - try { - await db.prepare("DELETE FROM branch_tips WHERE session_id = ?").run("session-1"); - await db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run("session-1"); - } finally { - await db.close(); - } - - await expect(getSqliteBranch(session)).rejects.toMatchObject({ code: "invalid_entry" }); - - await repo.repairBranchCache(metadata); - - expect((await getSqliteBranch(session)).map((entry) => entry.id)).toEqual([rootId, childId]); - }); - - it("fails when forking from a source with a missing branch cache", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const source = await repo.create({ cwd: root, id: "source" }); - const rootId = await source.appendMessage(createUserMessage("root")); - const childId = await source.appendMessage(createAssistantMessage("child")); - - const db = await sqlite.open(databasePath); - try { - await db.prepare("DELETE FROM branch_tips WHERE session_id = ?").run("source"); - await db.prepare("DELETE FROM branch_entries WHERE session_id = ?").run("source"); - } finally { - await db.close(); - } - - expect(rootId).not.toBe(childId); - await expect( - repo.fork(await source.getMetadata(), { - cwd: root, - id: "fork", - entryId: childId, - position: "at", - }), - ).rejects.toMatchObject({ code: "invalid_fork_target" }); - }); - - it("fails when the private branch cache is stale", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - const staleId = await session.appendMessage(createAssistantMessage("stale")); - const leafId = await session.appendMessage(createUserMessage("leaf")); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE entries SET parent_id = ? WHERE session_id = ? AND id = ?") - .run(rootId, "session-1", leafId); - } finally { - await db.close(); - } - - expect(staleId).not.toBe(leafId); - await expect(session.findEntriesOnBranch({ start: leafId, order: "oldestFirst" })).rejects.toMatchObject({ - code: "invalid_entry", - }); - }); - - it("deletes branch entries and tips with the session", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - await session.appendMessage(createUserMessage("root")); - const metadata = await session.getMetadata(); - - await repo.delete(metadata); - - const db = await sqlite.open(databasePath); - try { - expect(await db.prepare("SELECT entry_id FROM branch_entries WHERE session_id = ?").all("session-1")).toEqual( - [], - ); - expect(await db.prepare("SELECT tip_id FROM branch_tips WHERE session_id = ?").all("session-1")).toEqual([]); - } finally { - await db.close(); - } - }); -}); diff --git a/packages/session-backends/sqlite-node/test/branch-query.test.ts b/packages/session-backends/sqlite-node/test/branch-query.test.ts deleted file mode 100644 index 54f017b68..000000000 --- a/packages/session-backends/sqlite-node/test/branch-query.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { join } from "node:path"; -import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; -import { describe, expect, it } from "vitest"; -import { createNodeSqliteFactory, SqliteSessionRepository } from "../src/index.ts"; -import { createAssistantMessage, createTempDir, createUserMessage } from "./test-utils.ts"; - -describe("SQLite branch queries", () => { - it("does not decode entries outside bounded branch queries", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - const middleId = await session.appendMessage(createAssistantMessage("middle")); - const leafId = await session.appendMessage(createUserMessage("leaf")); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("not json", "session-1", middleId); - const branch = await db - .prepare("SELECT branch_id FROM branch_entries WHERE session_id = ? AND entry_id = ?") - .get<{ branch_id: string }>("session-1", leafId); - if (!branch) throw new Error("Missing branch cache for bounded SQLite query test"); - await db - .prepare("DELETE FROM branch_entries WHERE session_id = ? AND branch_id = ? AND entry_id = ?") - .run("session-1", branch.branch_id, middleId); - } finally { - await db.close(); - } - - expect((await session.findEntriesOnBranch({ start: leafId, stopAtId: leafId })).map((entry) => entry.id)).toEqual( - [leafId], - ); - expect( - ( - await session.findEntriesOnBranch({ - start: leafId, - stopAtId: rootId, - order: "oldestFirst", - limit: 1, - }) - ).map((entry) => entry.id), - ).toEqual([rootId]); - await expect(session.findEntriesOnBranch({ start: leafId, limit: 2 })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`Entry ${middleId} not found`), - }); - }); - - it("does not decode entries excluded by branch query filters and limits", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - await session.appendMessage(createUserMessage("root")); - const customId = await session.appendCustomEntry("note", { value: 1 }); - const leafId = await session.appendMessage(createAssistantMessage("leaf")); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("{}", "session-1", customId); - } finally { - await db.close(); - } - expect( - (await session.findEntriesOnBranch({ start: leafId, type: "message", limit: 1 })).map((entry) => entry.id), - ).toEqual([leafId]); - - const invalidJsonDb = await sqlite.open(databasePath); - try { - await invalidJsonDb - .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("not json", "session-1", customId); - } finally { - await invalidJsonDb.close(); - } - expect(await session.findEntriesOnBranch({ start: leafId, customType: "other" })).toEqual([]); - }); - - it("does not validate ancestors beyond newest-first stop bounds", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - const childId = await session.appendMessage(createAssistantMessage("child")); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE entries SET parent_id = ? WHERE session_id = ? AND id = ?") - .run("missing-parent", "session-1", childId); - } finally { - await db.close(); - } - expect( - (await session.findEntriesOnBranch({ start: childId, stopAtId: childId })).map((entry) => entry.id), - ).toEqual([childId]); - expect( - (await session.findEntriesOnBranch({ start: childId, stopAtType: "message" })).map((entry) => entry.id), - ).toEqual([childId]); - await expect(session.findEntriesOnBranch({ start: childId })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining("Entry missing-parent not found"), - }); - - const cycleDb = await sqlite.open(databasePath); - try { - await cycleDb - .prepare("UPDATE entries SET parent_id = ? WHERE session_id = ? AND id = ?") - .run(rootId, "session-1", childId); - await cycleDb - .prepare("UPDATE entries SET parent_id = ? WHERE session_id = ? AND id = ?") - .run(childId, "session-1", rootId); - } finally { - await cycleDb.close(); - } - expect( - (await session.findEntriesOnBranch({ start: childId, stopAtId: childId })).map((entry) => entry.id), - ).toEqual([childId]); - expect( - (await session.findEntriesOnBranch({ start: childId, stopAtType: "message" })).map((entry) => entry.id), - ).toEqual([childId]); - await expect(session.findEntriesOnBranch({ start: childId })).rejects.toMatchObject({ - code: "invalid_entry", - message: expect.stringContaining(`Entry ${childId} not found`), - }); - }); -}); diff --git a/packages/session-backends/sqlite-node/test/conformance.test.ts b/packages/session-backends/sqlite-node/test/conformance.test.ts deleted file mode 100644 index ac5a20c27..000000000 --- a/packages/session-backends/sqlite-node/test/conformance.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { join } from "node:path"; -import type { SessionMetadata, SessionRepo } from "@earendil-works/pi-agent-core"; -import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; -import { - createSessionBackendConformance, - type SessionBackendFixture, -} from "@earendil-works/pi-agent-core/session/testing"; -import { describe, it } from "vitest"; -import { createNodeSqliteFactory, type SqliteSessionMetadata, SqliteSessionRepository } from "../src/index.ts"; -import { createTempDir } from "./test-utils.ts"; - -function requireSqliteMetadata(metadata: SessionMetadata): SqliteSessionMetadata { - const cwd = "cwd" in metadata ? metadata.cwd : undefined; - if (typeof cwd !== "string") { - throw new Error(`Expected SQLite metadata for session ${metadata.id}`); - } - const path = "path" in metadata ? metadata.path : undefined; - if (typeof path !== "string") { - throw new Error(`Expected SQLite metadata for session ${metadata.id}`); - } - return { ...metadata, cwd, path }; -} - -const conformance = createSessionBackendConformance(async () => { - const root = createTempDir(); - const sqliteRepository = new SqliteSessionRepository({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite: createNodeSqliteFactory(), - databasePath: join(root, "sessions.sqlite"), - }); - const repository: SessionRepo = { - create: (options = {}) => sqliteRepository.create({ ...options, cwd: root }), - open: (metadata) => sqliteRepository.open(requireSqliteMetadata(metadata)), - list: () => sqliteRepository.list(), - delete: (metadata) => sqliteRepository.delete(requireSqliteMetadata(metadata)), - fork: (source, options = {}) => sqliteRepository.fork(requireSqliteMetadata(source), { ...options, cwd: root }), - }; - return { - repository, - async [Symbol.asyncDispose]() { - await sqliteRepository.close(); - }, - } satisfies SessionBackendFixture; -}); - -describe("SqliteSessionRepository conformance", () => { - for (const group of new Set(conformance.map((testCase) => testCase.group))) { - describe(group, () => { - for (const testCase of conformance.filter((candidate) => candidate.group === group)) { - it(testCase.name, () => testCase.run()); - } - }); - } -}); diff --git a/packages/session-backends/sqlite-node/test/facts-query.test.ts b/packages/session-backends/sqlite-node/test/facts-query.test.ts deleted file mode 100644 index 84cc4987b..000000000 --- a/packages/session-backends/sqlite-node/test/facts-query.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { applyMigrations, createNodeSqliteFactory } from "../src/index.ts"; -import { appendFact, readLatestFact, readLatestLabelFacts } from "../src/sqlite/storage/facts.ts"; -import { createTempDir } from "./test-utils.ts"; - -describe("SQLite fact queries", () => { - it("reads latest facts and latest non-null labels", async () => { - const databasePath = join(createTempDir(), "sessions.sqlite"); - const db = await createNodeSqliteFactory().open(databasePath); - try { - await applyMigrations(db); - appendFact(db, "session-1", 1, "label", "entry-1", JSON.stringify("old")); - appendFact(db, "session-1", 2, "label", "entry-2", JSON.stringify("kept")); - appendFact(db, "session-1", 3, "label", "entry-1", JSON.stringify("new")); - appendFact(db, "session-1", 4, "label", "entry-3", JSON.stringify("removed")); - appendFact(db, "session-1", 5, "label", "entry-3", null); - appendFact(db, "session-1", 6, "name", null, JSON.stringify("session name")); - appendFact(db, "other-session", 1, "label", "entry-1", JSON.stringify("other")); - - expect(readLatestFact(db, "session-1", "label", "entry-1")?.value).toBe(JSON.stringify("new")); - expect(readLatestFact(db, "session-1", "name", null)?.value).toBe(JSON.stringify("session name")); - expect(readLatestLabelFacts(db, "session-1")).toEqual([ - { key: "entry-1", value: JSON.stringify("new") }, - { key: "entry-2", value: JSON.stringify("kept") }, - ]); - } finally { - db.close(); - } - }); -}); diff --git a/packages/session-backends/sqlite-node/test/log-query.test.ts b/packages/session-backends/sqlite-node/test/log-query.test.ts deleted file mode 100644 index 385c05d9c..000000000 --- a/packages/session-backends/sqlite-node/test/log-query.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { join } from "node:path"; -import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; -import { describe, expect, it } from "vitest"; -import { createNodeSqliteFactory, SqliteSessionRepository } from "../src/index.ts"; -import { createTempDir, createUserMessage } from "./test-utils.ts"; - -describe("SQLite log queries", () => { - it("does not decode rows beyond the requested log limit", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const rootId = await session.appendMessage(createUserMessage("root")); - await session.setName("name"); - const tailId = await session.appendMessage(createUserMessage("tail")); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("not json", "session-1", tailId); - } finally { - await db.close(); - } - - expect(await session.getLog({ limit: 1 })).toEqual([ - expect.objectContaining({ kind: "entry", seq: 1, entry: expect.objectContaining({ id: rootId }) }), - ]); - expect(await session.getLog({ afterSeq: 1, limit: 1 })).toEqual([ - expect.objectContaining({ kind: "fact", seq: 2, fact: "name", name: "name" }), - ]); - }); -}); diff --git a/packages/session-backends/sqlite-node/test/migrations.test.ts b/packages/session-backends/sqlite-node/test/migrations.test.ts deleted file mode 100644 index fb1631100..000000000 --- a/packages/session-backends/sqlite-node/test/migrations.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { applyMigrations, createNodeSqliteFactory } from "../src/index.ts"; -import { createTempDir } from "./test-utils.ts"; - -describe("SQLite migrations", () => { - it("applies the current schema once and records its migration", async () => { - const databasePath = join(createTempDir(), "sessions.sqlite"); - const db = await createNodeSqliteFactory().open(databasePath); - try { - await applyMigrations(db); - await applyMigrations(db); - - const rows = db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>(); - expect(rows.map((row) => row.id)).toEqual(["001_initial.sql"]); - const tables = db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") - .all<{ name: string }>(); - expect(tables.map((row) => row.name)).toEqual( - expect.arrayContaining([ - "migrations", - "sessions", - "entries", - "session_sequences", - "session_stats", - "branch_entries", - "branch_tips", - "lanes", - "records", - "lane_moves", - "facts", - "writer_leases", - ]), - ); - const sessionColumns = db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>(); - expect(sessionColumns.map((column) => column.name)).not.toContain("leaf_id"); - const sessionIndexes = db.prepare("PRAGMA index_list(sessions)").all<{ name: string }>(); - expect(sessionIndexes.map((index) => index.name)).toContain("idx_sessions_cwd_created_at"); - expect(sessionIndexes.map((index) => index.name)).not.toContain("idx_sessions_parent"); - const laneColumns = db.prepare("PRAGMA table_info(lanes)").all<{ name: string }>(); - expect(laneColumns.map((column) => column.name)).toContain("open_operation_id"); - const entryIndexes = db.prepare("PRAGMA index_list(entries)").all<{ name: string }>(); - expect(entryIndexes.map((index) => index.name)).not.toContain("idx_entries_session_seq"); - const branchEntryIndexes = db.prepare("PRAGMA index_list(branch_entries)").all<{ name: string }>(); - expect(branchEntryIndexes.map((index) => index.name)).toContain("idx_branch_entries_session_entry"); - const recordIndexes = db.prepare("PRAGMA index_list(records)").all<{ name: string }>(); - expect(recordIndexes.map((index) => index.name)).toEqual( - expect.arrayContaining([ - "idx_records_session_lane_seq", - "idx_records_session_type_seq", - "idx_records_session_type_op_kind_seq", - ]), - ); - expect(recordIndexes.map((index) => index.name)).not.toContain("idx_records_session_seq"); - const laneMoveIndexes = db.prepare("PRAGMA index_list(lane_moves)").all<{ name: string }>(); - expect(laneMoveIndexes.map((index) => index.name)).not.toContain("idx_lane_moves_session_lane_seq"); - } finally { - db.close(); - } - }); -}); diff --git a/packages/session-backends/sqlite-node/test/repository.test.ts b/packages/session-backends/sqlite-node/test/repository.test.ts deleted file mode 100644 index 151c0a42a..000000000 --- a/packages/session-backends/sqlite-node/test/repository.test.ts +++ /dev/null @@ -1,496 +0,0 @@ -import { join } from "node:path"; -import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; -import { describe, expect, it } from "vitest"; -import { - createNodeSqliteFactory, - type SqliteDatabase, - type SqliteDatabaseFactory, - type SqliteRunResult, - SqliteSessionRepository, - type SqliteStatement, -} from "../src/index.ts"; -import { - appendSqliteCompaction, - createAssistantMessage, - createSetupFailureSqlite, - createTempDir, - createUserMessage, - getSqliteEntries, - moveSqliteMainLane, -} from "./test-utils.ts"; - -class ThrowingStatement implements SqliteStatement { - private readonly onRun: () => SqliteRunResult; - - constructor(onRun: () => SqliteRunResult) { - this.onRun = onRun; - } - - run(..._params: unknown[]): SqliteRunResult { - return this.onRun(); - } - - get(..._params: unknown[]): TRow | undefined { - return undefined; - } - - all(..._params: unknown[]): TRow[] { - return []; - } - - *iterate(..._params: unknown[]): Iterable {} -} - -class CountingDatabase implements SqliteDatabase { - closeCount = 0; - private readonly statementFactory: (sql: string) => SqliteStatement; - - constructor(statementFactory: (sql: string) => SqliteStatement) { - this.statementFactory = statementFactory; - } - - exec(_sql: string): void {} - - prepare(sql: string): SqliteStatement { - return this.statementFactory(sql); - } - - transaction(fn: () => T): T { - return fn(); - } - - close(): void { - this.closeCount += 1; - } -} - -function createCloseCountingSqliteFactory(): { - sqlite: SqliteDatabaseFactory; - counts: { opens: number; closes: number }; -} { - const source = createNodeSqliteFactory(); - const counts = { opens: 0, closes: 0 }; - return { - counts, - sqlite: { - async open(path) { - const db = await source.open(path); - counts.opens += 1; - return { - exec: (sql) => db.exec(sql), - prepare: (sql) => db.prepare(sql), - transaction: (fn) => db.transaction(fn), - close() { - counts.closes += 1; - db.close(); - }, - }; - }, - }, - }; -} - -describe("SQLite session repository", () => { - it("persists session metadata through create, list, open, and fork", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - await using repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); - const source = await repo.create({ - cwd: root, - id: "session-1", - metadata: { profile: "reviewer" }, - }); - const sourceMetadata = await source.getMetadata(); - expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" }); - expect((await repo.list({ cwd: root })).map((listed) => listed.metadata)).toEqual([{ profile: "reviewer" }]); - const reopened = await repo.open(sourceMetadata); - expect((await reopened.getMetadata()).metadata).toEqual({ profile: "reviewer" }); - const fork = await repo.fork(sourceMetadata, { cwd: root, id: "session-2" }); - expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" }); - const overridden = await repo.fork(sourceMetadata, { - cwd: root, - id: "session-3", - metadata: { profile: "writer" }, - }); - expect((await overridden.getMetadata()).metadata).toEqual({ profile: "writer" }); - }); - - it("rolls back the entire fork when copying an entry fails", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const source = await repo.create({ cwd: root, id: "source" }); - await source.appendMessage(createUserMessage("one")); - await source.appendMessage(createAssistantMessage("two")); - - const db = await sqlite.open(databasePath); - try { - await db.exec(` -CREATE TRIGGER fail_fork_entry BEFORE INSERT ON entries -WHEN new.session_id = 'fork' AND new.seq = 2 -BEGIN - SELECT RAISE(ABORT, 'fail fork'); -END; -`); - } finally { - await db.close(); - } - - await expect(repo.fork(await source.getMetadata(), { cwd: root, id: "fork" })).rejects.toMatchObject({ - code: "storage", - }); - const inspection = await sqlite.open(databasePath); - try { - expect( - await inspection.prepare("SELECT id FROM sessions WHERE id = ?").get<{ id: string }>("fork"), - ).toBeUndefined(); - expect( - await inspection.prepare("SELECT id FROM entries WHERE session_id = ?").all<{ id: string }>("fork"), - ).toEqual([]); - } finally { - await inspection.close(); - } - }); - - it("retains an opened database after a failed operation until disposal", async () => { - const root = createTempDir(); - const db = new CountingDatabase((sql) => { - if (sql.startsWith("INSERT INTO sessions")) { - return new ThrowingStatement(() => { - throw new Error("insert failed"); - }); - } - return new ThrowingStatement(() => ({ changes: 1 })); - }); - const sqlite: SqliteDatabaseFactory = { - open: async () => db, - }; - const env = new NodeExecutionEnv({ cwd: root }); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath: join(root, "sessions.sqlite") }); - - await expect(repo.create({ cwd: root, id: "session-1" })).rejects.toThrow("insert failed"); - expect(db.closeCount).toBe(0); - await repo[Symbol.asyncDispose](); - expect(db.closeCount).toBe(1); - }); - - it("closes the database when repository setup fails", async () => { - const root = createTempDir(); - const { sqlite, counts } = createSetupFailureSqlite(); - await using repository = new SqliteSessionRepository({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite, - databasePath: join(root, "sessions.sqlite"), - }); - - await expect(repository.create({ cwd: root, id: "session-1" })).rejects.toThrow("setup failed"); - expect(counts.closes).toBe(1); - }); - - it("closes active sessions when the repository is disposed", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - await using repo = new SqliteSessionRepository({ env, sqlite: createNodeSqliteFactory(), databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - - await repo[Symbol.asyncDispose](); - - await expect(session.appendMessage(createUserMessage("late"))).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining("SQLite session session-1 is closed"), - }); - }); - - it("retains one connection for repeated session operations", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const { sqlite, counts } = createCloseCountingSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - - const session = await repo.create({ cwd: root, id: "session-1" }); - for (let i = 0; i < 10; i++) await session.appendMessage(createUserMessage(`message ${i}`)); - await getSqliteEntries(session); - expect(counts).toEqual({ opens: 1, closes: 0 }); - await repo[Symbol.asyncDispose](); - expect(counts).toEqual({ opens: 1, closes: 1 }); - await repo[Symbol.asyncDispose](); - expect(counts).toEqual({ opens: 1, closes: 1 }); - }); - - it("rejects a missing lane leaf when listing lanes and opening", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const metadata = await session.getMetadata(); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE lanes SET leaf_id = ? WHERE session_id = ? AND lane = ?") - .run("missing", metadata.id, "main"); - } finally { - await db.close(); - } - - await expect(session.getLanes()).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining("Lane main points at missing entry missing"), - }); - await expect(repo.open(metadata)).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining("Lane main points at missing entry missing"), - }); - }); - - it.each([ - ["invalid JSON", "not json", "metadata is not valid JSON"], - ["a non-object value", "[]", "metadata must be an object"], - ])("rejects stored session metadata containing %s", async (_case, stored, message) => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - await repo.create({ cwd: root, id: "session-1" }); - - const db = await sqlite.open(databasePath); - try { - await db.prepare("UPDATE sessions SET metadata = ? WHERE id = ?").run(stored, "session-1"); - } finally { - await db.close(); - } - - await expect(repo.list()).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining(message), - }); - }); - - it.each([ - ["invalid JSON", "not json", "name is not valid JSON"], - ["a non-string value", "{}", "name must be a string"], - ])("rejects stored session names containing %s", async (_case, stored, message) => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - await session.setName("valid name"); - - const db = await sqlite.open(databasePath); - try { - await db.prepare("UPDATE facts SET value = ? WHERE session_id = ? AND kind = 'name'").run(stored, "session-1"); - } finally { - await db.close(); - } - - await expect(repo.list()).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining(message), - }); - await expect(session.getMetadata()).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining(message), - }); - }); - - it("omits a cleared session name from metadata", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - await using repo = new SqliteSessionRepository({ - env, - sqlite: createNodeSqliteFactory(), - databasePath, - }); - const session = await repo.create({ cwd: root, id: "session-1" }); - await session.setName("Temporary"); - expect(await session.getMetadata()).toMatchObject({ name: "Temporary" }); - - await session.setName(undefined); - - expect(await session.getName()).toBeUndefined(); - expect(await session.getMetadata()).not.toHaveProperty("name"); - expect((await repo.list())[0]).not.toHaveProperty("name"); - }); - - it("fails loudly when a stored entry is read and cannot be decoded", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const entryId = await session.appendMessage(createUserMessage("message")); - const metadata = await session.getMetadata(); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE entries SET payload = ? WHERE session_id = ? AND id = ?") - .run("not json", metadata.id, entryId); - } finally { - await db.close(); - } - - const reopened = await repo.open(metadata); - await expect(getSqliteEntries(reopened)).rejects.toMatchObject({ code: "invalid_entry" }); - }); - - it("fails loudly when a stored record cannot be decoded", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - await session.appendRecord({ - type: "operation_finished", - id: "record-1", - lane: "main", - runId: "run-1", - outcome: "completed", - }); - - const db = await sqlite.open(databasePath); - try { - await db - .prepare("UPDATE records SET payload = ? WHERE session_id = ? AND id = ?") - .run("not json", "session-1", "record-1"); - } finally { - await db.close(); - } - - await expect(session.findRecords()).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining("failed to decode payload"), - }); - }); - - it("does not publish connection state when an append transaction fails", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const db = await sqlite.open(databasePath); - try { - await db.exec(` - CREATE TRIGGER fail_branch_tip_insert - BEFORE INSERT ON branch_tips - BEGIN - SELECT RAISE(ABORT, 'branch insert failed'); - END; - `); - await expect(session.appendMessage(createUserMessage("root"))).rejects.toThrow("branch insert failed"); - const lane = await db - .prepare("SELECT leaf_id FROM lanes WHERE session_id = ? AND lane = ?") - .get<{ leaf_id: string | null }>("session-1", "main"); - expect(lane?.leaf_id).toBeNull(); - expect(await db.prepare("SELECT id FROM entries WHERE session_id = ?").all("session-1")).toEqual([]); - expect(await session.getStats()).toMatchObject({ messageCount: 0 }); - await db.exec("DROP TRIGGER fail_branch_tip_insert"); - } finally { - await db.close(); - } - const entryId = await session.appendMessage(createUserMessage("root")); - expect((await getSqliteEntries(session)).map((entry) => entry.id)).toEqual([entryId]); - expect(await session.getStats()).toMatchObject({ messageCount: 1 }); - }); - - it("accounts for assistant, compaction, and branch-summary usage", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - await using repo = new SqliteSessionRepository({ env, sqlite, databasePath }); - const session = await repo.create({ cwd: root, id: "session-1" }); - const userId = await session.appendMessage(createUserMessage("one")); - const assistant = { - ...createAssistantMessage("two"), - provider: "anthropic", - model: "claude-sonnet-4-5", - usage: { - input: 100, - output: 25, - cacheRead: 40, - cacheWrite: 10, - totalTokens: 175, - cost: { input: 0.1, output: 0.2, cacheRead: 0.03, cacheWrite: 0.04, total: 0.37 }, - }, - }; - const assistantId = await session.appendMessage(assistant); - await session.appendRecord({ - type: "usage", - id: "assistant-usage", - lane: "main", - cause: "assistant", - runId: "run", - entryId: assistantId, - attempt: 1, - stopReason: "stop", - usage: assistant.usage, - }); - const compactionUsage = { - input: 1, - output: 2, - cacheRead: 3, - cacheWrite: 4, - totalTokens: 10, - cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 }, - }; - const compactionId = await appendSqliteCompaction(session, "summary", 200, undefined, compactionUsage); - await session.appendRecord({ - type: "usage", - id: "compaction-usage", - lane: "main", - cause: "compaction", - runId: "run", - entryId: compactionId, - attempt: 1, - stopReason: "stop", - usage: compactionUsage, - }); - const branchUsage = { - input: 5, - output: 6, - cacheRead: 7, - cacheWrite: 8, - totalTokens: 26, - cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 }, - }; - const branchSummaryId = await moveSqliteMainLane(session, userId, { - summary: "branch summary", - usage: branchUsage, - }); - if (!branchSummaryId) throw new Error("Expected branch summary"); - await session.appendRecord({ - type: "usage", - id: "branch-summary-usage", - lane: "main", - cause: "branch_summary", - runId: "run", - entryId: branchSummaryId, - attempt: 1, - stopReason: "stop", - usage: branchUsage, - }); - expect(await session.getStats()).toEqual({ - messageCount: 2, - cachedTokens: 50, - uncachedTokens: 128, - totalTokens: 211, - costTotal: 0.73, - }); - }); -}); diff --git a/packages/session-backends/sqlite-node/test/search.test.ts b/packages/session-backends/sqlite-node/test/search.test.ts deleted file mode 100644 index bebffd6af..000000000 --- a/packages/session-backends/sqlite-node/test/search.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { join } from "node:path"; -import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; -import { describe, expect, it } from "vitest"; -import { createNodeSqliteFactory, createSqliteSessionSearch, SqliteSessionRepository } from "../src/index.ts"; -import { createSetupFailureSqlite, createTempDir, createUserMessage, getSqliteEntries } from "./test-utils.ts"; - -function createSqliteFixture(options: ConstructorParameters[0]) { - const repository = new SqliteSessionRepository(options); - return { - repository, - search: createSqliteSessionSearch(options), - [Symbol.asyncDispose]: () => repository[Symbol.asyncDispose](), - }; -} - -async function collect(iterable: AsyncIterable): Promise { - const items: T[] = []; - for await (const item of iterable) items.push(item); - return items; -} - -describe("SQLite FTS5 session search", () => { - it("matches trigrams", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const databasePath = join(root, "sessions.sqlite"); - await using fixture = createSqliteFixture({ env, sqlite, databasePath }); - const { repository: repo, search } = fixture; - const included = await repo.create({ cwd: root, id: "included", metadata: { name: "application-owned" } }); - const excluded = await repo.create({ cwd: `${root}/other`, id: "excluded" }); - const entryId = await included.appendMessage(createUserMessage("Find the auth defect")); - await included.setName("Canonical name"); - const excludedEntryId = await excluded.appendMessage(createUserMessage("Find the auth defect")); - - const authHits = await collect(search.search("auth")); - expect(authHits).toHaveLength(2); - expect(authHits).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - sessionId: "included", - entryId, - timestamp: expect.any(Number), - metadata: expect.objectContaining({ - id: "included", - createdAt: expect.any(Number), - name: "Canonical name", - metadata: { name: "application-owned" }, - }), - }), - expect.objectContaining({ sessionId: "excluded", entryId: excludedEntryId }), - ]), - ); - expect(await collect(search.search("uth"))).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - sessionId: "included", - entryId, - metadata: expect.objectContaining({ id: "included" }), - }), - expect.objectContaining({ sessionId: "excluded", entryId: excludedEntryId }), - ]), - ); - }); - - it("omits a cleared session name from search metadata", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const databasePath = join(root, "sessions.sqlite"); - await using fixture = createSqliteFixture({ env, sqlite, databasePath }); - const { repository, search } = fixture; - const session = await repository.create({ cwd: root, id: "session-1" }); - const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); - await session.setName("Temporary"); - await session.setName(undefined); - - const [result] = await collect(search.search("auth")); - expect(result).toMatchObject({ sessionId: "session-1", entryId, metadata: { id: "session-1" } }); - expect(result?.metadata).not.toHaveProperty("name"); - }); - - it("handles quoted search text without exposing FTS syntax", async () => { - const root = createTempDir(); - await using fixture = createSqliteFixture({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite: createNodeSqliteFactory(), - databasePath: join(root, "sessions.sqlite"), - }); - const { search } = fixture; - - expect(await collect(search.search('missing "phrase"'))).toEqual([]); - }); - - it("rebuilds existing entries when FTS is first initialized", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const databasePath = join(root, "sessions.sqlite"); - await using fixture = createSqliteFixture({ - env, - sqlite: createNodeSqliteFactory(), - databasePath, - }); - const { repository, search } = fixture; - const session = await repository.create({ cwd: root, id: "session-1" }); - const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); - - expect(await collect(search.search("auth"))).toEqual([ - expect.objectContaining({ sessionId: "session-1", entryId }), - ]); - }); - - it("honors entry type filters", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const databasePath = join(root, "sessions.sqlite"); - await using fixture = createSqliteFixture({ - env, - sqlite: createNodeSqliteFactory(), - databasePath, - }); - const { repository, search } = fixture; - const session = await repository.create({ cwd: root, id: "session-1" }); - const messageEntryId = await session.appendMessage(createUserMessage("Find the auth defect")); - await session.appendCustomEntry("note", { text: "Find the auth custom entry" }); - - expect(await collect(search.search("auth", { entryTypes: ["message"] }))).toEqual([ - expect.objectContaining({ sessionId: "session-1", entryId: messageEntryId }), - ]); - }); - - it("honors result limits", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const databasePath = join(root, "sessions.sqlite"); - await using fixture = createSqliteFixture({ - env, - sqlite: createNodeSqliteFactory(), - databasePath, - }); - const { repository, search } = fixture; - const first = await repository.create({ cwd: root, id: "session-1" }); - const second = await repository.create({ cwd: root, id: "session-2" }); - await first.appendMessage(createUserMessage("Find the auth defect")); - await second.appendMessage(createUserMessage("Find the auth defect too")); - - expect(await collect(search.search("auth", { limit: 1 }))).toHaveLength(1); - expect(await collect(search.search("auth", { limit: 0 }))).toEqual([]); - }); - - it("removes deleted session entries from the index", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const databasePath = join(root, "sessions.sqlite"); - await using fixture = createSqliteFixture({ - env, - sqlite: createNodeSqliteFactory(), - databasePath, - }); - const { repository, search } = fixture; - const session = await repository.create({ cwd: root, id: "session-1" }); - await session.appendMessage(createUserMessage("Find the auth defect")); - expect(await collect(search.search("auth"))).toHaveLength(1); - - await repository.delete(await session.getMetadata()); - - expect(await collect(search.search("auth"))).toEqual([]); - }); - - it("indexes and removes session entries through triggers after FTS initialization", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const databasePath = join(root, "sessions.sqlite"); - await using fixture = createSqliteFixture({ - env, - sqlite: createNodeSqliteFactory(), - databasePath, - }); - const { repository, search } = fixture; - expect(await collect(search.search("auth"))).toEqual([]); - const session = await repository.create({ cwd: root, id: "session-1" }); - await session.appendMessage(createUserMessage("Find the auth defect")); - expect(await collect(search.search("auth"))).toHaveLength(1); - - await repository.delete(await session.getMetadata()); - - expect(await collect(search.search("auth"))).toEqual([]); - }); - - it("removes deleted entries from FTS through triggers", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const databasePath = join(root, "sessions.sqlite"); - await using fixture = createSqliteFixture({ env, sqlite, databasePath }); - const { repository, search } = fixture; - const session = await repository.create({ cwd: root, id: "session-1" }); - const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); - expect(await collect(search.search("auth"))).toHaveLength(1); - - const db = await sqlite.open(databasePath); - try { - await db.prepare("DELETE FROM entries WHERE session_id = ? AND id = ?").run("session-1", entryId); - } finally { - await db.close(); - } - - expect(await collect(search.search("auth"))).toEqual([]); - }); - - it("does not initialize FTS for canonical writes or blank searches", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const databasePath = join(root, "sessions.sqlite"); - await using fixture = createSqliteFixture({ env, sqlite, databasePath }); - const { repository: repo, search } = fixture; - expect(await collect(search.search(" "))).toEqual([]); - const session = await repo.create({ cwd: root, id: "session-1" }); - - const db = await sqlite.open(databasePath); - try { - const fts = await db - .prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = 'session_search_fts'") - .get<{ found: number }>(); - expect(fts).toBeUndefined(); - } finally { - await db.close(); - } - await expect(session.appendMessage(createUserMessage("still writable"))).resolves.toBeTypeOf("string"); - }); - - it("rolls back canonical appends when co-located FTS trigger writes fail", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const databasePath = join(root, "sessions.sqlite"); - await using fixture = createSqliteFixture({ env, sqlite, databasePath }); - const { repository: repo, search } = fixture; - await collect(search.search("initialize")); - const session = await repo.create({ cwd: root, id: "session-1" }); - - const db = await sqlite.open(databasePath); - try { - await db.exec("DROP TABLE session_search_fts"); - } finally { - await db.close(); - } - - await expect(session.appendMessage(createUserMessage("must roll back"))).rejects.toThrow(); - await expect(getSqliteEntries(session)).resolves.toEqual([]); - }); - - it("rolls back canonical deletion when co-located FTS cleanup fails", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - const sqlite = createNodeSqliteFactory(); - const databasePath = join(root, "sessions.sqlite"); - await using fixture = createSqliteFixture({ env, sqlite, databasePath }); - const { repository: repo, search } = fixture; - await collect(search.search("initialize")); - const session = await repo.create({ cwd: root, id: "session-1" }); - await session.appendMessage(createUserMessage("must remain")); - const metadata = await session.getMetadata(); - - const db = await sqlite.open(databasePath); - try { - await db.exec("DROP TABLE session_search_fts"); - } finally { - await db.close(); - } - - await expect(repo.delete(metadata)).rejects.toThrow(); - const reopened = await repo.open(metadata); - await expect(getSqliteEntries(reopened)).resolves.toHaveLength(1); - }); - - it("closes the database when search setup fails", async () => { - const root = createTempDir(); - const { sqlite, counts } = createSetupFailureSqlite(); - const search = createSqliteSessionSearch({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite, - databasePath: join(root, "sessions.sqlite"), - }); - - await expect(collect(search.search("auth"))).rejects.toThrow("setup failed"); - expect(counts.closes).toBe(1); - }); - - it("initializes canonical storage when searched before the first session is created", async () => { - const root = createTempDir(); - const env = new NodeExecutionEnv({ cwd: root }); - await using fixture = createSqliteFixture({ - env, - sqlite: createNodeSqliteFactory(), - databasePath: join(root, "sessions.sqlite"), - }); - const { repository: repo, search } = fixture; - - expect(await collect(search.search("auth"))).toEqual([]); - const session = await repo.create({ cwd: root, id: "session-1" }); - const entryId = await session.appendMessage(createUserMessage("Find the auth defect")); - - expect(await collect(search.search("auth"))).toEqual([ - expect.objectContaining({ - sessionId: "session-1", - entryId, - metadata: expect.objectContaining({ id: "session-1" }), - }), - ]); - await expect(session.appendMessage(createUserMessage("Still writable"))).resolves.toBeTypeOf("string"); - }); -}); diff --git a/packages/session-backends/sqlite-node/test/test-utils.ts b/packages/session-backends/sqlite-node/test/test-utils.ts deleted file mode 100644 index ef181df9b..000000000 --- a/packages/session-backends/sqlite-node/test/test-utils.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { - AgentMessage, - BranchSummaryEntry, - CompactionEntry, - Session as CoreSession, - Entry, - MessageEntry, -} from "@earendil-works/pi-agent-core"; -import type { Usage } from "@earendil-works/pi-ai"; -import { afterEach, vi } from "vitest"; -import type { SqliteDatabaseFactory, SqliteSessionMetadata } from "../src/index.ts"; - -const tempDirs: string[] = []; - -afterEach(() => { - vi.useRealTimers(); - while (tempDirs.length > 0) rmSync(tempDirs.pop()!, { recursive: true, force: true }); -}); - -export function createTempDir(): string { - const dir = mkdtempSync(join(tmpdir(), "pi-session-backend-sqlite-node-")); - tempDirs.push(dir); - return dir; -} - -export function createSetupFailureSqlite(): { - sqlite: SqliteDatabaseFactory; - counts: { closes: number }; -} { - const counts = { closes: 0 }; - return { - counts, - sqlite: { - async open() { - return { - exec() { - throw new Error("setup failed"); - }, - prepare() { - throw new Error("unexpected prepare"); - }, - transaction(_fn: () => T): T { - throw new Error("unexpected transaction"); - }, - close() { - counts.closes += 1; - }, - }; - }, - }, - }; -} - -export function createUserMessage(text: string): AgentMessage { - return { - role: "user", - content: [{ type: "text", text }], - timestamp: Date.now(), - }; -} - -export function createAssistantMessage(text: string): AgentMessage { - return { - role: "assistant", - content: [{ type: "text", text }], - api: "anthropic-messages", - provider: "anthropic", - model: "claude-sonnet-4-5", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - }; -} - -export type SqliteTestSession = CoreSession; -export type SqliteTestMessage = MessageEntry["message"]; - -export async function appendSqliteCompaction( - session: SqliteTestSession, - summary: string, - tokensBefore: number, - details?: unknown, - usage?: Usage, - retainedTail: SqliteTestMessage[] = [], -): Promise { - const provisioned = { - type: "compaction", - id: session.idGenerator.next(), - summary, - retainedTail, - tokensBefore, - ...(details === undefined ? {} : { details }), - ...(usage === undefined ? {} : { usage }), - } satisfies Omit; - const entry = await session.appendEntry(provisioned, "main"); - return entry.id; -} - -export async function moveSqliteMainLane( - session: SqliteTestSession, - entryId: string | null, - summary?: { summary: string; details?: unknown; usage?: Usage }, -): Promise { - await session.moveLane("main", entryId); - if (!summary) return undefined; - const provisioned = { - type: "branch_summary", - id: session.idGenerator.next(), - fromId: entryId ?? "root", - summary: summary.summary, - ...(summary.details === undefined ? {} : { details: summary.details }), - ...(summary.usage === undefined ? {} : { usage: summary.usage }), - } satisfies Omit; - const entry = await session.appendEntry(provisioned, "main"); - return entry.id; -} - -export async function getSqliteBranch(session: SqliteTestSession, fromId?: string | null): Promise { - const start = fromId === undefined ? await session.getLeafId() : fromId; - if (start === null) return []; - const newestWindow = await session.findEntriesOnBranch({ start, stopAtType: "compaction" }); - return newestWindow.reverse(); -} - -export async function getSqliteEntries( - session: SqliteTestSession, - options?: { afterEntrySeq?: number; limit?: number }, -): Promise { - return session.findEntries({ - order: "oldestFirst", - limit: options?.limit, - cursor: options?.afterEntrySeq === undefined ? undefined : { afterSeq: options.afterEntrySeq }, - }); -} diff --git a/packages/session-backends/sqlite-node/test/writer-leases.test.ts b/packages/session-backends/sqlite-node/test/writer-leases.test.ts deleted file mode 100644 index 26463c7e1..000000000 --- a/packages/session-backends/sqlite-node/test/writer-leases.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { join } from "node:path"; -import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; -import { describe, expect, it, vi } from "vitest"; -import { createNodeSqliteFactory, type SqliteSessionMetadata, SqliteSessionRepository } from "../src/index.ts"; -import { createTempDir, createUserMessage } from "./test-utils.ts"; - -function createRepository(root: string, databasePath: string, lease?: { ttlMs: number; heartbeatIntervalMs: number }) { - const repository = new SqliteSessionRepository({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite: createNodeSqliteFactory(), - databasePath, - writerLease: lease, - }); - return repository; -} - -describe("SQLite session writer leases", () => { - it("shares one write queue across repeated opens in one repository", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - await using repository = createRepository(root, databasePath); - const session = await repository.create({ cwd: root, id: "session" }); - const reopened = await repository.open(await session.getMetadata()); - - const [first, second] = await Promise.all([ - session.appendMessage(createUserMessage("first")), - reopened.appendMessage(createUserMessage("second")), - ]); - - expect((await session.findEntries({ order: "oldestFirst" })).map((entry) => entry.id)).toEqual([first, second]); - }); - - it.each([ - [{ ttlMs: 0, heartbeatIntervalMs: 1 }, "writerLease.ttlMs must be positive"], - [ - { ttlMs: 100, heartbeatIntervalMs: 100 }, - "writerLease.heartbeatIntervalMs must be positive and less than ttlMs", - ], - ])("rejects invalid lease timing %o", (writerLease, message) => { - const root = createTempDir(); - expect( - () => - new SqliteSessionRepository({ - env: new NodeExecutionEnv({ cwd: root }), - sqlite: createNodeSqliteFactory(), - databasePath: join(root, "sessions.sqlite"), - writerLease, - }), - ).toThrow(message); - }); - - it("lists complete metadata without acquiring active sessions' writer leases", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - await using writerRepository = createRepository(root, databasePath); - await using readerRepository = createRepository(root, databasePath); - const first = await writerRepository.create({ - cwd: root, - id: "session-1", - metadata: { profile: "reviewer" }, - }); - const second = await writerRepository.create({ - cwd: root, - id: "session-2", - parentSessionId: "session-1", - metadata: { profile: "writer", name: "application-owned name" }, - }); - await first.setName("Review session"); - await second.setName("Write session"); - const [firstMetadata, secondMetadata] = await Promise.all([first.getMetadata(), second.getMetadata()]); - expect(firstMetadata).toMatchObject({ name: "Review session", metadata: { profile: "reviewer" } }); - expect(secondMetadata).toMatchObject({ - name: "Write session", - metadata: { profile: "writer", name: "application-owned name" }, - }); - const expected = [firstMetadata, secondMetadata]; - const sqlite = createNodeSqliteFactory(); - const inspection = await sqlite.open(databasePath); - let leasesBefore: { session_id: string; owner_id: string; fence: number; expires_at_ms: number }[]; - try { - leasesBefore = await inspection - .prepare("SELECT session_id, owner_id, fence, expires_at_ms FROM writer_leases ORDER BY session_id") - .all(); - } finally { - await inspection.close(); - } - - const listed = await readerRepository.list({ cwd: root }); - - expect([...listed].sort((left, right) => left.id.localeCompare(right.id))).toEqual( - [...expected].sort((left, right) => left.id.localeCompare(right.id)), - ); - const afterList = await sqlite.open(databasePath); - try { - expect( - await afterList - .prepare("SELECT session_id, owner_id, fence, expires_at_ms FROM writer_leases ORDER BY session_id") - .all(), - ).toEqual(leasesBefore); - } finally { - await afterList.close(); - } - await expect(readerRepository.open(expected[0]!)).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining("already has an active writer"), - }); - }); - - it("rejects a second writer until the first session releases its claim", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - await using firstRepository = createRepository(root, databasePath); - await using secondRepository = createRepository(root, databasePath); - const first = await firstRepository.create({ cwd: root, id: "session-1" }); - const metadata = await first.getMetadata(); - - await expect(secondRepository.open(metadata)).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining("already has an active writer"), - }); - - await firstRepository.close(); - const second = await secondRepository.open(metadata); - await expect(second.appendMessage(createUserMessage("new owner"))).resolves.toBeTypeOf("string"); - }); - - it("fences a stale owner after an expired lease is acquired by another writer", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - const lease = { ttlMs: 120_000, heartbeatIntervalMs: 60_000 }; - await using firstRepository = createRepository(root, databasePath, lease); - await using secondRepository = createRepository(root, databasePath, lease); - const first = await firstRepository.create({ cwd: root, id: "session-1" }); - const metadata = await first.getMetadata(); - const sqlite = createNodeSqliteFactory(); - const db = await sqlite.open(databasePath); - try { - await db.prepare("UPDATE writer_leases SET expires_at_ms = 0 WHERE session_id = ?").run(metadata.id); - } finally { - await db.close(); - } - - const second = await secondRepository.open(metadata); - await expect(first.appendMessage(createUserMessage("stale owner"))).rejects.toMatchObject({ - code: "storage", - message: expect.stringContaining("writer lease was lost"), - }); - expect(await second.findEntries()).toEqual([]); - - const inspection = await sqlite.open(databasePath); - let currentLease: { owner_id: string; fence: number } | undefined; - try { - currentLease = await inspection - .prepare("SELECT owner_id, fence FROM writer_leases WHERE session_id = ?") - .get<{ owner_id: string; fence: number }>(metadata.id); - expect(currentLease?.fence).toBe(2); - } finally { - await inspection.close(); - } - - await firstRepository.close(); - const afterStaleClose = await sqlite.open(databasePath); - try { - expect( - await afterStaleClose - .prepare("SELECT owner_id, fence FROM writer_leases WHERE session_id = ?") - .get(metadata.id), - ).toEqual(currentLease); - } finally { - await afterStaleClose.close(); - } - await expect(second.appendMessage(createUserMessage("current owner"))).resolves.toBeTypeOf("string"); - }); - - it("serializes lease-checked writes for sessions sharing one database connection", async () => { - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - await using repository = createRepository(root, databasePath); - const first = await repository.create({ cwd: root, id: "session-1" }); - const second = await repository.create({ cwd: root, id: "session-2" }); - - await expect( - Promise.all([ - first.appendMessage(createUserMessage("first")), - second.appendMessage(createUserMessage("second")), - ]), - ).resolves.toHaveLength(2); - }); - - it("renews an idle writer lease with a heartbeat", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); - const root = createTempDir(); - const databasePath = join(root, "sessions.sqlite"); - await using repository = createRepository(root, databasePath, { ttlMs: 30_000, heartbeatIntervalMs: 10_000 }); - const session = await repository.create({ cwd: root, id: "session-1" }); - const metadata = (await session.getMetadata()) as SqliteSessionMetadata; - const sqlite = createNodeSqliteFactory(); - - const readExpiry = async (): Promise => { - const db = await sqlite.open(databasePath); - try { - return ( - await db - .prepare("SELECT expires_at_ms FROM writer_leases WHERE session_id = ?") - .get<{ expires_at_ms: number }>(metadata.id) - )?.expires_at_ms; - } finally { - await db.close(); - } - }; - - const initialExpiry = await readExpiry(); - await vi.advanceTimersByTimeAsync(10_000); - expect(await readExpiry()).toBe((initialExpiry ?? 0) + 10_000); - }); -}); diff --git a/packages/session-backends/sqlite-node/vitest.config.ts b/packages/session-backends/sqlite-node/vitest.config.ts index 79b5636f1..8e83131e8 100644 --- a/packages/session-backends/sqlite-node/vitest.config.ts +++ b/packages/session-backends/sqlite-node/vitest.config.ts @@ -5,7 +5,6 @@ const telemetryIndex = fileURLToPath(new URL("../../telemetry/src/index.ts", imp const aiIndex = fileURLToPath(new URL("../../ai/src/index.ts", import.meta.url)); const agentIndex = fileURLToPath(new URL("../../agent/src/index.ts", import.meta.url)); const agentNode = fileURLToPath(new URL("../../agent/src/node.ts", import.meta.url)); -const agentSessionTesting = fileURLToPath(new URL("../../agent/src/harness/session/testing/index.ts", import.meta.url)); export default defineConfig({ test: { @@ -24,7 +23,6 @@ export default defineConfig({ resolve: { alias: [ { find: /^@earendil-works\/pi-telemetry$/, replacement: telemetryIndex }, - { find: /^@earendil-works\/pi-agent-core\/session\/testing$/, replacement: agentSessionTesting }, { find: /^@earendil-works\/pi-agent-core\/node$/, replacement: agentNode }, { find: /^@earendil-works\/pi-agent-core$/, replacement: agentIndex }, { find: /^@earendil-works\/pi-ai$/, replacement: aiIndex }, diff --git a/scripts/browser-smoke-entry.ts b/scripts/browser-smoke-entry.ts index bf17732f5..137c207c4 100644 --- a/scripts/browser-smoke-entry.ts +++ b/scripts/browser-smoke-entry.ts @@ -6,7 +6,6 @@ import { bashExecutionToText, convertToLlm, createCustomMessage, - InMemorySessionRepo, FileError, formatPromptTemplateInvocation, formatSkillInvocation, @@ -28,7 +27,6 @@ const stream = createAssistantMessageEventStream(); const agent = new Agent({ initialState: { model }, streamFn: streamSimple }); agent.steer({ role: "user", content: [{ type: "text", text: "queued" }], timestamp: 0 }); -const repo = new InMemorySessionRepo(); const result = getOrThrow(ok({ value: 1 })); const customMessage = createCustomMessage("note", "hello", true, undefined, "2026-01-01T00:00:00.000Z"); const llmMessages = convertToLlm([customMessage]); @@ -41,7 +39,6 @@ console.log( schema.type, typeof stream.push, agent.hasQueuedMessages(), - typeof repo.create, result.value, llmMessages.length, bashExecutionToText({ diff --git a/tsconfig.json b/tsconfig.json index a62e6ac5b..112b976ab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,9 +14,6 @@ "@earendil-works/pi-ai/dist/*": ["./packages/ai/src/*"], "@earendil-works/pi-agent-core": ["./packages/agent/src/index.ts"], "@earendil-works/pi-agent-core/node": ["./packages/agent/src/node.ts"], - "@earendil-works/pi-agent-core/session/testing": [ - "./packages/agent/src/harness/session/testing/index.ts" - ], "@earendil-works/pi-agent-core/*": ["./packages/agent/src/*"], "@earendil-works/pi-session-backend-sqlite-node": ["./packages/session-backends/sqlite-node/src/index.ts"], "@earendil-works/pi-coding-agent": ["./packages/coding-agent/src/index.ts"],