mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-26 18:49:59 +00:00
feat(desktop): show session tokens and cost on the session view (#53785)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
parent
46e4695b96
commit
7b33bcea4e
11 changed files with 264 additions and 78 deletions
|
|
@ -145,7 +145,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
|||
const panel = page.locator("#review-panel")
|
||||
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
||||
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
const contextButton = page.getByRole("button", { name: "Toggle session context" })
|
||||
const openFile = panel.getByRole("button", { name: "Open file" })
|
||||
const tab = (name: string) => panel.getByRole("tab", { name, exact: true })
|
||||
await contextButton.click()
|
||||
|
|
@ -311,7 +311,7 @@ test("context closes the side region only when its button opened it", async ({ p
|
|||
})
|
||||
const panel = page.locator("#review-panel")
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
const contextButton = page.getByRole("button", { name: "Toggle session context" })
|
||||
const context = panel.getByRole("tab", { name: "Context", exact: true })
|
||||
|
||||
await contextButton.click()
|
||||
|
|
@ -335,6 +335,83 @@ test("context closes the side region only when its button opened it", async ({ p
|
|||
await expect(panel.locator("#session-side-panel-review-tab")).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
test("context hides free-model cost but keeps paid-model zero cost", async ({ page }) => {
|
||||
const cases = [
|
||||
{ id: "ses_context_free", title: "Free model", model: "free", cost: 0, usage: false, text: "0" },
|
||||
{ id: "ses_context_paid", title: "Paid model", model: "paid", cost: 0, usage: false, text: "0·$0.00" },
|
||||
{ id: "ses_context_used", title: "Paid usage", model: "paid", cost: 1.25, usage: true, text: "2k·$1.25" },
|
||||
{ id: "ses_context_free_used", title: "Free usage", model: "free", cost: 0, usage: true, text: "2k" },
|
||||
{ id: "ses_context_mixed", title: "Previously paid", model: "free", cost: 1.25, usage: true, text: "2k·$1.25" },
|
||||
{ id: "ses_context_unknown", title: "Unknown pricing", model: "missing", cost: 0, usage: false, text: "0·$0.00" },
|
||||
{ id: "ses_context_output", title: "Output pricing", model: "output", cost: 0, usage: false, text: "0·$0.00" },
|
||||
// The root's model is free, but its subagent spent on a paid model: the family total shows.
|
||||
{ id: "ses_context_family", title: "Paid subagent", model: "free", cost: 0, usage: true, text: "2k·$1.25" },
|
||||
// A completed compaction resets the context until the next reply reports usage, as in the TUI.
|
||||
{ id: "ses_context_compacted", title: "Compacted", model: "paid", cost: 1.25, usage: true, text: "0·$1.25" },
|
||||
]
|
||||
|
||||
await openSession(page, {
|
||||
name: "ContextPricing",
|
||||
provider: provider(
|
||||
{ id: "free", name: "Free", cost: { input: 0, output: 0 } },
|
||||
{ id: "paid", name: "Paid", cost: { input: 1, output: 3 } },
|
||||
{ id: "output", name: "Output", cost: { input: 0, output: 1 } },
|
||||
),
|
||||
sessions: [
|
||||
...cases.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
model: { id: item.model, providerID: "opencode" },
|
||||
cost: item.cost,
|
||||
})),
|
||||
{
|
||||
id: "ses_context_family_child",
|
||||
parentID: "ses_context_family",
|
||||
title: "Subagent run",
|
||||
model: { id: "paid", providerID: "opencode" },
|
||||
cost: 1.25,
|
||||
},
|
||||
],
|
||||
pageMessages: (id) => ({
|
||||
items: cases.flatMap((item): SessionMessageInfo[] =>
|
||||
item.id === id && item.usage
|
||||
? [
|
||||
{
|
||||
id: `msg_${item.id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: item.model, providerID: "opencode" },
|
||||
tokens: { input: 1500, output: 500, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
content: [{ type: "text", text: "Done." }],
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
...(item.id === "ses_context_compacted"
|
||||
? [
|
||||
{
|
||||
id: `msg_${item.id}_compaction`,
|
||||
type: "compaction" as const,
|
||||
status: "completed" as const,
|
||||
reason: "manual" as const,
|
||||
summary: "Current state",
|
||||
recent: "",
|
||||
time: { created: 3 },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
}),
|
||||
})
|
||||
const button = page.getByRole("button", { name: "Toggle session context", exact: true })
|
||||
|
||||
for (const item of cases) {
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: item.title }).click()
|
||||
await expectSessionTitle(page, item.title)
|
||||
await expect(button).toHaveText(item.text)
|
||||
}
|
||||
})
|
||||
|
||||
test("file tree expands Windows paths and scrolls long names in both directions", async ({ page }) => {
|
||||
const directory = "C:/OpenCode/OpenFileExpand"
|
||||
const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts"
|
||||
|
|
@ -864,7 +941,7 @@ test("restores review state and the side-panel tab per session", async ({ page }
|
|||
await page.getByRole("option", { name: "Branch changes" }).click()
|
||||
await page.getByRole("button", { name: "beta.ts" }).click()
|
||||
await selectedFile("beta.ts")
|
||||
await page.getByRole("button", { name: "View context usage" }).click()
|
||||
await page.getByRole("button", { name: "Toggle session context" }).click()
|
||||
await selectedTab("Context")
|
||||
|
||||
await switchSession("Gamma review state")
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import type {
|
|||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
SessionInboxInfo,
|
||||
SessionInboxCompaction,
|
||||
ShellInfo,
|
||||
|
|
@ -204,6 +205,8 @@ function createSync() {
|
|||
}
|
||||
}
|
||||
|
||||
const CHILD_PAGE = 200
|
||||
|
||||
export function createData(config: CreateDataInput) {
|
||||
const api = config.api
|
||||
let disposed = false
|
||||
|
|
@ -499,6 +502,16 @@ export function createData(config: CreateDataInput) {
|
|||
return created
|
||||
}
|
||||
|
||||
// Every direct subagent, not only the server's default first page, so a root's family cost
|
||||
// counts all of them. Pages stay newest first, the order the family index has always used.
|
||||
async function listChildren(input: SessionListInput): Promise<SessionInfo[]> {
|
||||
const page = await api().session.list(input)
|
||||
|
||||
if (page.data.length < CHILD_PAGE || !page.cursor.next) return page.data
|
||||
|
||||
return [...page.data, ...(await listChildren({ cursor: page.cursor.next, limit: CHILD_PAGE }))]
|
||||
}
|
||||
|
||||
// Walk parentID upward through loaded session info to the family root. When a
|
||||
// parent's info is missing, that missing ID is the furthest-known ancestor and
|
||||
// is returned so orphan subtrees group under it until the parent arrives. A
|
||||
|
|
@ -1619,11 +1632,7 @@ export function createData(config: CreateDataInput) {
|
|||
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
|
||||
const [info, children] = await Promise.all([
|
||||
api().session.get({ sessionID }),
|
||||
options?.children
|
||||
? api()
|
||||
.session.list({ parentID: sessionID, order: "desc" })
|
||||
.then((response) => response.data)
|
||||
: [],
|
||||
options?.children ? listChildren({ parentID: sessionID, order: "desc", limit: CHILD_PAGE }) : [],
|
||||
])
|
||||
const sessions = [info, ...children]
|
||||
batch(() => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./data"
|
||||
export * from "./connection"
|
||||
export * from "./pty"
|
||||
export * from "./usage"
|
||||
|
|
|
|||
22
packages/client/src/solid/usage.ts
Normal file
22
packages/client/src/solid/usage.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { SessionMessageAssistant, SessionMessageInfo } from "../promise"
|
||||
|
||||
/**
|
||||
* The assistant step whose tokens fill the context window now: the latest one after the last completed
|
||||
* compaction and before the revert boundary. Every client shows context usage from this step, so the TUI
|
||||
* and the desktop app agree on it.
|
||||
*/
|
||||
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
|
||||
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
|
||||
|
||||
if (boundary && boundaryIndex === -1) return undefined
|
||||
const end = boundaryIndex === -1 ? messages.length : boundaryIndex
|
||||
|
||||
const compactionIndex = messages.findLastIndex(
|
||||
(message, index) => message.type === "compaction" && message.status === "completed" && index < end,
|
||||
)
|
||||
|
||||
return messages.findLast(
|
||||
(message, index): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } =>
|
||||
message.type === "assistant" && message.tokens !== undefined && index > compactionIndex && index < end,
|
||||
)
|
||||
}
|
||||
|
|
@ -52,6 +52,47 @@ test("uses the configured initial window and retains normal cursor page sizes",
|
|||
}
|
||||
})
|
||||
|
||||
test("counts every subagent page in a root session's cost", async () => {
|
||||
const child = (index: number): SessionInfo => ({
|
||||
...session(0),
|
||||
id: `ses_child_${index}`,
|
||||
parentID: "ses_root",
|
||||
cost: 1,
|
||||
})
|
||||
const requests: string[] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const url = new URL((input instanceof Request ? input : new Request(input, init)).url)
|
||||
requests.push(`${url.pathname}?${url.searchParams}`)
|
||||
|
||||
if (url.pathname === "/api/session/ses_root") return Response.json({ data: { ...session(0), id: "ses_root", cost: 0.5 } })
|
||||
|
||||
if (url.searchParams.get("cursor") === "page_2")
|
||||
return Response.json({ data: Array.from({ length: 50 }, (_, index) => child(200 + index)), cursor: { next: "page_3" } })
|
||||
|
||||
return Response.json({ data: Array.from({ length: 200 }, (_, index) => child(index)), cursor: { next: "page_2" } })
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({ api: () => api, directory: "/project", event: { on: () => () => {}, listen: () => () => {} } }),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
try {
|
||||
await setup.data.session.sync("ses_root", { children: true })
|
||||
expect(setup.data.session.family("ses_root")).toHaveLength(251)
|
||||
expect(setup.data.session.cost("ses_root")).toBe(250.5)
|
||||
expect(requests.toSorted()).toEqual([
|
||||
"/api/session/ses_root?",
|
||||
"/api/session?limit=200&cursor=page_2",
|
||||
"/api/session?limit=200&order=desc&parentID=ses_root",
|
||||
])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the previous model in a live variant notice until the projected message arrives", async () => {
|
||||
const projected = Promise.withResolvers<void>()
|
||||
const previous = { providerID: "provider", id: "model", variant: "high" }
|
||||
|
|
|
|||
43
packages/client/test/solid-usage.test.ts
Normal file
43
packages/client/test/solid-usage.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "../src/promise"
|
||||
import { lastAssistantWithUsage } from "../src/solid/usage"
|
||||
|
||||
const assistant = (id: string, input: number): SessionMessageInfo => ({
|
||||
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 },
|
||||
})
|
||||
|
||||
describe("lastAssistantWithUsage", () => {
|
||||
test("tracks usage across undo and redo boundaries", () => {
|
||||
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)
|
||||
})
|
||||
|
||||
test("resets usage at completed compaction until the next assistant reports it", () => {
|
||||
const compaction: SessionMessageInfo = {
|
||||
id: "msg_compaction",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
summary: "Current state",
|
||||
recent: "",
|
||||
time: { created: 0 },
|
||||
}
|
||||
|
||||
const messages = [assistant("msg_before", 30), compaction]
|
||||
|
||||
expect(lastAssistantWithUsage(messages)).toBeUndefined()
|
||||
|
||||
messages.push(assistant("msg_after", 5))
|
||||
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(5)
|
||||
})
|
||||
})
|
||||
|
|
@ -5,6 +5,7 @@ export default {
|
|||
"usage.usage": "Context",
|
||||
"usage.cost": "Cost",
|
||||
"usage.view": "View context usage",
|
||||
"usage.toggle": "Toggle session context",
|
||||
"systemPrompt.title": "System Prompt",
|
||||
"rawMessages.title": "Raw messages",
|
||||
"export.session": "Export session",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Show, createMemo, type ComponentProps, type JSX } from "solid-js"
|
||||
import { ProgressCircle } from "@opencode/ui/progress-circle"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { lastAssistantWithUsage } from "@opencode/client/solid"
|
||||
import { useExtension, type MountedSession } from "../sdk"
|
||||
import { catalogModel, syncCatalog } from "./catalog"
|
||||
|
||||
|
|
@ -39,10 +40,15 @@ export function SessionContextUsage(props: {
|
|||
}),
|
||||
)
|
||||
|
||||
const context = createMemo(() => {
|
||||
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
|
||||
const info = createMemo(() =>
|
||||
props.session.id ? props.session.server.data.session.get(props.session.id) : undefined,
|
||||
)
|
||||
|
||||
if (message?.type !== "assistant" || !message.tokens) return
|
||||
// Same step as the TUI: after the last completed compaction and before an undo boundary.
|
||||
const context = createMemo(() => {
|
||||
const message = lastAssistantWithUsage(messages(), info()?.revert?.messageID)
|
||||
|
||||
if (!message) return
|
||||
const model = catalogModel(props.session, message.model)?.model
|
||||
|
||||
const total =
|
||||
|
|
@ -58,11 +64,28 @@ export function SessionContextUsage(props: {
|
|||
}
|
||||
})
|
||||
|
||||
// A root session's cost includes its subagent sessions, as in the TUI.
|
||||
const cost = createMemo(() =>
|
||||
usd().format(props.session.id ? props.session.server.data.session.cost(props.session.id) : 0),
|
||||
const tokens = createMemo(() =>
|
||||
new Intl.NumberFormat(i18n.locale(), { notation: "compact", maximumFractionDigits: 0 })
|
||||
.format(context()?.total ?? 0)
|
||||
.toLocaleLowerCase(i18n.locale()),
|
||||
)
|
||||
|
||||
// A root session's cost includes its subagent sessions, as in the TUI.
|
||||
const spent = createMemo(() => (props.session.id ? props.session.server.data.session.cost(props.session.id) : 0))
|
||||
|
||||
const cost = createMemo(() => usd().format(spent()))
|
||||
|
||||
// Spend anywhere in the family keeps the cost visible, even when this session's model is free.
|
||||
const showCost = createMemo(() => {
|
||||
if (spent() > 0) return true
|
||||
const ref = info()?.model ?? messages().findLast((item) => item.type === "assistant")?.model
|
||||
const model = ref ? catalogModel(props.session, ref)?.model : undefined
|
||||
|
||||
if (!model) return true
|
||||
|
||||
return model.cost.some((cost) => cost.input > 0 || cost.output > 0 || cost.cache.read > 0 || cost.cache.write > 0)
|
||||
})
|
||||
|
||||
const openContext = () => {
|
||||
if (!props.session.id) return
|
||||
layout.toggle(`${ctx.id}:main`, props.session)
|
||||
|
|
@ -86,13 +109,15 @@ export function SessionContextUsage(props: {
|
|||
|
||||
const compactCircle = () => (
|
||||
<div class="flex items-center justify-center">
|
||||
<ProgressCircle appearance="compact" percentage={context()?.usage ?? 0} />
|
||||
<ProgressCircle appearance="compact" size={16} percentage={context()?.usage ?? 0} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const tooltipValue = () => (
|
||||
<div class="flex w-[120px] flex-col gap-2">
|
||||
<ContextTooltipRow name={ctx.t("usage.cost")} value={cost()} />
|
||||
<Show when={showCost()}>
|
||||
<ContextTooltipRow name={ctx.t("usage.cost")} value={cost()} />
|
||||
</Show>
|
||||
<ContextTooltipRow name={ctx.t("usage.usage")} value={`${context()?.usage ?? 0}%`} />
|
||||
<ContextTooltipRow name={ctx.t("usage.tokens")} value={context()?.total.toLocaleString(i18n.locale()) ?? "0"} />
|
||||
</div>
|
||||
|
|
@ -100,18 +125,33 @@ export function SessionContextUsage(props: {
|
|||
|
||||
return (
|
||||
<Show when={props.session.id}>
|
||||
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
|
||||
<Tooltip
|
||||
value={variant() === "indicator" ? tooltipValue() : ctx.t("usage.toggle")}
|
||||
placement={props.placement ?? "top"}
|
||||
>
|
||||
<Show
|
||||
when={variant() === "indicator"}
|
||||
fallback={
|
||||
<IconButton
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={compactCircle()}
|
||||
class="group shrink-0"
|
||||
style={{ padding: "0 6px", color: "var(--v2-text-text-faint)" }}
|
||||
onClick={openContext}
|
||||
aria-label={ctx.t("usage.view")}
|
||||
/>
|
||||
aria-expanded={layout.state(`${ctx.id}:main`, props.session) === "visible"}
|
||||
aria-label={ctx.t("usage.toggle")}
|
||||
>
|
||||
<span class="flex items-center gap-2 whitespace-nowrap group-active:text-v2-text-text-muted">
|
||||
{compactCircle()}
|
||||
<span>{tokens()}</span>
|
||||
<Show when={showCost()}>
|
||||
<span aria-hidden="true" class="flex w-1.5 shrink-0 items-center justify-center">
|
||||
·
|
||||
</span>
|
||||
<span>{cost()}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{circle()}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { useI18n } from "@opencode/ui/context/i18n"
|
|||
import { File } from "@opencode/session-ui/file"
|
||||
import { Markdown } from "@opencode/session-ui/markdown"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { lastAssistantWithUsage } from "@opencode/client/solid"
|
||||
import { createKeyed, useExtension, type MountedSession } from "../sdk"
|
||||
import { catalogModel, syncCatalog } from "./catalog"
|
||||
import { fetchSessionExport, sessionExportFilename } from "./export"
|
||||
|
|
@ -108,10 +109,11 @@ export default function SessionContextTab(props: { session: MountedSession }) {
|
|||
}),
|
||||
)
|
||||
|
||||
// Same step as the TUI and the header button: after the last completed compaction and before an undo boundary.
|
||||
const context = createMemo(() => {
|
||||
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
|
||||
const message = lastAssistantWithUsage(messages(), info()?.revert?.messageID)
|
||||
|
||||
if (message?.type !== "assistant" || !message.tokens) return
|
||||
if (!message) return
|
||||
const entry = catalogModel(props.session, message.model)
|
||||
|
||||
const total =
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode/client"
|
||||
import type { ModelInfo, SessionMessageInfo } from "@opencode/client"
|
||||
import { lastAssistantWithUsage } from "@opencode/client/solid"
|
||||
import { Locale } from "./locale"
|
||||
|
||||
type SessionNode = {
|
||||
|
|
@ -42,19 +43,6 @@ export function sessionFamily<T extends SessionNode>(sessions: readonly T[], ses
|
|||
return walk(root(current).id, [])
|
||||
}
|
||||
|
||||
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
|
||||
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
|
||||
if (boundary && boundaryIndex === -1) return undefined
|
||||
const end = boundaryIndex === -1 ? messages.length : boundaryIndex
|
||||
const compactionIndex = messages.findLastIndex(
|
||||
(message, index) => message.type === "compaction" && message.status === "completed" && index < end,
|
||||
)
|
||||
return messages.findLast(
|
||||
(message, index): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } =>
|
||||
message.type === "assistant" && message.tokens !== undefined && index > compactionIndex && index < end,
|
||||
)
|
||||
}
|
||||
|
||||
export function contextUsage(
|
||||
messages: ReadonlyArray<SessionMessageInfo>,
|
||||
models: ReadonlyArray<ModelInfo> | undefined,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode/client"
|
||||
import { lastAssistantWithUsage, sessionFamily } from "../../src/util/session"
|
||||
|
||||
const assistant = (id: string, input: number): SessionMessageInfo => ({
|
||||
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 },
|
||||
})
|
||||
import { sessionFamily } from "../../src/util/session"
|
||||
|
||||
describe("util.session", () => {
|
||||
test("flattens nested subagents from any session in the family", () => {
|
||||
|
|
@ -33,31 +22,4 @@ describe("util.session", () => {
|
|||
{ session: sessions[6], prefix: "└─ " },
|
||||
])
|
||||
})
|
||||
|
||||
test("tracks usage across undo and redo boundaries", () => {
|
||||
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)
|
||||
})
|
||||
|
||||
test("resets usage at completed compaction until the next assistant reports it", () => {
|
||||
const compaction: SessionMessageInfo = {
|
||||
id: "msg_compaction",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
summary: "Current state",
|
||||
recent: "",
|
||||
time: { created: 0 },
|
||||
}
|
||||
const messages = [assistant("msg_before", 30), compaction]
|
||||
|
||||
expect(lastAssistantWithUsage(messages)).toBeUndefined()
|
||||
|
||||
messages.push(assistant("msg_after", 5))
|
||||
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(5)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue