feat: sqlite session storage (#6594)

This PR:

- Adds retainedTail to compaction entries in the new agent harness so we don't have to walk up the tree for the 2000 tokens before compaction,
- Changes getPathToRoot to getPathToRootOrCompaction to only load until last compaction, as unnecessary to access all nodes where it is called,
- Adds a SQLite storage backend, in a separate packages/session-backend-sqlite, with a migration system and schemas as per on-site discussions: sessions to match session header messages (except for metadata, which I couldn't understand what it's used for or where it gets written, so I omitted it), session_entries for shared entry types as columns plus payload as a json for what remains, session_sequences to represent the append-only, serialized nature of the jsonl files, branch_entries to attribute nodes to branches (relationship one-to-many), and session_materialized with the session info (see /session in TUI) to act as a "cache" or quick-access for costs, message count, token info, labels, session name, and model-thinking-level config (e.g. for fast resume).
- This is compatible with the new agent harness Session abstraction.
This commit is contained in:
Cristina Poncela Cubeiro
2026-07-21 11:36:31 +02:00
committed by GitHub
parent 54fad505b9
commit 9e7582aa03
40 changed files with 2659 additions and 145 deletions
+169 -6
View File
@@ -4,7 +4,13 @@ import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/jsonl-storage.ts";
import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts";
import { type MessageEntry, ok, type SessionMetadata } from "../../src/harness/types.ts";
import {
type BranchSummaryEntry,
type CompactionEntry,
type MessageEntry,
ok,
type SessionMetadata,
} from "../../src/harness/types.ts";
import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts";
describe("InMemorySessionStorage", () => {
@@ -80,7 +86,74 @@ describe("InMemorySessionStorage", () => {
expect(await storage.getLabel("entry-1")).toBeUndefined();
});
it("walks paths to root", async () => {
it("includes summary-entry usage in session stats", async () => {
const assistant: MessageEntry = {
type: "message",
id: "assistant",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: "reply" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage: {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
},
stopReason: "stop",
timestamp: 0,
},
};
const compaction: CompactionEntry = {
type: "compaction",
id: "compaction",
parentId: "assistant",
timestamp: "2026-01-01T00:00:01.000Z",
summary: "summary",
firstKeptEntryId: "assistant",
tokensBefore: 1234,
usage: {
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 branchSummary: BranchSummaryEntry = {
type: "branch_summary",
id: "branch-summary",
parentId: "compaction",
timestamp: "2026-01-01T00:00:02.000Z",
fromId: "assistant",
summary: "branch",
usage: {
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 storage = new InMemorySessionStorage({ entries: [assistant, compaction, branchSummary] });
expect(await storage.getSessionStats()).toEqual({
messageCount: 1,
cachedTokens: 40,
uncachedTokens: 68,
totalTokens: 136,
costTotal: 1.36,
});
});
it("walks paths to root or retained-tail compaction", async () => {
const root: MessageEntry = {
type: "message",
id: "root",
@@ -94,9 +167,29 @@ describe("InMemorySessionStorage", () => {
parentId: "root",
message: createAssistantMessage("child"),
};
const storage = new InMemorySessionStorage({ entries: [root, child] });
expect((await storage.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
expect(await storage.getPathToRoot(null)).toEqual([]);
const compaction: CompactionEntry = {
type: "compaction",
id: "compaction",
parentId: "child",
timestamp: "2026-01-01T00:00:01.000Z",
summary: "summary",
firstKeptEntryId: "child",
tokensBefore: 1234,
retainedTail: [createAssistantMessage("child")],
};
const afterCompaction: MessageEntry = {
...root,
id: "after-compaction",
parentId: "compaction",
message: createUserMessage("after"),
};
const storage = new InMemorySessionStorage({ entries: [root, child, compaction, afterCompaction] });
expect((await storage.getPathToRootOrCompaction("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
expect((await storage.getPathToRootOrCompaction("after-compaction")).map((entry) => entry.id)).toEqual([
"compaction",
"after-compaction",
]);
expect(await storage.getPathToRootOrCompaction(null)).toEqual([]);
});
});
@@ -255,7 +348,7 @@ describe("JsonlSessionStorage", () => {
const reloaded = await JsonlSessionStorage.open(env, filePath);
expect(await reloaded.getLeafId()).toBe("root");
expect((await reloaded.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: "root" });
expect((await loaded.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
expect((await loaded.getPathToRootOrCompaction("child")).map((entry) => entry.id)).toEqual(["root", "child"]);
});
it("finds entries by type", async () => {
@@ -309,6 +402,76 @@ describe("JsonlSessionStorage", () => {
expect(await loaded.getLabel("entry-1")).toBeUndefined();
});
it("includes summary-entry usage in session stats", async () => {
const dir = createTempDir();
const env = new NodeExecutionEnv({ cwd: dir });
const filePath = join(dir, "session.jsonl");
const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" });
await storage.appendEntry({
type: "message",
id: "assistant",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
message: {
role: "assistant",
content: [{ type: "text", text: "reply" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage: {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
},
stopReason: "stop",
timestamp: 0,
},
});
await storage.appendEntry({
type: "compaction",
id: "compaction",
parentId: "assistant",
timestamp: "2026-01-01T00:00:01.000Z",
summary: "summary",
firstKeptEntryId: "assistant",
tokensBefore: 1234,
usage: {
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 },
},
});
await storage.appendEntry({
type: "branch_summary",
id: "branch-summary",
parentId: "compaction",
timestamp: "2026-01-01T00:00:02.000Z",
fromId: "assistant",
summary: "branch",
usage: {
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 },
},
});
expect(await storage.getSessionStats()).toEqual({
messageCount: 1,
cachedTokens: 40,
uncachedTokens: 68,
totalTokens: 136,
costTotal: 1.36,
});
});
it("reads session metadata through the line-reading filesystem operation", async () => {
const dir = createTempDir();
const filePath = join(dir, "session.jsonl");