Compare commits

...

4 Commits

Author SHA1 Message Date
Aiden Cline 8c6ff62466 Merge remote-tracking branch 'origin/v2' into pr-35468-review
# Conflicts:
#	packages/core/src/session/runner/llm.ts
2026-07-06 10:14:30 -05:00
Aiden Cline 3401d57bdb fix: address session usage review 2026-07-05 19:42:40 -05:00
Aiden Cline 59f0ac4774 fix(tui): follow undo context usage 2026-07-05 19:10:23 -05:00
Aiden Cline a61c5e8eee fix: update v2 session usage metrics 2026-07-05 14:09:06 -05:00
15 changed files with 705 additions and 33 deletions
+1
View File
@@ -44,5 +44,6 @@ export const migrations = (
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
import("./migration/20260703181610_event_created_column"),
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
import("./migration/20260706120000_v2_session_usage"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,55 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260706120000_v2_session_usage",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
UPDATE session
SET
cost = coalesce((
SELECT sum(coalesce(json_extract(session_message.data, '$.cost'), 0))
FROM session_message
WHERE session_message.session_id = session.id
AND session_message.type = 'assistant'
), 0),
tokens_input = coalesce((
SELECT sum(coalesce(json_extract(session_message.data, '$.tokens.input'), 0))
FROM session_message
WHERE session_message.session_id = session.id
AND session_message.type = 'assistant'
), 0),
tokens_output = coalesce((
SELECT sum(coalesce(json_extract(session_message.data, '$.tokens.output'), 0))
FROM session_message
WHERE session_message.session_id = session.id
AND session_message.type = 'assistant'
), 0),
tokens_reasoning = coalesce((
SELECT sum(coalesce(json_extract(session_message.data, '$.tokens.reasoning'), 0))
FROM session_message
WHERE session_message.session_id = session.id
AND session_message.type = 'assistant'
), 0),
tokens_cache_read = coalesce((
SELECT sum(coalesce(json_extract(session_message.data, '$.tokens.cache.read'), 0))
FROM session_message
WHERE session_message.session_id = session.id
AND session_message.type = 'assistant'
), 0),
tokens_cache_write = coalesce((
SELECT sum(coalesce(json_extract(session_message.data, '$.tokens.cache.write'), 0))
FROM session_message
WHERE session_message.session_id = session.id
AND session_message.type = 'assistant'
), 0)
WHERE EXISTS (
SELECT 1
FROM session_message
WHERE session_message.session_id = session.id
)
`)
})
},
} satisfies DatabaseMigration.Migration
+24 -1
View File
@@ -648,7 +648,12 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Ended, (event) =>
Effect.gen(function* () {
yield* run(db, event)
yield* applyUsage(db, event.data.sessionID, event.data)
}),
)
yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
@@ -695,6 +700,23 @@ const layer = Layer.effectDiscard(
.get()
.pipe(Effect.orDie)
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.messageID}`))
const revertedUsage = (yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, boundary.seq),
),
)
.all()
.pipe(Effect.orDie)
).reduce((total, row) => {
const value = messageUsage(row)
if (value) addUsage(total, value)
return total
}, emptyUsage())
yield* db
.delete(SessionMessageTable)
.where(
@@ -702,6 +724,7 @@ const layer = Layer.effectDiscard(
)
.run()
.pipe(Effect.orDie)
yield* applyUsage(db, event.data.sessionID, revertedUsage, -1)
yield* db
.delete(SessionInputTable)
.where(
+30 -2
View File
@@ -8,14 +8,16 @@ import {
Message,
SystemPart,
isContextOverflowFailure,
type ProviderMetadata,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { Cause, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
import { ModelV2 } from "../../model"
import { SystemContext } from "../../system-context/index"
import { SystemContextBuiltIns } from "../../system-context/builtins"
import { InstructionContext } from "../../instruction-context"
@@ -44,6 +46,32 @@ import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform"
type StepTokens = {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens, metadata?: ProviderMetadata) {
const totalNanoAiu = metadata?.copilot?.totalNanoAiu
if (typeof totalNanoAiu === "number" && Number.isFinite(totalNanoAiu) && totalNanoAiu >= 0)
return totalNanoAiu / 100_000_000_000
const context = tokens.input + tokens.cache.read + tokens.cache.write
const tier = costs
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
const cost = tier ?? costs.find((cost) => cost.tier === undefined) ?? costs[0]
if (!cost) return 0
return (
(tokens.input * cost.input +
(tokens.output + tokens.reasoning) * cost.output +
tokens.cache.read * cost.cache.read +
tokens.cache.write * cost.cache.write) /
1_000_000
)
}
/**
* Runs one durable coding-agent Session until it settles.
*
@@ -297,7 +325,7 @@ const layer = Layer.effect(
sessionID: session.id,
assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish,
cost: 0,
cost: calculateCost(resolved.cost, settlement.tokens, settlement.providerMetadata),
tokens: settlement.tokens,
snapshot: endSnapshot,
files,
+5 -1
View File
@@ -80,6 +80,8 @@ export interface Resolved {
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
export interface Interface {
@@ -92,13 +94,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
model,
ref: ModelV2.Ref.make({
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
...(variant === undefined ? {} : { variant }),
}),
cost,
})
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
@@ -265,6 +268,7 @@ const layer = Layer.effect(
providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}),
cost: selected.cost,
}
}),
})
@@ -1,5 +1,5 @@
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
import { DateTime, Effect } from "effect"
import { Effect } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
import { SessionEvent } from "../event"
@@ -64,12 +64,17 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
providerMetadata?: ProviderMetadata
}
>()
const timestamp = DateTime.now
let assistantMessageID: SessionMessage.ID | undefined
let assistantActive = false
let assistantFailed = false
let providerFailed = false
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
let stepSettlement:
| {
readonly finish: string
readonly tokens: ReturnType<typeof tokens>
readonly providerMetadata?: ProviderMetadata
}
| undefined
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
@@ -381,7 +386,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* flush()
assistantActive = false
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
stepSettlement = {
finish: event.reason,
tokens: tokens(event.usage),
providerMetadata: event.usage?.providerMetadata ?? event.providerMetadata,
}
return
case "finish":
return
@@ -9,6 +9,7 @@ import { eq, inArray, sql } from "drizzle-orm"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import { migrations } from "@opencode-ai/core/database/migration.gen"
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
import v2SessionUsageMigration from "@opencode-ai/core/database/migration/20260706120000_v2_session_usage"
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input"
@@ -403,6 +404,53 @@ describe("DatabaseMigration", () => {
)
})
test("backfills V2 session usage without replacing legacy-only totals", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE session (id text PRIMARY KEY, cost real NOT NULL, tokens_input integer NOT NULL, tokens_output integer NOT NULL, tokens_reasoning integer NOT NULL, tokens_cache_read integer NOT NULL, tokens_cache_write integer NOT NULL)`,
)
yield* db.run(
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, data text NOT NULL)`,
)
yield* db.run(
sql`INSERT INTO session VALUES ('v2', 0, 0, 0, 0, 0, 0), ('legacy', 9, 8, 7, 6, 5, 4)`,
)
yield* db.run(
sql`INSERT INTO session_message VALUES ('assistant', 'v2', 'assistant', '{"cost":1.25,"tokens":{"input":2,"output":3,"reasoning":4,"cache":{"read":5,"write":6}}}')`,
)
yield* DatabaseMigration.applyOnly(db, [v2SessionUsageMigration])
expect(
yield* db.all(
sql`SELECT id, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write FROM session ORDER BY id`,
),
).toEqual([
{
id: "legacy",
cost: 9,
tokens_input: 8,
tokens_output: 7,
tokens_reasoning: 6,
tokens_cache_read: 5,
tokens_cache_write: 4,
},
{
id: "v2",
cost: 1.25,
tokens_input: 2,
tokens_output: 3,
tokens_reasoning: 4,
tokens_cache_read: 5,
tokens_cache_write: 6,
},
])
}),
)
})
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
await run(
Effect.gen(function* () {
+44 -4
View File
@@ -41,12 +41,15 @@ const assistantRow = (
id: SessionMessage.ID,
seq: number,
time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
usage?: Pick<SessionMessage.Assistant, "cost" | "tokens">,
) => {
const {
id: _,
type,
...data
} = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time }))
} = encodeMessage(
SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time, ...usage }),
)
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
}
@@ -67,12 +70,29 @@ describe("SessionProjector", () => {
directory: "/project",
title: "test",
version: "test",
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
.run()
const boundary = SessionMessage.ID.make("msg_boundary")
yield* db
.insert(SessionMessageTable)
.values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)])
.values([
assistantRow(boundary, 1),
assistantRow(
SessionMessage.ID.make("msg_later"),
2,
{ created },
{
cost: 1.25,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
},
),
])
.run()
yield* db
.insert(SessionContextCheckpointTable)
@@ -101,6 +121,14 @@ describe("SessionProjector", () => {
expect(
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
).toEqual([boundary])
expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({
cost: 0,
tokens_input: 0,
tokens_output: 0,
tokens_reasoning: 0,
tokens_cache_read: 0,
tokens_cache_write: 0,
})
// A committed revert resets the context checkpoint so the next turn re-initializes.
expect(yield* db.select().from(SessionContextCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined()
}),
@@ -466,8 +494,8 @@ describe("SessionProjector", () => {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
cost: 1.25,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
const rows = yield* db
@@ -484,8 +512,20 @@ describe("SessionProjector", () => {
expect(messages[1]).toMatchObject({
type: "assistant",
finish: "stop",
cost: 1.25,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
time: { completed: DateTime.makeUnsafe(0) },
})
expect(
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
).toMatchObject({
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
}),
)
+26 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import {
LLMClient,
LLMError,
@@ -116,6 +116,29 @@ const recoveryModel = Model.make({
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
})
test("calculates step cost using the matching context tier", () => {
expect(
SessionRunnerLLM.calculateCost(
[
{ input: 1, output: 2, cache: { read: 0.1, write: 0.5 } },
{ tier: { type: "context", size: 100 }, input: 3, output: 4, cache: { read: 0.2, write: 0.6 } },
],
{ input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 1 } },
),
).toBeCloseTo(0.0002926)
})
test("prefers Copilot billed cost over token estimation", () => {
expect(
SessionRunnerLLM.calculateCost(
[{ input: 1, output: 2, cache: { read: 0.1, write: 0.5 } }],
{ input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 1 } },
{ copilot: { totalNanoAiu: 4_473_525_000 } },
),
).toBeCloseTo(0.04473525)
})
const authorizations: Tool.Context[] = []
const executions: string[] = []
const permission = Layer.succeed(
@@ -1531,6 +1554,7 @@ describe("SessionRunnerLLM", () => {
LLMEvent.stepFinish({
index: 0,
reason: "tool-calls",
providerMetadata: { copilot: { totalNanoAiu: 4_473_525_000 } },
usage: {
inputTokens: 10,
nonCachedInputTokens: 8,
@@ -1551,6 +1575,7 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
finish: "tool-calls",
cost: 0.04473525,
tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } },
content: [
{ type: "reasoning", id: "reasoning-1", text: "Think" },
+42 -3
View File
@@ -104,6 +104,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: process.cwd(),
})
const messageIndex = new Map<string, Map<string, number>>()
const sessionUsageGeneration = new Map<string, number>()
const sessionUsageApplied = new Map<string, number>()
let connectionGeneration = 0
let statusChanges: Set<string> | undefined
let bootstrapping: Promise<void> | undefined
@@ -362,7 +364,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
})
break
case "session.step.ended":
case "session.step.ended": {
setSessionStatus(event.data.sessionID, "running")
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
@@ -374,7 +376,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (event.data.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
})
void result.session
.refreshUsage(event.data.sessionID)
.catch((error) => console.error("Failed to refresh session usage", error))
break
}
case "session.step.failed":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
@@ -553,10 +559,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("session", "info", event.data.sessionID, "revert", event.data.revert)
break
case "session.revert.cleared":
case "session.revert.committed":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", undefined)
break
case "session.revert.committed":
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.messageID)
if (position === undefined) return
draft.splice(position + 1)
index.clear()
draft.forEach((item, itemIndex) => index.set(item.id, itemIndex))
})
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", undefined)
void Promise.all([
result.session.refreshUsage(event.data.sessionID),
result.session.message.refresh(event.data.sessionID),
]).catch((error) => console.error("Failed to refresh session after revert", error))
break
case "session.compaction.delta":
break
case "session.compaction.ended":
@@ -682,6 +702,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
registerSession(sessionID)
},
async refreshUsage(sessionID: string) {
const generation = (sessionUsageGeneration.get(sessionID) ?? 0) + 1
sessionUsageGeneration.set(sessionID, generation)
const info = mutable(await sdk.api.session.get({ sessionID }))
if ((sessionUsageApplied.get(sessionID) ?? 0) > generation) return
sessionUsageApplied.set(sessionID, generation)
setStore("session", "info", sessionID, (current) =>
current ? { ...current, cost: info.cost, tokens: info.tokens } : info,
)
registerSession(sessionID)
},
message: {
ids(sessionID: string) {
return (store.session.message[sessionID] ?? []).map((message) => message.id)
@@ -863,6 +894,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
async function bootstrap() {
if (bootstrapping) return bootstrapping
const usageApplied = new Map(sessionUsageApplied)
bootstrapping = Promise.allSettled([
sdk.api.session
.list({
@@ -876,7 +908,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
"session",
"info",
produce((draft) => {
for (const session of response.data) draft[session.id] = mutable(session)
for (const session of response.data) {
const current = draft[session.id]
draft[session.id] = mutable(session)
if ((sessionUsageApplied.get(session.id) ?? 0) === (usageApplied.get(session.id) ?? 0) || !current)
continue
draft[session.id].cost = current.cost
draft[session.id].tokens = current.tokens
}
}),
)
for (const session of response.data) registerSession(session.id)
@@ -1,7 +1,8 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createMemo } from "solid-js"
import { useData } from "../../context/data"
import { lastAssistantWithUsage } from "../../util/session"
const id = "internal:sidebar-context"
@@ -11,13 +12,14 @@ const money = new Intl.NumberFormat("en-US", {
})
function View(props: { api: TuiPluginApi; session_id: string }) {
const data = useData()
const theme = () => props.api.theme.current
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
const session = createMemo(() => props.api.state.session.get(props.session_id))
const msg = createMemo(() => data.session.message.list(props.session_id))
const session = createMemo(() => data.session.get(props.session_id))
const cost = createMemo(() => session()?.cost ?? 0)
const state = createMemo(() => {
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
const last = lastAssistantWithUsage(msg(), session()?.revert?.messageID)
if (!last) {
return {
tokens: 0,
@@ -27,7 +29,9 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const model = data.location
.model.list(session()?.location)
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
return {
tokens,
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
@@ -6,6 +6,7 @@ import { SplitBorder } from "../../ui/border"
import { Locale } from "../../util/locale"
import { useTerminalDimensions } from "@opentui/solid"
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
import { lastAssistantWithUsage } from "../../util/session"
export function SubagentFooter() {
const route = useRouteData("session")
@@ -22,17 +23,15 @@ export function SubagentFooter() {
const usage = createMemo(() => {
const current = session()
if (!current) return
const last = lastAssistantWithUsage(data.session.message.list(route.sessionID), current.revert?.messageID)
if (!last) return
const tokens =
current.tokens.input +
current.tokens.output +
current.tokens.reasoning +
current.tokens.cache.read +
current.tokens.cache.write
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (tokens <= 0) return
const model = data.location
.model.list(current.location)
?.find((model) => model.providerID === current.model?.providerID && model.id === current.model.id)
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = current.cost
@@ -83,10 +82,10 @@ export function SubagentFooter() {
</box>
<box flexDirection="row" gap={2}>
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => keymap.dispatchCommand("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
+14
View File
@@ -1,3 +1,17 @@
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
export function isDefaultTitle(title: string) {
return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
}
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessage>, boundary?: string) {
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
if (boundary && boundaryIndex === -1) return undefined
return messages.findLast(
(
message,
index,
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } =>
message.type === "assistant" && message.tokens !== undefined && (boundaryIndex === -1 || index < boundaryIndex),
)
}
+366 -2
View File
@@ -108,6 +108,348 @@ test("refreshes resources into reactive getters", async () => {
}
})
test("refreshes usage without applying stale session snapshots", async () => {
const events = createEventStream()
const sessionID = "ses_usage_refresh"
let resolveSessions!: (response: Response) => void
const resolveSession: Array<(response: Response) => void> = []
let sessionsRequested = false
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
sessionsRequested = true
return new Promise<Response>((resolve) => {
resolveSessions = resolve
})
}
if (url.pathname === `/api/session/${sessionID}`) {
return new Promise<Response>((resolve) => {
resolveSession.push(resolve)
})
}
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => sessionsRequested)
emitEvent(events, {
id: "evt_usage_2",
created: 2,
type: "session.step.ended",
durable: durable(sessionID, 2),
data: {
sessionID,
assistantMessageID: "msg_usage_2",
finish: "stop",
cost: 0.5,
tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } },
},
})
await wait(() => resolveSession.length === 1)
resolveSessions(
json({
data: [
{
id: sessionID,
projectID: "proj_test",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Stale usage",
location: { directory },
},
],
watermarks: {},
cursor: {},
}),
)
resolveSession[0](
json({
data: {
id: sessionID,
projectID: "proj_test",
cost: 0.5,
tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } },
time: { created: 0, updated: 0 },
title: "Current usage",
location: { directory },
},
}),
)
await wait(() => data.session.get(sessionID)?.cost === 0.5)
expect(data.session.get(sessionID)?.tokens).toEqual({
input: 5,
output: 2,
reasoning: 1,
cache: { read: 1, write: 1 },
})
emitEvent(events, {
id: "evt_usage_3",
created: 3,
type: "session.step.ended",
durable: durable(sessionID, 3),
data: {
sessionID,
assistantMessageID: "msg_usage_3",
finish: "stop",
cost: 0.25,
tokens: { input: 3, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
emitEvent(events, {
id: "evt_usage_4",
created: 4,
type: "session.step.ended",
durable: durable(sessionID, 4),
data: {
sessionID,
assistantMessageID: "msg_usage_4",
finish: "stop",
cost: 0.25,
tokens: { input: 2, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
await wait(() => resolveSession.length === 3)
emitEvent(events, {
id: "evt_usage_rename",
created: 5,
type: "session.renamed",
durable: durable(sessionID, 5),
data: { sessionID, title: "Live title" },
})
resolveSession[2](
json({
data: {
id: sessionID,
projectID: "proj_test",
cost: 1,
tokens: { input: 10, output: 4, reasoning: 1, cache: { read: 1, write: 1 } },
time: { created: 0, updated: 0 },
title: "Latest usage",
location: { directory },
},
}),
)
await wait(() => data.session.get(sessionID)?.cost === 1)
resolveSession[1](
json({
data: {
id: sessionID,
projectID: "proj_test",
cost: 0.75,
tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } },
time: { created: 0, updated: 0 },
title: "Older usage",
location: { directory },
},
}),
)
await Bun.sleep(20)
expect(data.session.get(sessionID)?.cost).toBe(1)
expect(data.session.get(sessionID)?.title).toBe("Live title")
emitEvent(events, {
id: "evt_usage_6",
created: 6,
type: "session.step.ended",
durable: durable(sessionID, 6),
data: {
sessionID,
assistantMessageID: "msg_usage_6",
finish: "stop",
cost: 0.25,
tokens: { input: 2, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
emitEvent(events, {
id: "evt_usage_7",
created: 7,
type: "session.step.ended",
durable: durable(sessionID, 7),
data: {
sessionID,
assistantMessageID: "msg_usage_7",
finish: "stop",
cost: 0.25,
tokens: { input: 2, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
await wait(() => resolveSession.length === 5)
resolveSession[3](
json({
data: {
id: sessionID,
projectID: "proj_test",
cost: 1.25,
tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } },
time: { created: 0, updated: 0 },
title: "Stale title",
location: { directory },
},
}),
)
await wait(() => data.session.get(sessionID)?.cost === 1.25)
expect(data.session.get(sessionID)?.title).toBe("Live title")
resolveSession[4](
json({
data: {
id: sessionID,
projectID: "proj_test",
cost: 1.25,
tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } },
time: { created: 0, updated: 0 },
title: "Stale title",
location: { directory },
},
}),
)
} finally {
app.renderer.destroy()
}
})
test("truncates committed revert messages and refreshes usage", async () => {
const events = createEventStream()
const sessionID = "ses_revert_usage"
let cost = 0
let tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
if (url.pathname !== `/api/session/${sessionID}`) return
return json({
data: {
id: sessionID,
projectID: "proj_test",
cost,
tokens,
time: { created: 0, updated: 0 },
title: "Revert usage",
location: { directory },
},
})
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await data.session.refresh(sessionID)
emitEvent(events, {
id: "evt_revert_boundary_started",
created: 1,
type: "session.step.started",
durable: durable(sessionID, 1),
data: {
sessionID,
assistantMessageID: "msg_revert_boundary",
agent: "build",
model: { providerID: "provider", id: "model" },
},
})
cost = 0.5
tokens = { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }
emitEvent(events, {
id: "evt_revert_boundary_ended",
created: 2,
type: "session.step.ended",
durable: durable(sessionID, 2),
data: {
sessionID,
assistantMessageID: "msg_revert_boundary",
finish: "stop",
cost: 0.5,
tokens,
},
})
await wait(() => data.session.get(sessionID)?.cost === 0.5)
emitEvent(events, {
id: "evt_revert_later_started",
created: 3,
type: "session.step.started",
durable: durable(sessionID, 3),
data: {
sessionID,
assistantMessageID: "msg_revert_later",
agent: "build",
model: { providerID: "provider", id: "model" },
},
})
cost = 0.75
tokens = { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } }
emitEvent(events, {
id: "evt_revert_later_ended",
created: 4,
type: "session.step.ended",
durable: durable(sessionID, 4),
data: {
sessionID,
assistantMessageID: "msg_revert_later",
finish: "stop",
cost: 0.25,
tokens: { input: 3, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
},
})
await wait(() => data.session.get(sessionID)?.cost === 0.75)
emitEvent(events, {
id: "evt_revert_staged",
created: 5,
type: "session.revert.staged",
durable: durable(sessionID, 5),
data: { sessionID, revert: { messageID: "msg_revert_boundary" } },
})
await wait(() => data.session.get(sessionID)?.revert?.messageID === "msg_revert_boundary")
cost = 0.5
tokens = { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }
emitEvent(events, {
id: "evt_revert_committed",
created: 6,
type: "session.revert.committed",
durable: durable(sessionID, 6),
data: { sessionID, messageID: "msg_revert_boundary" },
})
await wait(() => data.session.get(sessionID)?.cost === 0.5)
expect(data.session.message.ids(sessionID)).toEqual(["msg_revert_boundary"])
expect(data.session.get(sessionID)?.revert).toBeUndefined()
expect(data.session.get(sessionID)?.tokens).toEqual(tokens)
} finally {
app.renderer.destroy()
}
})
test("reconnects the event stream and bootstraps fresh data", async () => {
const events = createEventStream()
const requests = { active: 0, event: 0, model: 0 }
@@ -348,9 +690,24 @@ test("connectedOnce is false until first connect and persists across disconnect"
test("tracks session status from active sessions and execution events", async () => {
const events = createEventStream()
let settled = false
const calls = createFetch((url) => {
if (url.pathname === "/api/session/active")
return json({ data: { "session-active": { type: "running" } }, watermarks: {} })
if (url.pathname === "/api/session/session-live")
return json({
data: {
id: "session-live",
projectID: "proj_test",
cost: settled ? 0.75 : 0,
tokens: settled
? { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }
: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
title: "Live session",
location: { directory },
},
})
}, events)
let data!: ReturnType<typeof useData>
@@ -374,7 +731,9 @@ test("tracks session status from active sessions and execution events", async ()
try {
await wait(() => data.session.status("session-active") === "running")
expect(data.session.status("session-idle")).toBe("idle")
await data.session.refresh("session-live")
settled = true
emitEvent(events, {
id: "evt_step_started",
created: 0,
@@ -398,15 +757,20 @@ test("tracks session status from active sessions and execution events", async ()
sessionID: "session-live",
assistantMessageID: "message-live",
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
cost: 0.75,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
},
})
await wait(() => {
const assistant = data.session.message.get("session-live", "message-live")
return assistant?.type === "assistant" && assistant.finish === "stop"
})
await wait(() => data.session.get("session-live")?.cost === 0.75)
expect(data.session.status("session-live")).toBe("running")
expect(data.session.get("session-live")).toMatchObject({
cost: 0.75,
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
})
emitEvent(events, {
id: "evt_execution_settled",
+20 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { isDefaultTitle } from "../../src/util/session"
import type { SessionMessage } from "@opencode-ai/sdk/v2"
import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session"
describe("util.session", () => {
test("recognizes generated parent and child titles", () => {
@@ -7,4 +8,22 @@ describe("util.session", () => {
expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue()
expect(isDefaultTitle("New session - custom")).toBeFalse()
})
test("tracks usage across undo and redo boundaries", () => {
const assistant = (id: string, input: number): SessionMessage => ({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0 },
})
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
expect(lastAssistantWithUsage(messages, "msg_a")?.tokens.input).toBe(10)
expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined()
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
})
})