From d9b91132da575c8c19e4364fabc4f4175ca6619a Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 03:10:36 +0530 Subject: [PATCH 01/19] fix(stats): keep reverted usage in session stats --- packages/schema/src/session-event.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index cf26e0543aeb..ebef4d2e8068 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -171,7 +171,7 @@ export const UsageRecorded = Event.durable({ ...options, schema: { ...Base, - source: Schema.Literals(["title", "compaction"]), + source: Schema.Literals(["title", "compaction", "revert"]), cost: Money.USD, tokens: TokenUsage.Info, }, From f09b9788bae7aede31a4c2afcb5e974d4e4be428 Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 03:10:55 +0530 Subject: [PATCH 02/19] fix(stats): keep reverted usage in session stats Added SQL usage tracking for session revert commits. --- packages/core/src/session/revert.ts | 55 ++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts index bb08555f3018..9b2d1d081f12 100644 --- a/packages/core/src/session/revert.ts +++ b/packages/core/src/session/revert.ts @@ -1,6 +1,6 @@ export * as SessionRevert from "./revert.js" -import { and, asc, eq, gt } from "drizzle-orm" +import { and, asc, eq, gt, sql } from "drizzle-orm" import { Effect } from "effect" import { Database } from "../database/database.js" import { Bus } from "../bus.js" @@ -8,6 +8,7 @@ import { Instance } from "../instance/service.js" import { RelativePath } from "../schema.js" import { Snapshot } from "../snapshot.js" import { SessionEvent } from "./event.js" +import { Money } from "@opencode/schema/money" import { MessageNotFoundError } from "./error.js" import { SessionMessage } from "./message.js" import { SessionSchema } from "./schema.js" @@ -76,6 +77,58 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Interface, session: SessionSchema.Info) { if (!session.revert) return + const database = yield* Database.Service + const boundary = yield* database.db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, session.id), eq(SessionMessageTable.id, session.revert.messageID))) + .get() + .pipe(Effect.orDie) + // Committing deletes every message row from the boundary onward, which drops their usage from + // SessionStats. Record it durably first so stats can keep counting the reverted work. The projector + // leaves the session counters alone for this source: the steps already contributed to them when they + // ended, so applying it there would double count. + if (boundary) { + const usage = yield* database.db + .get<{ + input: number + output: number + reasoning: number + cacheRead: number + cacheWrite: number + cost: number + }>( + sql` + SELECT + coalesce(sum(json_extract(${SessionMessageTable.data}, '$.tokens.input')), 0) AS input, + coalesce(sum(json_extract(${SessionMessageTable.data}, '$.tokens.output')), 0) AS output, + coalesce(sum(json_extract(${SessionMessageTable.data}, '$.tokens.reasoning')), 0) AS reasoning, + coalesce(sum(json_extract(${SessionMessageTable.data}, '$.tokens.cache.read')), 0) AS cacheRead, + coalesce(sum(json_extract(${SessionMessageTable.data}, '$.tokens.cache.write')), 0) AS cacheWrite, + coalesce(sum(json_extract(${SessionMessageTable.data}, '$.cost')), 0) AS cost + FROM ${SessionMessageTable} + WHERE ${SessionMessageTable.session_id} = ${session.id} + AND ${SessionMessageTable.type} = 'assistant' + AND ${SessionMessageTable.seq} >= ${boundary.seq} + `, + ) + .pipe(Effect.orDie) + if ( + usage && + (usage.input + usage.output + usage.reasoning + usage.cacheRead + usage.cacheWrite > 0 || usage.cost > 0) + ) + yield* bus.publish(SessionEvent.UsageRecorded, { + sessionID: session.id, + source: "revert", + cost: Money.USD.make(usage.cost), + tokens: { + input: usage.input, + output: usage.output, + reasoning: usage.reasoning, + cache: { read: usage.cacheRead, write: usage.cacheWrite }, + }, + }) + } yield* bus.publish(SessionEvent.RevertEvent.Committed, { sessionID: session.id, to: session.revert.messageID, From bb1ead26acdccd3a8d82ff0fb438a4e67e0b9b5c Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 03:11:11 +0530 Subject: [PATCH 03/19] fix(stats): keep reverted usage in session stats Prevent double counting of usage for reverted sessions. --- packages/core/src/session/projector.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 86b6eed40d82..8b7e091cd3b5 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -645,7 +645,11 @@ const layer = Layer.effectDiscard( .pipe(Effect.orDie), ) yield* bus.project(SessionEvent.MessageContentUpdated, (event) => run(db, event)) - yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data)) + yield* bus.project(SessionEvent.UsageRecorded, (event) => + // Reverted usage reached the session counters when its steps ended; the event only preserves it + // for SessionStats after the message rows are deleted, so applying it here would double count. + event.data.source === "revert" ? Effect.void : applyUsage(db, event.data.sessionID, event.data), + ) yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event)) yield* bus.project(SessionEvent.InboxDelivered, (event) => Effect.gen(function* () { From f7d7ebdcaa0d0547bd4298e6b062d0e3041728d6 Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 03:11:25 +0530 Subject: [PATCH 04/19] fix(stats): keep reverted usage in session stats --- packages/core/src/session/stats.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/session/stats.ts b/packages/core/src/session/stats.ts index 17a51576ec8e..9e61b98d636a 100644 --- a/packages/core/src/session/stats.ts +++ b/packages/core/src/session/stats.ts @@ -298,7 +298,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { EventTable.type, Event.versionedType(SessionEvent.UsageRecorded.type, SessionEvent.UsageRecorded.durable.version), ), - sql`json_extract(${EventTable.data}, '$.source') = 'compaction'`, + sql`json_extract(${EventTable.data}, '$.source') IN ('compaction', 'revert')`, gte(EventTable.created, from), lt(EventTable.created, to), ), From 70c9038dacf7cb0c541c3a0c2b66b63ab302da81 Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 03:11:38 +0530 Subject: [PATCH 05/19] test(stats): cover reverted usage preserved in session stats Add tests for Session.revert functionality to ensure stats are correctly updated without double counting. --- packages/core/test/session-revert.test.ts | 101 ++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/packages/core/test/session-revert.test.ts b/packages/core/test/session-revert.test.ts index bc3906857760..322cd9233dd6 100644 --- a/packages/core/test/session-revert.test.ts +++ b/packages/core/test/session-revert.test.ts @@ -18,6 +18,9 @@ import { SessionExecution } from "@opencode/core/session/execution" import { SessionInbox } from "@opencode/core/session/inbox" import { SessionMessage } from "@opencode/core/session/message" import { SessionProjector } from "@opencode/core/session/projector" +import { SessionStats } from "@opencode/core/session/stats" +import { SessionTable } from "@opencode/core/session/sql" +import { eq } from "drizzle-orm" import { Snapshot } from "@opencode/core/snapshot" import { Money } from "@opencode/schema/money" import { LayerNode } from "@opencode/util/effect/layer-node" @@ -119,3 +122,101 @@ describe("Session.revert files", () => { { timeout: 15_000 }, ) }) + +describe("Session.revert stats", () => { + it.live( + "keeps reverted step usage in stats without double counting session totals", + () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const directory = path.join(tmp.path, "project") + yield* Effect.promise(async () => { + await fs.mkdir(directory, { recursive: true }) + await $`git init -q`.cwd(directory).quiet() + }) + + const session = yield* Session.Service + const database = yield* Database.Service + const bus = yield* Bus.Service + const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } }) + + yield* Effect.gen(function* () { + const plugins = yield* Plugin.Service + yield* plugins.awaitActivation + const snapshot = yield* Snapshot.Service + + const prompt = (text: string) => + Effect.gen(function* () { + const admitted = yield* session.prompt({ sessionID: created.id, text, resume: false }) + yield* SessionInbox.promote(database.db, bus, created.id, "steer") + return admitted + }) + + const step = Effect.fnUntraced(function* () { + const before = yield* snapshot.capture() + if (!before) throw new Error("Initial snapshot missing") + const assistantMessageID = SessionMessage.ID.create() + yield* bus.publish(SessionEvent.Step.Started, { + sessionID: created.id, + assistantMessageID, + agent: Agent.defaultID, + model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") }, + snapshot: before, + started: 0, + }) + const after = yield* snapshot.capture() + if (!after) throw new Error("Snapshot missing") + yield* bus.publish(SessionEvent.Step.Ended, { + sessionID: created.id, + assistantMessageID, + finish: "stop", + cost: Money.USD.make(0.5), + tokens: { input: 1000, output: 100, reasoning: 10, cache: { read: 5, write: 3 } }, + snapshot: after, + files: yield* snapshot.files({ from: before, to: after }), + }) + }) + + yield* prompt("First") + yield* step() + const second = yield* prompt("Second") + yield* step() + + const before = yield* SessionStats.get({ timezone: "UTC", tools: "none" }) + expect(before.tokens).toEqual({ + input: 2000, + output: 200, + reasoning: 20, + cache: { read: 10, write: 6 }, + }) + + // Prompting with a staged revert commits it, deleting the second exchange's message rows. + yield* session.revert.stage({ sessionID: created.id, messageID: second.id }) + yield* prompt("Third") + + const stats = yield* SessionStats.get({ timezone: "UTC", tools: "none" }) + expect(stats.tokens).toEqual({ + input: 2000, + output: 200, + reasoning: 20, + cache: { read: 10, write: 6 }, + }) + expect(stats.cost).toBe(Money.USD.make(1)) + + const row = yield* database.db + .select() + .from(SessionTable) + .where(eq(SessionTable.id, created.id)) + .get() + .pipe(Effect.orDie) + expect(row?.tokens_input).toBe(2000) + expect(row?.tokens_output).toBe(200) + expect(row?.tokens_reasoning).toBe(20) + expect(row?.tokens_cache_read).toBe(10) + expect(row?.tokens_cache_write).toBe(6) + expect(row?.cost).toBe(1) + }).pipe(Effect.provide(LocationServiceMap.Service.get(created.location))) + }), + { timeout: 15_000 }, + ) +}) From d176a655275234ef7469bbea828e3121be1334e4 Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 03:11:53 +0530 Subject: [PATCH 06/19] chore(client): regenerate types for revert usage source --- packages/client/src/promise/generated/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index c0bbf1cb451b..70c15b22ec85 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1476,7 +1476,7 @@ export type SessionUsageRecorded = { type: "session.usage.recorded" durable: { aggregateID: string; seq: EventSeq; version: 1 } location?: LocationRef - data: { sessionID: SessionID; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo } + data: { sessionID: SessionID; source: "title" | "compaction" | "revert"; cost: MoneyUSD; tokens: TokenUsageInfo } } export type LocationShutdown = { From 1ad10cf29e22c5dc84ccef8af9d1d4015f4c03e0 Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 03:12:06 +0530 Subject: [PATCH 07/19] chore(client): regenerate api types for revert usage source --- packages/client/src/effect/api/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 7b740e56765e..f5e86c22dff1 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1210,7 +1210,7 @@ export type SessionLogOutput = | undefined readonly data: { readonly sessionID: Session.ID - readonly source: "title" | "compaction" + readonly source: "title" | "compaction" | "revert" readonly cost: Money.USD readonly tokens: { readonly input: number From eb465e6a8017bf428ef2a165ad220aadde0e041c Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 09:28:26 +0530 Subject: [PATCH 08/19] revert: drop durable-event approach, restore upstream --- packages/core/src/session/revert.ts | 63 ++--------------------------- 1 file changed, 4 insertions(+), 59 deletions(-) diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts index 9b2d1d081f12..6bffb3c4017d 100644 --- a/packages/core/src/session/revert.ts +++ b/packages/core/src/session/revert.ts @@ -1,6 +1,6 @@ export * as SessionRevert from "./revert.js" -import { and, asc, eq, gt, sql } from "drizzle-orm" +import { and, asc, eq, gt } from "drizzle-orm" import { Effect } from "effect" import { Database } from "../database/database.js" import { Bus } from "../bus.js" @@ -8,7 +8,6 @@ import { Instance } from "../instance/service.js" import { RelativePath } from "../schema.js" import { Snapshot } from "../snapshot.js" import { SessionEvent } from "./event.js" -import { Money } from "@opencode/schema/money" import { MessageNotFoundError } from "./error.js" import { SessionMessage } from "./message.js" import { SessionSchema } from "./schema.js" @@ -31,9 +30,7 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: { return yield* Effect.gen(function* () { const snapshot = yield* Snapshot.Service - const original = input.session.revert?.snapshot - ? Snapshot.ID.make(input.session.revert.snapshot) - : yield* snapshot.capture() + const original = input.session.revert?.snapshot ?? (yield* snapshot.capture()) const next = yield* plan(database.db, { sessionID: input.session.id, messageID: input.messageID }) const restore = new Map() if (original) { @@ -64,7 +61,7 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio yield* Effect.gen(function* () { const snapshot = yield* Snapshot.Service if (!session.revert) return - const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined + const original = session.revert.snapshot if (original) yield* snapshot.restore({ files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])), @@ -77,58 +74,6 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Interface, session: SessionSchema.Info) { if (!session.revert) return - const database = yield* Database.Service - const boundary = yield* database.db - .select({ seq: SessionMessageTable.seq }) - .from(SessionMessageTable) - .where(and(eq(SessionMessageTable.session_id, session.id), eq(SessionMessageTable.id, session.revert.messageID))) - .get() - .pipe(Effect.orDie) - // Committing deletes every message row from the boundary onward, which drops their usage from - // SessionStats. Record it durably first so stats can keep counting the reverted work. The projector - // leaves the session counters alone for this source: the steps already contributed to them when they - // ended, so applying it there would double count. - if (boundary) { - const usage = yield* database.db - .get<{ - input: number - output: number - reasoning: number - cacheRead: number - cacheWrite: number - cost: number - }>( - sql` - SELECT - coalesce(sum(json_extract(${SessionMessageTable.data}, '$.tokens.input')), 0) AS input, - coalesce(sum(json_extract(${SessionMessageTable.data}, '$.tokens.output')), 0) AS output, - coalesce(sum(json_extract(${SessionMessageTable.data}, '$.tokens.reasoning')), 0) AS reasoning, - coalesce(sum(json_extract(${SessionMessageTable.data}, '$.tokens.cache.read')), 0) AS cacheRead, - coalesce(sum(json_extract(${SessionMessageTable.data}, '$.tokens.cache.write')), 0) AS cacheWrite, - coalesce(sum(json_extract(${SessionMessageTable.data}, '$.cost')), 0) AS cost - FROM ${SessionMessageTable} - WHERE ${SessionMessageTable.session_id} = ${session.id} - AND ${SessionMessageTable.type} = 'assistant' - AND ${SessionMessageTable.seq} >= ${boundary.seq} - `, - ) - .pipe(Effect.orDie) - if ( - usage && - (usage.input + usage.output + usage.reasoning + usage.cacheRead + usage.cacheWrite > 0 || usage.cost > 0) - ) - yield* bus.publish(SessionEvent.UsageRecorded, { - sessionID: session.id, - source: "revert", - cost: Money.USD.make(usage.cost), - tokens: { - input: usage.input, - output: usage.output, - reasoning: usage.reasoning, - cache: { read: usage.cacheRead, write: usage.cacheWrite }, - }, - }) - } yield* bus.publish(SessionEvent.RevertEvent.Committed, { sessionID: session.id, to: session.revert.messageID, @@ -161,7 +106,7 @@ const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface[" const message = SessionHistory.decodeMessageRowSync(row) if (message.type !== "assistant" || !message.snapshot?.start) continue for (const file of message.snapshot.files ?? []) - if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start)) + if (!files.has(file)) files.set(file, message.snapshot.start) } return files }) From 9c16918e5f005a22abdd256f424baba579ae68eb Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 09:28:41 +0530 Subject: [PATCH 09/19] database: register session_revert_usage migration --- packages/core/src/database/migration.gen.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 2752a953cf30..55a29b1dc261 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -49,6 +49,7 @@ import m46 from "./migration/20260910120000_clear_v1_session_permission.js" import m47 from "./migration/20260923013825_project_time_active.js" import m48 from "./migration/20261007190000_azure_cli_external_credential.js" import m49 from "./migration/20261010180456_project_time_archived.js" +import m50 from "./migration/20261022034137_session_revert_usage.js" export const migrations = [ m00, @@ -101,4 +102,5 @@ export const migrations = [ m47, m48, m49, + m50, ] satisfies DatabaseMigration.Migration[] From 4a3e6448292ebb3fd95171501be3a2643ec16897 Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 09:28:52 +0530 Subject: [PATCH 10/19] session: add session_revert_usage table Added SessionRevertUsageTable to track reverted session messages and their usage statistics. --- packages/core/src/session/sql.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 88e9f925600b..a4f24f58f508 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -98,6 +98,35 @@ export const SessionMessageTable = sqliteTable( ], ) +// Messages removed by a revert commit, copied out before the rows are deleted so SessionStats +// can keep counting the reverted prompts, steps and usage. Written in the projector, which +// runs regardless of event persistence (unlike durable events, which only workerd stores). +export const SessionRevertUsageTable = sqliteTable( + "session_revert_usage", + { + id: text().$type().primaryKey(), + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + type: text().$type().notNull(), + time_created: integer().notNull(), + model_provider_id: text(), + model_id: text(), + model_variant: text(), + cost: real().notNull().default(0), + tokens_input: integer().notNull().default(0), + tokens_output: integer().notNull().default(0), + tokens_reasoning: integer().notNull().default(0), + tokens_cache_read: integer().notNull().default(0), + tokens_cache_write: integer().notNull().default(0), + }, + (table) => [ + index("session_revert_usage_session_time_created_idx").on(table.session_id, table.time_created), + index("session_revert_usage_time_created_idx").on(table.time_created), + ], +) + export const SessionPendingTable = sqliteTable( "session_pending", { From 125baaf87b3b891cdda4000d72bdfe23aee2dbe9 Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 09:29:05 +0530 Subject: [PATCH 11/19] database: regenerate schema for session_revert_usage --- packages/core/src/database/schema.gen.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 3ce8c6fdab94..7501d86aeb63 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -180,6 +180,24 @@ const schema: Omit = { CONSTRAINT \`fk_session_pending_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`session_revert_usage\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`type\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`model_provider_id\` text, + \`model_id\` text, + \`model_variant\` text, + \`cost\` real DEFAULT 0 NOT NULL, + \`tokens_input\` integer DEFAULT 0 NOT NULL, + \`tokens_output\` integer DEFAULT 0 NOT NULL, + \`tokens_reasoning\` integer DEFAULT 0 NOT NULL, + \`tokens_cache_read\` integer DEFAULT 0 NOT NULL, + \`tokens_cache_write\` integer DEFAULT 0 NOT NULL, + CONSTRAINT \`fk_session_revert_usage_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`session_v2\` ( \`id\` text PRIMARY KEY, @@ -270,6 +288,12 @@ const schema: Omit = { yield* tx.run( `CREATE UNIQUE INDEX \`session_pending_session_admitted_seq_idx\` ON \`session_pending\` (\`session_id\`,\`admitted_seq\`);`, ) + yield* tx.run( + `CREATE INDEX \`session_revert_usage_session_time_created_idx\` ON \`session_revert_usage\` (\`session_id\`,\`time_created\`);`, + ) + yield* tx.run( + `CREATE INDEX \`session_revert_usage_time_created_idx\` ON \`session_revert_usage\` (\`time_created\`);`, + ) yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`) yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`) yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`) From dc3a1b709b673cc01661243920bcd8b80ffaf98a Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 09:29:21 +0530 Subject: [PATCH 12/19] test: revert stats on non-persisting bus plus fork double-count --- packages/core/test/session-revert.test.ts | 178 ++++++++++++++++------ 1 file changed, 132 insertions(+), 46 deletions(-) diff --git a/packages/core/test/session-revert.test.ts b/packages/core/test/session-revert.test.ts index 322cd9233dd6..efbee77a581c 100644 --- a/packages/core/test/session-revert.test.ts +++ b/packages/core/test/session-revert.test.ts @@ -19,8 +19,8 @@ import { SessionInbox } from "@opencode/core/session/inbox" import { SessionMessage } from "@opencode/core/session/message" import { SessionProjector } from "@opencode/core/session/projector" import { SessionStats } from "@opencode/core/session/stats" -import { SessionTable } from "@opencode/core/session/sql" -import { eq } from "drizzle-orm" +import { SessionMessageTable, SessionRevertUsageTable, SessionTable } from "@opencode/core/session/sql" +import { and, asc, eq } from "drizzle-orm" import { Snapshot } from "@opencode/core/snapshot" import { Money } from "@opencode/schema/money" import { LayerNode } from "@opencode/util/effect/layer-node" @@ -123,64 +123,85 @@ describe("Session.revert files", () => { ) }) +// The production bus only persists events on workerd; local CLI and server runs deliver +// events in memory with an empty event table. These tests use the default (non-persisting) +// bus to prove stats keep reverted usage where the CLI and server actually run. +const itEphemeral = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]), + [ + Global.node.replace(tempGlobalLayer), + SessionExecution.node.replace(SessionExecution.noopLayer), + offlineModels, + ], + ), +) + describe("Session.revert stats", () => { - it.live( + const initProject = (directory: string) => + Effect.promise(async () => { + await fs.mkdir(directory, { recursive: true }) + await $`git init -q`.cwd(directory).quiet() + }) + + const prompt = Effect.fnUntraced(function* (sessionID: Session.ID, text: string) { + const session = yield* Session.Service + const database = yield* Database.Service + const bus = yield* Bus.Service + const admitted = yield* session.prompt({ sessionID, text, resume: false }) + yield* SessionInbox.promote(database.db, bus, sessionID, "steer") + return admitted + }) + + // One assistant step with fixed usage; returns the assistant message id. + const step = Effect.fnUntraced(function* (sessionID: Session.ID) { + const bus = yield* Bus.Service + const snapshot = yield* Snapshot.Service + const before = yield* snapshot.capture() + if (!before) throw new Error("Initial snapshot missing") + const assistantMessageID = SessionMessage.ID.create() + yield* bus.publish(SessionEvent.Step.Started, { + sessionID, + assistantMessageID, + agent: Agent.defaultID, + model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") }, + snapshot: before, + started: 0, + }) + const after = yield* snapshot.capture() + if (!after) throw new Error("Snapshot missing") + yield* bus.publish(SessionEvent.Step.Ended, { + sessionID, + assistantMessageID, + finish: "stop", + cost: Money.USD.make(0.5), + tokens: { input: 1000, output: 100, reasoning: 10, cache: { read: 5, write: 3 } }, + snapshot: after, + files: yield* snapshot.files({ from: before, to: after }), + }) + return assistantMessageID + }) + + itEphemeral.live( "keeps reverted step usage in stats without double counting session totals", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped() const directory = path.join(tmp.path, "project") - yield* Effect.promise(async () => { - await fs.mkdir(directory, { recursive: true }) - await $`git init -q`.cwd(directory).quiet() - }) + yield* initProject(directory) const session = yield* Session.Service const database = yield* Database.Service - const bus = yield* Bus.Service const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } }) yield* Effect.gen(function* () { const plugins = yield* Plugin.Service yield* plugins.awaitActivation - const snapshot = yield* Snapshot.Service - - const prompt = (text: string) => - Effect.gen(function* () { - const admitted = yield* session.prompt({ sessionID: created.id, text, resume: false }) - yield* SessionInbox.promote(database.db, bus, created.id, "steer") - return admitted - }) - - const step = Effect.fnUntraced(function* () { - const before = yield* snapshot.capture() - if (!before) throw new Error("Initial snapshot missing") - const assistantMessageID = SessionMessage.ID.create() - yield* bus.publish(SessionEvent.Step.Started, { - sessionID: created.id, - assistantMessageID, - agent: Agent.defaultID, - model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") }, - snapshot: before, - started: 0, - }) - const after = yield* snapshot.capture() - if (!after) throw new Error("Snapshot missing") - yield* bus.publish(SessionEvent.Step.Ended, { - sessionID: created.id, - assistantMessageID, - finish: "stop", - cost: Money.USD.make(0.5), - tokens: { input: 1000, output: 100, reasoning: 10, cache: { read: 5, write: 3 } }, - snapshot: after, - files: yield* snapshot.files({ from: before, to: after }), - }) - }) - yield* prompt("First") - yield* step() - const second = yield* prompt("Second") - yield* step() + yield* prompt(created.id, "First") + yield* step(created.id) + const second = yield* prompt(created.id, "Second") + yield* step(created.id) const before = yield* SessionStats.get({ timezone: "UTC", tools: "none" }) expect(before.tokens).toEqual({ @@ -192,7 +213,7 @@ describe("Session.revert stats", () => { // Prompting with a staged revert commits it, deleting the second exchange's message rows. yield* session.revert.stage({ sessionID: created.id, messageID: second.id }) - yield* prompt("Third") + yield* prompt(created.id, "Third") const stats = yield* SessionStats.get({ timezone: "UTC", tools: "none" }) expect(stats.tokens).toEqual({ @@ -219,4 +240,69 @@ describe("Session.revert stats", () => { }), { timeout: 15_000 }, ) + + itEphemeral.live( + "does not recount parent rows a fork copied when the fork reverts them", + () => + Effect.gen(function* () { + const tmp = yield* tmpdirScoped() + const directory = path.join(tmp.path, "project") + yield* initProject(directory) + + const session = yield* Session.Service + const database = yield* Database.Service + const bus = yield* Bus.Service + const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } }) + + yield* Effect.gen(function* () { + const plugins = yield* Plugin.Service + yield* plugins.awaitActivation + + yield* prompt(created.id, "First") + yield* step(created.id) + yield* prompt(created.id, "Second") + const secondStep = yield* step(created.id) + + const forkID = Session.ID.make("ses_revert_stats_fork") + yield* bus.publish(SessionEvent.Forked, { + sessionID: forkID, + parentID: created.id, + boundary: { type: "through", messageID: secondStep }, + }) + + // The revert boundary is the fork's copy of the second prompt, not the original. + const copied = yield* database.db + .select({ id: SessionMessageTable.id }) + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, forkID), eq(SessionMessageTable.type, "user"))) + .orderBy(asc(SessionMessageTable.seq)) + .all() + .pipe(Effect.orDie) + expect(copied.length).toBe(2) + + yield* session.revert.stage({ sessionID: forkID, messageID: copied[1].id }) + yield* prompt(forkID, "Third") + + // The fork's copied second exchange must not be counted again (the filter matches + // SessionStats: copied rows belong to the parent's stats). No new usage was produced, + // so totals stay exactly where they were. + const stats = yield* SessionStats.get({ timezone: "UTC", tools: "none" }) + expect(stats.tokens).toEqual({ + input: 2000, + output: 200, + reasoning: 20, + cache: { read: 10, write: 6 }, + }) + expect(stats.cost).toBe(Money.USD.make(1)) + + const preserved = yield* database.db + .select({ id: SessionRevertUsageTable.id }) + .from(SessionRevertUsageTable) + .all() + .pipe(Effect.orDie) + expect(preserved.length).toBe(0) + }).pipe(Effect.provide(LocationServiceMap.Service.get(created.location))) + }), + { timeout: 15_000 }, + ) }) From 1a7848cb89eb5243f22ce8b06d4096e288d0aa78 Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 09:29:32 +0530 Subject: [PATCH 13/19] stats: count preserved revert usage per day via union --- packages/core/src/session/stats.ts | 47 ++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/core/src/session/stats.ts b/packages/core/src/session/stats.ts index 9e61b98d636a..7bcfccf85c80 100644 --- a/packages/core/src/session/stats.ts +++ b/packages/core/src/session/stats.ts @@ -12,7 +12,7 @@ import { ToolMode } from "@opencode/schema/session-stats" import { TokenUsage } from "@opencode/schema/token-usage" import { Database } from "../database/database.js" import { EventTable } from "../event/sql.js" -import { SessionMessageTable, SessionTable } from "./sql.js" +import { SessionMessageTable, SessionRevertUsageTable, SessionTable } from "./sql.js" type Input = { readonly from?: number @@ -92,13 +92,22 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { (yield* db .get<{ time: number | null }>( sql` - SELECT min(message.time_created) AS time - FROM ${SessionMessageTable} AS message - JOIN ${SessionTable} AS session ON session.id = message.session_id - WHERE message.type IN ('user', 'assistant') - AND message.time_created < ${to} - AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created) - ${project} + SELECT min(time_created) AS time + FROM ( + SELECT message.time_created AS time_created + FROM ${SessionMessageTable} AS message + JOIN ${SessionTable} AS session ON session.id = message.session_id + WHERE message.type IN ('user', 'assistant') + AND message.time_created < ${to} + AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created) + ${project} + UNION ALL + SELECT message.time_created AS time_created + FROM ${SessionRevertUsageTable} AS message + JOIN ${SessionTable} AS session ON session.id = message.session_id + WHERE message.time_created < ${to} + ${project} + ) `, ) .pipe(Effect.orDie))?.time ?? @@ -146,6 +155,26 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { AND message.time_created < ${range.to} AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created) ${project} + UNION ALL + SELECT + message.session_id AS sessionID, + session.parent_id AS parentID, + message.type AS type, + message.time_created AS timeCreated, + message.model_provider_id AS providerID, + message.model_id AS modelID, + message.model_variant AS variant, + message.tokens_input AS input, + message.tokens_output AS output, + message.tokens_reasoning AS reasoning, + message.tokens_cache_read AS cacheRead, + message.tokens_cache_write AS cacheWrite, + message.cost AS cost + FROM ${SessionRevertUsageTable} AS message + JOIN ${SessionTable} AS session ON session.id = message.session_id + WHERE message.time_created >= ${range.from} + AND message.time_created < ${range.to} + ${project} `, ) .pipe( @@ -298,7 +327,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) { EventTable.type, Event.versionedType(SessionEvent.UsageRecorded.type, SessionEvent.UsageRecorded.durable.version), ), - sql`json_extract(${EventTable.data}, '$.source') IN ('compaction', 'revert')`, + sql`json_extract(${EventTable.data}, '$.source') = 'compaction'`, gte(EventTable.created, from), lt(EventTable.created, to), ), From f90ff372518432cdff3a62ea2e8c2626ff3db3bc Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 09:29:43 +0530 Subject: [PATCH 14/19] projector: copy reverted rows into session_revert_usage before delete --- packages/core/src/session/projector.ts | 54 ++++++++++++++++++-------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 8b7e091cd3b5..99cd875e3f99 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -11,7 +11,6 @@ import { SessionEvent } from "./event.js" import { SessionMessage } from "./message.js" import { SessionMessageUpdater } from "./message-updater.js" import { SessionInbox } from "./inbox.js" -import { Workspace } from "@opencode/schema/workspace" import { InstructionState } from "./instruction-state.js" import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js" import { InstructionEntry } from "./instruction-entry.js" @@ -224,16 +223,11 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( } // A summary references the first message it kept, which the fork copied under a new ID. const keepID = row.type === "compaction" ? row.data.metadata?.keepID : undefined - if (typeof keepID !== "string") return copy + if (!Schema.is(SessionMessage.ID)(keepID)) return copy const kept = yield* db .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) - .where( - and( - eq(SessionMessageTable.session_id, event.data.parentID), - eq(SessionMessageTable.id, SessionMessage.ID.make(keepID)), - ), - ) + .where(and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, keepID))) .get() .pipe(Effect.orDie) if (!kept) return copy @@ -311,7 +305,7 @@ function run(db: DatabaseService, event: MessageEvent) { ? { location: { directory: AbsolutePath.make(row.directory), - workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined, + workspaceID: row.workspaceID ?? undefined, }, projectID: row.projectID, subpath: row.subpath === null ? undefined : RelativePath.make(row.subpath), @@ -467,7 +461,7 @@ const layer = Layer.effectDiscard( .values({ id: event.data.sessionID, project_id: event.data.projectID, - workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null, + workspace_id: event.data.location.workspaceID ?? null, parent_id: event.data.parentID, slug: event.data.slug, directory: event.data.location.directory, @@ -497,7 +491,7 @@ const layer = Layer.effectDiscard( directory: event.data.location.directory, path: event.data.subpath, ...(event.data.projectID ? { project_id: event.data.projectID } : {}), - workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null, + workspace_id: event.data.location.workspaceID ?? null, time_updated: event.created, }) .where(eq(SessionTable.id, event.data.sessionID)) @@ -645,11 +639,7 @@ const layer = Layer.effectDiscard( .pipe(Effect.orDie), ) yield* bus.project(SessionEvent.MessageContentUpdated, (event) => run(db, event)) - yield* bus.project(SessionEvent.UsageRecorded, (event) => - // Reverted usage reached the session counters when its steps ended; the event only preserves it - // for SessionStats after the message rows are deleted, so applying it here would double count. - event.data.source === "revert" ? Effect.void : applyUsage(db, event.data.sessionID, event.data), - ) + yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data)) yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event)) yield* bus.project(SessionEvent.InboxDelivered, (event) => Effect.gen(function* () { @@ -776,7 +766,7 @@ const layer = Layer.effectDiscard( yield* db .update(SessionTable) .set({ - revert: { ...revert, files: revert.files ? [...revert.files] : undefined }, + revert, time_updated: event.created, }) .where(eq(SessionTable.id, event.data.sessionID)) @@ -803,6 +793,36 @@ const layer = Layer.effectDiscard( .get() .pipe(Effect.orDie) if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.to}`)) + // Preserve the reverted rows' prompts, steps and usage for SessionStats before + // deleting them. The fork filter matches SessionStats: rows a fork copied from its + // parent belong to the parent's stats, not this session's. + yield* db + .run( + sql` + INSERT OR IGNORE INTO session_revert_usage (id, session_id, type, time_created, model_provider_id, model_id, model_variant, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write) + SELECT + message.id, + message.session_id, + message.type, + message.time_created, + json_extract(message.data, '$.model.providerID'), + json_extract(message.data, '$.model.id'), + json_extract(message.data, '$.model.variant'), + coalesce(json_extract(message.data, '$.cost'), 0), + coalesce(json_extract(message.data, '$.tokens.input'), 0), + coalesce(json_extract(message.data, '$.tokens.output'), 0), + coalesce(json_extract(message.data, '$.tokens.reasoning'), 0), + coalesce(json_extract(message.data, '$.tokens.cache.read'), 0), + coalesce(json_extract(message.data, '$.tokens.cache.write'), 0) + FROM ${SessionMessageTable} AS message + JOIN ${SessionTable} AS session ON session.id = message.session_id + WHERE message.type IN ('user', 'assistant') + AND message.session_id = ${event.data.sessionID} + AND message.seq >= ${boundary.seq} + AND (session.fork_session_id IS NULL OR message.time_created >= session.time_created) + `, + ) + .pipe(Effect.orDie) yield* db .delete(SessionMessageTable) .where( From de0e5668528ad4d2e9e9121afa9f4bbf7f7fa77d Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 09:29:54 +0530 Subject: [PATCH 15/19] schema: drop revert usage source, restore upstream Removed 'revert' from the source literals in session event schema. --- packages/schema/src/session-event.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index ebef4d2e8068..c800a0235ff6 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -171,7 +171,7 @@ export const UsageRecorded = Event.durable({ ...options, schema: { ...Base, - source: Schema.Literals(["title", "compaction", "revert"]), + source: Schema.Literals(["title", "compaction"]), cost: Money.USD, tokens: TokenUsage.Info, }, @@ -725,8 +725,8 @@ export const EphemeralDefinitions = Event.inventory( ) export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) - .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "Session.Event.Durable" }) + .pipe(Schema.toTaggedUnion("type")) export type DurableEvent = typeof Durable.Type export const All = Schema.Union([Durable, ...EphemeralDefinitions], { mode: "oneOf" }).pipe( From 28c919a429e6d79f9476ae0999c1325108e83303 Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 09:30:08 +0530 Subject: [PATCH 16/19] database: migration creating session_revert_usage --- .../20261022034137_session_revert_usage.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/core/src/database/migration/20261022034137_session_revert_usage.ts diff --git a/packages/core/src/database/migration/20261022034137_session_revert_usage.ts b/packages/core/src/database/migration/20261022034137_session_revert_usage.ts new file mode 100644 index 000000000000..e8a825e98160 --- /dev/null +++ b/packages/core/src/database/migration/20261022034137_session_revert_usage.ts @@ -0,0 +1,36 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration.js" + +const migration: DatabaseMigration.Migration = { + id: "20261022034137_session_revert_usage", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_revert_usage\` ( + \`id\` text PRIMARY KEY, + \`session_id\` text NOT NULL, + \`type\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`model_provider_id\` text, + \`model_id\` text, + \`model_variant\` text, + \`cost\` real DEFAULT 0 NOT NULL, + \`tokens_input\` integer DEFAULT 0 NOT NULL, + \`tokens_output\` integer DEFAULT 0 NOT NULL, + \`tokens_reasoning\` integer DEFAULT 0 NOT NULL, + \`tokens_cache_read\` integer DEFAULT 0 NOT NULL, + \`tokens_cache_write\` integer DEFAULT 0 NOT NULL, + CONSTRAINT \`fk_session_revert_usage_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE + ); + `) + yield* tx.run( + `CREATE INDEX \`session_revert_usage_session_time_created_idx\` ON \`session_revert_usage\` (\`session_id\`,\`time_created\`);`, + ) + yield* tx.run( + `CREATE INDEX \`session_revert_usage_time_created_idx\` ON \`session_revert_usage\` (\`time_created\`);`, + ) + }) + }, +} + +export default migration From 95ea7d677e9373bae4eb159b287376f6c6f1fe8d Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 09:30:19 +0530 Subject: [PATCH 17/19] core: regenerate schema snapshot --- packages/core/schema.json | 188 +++++++++++++++++++++++++++++++++++++- 1 file changed, 186 insertions(+), 2 deletions(-) diff --git a/packages/core/schema.json b/packages/core/schema.json index 0f52ae1bed12..7ebbebef0859 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,8 @@ { "version": "7", "dialect": "sqlite", - "id": "5067fae0-90ff-4601-82d7-dd050ea9dc8f", - "prevIds": ["e98619ef-c334-45e3-8c48-9932ed1ef290"], + "id": "9b8d5c43-f59d-4cca-9c90-f66c5bc96060", + "prevIds": ["5067fae0-90ff-4601-82d7-dd050ea9dc8f"], "ddl": [ { "name": "account_state", @@ -68,6 +68,10 @@ "name": "session_pending", "entityType": "tables" }, + { + "name": "session_revert_usage", + "entityType": "tables" + }, { "name": "session_v2", "entityType": "tables" @@ -1080,6 +1084,136 @@ "entityType": "columns", "table": "session_pending" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_revert_usage" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_revert_usage" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_revert_usage" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_revert_usage" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_provider_id", + "entityType": "columns", + "table": "session_revert_usage" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_id", + "entityType": "columns", + "table": "session_revert_usage" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model_variant", + "entityType": "columns", + "table": "session_revert_usage" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session_revert_usage" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session_revert_usage" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session_revert_usage" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session_revert_usage" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session_revert_usage" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session_revert_usage" + }, { "type": "text", "notNull": false, @@ -1629,6 +1763,17 @@ "entityType": "fks", "table": "session_pending" }, + { + "columns": ["session_id"], + "tableTo": "session_v2", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_revert_usage_session_id_session_v2_id_fk", + "entityType": "fks", + "table": "session_revert_usage" + }, { "columns": ["project_id"], "tableTo": "project", @@ -1770,6 +1915,13 @@ "table": "session_pending", "entityType": "pks" }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_revert_usage_pk", + "table": "session_revert_usage", + "entityType": "pks" + }, { "columns": ["id"], "nameExplicit": false, @@ -2016,6 +2168,38 @@ "entityType": "indexes", "table": "session_pending" }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_revert_usage_session_time_created_idx", + "entityType": "indexes", + "table": "session_revert_usage" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_revert_usage_time_created_idx", + "entityType": "indexes", + "table": "session_revert_usage" + }, { "columns": [ { From b30385cb5f5b20bb1c9e138eb1e5a00c4f0502fb Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 09:31:34 +0530 Subject: [PATCH 18/19] client: restore generated api types Removed 'revert' option from the source field in data. --- packages/client/src/effect/api/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index f5e86c22dff1..7b740e56765e 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1210,7 +1210,7 @@ export type SessionLogOutput = | undefined readonly data: { readonly sessionID: Session.ID - readonly source: "title" | "compaction" | "revert" + readonly source: "title" | "compaction" readonly cost: Money.USD readonly tokens: { readonly input: number From 8419a9b5181e5762cc94509483b345746a4ca91a Mon Sep 17 00:00:00 2001 From: Lakshya Dhariwal Date: Thu, 22 Oct 2026 09:31:48 +0530 Subject: [PATCH 19/19] Update source options in session usage type Removed 'revert' option from the 'source' field in data. --- packages/client/src/promise/generated/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 70c15b22ec85..c0bbf1cb451b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1476,7 +1476,7 @@ export type SessionUsageRecorded = { type: "session.usage.recorded" durable: { aggregateID: string; seq: EventSeq; version: 1 } location?: LocationRef - data: { sessionID: SessionID; source: "title" | "compaction" | "revert"; cost: MoneyUSD; tokens: TokenUsageInfo } + data: { sessionID: SessionID; source: "title" | "compaction"; cost: MoneyUSD; tokens: TokenUsageInfo } } export type LocationShutdown = {