From 0b295cc9699b2060db8d55465be5269d2d04afe6 Mon Sep 17 00:00:00 2001 From: "Shawn L. Kiser" <35721408+slkiser@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:59:33 +0200 Subject: [PATCH 1/2] fix(stats): count forked sessions' copied messages once (backport #306) OpenCode 1's Session.fork copies the parent's messages into the fork session with new ids but identical times, model, tokens, and cost, so all-history, completed, and multi-session reads counted each copy again. Drop a finished assistant row when an earlier row with the same fingerprint (created/completed time, provider, model, tokens, cost) is in a different session. Unfinished rows and same-session duplicates are kept, and a fork's own single-session read still shows its copied history. Fixes #306 --- src/lib/opencode-storage.ts | 56 ++++++++- tests/lib.opencode-storage.test.ts | 190 +++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 4 deletions(-) diff --git a/src/lib/opencode-storage.ts b/src/lib/opencode-storage.ts index 47b2b38e..437b4b2c 100644 --- a/src/lib/opencode-storage.ts +++ b/src/lib/opencode-storage.ts @@ -310,6 +310,51 @@ function buildCompletedAssistantQuery(params: { }; } +function forkCopyFingerprint(message: OpenCodeMessage, completed: number): string { + const tokens = message.tokens; + return JSON.stringify([ + message.time?.created, + completed, + message.providerID, + message.modelID, + tokens?.input, + tokens?.output, + tokens?.reasoning, + tokens?.cache?.read, + tokens?.cache?.write, + message.cost, + ]); +} + +/** + * OpenCode copies a parent's finished messages into a forked session with new + * ids but identical times, model, tokens, and cost. Keep only the first row of + * each finished fingerprint: queries sort by time then id, and copies get newer + * ids, so the first row is the original. Unfinished rows are always kept. + * A copy lives in another session, so only cross-session matches are dropped; + * identical rows in the first row's own session are kept. + */ +function dropForkCopies(messages: OpenCodeMessage[]): OpenCodeMessage[] { + const firstSessionByFingerprint = new Map(); + const out: OpenCodeMessage[] = []; + for (const message of messages) { + const completed = completedAt(message); + if (completed === null) { + out.push(message); + continue; + } + const fingerprint = forkCopyFingerprint(message, completed); + const firstSessionID = firstSessionByFingerprint.get(fingerprint); + if (firstSessionID === undefined) { + firstSessionByFingerprint.set(fingerprint, message.sessionID); + } else if (firstSessionID !== message.sessionID) { + continue; + } + out.push(message); + } + return out; +} + function compareMessageOrder(a: OpenCodeMessage, b: OpenCodeMessage): number { const aCreated = typeof a.time?.created === "number" ? a.time.created : Number.MAX_SAFE_INTEGER; const bCreated = typeof b.time?.created === "number" ? b.time.created : Number.MAX_SAFE_INTEGER; @@ -369,7 +414,7 @@ export async function iterAssistantMessages(params: { try { const q = buildMessageQuery({ sinceMs: params.sinceMs, untilMs: params.untilMs }); const rows = conn.all(q.sql, q.args); - return mapAssistantMessages(rows); + return dropForkCopies(mapAssistantMessages(rows)); } finally { conn.close(); } @@ -392,13 +437,15 @@ export async function iterCompletedAssistantMessages(params: { try { if (await hasJsonExtract(conn)) { const query = buildCompletedAssistantQuery(params); - return mapCompletedAssistantMessages(conn.all(query.sql, query.args)); + return dropForkCopies( + mapCompletedAssistantMessages(conn.all(query.sql, query.args)), + ); } const rows = conn.all( `SELECT id, session_id, time_created, time_updated, data FROM "message"`, ); - return mapCompletedAssistantMessages(rows) + const messages = mapCompletedAssistantMessages(rows) .filter((message) => { const atMs = completedAt(message); if (atMs === null) return false; @@ -411,6 +458,7 @@ export async function iterCompletedAssistantMessages(params: { return true; }) .sort(compareCompletedMessageOrder); + return dropForkCopies(messages); } finally { conn.close(); } @@ -483,7 +531,7 @@ export async function iterAssistantMessagesForSessions(params: { } messages.sort(compareMessageOrder); - return messages; + return dropForkCopies(messages); } finally { conn.close(); } diff --git a/tests/lib.opencode-storage.test.ts b/tests/lib.opencode-storage.test.ts index 9bddaea0..9dea2a71 100644 --- a/tests/lib.opencode-storage.test.ts +++ b/tests/lib.opencode-storage.test.ts @@ -142,3 +142,193 @@ describe("opencode storage multi-session reads", () => { expect(conn.close).toHaveBeenCalledTimes(1); }); }); + +describe("opencode storage forked session copies", () => { + function messageRow(params: { + id: string; + sessionID: string; + created?: number; + completed?: number | null; + input?: number; + output?: number; + cost?: number; + }) { + const created = params.created ?? 100; + const completed = params.completed === undefined ? 150 : params.completed; + return { + id: params.id, + session_id: params.sessionID, + time_created: created, + data: JSON.stringify({ + role: "assistant", + providerID: "openai", + modelID: "gpt-5", + tokens: { + input: params.input ?? 1000, + output: params.output ?? 500, + reasoning: 10, + cache: { read: 20, write: 30 }, + }, + cost: params.cost ?? 10, + time: completed === null ? { created } : { created, completed }, + agent: "build", + }), + }; + } + + const originalRow = messageRow({ id: "msg_01original", sessionID: "ses_parent" }); + // Message ids ascend with time. OpenCode copies finished parent rows into the + // fork with a newer id and identical data. + const forkCopyRow = messageRow({ id: "msg_05forkevent_1", sessionID: "ses_fork" }); + const forkNewRow = messageRow({ + id: "msg_06forknew", + sessionID: "ses_fork", + created: 200, + completed: 250, + input: 40, + cost: 1, + }); + + function mockConnection( + rowsForSql: (sql: string, params?: unknown[]) => unknown[], + options: { jsonExtract?: boolean } = {}, + ) { + const jsonExtractResult = options.jsonExtract === false ? null : "assistant"; + sqliteMocks.openOpenCodeSqliteReadOnly.mockResolvedValue({ + get: vi.fn((sql: string) => + sql.includes('FROM "session"') ? { ok: 1 } : { r: jsonExtractResult }, + ), + all: vi.fn(rowsForSql), + close: vi.fn(), + }); + } + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + fsMocks.existsSync.mockReturnValue(true); + }); + + it("counts a forked session's copied message once in all-history reads", async () => { + mockConnection(() => [originalRow, forkCopyRow, forkNewRow]); + + const { iterAssistantMessages, iterCompletedAssistantMessages } = await import( + "../src/lib/opencode-storage.js" + ); + + const messages = await iterAssistantMessages({}); + expect(messages.map((message) => message.id)).toEqual(["msg_01original", "msg_06forknew"]); + + const completed = await iterCompletedAssistantMessages({}); + expect(completed.map((message) => message.id)).toEqual(["msg_01original", "msg_06forknew"]); + }); + + it("counts a forked session's copied message once in completed reads without json_extract", async () => { + mockConnection(() => [forkNewRow, forkCopyRow, originalRow], { jsonExtract: false }); + + const { iterCompletedAssistantMessages } = await import("../src/lib/opencode-storage.js"); + const completed = await iterCompletedAssistantMessages({}); + + expect(completed.map((message) => message.id)).toEqual(["msg_01original", "msg_06forknew"]); + }); + + it("counts a forked session's copied message once across a session set", async () => { + mockConnection((_sql, params) => { + const sessionIDs = params ?? []; + return [originalRow, forkCopyRow, forkNewRow].filter((row) => + sessionIDs.includes(row.session_id), + ); + }); + + const { iterAssistantMessagesForSessions } = await import("../src/lib/opencode-storage.js"); + const messages = await iterAssistantMessagesForSessions({ + sessionIDs: ["ses_parent", "ses_fork"], + }); + + expect(messages.map((message) => message.id)).toEqual(["msg_01original", "msg_06forknew"]); + }); + + it("keeps a fork's copied history in its own single-session read", async () => { + mockConnection(() => [forkCopyRow, forkNewRow]); + + const { iterAssistantMessagesForSession } = await import("../src/lib/opencode-storage.js"); + const messages = await iterAssistantMessagesForSession({ sessionID: "ses_fork" }); + + expect(messages.map((message) => message.id)).toEqual(["msg_05forkevent_1", "msg_06forknew"]); + }); + + it("keeps finished messages that differ only in tokens and identical unfinished messages", async () => { + const otherTokensRow = messageRow({ + id: "msg_other_tokens", + sessionID: "ses_parent", + output: 501, + }); + const unfinishedRow = messageRow({ + id: "msg_unfinished_a", + sessionID: "ses_parent", + created: 300, + completed: null, + }); + const unfinishedTwinRow = { ...unfinishedRow, id: "msg_unfinished_b" }; + mockConnection(() => [originalRow, otherTokensRow, unfinishedRow, unfinishedTwinRow]); + + const { iterAssistantMessages } = await import("../src/lib/opencode-storage.js"); + const messages = await iterAssistantMessages({}); + + expect(messages.map((message) => message.id)).toEqual([ + "msg_01original", + "msg_other_tokens", + "msg_unfinished_a", + "msg_unfinished_b", + ]); + }); + + it("keeps identical finished messages that are in the same session", async () => { + const sameSessionTwinRow = { ...originalRow, id: "msg_02sametwin" }; + mockConnection(() => [originalRow, sameSessionTwinRow]); + + const { iterAssistantMessages, iterCompletedAssistantMessages } = await import( + "../src/lib/opencode-storage.js" + ); + + const messages = await iterAssistantMessages({}); + expect(messages.map((message) => message.id)).toEqual(["msg_01original", "msg_02sametwin"]); + + const completed = await iterCompletedAssistantMessages({}); + expect(completed.map((message) => message.id)).toEqual(["msg_01original", "msg_02sametwin"]); + }); + + it("drops both fork copies when the parent has two identical messages", async () => { + const parentTwinRow = { ...originalRow, id: "msg_02parenttwin" }; + const forkTwinCopyRow = { ...originalRow, id: "msg_05forkevent_2", session_id: "ses_fork" }; + mockConnection(() => [originalRow, parentTwinRow, forkCopyRow, forkTwinCopyRow]); + + const { iterAssistantMessages } = await import("../src/lib/opencode-storage.js"); + const messages = await iterAssistantMessages({}); + + expect(messages.map((message) => message.id)).toEqual(["msg_01original", "msg_02parenttwin"]); + }); + + it("counts a fork copy once when the original and copy come from different query chunks", async () => { + const fillerSessionIDs = Array.from({ length: 899 }, (_, index) => `ses_filler_${index}`); + const queriedChunks: unknown[][] = []; + mockConnection((_sql, params) => { + const sessionIDs = params ?? []; + queriedChunks.push(sessionIDs); + return [originalRow, forkCopyRow, forkNewRow].filter((row) => + sessionIDs.includes(row.session_id), + ); + }); + + const { iterAssistantMessagesForSessions } = await import("../src/lib/opencode-storage.js"); + const messages = await iterAssistantMessagesForSessions({ + sessionIDs: ["ses_parent", ...fillerSessionIDs, "ses_fork"], + }); + + expect(queriedChunks).toHaveLength(2); + expect(queriedChunks[0]).toContain("ses_parent"); + expect(queriedChunks[0]).not.toContain("ses_fork"); + expect(queriedChunks[1]).toEqual(["ses_fork"]); + expect(messages.map((message) => message.id)).toEqual(["msg_01original", "msg_06forknew"]); + }); +}); From fe610dc08c4df4d2b6d1a61c47c19563c60f2a23 Mon Sep 17 00:00:00 2001 From: "Shawn L. Kiser" <35721408+slkiser@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:23:44 +0200 Subject: [PATCH 2/2] fix(stats): count fork copies by the session with the most copies (backport #306) Remembering only the first session per fingerprint undercounted: when a parent had two identical finished messages, an early fork copied one and a later fork copied both, reading the two forks (or all history after the parent was deleted) returned one row instead of two. Count each finished fingerprint as many times as the session that has it most often. Unfinished rows are still always kept. Refs #306 --- src/lib/opencode-storage.ts | 25 +++++++++-------- tests/lib.opencode-storage.test.ts | 43 +++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/lib/opencode-storage.ts b/src/lib/opencode-storage.ts index 437b4b2c..a27532d3 100644 --- a/src/lib/opencode-storage.ts +++ b/src/lib/opencode-storage.ts @@ -327,15 +327,14 @@ function forkCopyFingerprint(message: OpenCodeMessage, completed: number): strin } /** - * OpenCode copies a parent's finished messages into a forked session with new - * ids but identical times, model, tokens, and cost. Keep only the first row of - * each finished fingerprint: queries sort by time then id, and copies get newer - * ids, so the first row is the original. Unfinished rows are always kept. - * A copy lives in another session, so only cross-session matches are dropped; - * identical rows in the first row's own session are kept. + * OpenCode copies a parent's finished messages into a fork with new ids but + * identical times, model, tokens, and cost. Count each finished fingerprint as + * many times as the session that has it most often, so copies are not added on + * top of the original. Unfinished rows are always kept. */ function dropForkCopies(messages: OpenCodeMessage[]): OpenCodeMessage[] { - const firstSessionByFingerprint = new Map(); + const countBySessionAndFingerprint = new Map(); + const keptCountByFingerprint = new Map(); const out: OpenCodeMessage[] = []; for (const message of messages) { const completed = completedAt(message); @@ -344,12 +343,12 @@ function dropForkCopies(messages: OpenCodeMessage[]): OpenCodeMessage[] { continue; } const fingerprint = forkCopyFingerprint(message, completed); - const firstSessionID = firstSessionByFingerprint.get(fingerprint); - if (firstSessionID === undefined) { - firstSessionByFingerprint.set(fingerprint, message.sessionID); - } else if (firstSessionID !== message.sessionID) { - continue; - } + const sessionKey = JSON.stringify([message.sessionID, fingerprint]); + const sessionCount = (countBySessionAndFingerprint.get(sessionKey) ?? 0) + 1; + countBySessionAndFingerprint.set(sessionKey, sessionCount); + const keptCount = keptCountByFingerprint.get(fingerprint) ?? 0; + if (sessionCount <= keptCount) continue; + keptCountByFingerprint.set(fingerprint, sessionCount); out.push(message); } return out; diff --git a/tests/lib.opencode-storage.test.ts b/tests/lib.opencode-storage.test.ts index 9dea2a71..c8814da0 100644 --- a/tests/lib.opencode-storage.test.ts +++ b/tests/lib.opencode-storage.test.ts @@ -257,10 +257,10 @@ describe("opencode storage forked session copies", () => { expect(messages.map((message) => message.id)).toEqual(["msg_05forkevent_1", "msg_06forknew"]); }); - it("keeps finished messages that differ only in tokens and identical unfinished messages", async () => { + it("keeps finished messages that differ only in tokens and identical unfinished messages across sessions", async () => { const otherTokensRow = messageRow({ id: "msg_other_tokens", - sessionID: "ses_parent", + sessionID: "ses_fork", output: 501, }); const unfinishedRow = messageRow({ @@ -269,7 +269,7 @@ describe("opencode storage forked session copies", () => { created: 300, completed: null, }); - const unfinishedTwinRow = { ...unfinishedRow, id: "msg_unfinished_b" }; + const unfinishedTwinRow = { ...unfinishedRow, id: "msg_unfinished_b", session_id: "ses_fork" }; mockConnection(() => [originalRow, otherTokensRow, unfinishedRow, unfinishedTwinRow]); const { iterAssistantMessages } = await import("../src/lib/opencode-storage.js"); @@ -309,6 +309,43 @@ describe("opencode storage forked session copies", () => { expect(messages.map((message) => message.id)).toEqual(["msg_01original", "msg_02parenttwin"]); }); + it("keeps two rows when an earlier fork copied one twin and a later fork copied both", async () => { + // The parent had two identical finished messages and is not part of the read. + const earlyForkCopyRow = { + ...originalRow, + id: "msg_03earlyfork_1", + session_id: "ses_fork_early", + }; + const lateForkCopyRow = { ...originalRow, id: "msg_07latefork_1", session_id: "ses_fork_late" }; + const lateForkTwinCopyRow = { + ...originalRow, + id: "msg_07latefork_2", + session_id: "ses_fork_late", + }; + const rows = [earlyForkCopyRow, lateForkCopyRow, lateForkTwinCopyRow]; + mockConnection((_sql, params) => { + const sessionIDs = params ?? []; + return rows.filter((row) => sessionIDs.includes(row.session_id)); + }); + + const { iterAssistantMessagesForSessions } = await import("../src/lib/opencode-storage.js"); + const sessionMessages = await iterAssistantMessagesForSessions({ + sessionIDs: ["ses_fork_early", "ses_fork_late"], + }); + expect(sessionMessages.map((message) => message.id)).toEqual([ + "msg_03earlyfork_1", + "msg_07latefork_2", + ]); + + mockConnection(() => rows); + const { iterAssistantMessages } = await import("../src/lib/opencode-storage.js"); + const allMessages = await iterAssistantMessages({}); + expect(allMessages.map((message) => message.id)).toEqual([ + "msg_03earlyfork_1", + "msg_07latefork_2", + ]); + }); + it("counts a fork copy once when the original and copy come from different query chunks", async () => { const fillerSessionIDs = Array.from({ length: 899 }, (_, index) => `ses_filler_${index}`); const queriedChunks: unknown[][] = [];