Track session usage totals (#26644)

This commit is contained in:
Dax
2026-05-12 01:18:57 -04:00
committed by GitHub
parent e36bc20f84
commit 36d40fee4d
20 changed files with 1882 additions and 26 deletions

View File

@@ -1,11 +1,7 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"provider": {}, "provider": {},
"permission": { "permission": {},
"edit": {
"packages/opencode/migration/*": "ask",
},
},
"mcp": {}, "mcp": {},
"tools": { "tools": {
"github-triage": false, "github-triage": false,

View File

@@ -0,0 +1,6 @@
ALTER TABLE `session` ADD `cost` real DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `session` ADD `tokens_input` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `session` ADD `tokens_output` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `session` ADD `tokens_reasoning` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `session` ADD `tokens_cache_read` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `session` ADD `tokens_cache_write` integer DEFAULT 0 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -164,8 +164,8 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
Effect.gen(function* () { Effect.gen(function* () {
const messages = yield* svc.messages({ sessionID: session.id }) const messages = yield* svc.messages({ sessionID: session.id })
let sessionCost = 0 const sessionCost = session.cost ?? 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } const sessionTokens = session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record<string, number> = {} let sessionToolUsage: Record<string, number> = {}
let sessionModelUsage: Record< let sessionModelUsage: Record<
string, string,
@@ -178,8 +178,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
for (const message of messages) { for (const message of messages) {
if (message.info.role === "assistant") { if (message.info.role === "assistant") {
sessionCost += message.info.cost || 0
const modelKey = `${message.info.providerID}/${message.info.modelID}` const modelKey = `${message.info.providerID}/${message.info.modelID}`
if (!sessionModelUsage[modelKey]) { if (!sessionModelUsage[modelKey]) {
sessionModelUsage[modelKey] = { sessionModelUsage[modelKey] = {
@@ -192,12 +190,6 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
sessionModelUsage[modelKey].cost += message.info.cost || 0 sessionModelUsage[modelKey].cost += message.info.cost || 0
if (message.info.tokens) { if (message.info.tokens) {
sessionTokens.input += message.info.tokens.input || 0
sessionTokens.output += message.info.tokens.output || 0
sessionTokens.reasoning += message.info.tokens.reasoning || 0
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
sessionModelUsage[modelKey].tokens.output += sessionModelUsage[modelKey].tokens.output +=
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)

View File

@@ -337,6 +337,7 @@ export function Prompt(props: PromptProps) {
const usage = createMemo(() => { const usage = createMemo(() => {
if (!props.sessionID) return if (!props.sessionID) return
const session = sync.session.get(props.sessionID)
const msg = sync.data.message[props.sessionID] ?? [] const msg = sync.data.message[props.sessionID] ?? []
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
if (!last) return if (!last) return
@@ -347,7 +348,7 @@ export function Prompt(props: PromptProps) {
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) const cost = session?.cost ?? 0
return { return {
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
cost: cost > 0 ? money.format(cost) : undefined, cost: cost > 0 ? money.format(cost) : undefined,

View File

@@ -345,7 +345,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "message.part.removed": { case "message.part.removed": {
const parts = store.part[event.properties.messageID] const parts = store.part[event.properties.messageID]
const result = Binary.search(parts, event.properties.partID, (p) => p.id) const result = Binary.search(parts, event.properties.partID, (p) => p.id)
if (result.found) if (result.found) {
setStore( setStore(
"part", "part",
event.properties.messageID, event.properties.messageID,
@@ -353,6 +353,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
draft.splice(result.index, 1) draft.splice(result.index, 1)
}), }),
) )
}
break break
} }

View File

@@ -13,7 +13,8 @@ const money = new Intl.NumberFormat("en-US", {
function View(props: { api: TuiPluginApi; session_id: string }) { function View(props: { api: TuiPluginApi; session_id: string }) {
const theme = () => props.api.theme.current const theme = () => props.api.theme.current
const msg = createMemo(() => props.api.state.session.messages(props.session_id)) const msg = createMemo(() => props.api.state.session.messages(props.session_id))
const cost = createMemo(() => msg().reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)) const session = createMemo(() => props.api.state.session.get(props.session_id))
const cost = createMemo(() => session()?.cost ?? 0)
const state = createMemo(() => { const state = createMemo(() => {
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)

View File

@@ -147,6 +147,9 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
count() { count() {
return sync.data.session.length return sync.data.session.length
}, },
get(sessionID) {
return sync.session.get(sessionID)
},
diff(sessionID) { diff(sessionID) {
return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => return (sync.data.session_diff[sessionID] ?? []).flatMap((item) =>
item.file === undefined ? [] : [{ ...item, file: item.file }], item.file === undefined ? [] : [{ ...item, file: item.file }],

View File

@@ -42,7 +42,7 @@ export function SubagentFooter() {
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0) const cost = session()?.cost ?? 0
const money = new Intl.NumberFormat("en-US", { const money = new Intl.NumberFormat("en-US", {
style: "currency", style: "currency",

View File

@@ -2,7 +2,9 @@ import { Context, Effect, Layer } from "effect"
import { Database } from "./storage/db" import { Database } from "./storage/db"
import { DataMigrationTable } from "./data-migration.sql" import { DataMigrationTable } from "./data-migration.sql"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm" import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
import { MessageTable, SessionTable } from "./session/session.sql"
import type { SessionID } from "./session/schema"
export type Migration<R = never> = { export type Migration<R = never> = {
name: string name: string
@@ -18,7 +20,101 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Da
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const migrations: Migration[] = [] const migrations: Migration[] = [
{
name: "session_usage_from_messages",
run: Effect.gen(function* () {
type Usage = {
cost: number
tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
}
for (let cursor: SessionID | undefined, page = 1; ; page++) {
const next = yield* Effect.gen(function* () {
const sessions = yield* Effect.sync(() =>
Database.use((db) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(cursor ? gt(SessionTable.id, cursor) : undefined)
.orderBy(asc(SessionTable.id))
.limit(100)
.all(),
),
)
if (sessions.length === 0) return
yield* Effect.sync(() =>
Database.transaction((db) => {
const usageBySession = new Map<SessionID, Usage>(
sessions.map((session) => [
session.id,
{ cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } },
]),
)
for (const row of db
.select({
session_id: MessageTable.session_id,
cost: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.cost'), 0)), 0)`,
tokens_input: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.input'), 0)), 0)`,
tokens_output: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.output'), 0)), 0)`,
tokens_reasoning: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.reasoning'), 0)), 0)`,
tokens_cache_read: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.read'), 0)), 0)`,
tokens_cache_write: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.write'), 0)), 0)`,
})
.from(MessageTable)
.where(
and(
inArray(MessageTable.session_id, sessions.map((session) => session.id)),
sql`json_extract(${MessageTable.data}, '$.role') = 'assistant'`,
),
)
.groupBy(MessageTable.session_id)
.all()) {
const current = usageBySession.get(row.session_id)
if (!current) continue
current.cost = row.cost
current.tokens.input = row.tokens_input
current.tokens.output = row.tokens_output
current.tokens.reasoning = row.tokens_reasoning
current.tokens.cache.read = row.tokens_cache_read
current.tokens.cache.write = row.tokens_cache_write
}
for (const [sessionID, value] of usageBySession) {
db.update(SessionTable)
.set({
cost: value.cost,
tokens_input: value.tokens.input,
tokens_output: value.tokens.output,
tokens_reasoning: value.tokens.reasoning,
tokens_cache_read: value.tokens.cache.read,
tokens_cache_write: value.tokens.cache.write,
})
.where(eq(SessionTable.id, sessionID))
.run()
}
}),
)
return sessions.at(-1)?.id
}).pipe(
Effect.withSpan("DataMigration.sessionUsage.page", {
attributes: {
"data_migration.name": "session_usage_from_messages",
"data_migration.page": page,
"data_migration.cursor": cursor ?? "",
},
}),
)
if (!next) return
cursor = next
yield* Effect.sleep("10 millis")
}
}),
},
]
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
if (migrations.length === 0) return if (migrations.length === 0) return

View File

@@ -1,6 +1,8 @@
import { NotFoundError } from "@/storage/storage" import { NotFoundError } from "@/storage/storage"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { and } from "drizzle-orm" import { and } from "drizzle-orm"
import { sql } from "drizzle-orm"
import type { TxOrDb } from "@/storage/db"
import { SyncEvent } from "@/sync" import { SyncEvent } from "@/sync"
import * as Session from "./session" import * as Session from "./session"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
@@ -19,6 +21,28 @@ function foreign(err: unknown) {
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
type Usage = Pick<MessageV2.StepFinishPart, "cost" | "tokens">
function usage(part: MessageV2.Part | (typeof PartTable.$inferSelect)["data"]): Usage | undefined {
if (part.type !== "step-finish") return undefined
if (!("cost" in part) || !("tokens" in part)) return undefined
return { cost: part.cost, tokens: part.tokens }
}
function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sign = 1) {
db.update(SessionTable)
.set({
cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
})
.where(eq(SessionTable.id, sessionID))
.run()
}
function grab<T extends object, K1 extends keyof T, X>( function grab<T extends object, K1 extends keyof T, X>(
obj: T, obj: T,
field1: K1, field1: K1,
@@ -54,6 +78,12 @@ export function toPartialRow(info: DeepPartial<Session.Info>) {
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")), summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
summary_files: grab(info, "summary", (v) => grab(v, "files")), summary_files: grab(info, "summary", (v) => grab(v, "files")),
summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")), summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
cost: grab(info, "cost"),
tokens_input: grab(info, "tokens", (v) => grab(v, "input")),
tokens_output: grab(info, "tokens", (v) => grab(v, "output")),
tokens_reasoning: grab(info, "tokens", (v) => grab(v, "reasoning")),
tokens_cache_read: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "read"))),
tokens_cache_write: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "write"))),
revert: grab(info, "revert"), revert: grab(info, "revert"),
permission: grab(info, "permission"), permission: grab(info, "permission"),
time_created: grab(info, "time", (v) => grab(v, "created")), time_created: grab(info, "time", (v) => grab(v, "created")),
@@ -112,12 +142,28 @@ export default [
}), }),
SyncEvent.project(MessageV2.Event.Removed, (db, data) => { SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
for (const row of db
.select()
.from(PartTable)
.where(and(eq(PartTable.message_id, data.messageID), eq(PartTable.session_id, data.sessionID)))
.all()) {
const previous = usage(row.data)
if (previous) applyUsage(db, data.sessionID, previous, -1)
}
db.delete(MessageTable) db.delete(MessageTable)
.where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID))) .where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
.run() .run()
}), }),
SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => { SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => {
const row = db
.select()
.from(PartTable)
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
.get()
const previous = row && usage(row.data)
if (previous) applyUsage(db, data.sessionID, previous, -1)
db.delete(PartTable) db.delete(PartTable)
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID))) .where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
.run() .run()
@@ -125,6 +171,7 @@ export default [
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => { SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
const { id, messageID, sessionID, ...rest } = data.part const { id, messageID, sessionID, ...rest } = data.part
const row = db.select().from(PartTable).where(eq(PartTable.id, id)).get()
try { try {
db.insert(PartTable) db.insert(PartTable)
@@ -137,6 +184,10 @@ export default [
}) })
.onConflictDoUpdate({ target: PartTable.id, set: { data: rest } }) .onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
.run() .run()
const previous = row && usage(row.data)
const next = usage(data.part)
if (previous) applyUsage(db, row.session_id, previous, -1)
if (next) applyUsage(db, sessionID, next)
} catch (err) { } catch (err) {
if (!foreign(err)) throw err if (!foreign(err)) throw err
log.warn("ignored late part update", { partID: id, messageID, sessionID }) log.warn("ignored late part update", { partID: id, messageID, sessionID })

View File

@@ -1,4 +1,4 @@
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "./message-v2"
import type { SessionMessage } from "../v2/session-message" import type { SessionMessage } from "../v2/session-message"
@@ -10,7 +10,7 @@ import type { WorkspaceID } from "../control-plane/schema"
import { Timestamps } from "../storage/schema.sql" import { Timestamps } from "../storage/schema.sql"
type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID"> type PartData = Omit<MessageV2.Part, "id" | "sessionID" | "messageID">
type InfoData = Omit<MessageV2.Info, "id" | "sessionID"> type InfoData<T extends MessageV2.Info = MessageV2.Info> = T extends unknown ? Omit<T, "id" | "sessionID"> : never
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
export const SessionTable = sqliteTable( export const SessionTable = sqliteTable(
@@ -33,6 +33,12 @@ export const SessionTable = sqliteTable(
summary_deletions: integer(), summary_deletions: integer(),
summary_files: integer(), summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(), summary_diffs: text({ mode: "json" }).$type<Snapshot.FileDiff[]>(),
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),
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
permission: text({ mode: "json" }).$type<Permission.Ruleset>(), permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
agent: text(), agent: text(),

View File

@@ -87,6 +87,16 @@ export function fromRow(row: SessionRow): Info {
: undefined, : undefined,
version: row.version, version: row.version,
summary, summary,
cost: row.cost,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
reasoning: row.tokens_reasoning,
cache: {
read: row.tokens_cache_read,
write: row.tokens_cache_write,
},
},
share, share,
revert, revert,
permission: row.permission ?? undefined, permission: row.permission ?? undefined,
@@ -117,6 +127,12 @@ export function toRow(info: Info) {
summary_deletions: info.summary?.deletions, summary_deletions: info.summary?.deletions,
summary_files: info.summary?.files, summary_files: info.summary?.files,
summary_diffs: info.summary?.diffs, summary_diffs: info.summary?.diffs,
cost: info.cost ?? 0,
tokens_input: (info.tokens ?? EmptyTokens).input,
tokens_output: (info.tokens ?? EmptyTokens).output,
tokens_reasoning: (info.tokens ?? EmptyTokens).reasoning,
tokens_cache_read: (info.tokens ?? EmptyTokens).cache.read,
tokens_cache_write: (info.tokens ?? EmptyTokens).cache.write,
revert: info.revert ?? null, revert: info.revert ?? null,
permission: info.permission, permission: info.permission,
time_created: info.time.created, time_created: info.time.created,
@@ -147,6 +163,18 @@ const Summary = Schema.Struct({
diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)), diffs: optionalOmitUndefined(Schema.Array(Snapshot.FileDiff)),
}) })
const Tokens = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
})
const EmptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const Share = Schema.Struct({ const Share = Schema.Struct({
url: Schema.String, url: Schema.String,
}) })
@@ -184,6 +212,8 @@ export const Info = Schema.Struct({
path: optionalOmitUndefined(Schema.String), path: optionalOmitUndefined(Schema.String),
parentID: optionalOmitUndefined(SessionID), parentID: optionalOmitUndefined(SessionID),
summary: optionalOmitUndefined(Summary), summary: optionalOmitUndefined(Summary),
cost: optionalOmitUndefined(Schema.Finite),
tokens: optionalOmitUndefined(Tokens),
share: optionalOmitUndefined(Share), share: optionalOmitUndefined(Share),
title: Schema.String, title: Schema.String,
agent: optionalOmitUndefined(Schema.String), agent: optionalOmitUndefined(Schema.String),
@@ -281,6 +311,8 @@ const UpdatedInfo = Schema.Struct({
path: Schema.optional(Schema.NullOr(Schema.String)), path: Schema.optional(Schema.NullOr(Schema.String)),
parentID: Schema.optional(Schema.NullOr(SessionID)), parentID: Schema.optional(Schema.NullOr(SessionID)),
summary: Schema.optional(Schema.NullOr(Summary)), summary: Schema.optional(Schema.NullOr(Summary)),
cost: Schema.optional(Schema.Finite),
tokens: Schema.optional(Tokens),
share: Schema.optional(UpdatedShare), share: Schema.optional(UpdatedShare),
title: Schema.optional(Schema.NullOr(Schema.String)), title: Schema.optional(Schema.NullOr(Schema.String)),
agent: Schema.optional(Schema.NullOr(Schema.String)), agent: Schema.optional(Schema.NullOr(Schema.String)),
@@ -503,6 +535,8 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
agent: input.agent, agent: input.agent,
model: input.model, model: input.model,
permission: input.permission, permission: input.permission,
cost: 0,
tokens: EmptyTokens,
time: { time: {
created: Date.now(), created: Date.now(),
updated: Date.now(), updated: Date.now(),

View File

@@ -216,6 +216,12 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
summary_deletions: data.summary?.deletions ?? null, summary_deletions: data.summary?.deletions ?? null,
summary_files: data.summary?.files ?? null, summary_files: data.summary?.files ?? null,
summary_diffs: data.summary?.diffs ?? null, summary_diffs: data.summary?.diffs ?? null,
cost: 0,
tokens_input: 0,
tokens_output: 0,
tokens_reasoning: 0,
tokens_cache_read: 0,
tokens_cache_write: 0,
revert: data.revert ?? null, revert: data.revert ?? null,
permission: data.permission ?? null, permission: data.permission ?? null,
time_created: data.time?.created ?? now, time_created: data.time?.created ?? now,

View File

@@ -29,6 +29,16 @@ export class Info extends Schema.Class<Info>("Session.Info")({
path: optionalOmitUndefined(Schema.String), path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String), agent: optionalOmitUndefined(Schema.String),
model: Modelv2.Ref.pipe(optionalOmitUndefined), model: Modelv2.Ref.pipe(optionalOmitUndefined),
cost: Schema.Finite,
tokens: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
time: Schema.Struct({ time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis, created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis, updated: V2Schema.DateTimeUtcFromMillis,
@@ -136,6 +146,16 @@ export const layer = Layer.effect(
variant: Modelv2.VariantID.make(row.model.variant ?? "default"), variant: Modelv2.VariantID.make(row.model.variant ?? "default"),
} }
: undefined, : undefined,
cost: row.cost,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
reasoning: row.tokens_reasoning,
cache: {
read: row.tokens_cache_read,
write: row.tokens_cache_write,
},
},
time: { time: {
created: DateTime.makeUnsafe(row.time_created), created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated), updated: DateTime.makeUnsafe(row.time_updated),

View File

@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Ref, Scope } from "effect" import { Deferred, Effect, Exit, Fiber, Latch, Ref, Scope } from "effect"
import { Runner } from "@/effect/runner" import { Runner } from "@/effect/runner"
import { it } from "../lib/effect" import { it } from "../lib/effect"
@@ -352,11 +352,18 @@ describe("Runner", () => {
Effect.gen(function* () { Effect.gen(function* () {
const s = yield* Scope.Scope const s = yield* Scope.Scope
const runner = Runner.make<string>(s, { onInterrupt: Effect.succeed("interrupted") }) const runner = Runner.make<string>(s, { onInterrupt: Effect.succeed("interrupted") })
const ready = yield* Latch.make()
const sh = yield* runner const sh = yield* runner
.startShell(Effect.never.pipe(Effect.ensuring(Effect.die("boom")), Effect.as("ignored"))) .startShell(
Effect.gen(function* () {
yield* ready.open
return yield* Effect.never.pipe(Effect.as("ignored"))
}).pipe(Effect.ensuring(Effect.die("boom"))),
ready,
)
.pipe(Effect.forkChild) .pipe(Effect.forkChild)
yield* Effect.sleep("10 millis") yield* ready.await.pipe(Effect.timeout("250 millis"))
yield* runner.cancel yield* runner.cancel
expect(Exit.isFailure(yield* Fiber.await(sh))).toBe(true) expect(Exit.isFailure(yield* Fiber.await(sh))).toBe(true)

View File

@@ -292,6 +292,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
}, },
session: { session: {
count: opts.state?.session?.count ?? (() => 0), count: opts.state?.session?.count ?? (() => 0),
get: opts.state?.session?.get ?? (() => undefined),
diff: opts.state?.session?.diff ?? (() => []), diff: opts.state?.session?.diff ?? (() => []),
todo: opts.state?.session?.todo ?? (() => []), todo: opts.state?.session?.todo ?? (() => []),
messages: opts.state?.session?.messages ?? (() => []), messages: opts.state?.session?.messages ?? (() => []),

View File

@@ -12,6 +12,8 @@ const info = {
directory: "/tmp/opencode", directory: "/tmp/opencode",
parentID: undefined, parentID: undefined,
summary: undefined, summary: undefined,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
share: undefined, share: undefined,
title: "Test session", title: "Test session",
version: "1.0.0", version: "1.0.0",

View File

@@ -11,6 +11,7 @@ import type {
Provider, Provider,
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
Session,
SessionStatus, SessionStatus,
TextPart, TextPart,
Config as SdkConfig, Config as SdkConfig,
@@ -310,6 +311,7 @@ export type TuiState = {
readonly vcs: { branch?: string } | undefined readonly vcs: { branch?: string } | undefined
session: { session: {
count: () => number count: () => number
get: (sessionID: string) => Session | undefined
diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem> diff: (sessionID: string) => ReadonlyArray<TuiSidebarFileItem>
todo: (sessionID: string) => ReadonlyArray<TuiSidebarTodoItem> todo: (sessionID: string) => ReadonlyArray<TuiSidebarTodoItem>
messages: (sessionID: string) => ReadonlyArray<Message> messages: (sessionID: string) => ReadonlyArray<Message>

View File

@@ -741,6 +741,16 @@ export type Session = {
files: number files: number
diffs?: Array<SnapshotFileDiff> diffs?: Array<SnapshotFileDiff>
} }
cost?: number
tokens?: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
share?: { share?: {
url: string url: string
} }
@@ -1430,6 +1440,16 @@ export type GlobalSession = {
files: number files: number
diffs?: Array<SnapshotFileDiff> diffs?: Array<SnapshotFileDiff>
} }
cost?: number
tokens?: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
share?: { share?: {
url: string url: string
} }
@@ -1893,6 +1913,16 @@ export type SyncEventSessionUpdated = {
files: number files: number
diffs?: Array<SnapshotFileDiff> diffs?: Array<SnapshotFileDiff>
} | null } | null
cost?: number | null
tokens?: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
} | null
share?: { share?: {
url?: string | null url?: string | null
} }
@@ -3085,6 +3115,16 @@ export type SessionInfo = {
providerID: string providerID: string
variant: string variant: string
} }
cost: number
tokens: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
time: { time: {
created: number created: number
updated: number updated: number