refactor(tui): derive turn usage from steps

This commit is contained in:
James Long
2026-07-23 03:08:23 +00:00
parent c26ef364c9
commit 716f235a75
10 changed files with 192 additions and 319 deletions
+1 -2
View File
@@ -142,8 +142,7 @@ const serialize = (message: SessionMessage.Info) => {
.join("\n")
}
if (message.type === "system") return `[System update]: ${message.text}`
if (message.type === "synthetic")
return message.metadata?.modelVisible === false ? "" : `[Synthetic context]: ${message.text}`
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output?.output ?? "")}`
return ""
+2 -69
View File
@@ -102,15 +102,10 @@ const layer = Layer.effect(
const agent = loaded.agent
const resolved = loaded.model
const model = resolved.model
const previousCacheRead = loaded.messages.findLast(
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
message.type === "assistant" && message.tokens !== undefined,
)?.tokens.cache.read
const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost }
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed")
return { _tag: "RestartAfterCompaction", step: currentStep, previousCacheRead } as const
if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const
return yield* new StepFailedError({ error: compacted.error })
}
const prepared = yield* modelRequests.prepare({
@@ -238,7 +233,7 @@ const layer = Layer.effect(
(yield* restore(recoverOverflow({ session, messages: loaded.messages, model, cost: resolved.cost })))
.status === "completed"
)
return { _tag: "RestartAfterOverflowCompaction", step: currentStep, previousCacheRead } as const
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
// An unrecovered held-back overflow becomes the step's durable provider error. A
// thrown LLM failure records the assistant failure unless a provider error was
@@ -346,14 +341,6 @@ const layer = Layer.effect(
_tag: "Completed",
needsContinuation,
step: currentStep,
previousCacheRead,
settlement:
stepSettlement
? {
finish: stepSettlement.finish,
tokens: stepSettlement.usageAvailable ? stepSettlement.tokens : undefined,
}
: undefined,
} as const
}),
)
@@ -371,7 +358,6 @@ const layer = Layer.effect(
let currentPromotion = promotion
let currentStep = step
let assistantMessageID: SessionMessage.ID | undefined
let previousCacheRead: number | undefined
while (true) {
const attempt = yield* Effect.suspend(() =>
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
@@ -396,13 +382,10 @@ const layer = Layer.effect(
.pipe(Effect.andThen(Effect.fail(error.cause)))
}),
)
previousCacheRead ??= attempt.previousCacheRead
if (attempt._tag === "Completed")
return {
needsContinuation: attempt.needsContinuation,
step: attempt.step,
previousCacheRead,
settlement: attempt.settlement,
}
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
yield* Effect.yieldNow
@@ -461,14 +444,6 @@ const layer = Layer.effect(
while (shouldRun) {
let needsContinuation = true
let step = 1
const steps: Array<{
readonly label: string
readonly newTokens: number
readonly cached: number
readonly total: number
readonly breakdown: string
readonly cacheBust?: number
}> = []
// Repeat steps while continuation is needed. A step needs continuation only
// when it recorded local tool calls whose results the model has not yet seen;
// a provider error suppresses it. Pending steers also continue the loop so
@@ -481,30 +456,6 @@ const layer = Layer.effect(
titleAttempted.add(input.sessionID)
forkTitle(title.generateForFirstPrompt(yield* getSession(input.sessionID)).pipe(Effect.ignore))
}
if (result.settlement) {
const tokens = result.settlement.tokens
if (tokens) {
const breakdown = [
tokens.input > 0 ? `${tokens.input.toLocaleString("en-US")} input` : undefined,
tokens.output > 0 ? `${tokens.output.toLocaleString("en-US")} output` : undefined,
tokens.reasoning > 0 ? `${tokens.reasoning.toLocaleString("en-US")} reasoning` : undefined,
tokens.cache.write > 0 ? `${tokens.cache.write.toLocaleString("en-US")} cache write` : undefined,
]
.filter((value): value is string => value !== undefined)
.join(", ")
const newTokens = tokens.input + tokens.output + tokens.reasoning + tokens.cache.write
steps.push({
label: `Step ${steps.length + 1} [${result.settlement.finish}]`,
newTokens,
cached: tokens.cache.read,
total: newTokens + tokens.cache.read,
breakdown,
...(result.previousCacheRead !== undefined && tokens.cache.read < result.previousCacheRead
? { cacheBust: result.previousCacheRead - tokens.cache.read }
: {}),
})
}
}
needsContinuation = result.needsContinuation
step = result.step + 1
if (needsContinuation) {
@@ -516,24 +467,6 @@ const layer = Layer.effect(
promotion = "steer"
needsContinuation = yield* SessionPending.has(db, input.sessionID, "steer")
}
if (steps.length > 0) {
const lines = steps.flatMap((item) => [
`${item.label}: New tokens: ${item.newTokens.toLocaleString("en-US")} · Cached: ${item.cached.toLocaleString("en-US")} · Total: ${item.total.toLocaleString("en-US")}${item.breakdown ? ` [${item.breakdown}]` : ""}`,
...(item.cacheBust === undefined
? []
: [`! Cache bust: ${item.cacheBust.toLocaleString("en-US")} fewer cached tokens than the previous step`]),
])
yield* events.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
text: "",
description: `Turn token usage:\n${lines.join("\n")}`,
metadata: {
kind: "turn-token-usage",
modelVisible: false,
steps,
},
})
}
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionPending.has(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionPending.has(db, input.sessionID, "queue")
@@ -76,7 +76,6 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
| {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
readonly tokens: ReturnType<typeof SessionUsage.tokens>
readonly usageAvailable: boolean
}
| undefined
@@ -470,11 +469,7 @@ export const createLLMEventPublisher = (events: Pick<EventV2.Interface, "publish
case "step-finish":
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = {
finish: event.reason,
tokens: SessionUsage.tokens(event.usage),
usageAvailable: event.usage !== undefined,
}
stepSettlement = { finish: event.reason, tokens: SessionUsage.tokens(event.usage) }
if (event.reason === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
@@ -201,7 +201,6 @@ function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, provider
}),
]
case "synthetic":
if (message.metadata?.modelVisible === false) return []
return [Message.make({ id: message.id, role: "user", content: message.text })]
case "skill":
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
@@ -86,13 +86,6 @@ describe("toLLMMessages", () => {
text: "Synthetic context",
time: { created },
}),
SessionMessage.Synthetic.make({
id: id("hidden-synthetic"),
type: "synthetic",
text: "Internal accounting",
metadata: { modelVisible: false },
time: { created },
}),
SessionMessage.Shell.make({
id: id("shell"),
type: "shell",
@@ -223,22 +223,12 @@ describe("SessionRunnerLLM recorded", () => {
yield* session.resume(sessionID)
const messages = yield* session.context(sessionID)
expect(messages).toHaveLength(3)
expect(messages).toHaveLength(2)
expect(messages[0]).toMatchObject({ id: prompt.id, type: "user", text: "Say hello in one short sentence." })
expect(messages[1]).toMatchObject({ type: "assistant", agent: "build", finish: "stop" })
expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([
{ type: "text", text: "Hello!" },
])
expect(messages[2]).toMatchObject({
type: "synthetic",
description:
"Turn token usage:\nStep 1 [stop]: New tokens: 24 · Cached: 0 · Total: 24 [22 input, 2 output]",
metadata: {
kind: "turn-token-usage",
modelVisible: false,
steps: [{ label: "Step 1 [stop]", newTokens: 24, cached: 0, total: 24, breakdown: "22 input, 2 output" }],
},
})
expect(
(yield* db
.select({ type: EventTable.type })
@@ -254,7 +244,6 @@ describe("SessionRunnerLLM recorded", () => {
"session.text.started.1",
"session.text.ended.1",
"session.step.ended.1",
"session.synthetic.1",
])
}),
)
+1 -125
View File
@@ -2016,7 +2016,7 @@ describe("SessionRunnerLLM", () => {
expect(userTexts(requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
const context = yield* (yield* SessionStore.Service).context(sessionID)
expect(context.map((message) => message.type)).toEqual(["compaction", "assistant", "synthetic"])
expect(context.map((message) => message.type)).toEqual(["compaction", "assistant"])
expect(context[0]).toMatchObject({
type: "compaction",
summary: "## Objective\n- Preserve the task",
@@ -2385,24 +2385,6 @@ describe("SessionRunnerLLM", () => {
},
],
},
{
type: "synthetic",
description:
"Turn token usage:\nStep 1 [tool-calls]: New tokens: 12 · Cached: 2 · Total: 14 [8 input, 3 output, 1 reasoning]",
metadata: {
kind: "turn-token-usage",
modelVisible: false,
steps: [
{
label: "Step 1 [tool-calls]",
newTokens: 12,
cached: 2,
total: 14,
breakdown: "8 input, 3 output, 1 reasoning",
},
],
},
},
])
}),
)
@@ -2452,112 +2434,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("appends each step's token usage after a completed turn", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Use a tool")
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-token-usage", name: "echo", input: { text: "hello" } }),
LLMEvent.stepFinish({
index: 0,
reason: "tool-calls",
usage: {
inputTokens: 10,
nonCachedInputTokens: 8,
outputTokens: 4,
reasoningTokens: 1,
cacheReadInputTokens: 2,
},
}),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({
index: 0,
reason: "stop",
usage: {
inputTokens: 20,
nonCachedInputTokens: 15,
outputTokens: 6,
reasoningTokens: 2,
cacheReadInputTokens: 5,
},
}),
LLMEvent.finish({ reason: "stop" }),
],
]
yield* session.resume(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Use a tool" },
{ type: "assistant", tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } } },
{ type: "assistant", tokens: { input: 15, output: 4, reasoning: 2, cache: { read: 5, write: 0 } } },
{
type: "synthetic",
text: "",
description:
"Turn token usage:\nStep 1 [tool-calls]: New tokens: 12 · Cached: 2 · Total: 14 [8 input, 3 output, 1 reasoning]\nStep 2 [stop]: New tokens: 21 · Cached: 5 · Total: 26 [15 input, 4 output, 2 reasoning]",
metadata: {
kind: "turn-token-usage",
modelVisible: false,
steps: [
{
label: "Step 1 [tool-calls]",
newTokens: 12,
cached: 2,
total: 14,
breakdown: "8 input, 3 output, 1 reasoning",
},
{
label: "Step 2 [stop]",
newTokens: 21,
cached: 5,
total: 26,
breakdown: "15 input, 4 output, 2 reasoning",
},
],
},
},
])
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({
index: 0,
reason: "stop",
usage: { inputTokens: 21, nonCachedInputTokens: 20, outputTokens: 5, cacheReadInputTokens: 1 },
}),
LLMEvent.finish({ reason: "stop" }),
]
responses = undefined
requests.length = 0
yield* admit(session, "Continue")
yield* session.resume(sessionID)
expect(userTexts(requests[0]!)).toEqual(["Use a tool", "Continue"])
expect((yield* session.context(sessionID)).findLast((message) => message.type === "synthetic")).toMatchObject({
description:
"Turn token usage:\nStep 1 [stop]: New tokens: 25 · Cached: 1 · Total: 26 [20 input, 5 output]\n! Cache bust: 4 fewer cached tokens than the previous step",
metadata: {
steps: [
{
label: "Step 1 [stop]",
newTokens: 25,
cached: 1,
total: 26,
breakdown: "20 input, 5 output",
cacheBust: 4,
},
],
},
})
}),
)
it.effect("reloads a model switch before a tool-driven continuation step", () =>
Effect.gen(function* () {
const session = yield* setup
+101 -95
View File
@@ -1034,8 +1034,10 @@ function SessionRowView(props: {
message: (messageID: string) => SessionMessageInfo | undefined
boundaryID?: string
}) {
const config = useConfig()
const hidden = () => props.row.type === "turn-usage" && config.data.debug?.turn_tokens !== true
return (
<box id={props.boundaryID} marginTop={1} flexShrink={0}>
<box id={props.boundaryID} height={hidden() ? 0 : undefined} marginTop={hidden() ? 0 : 1} flexShrink={0}>
<Switch>
<Match when={props.row.type === "message" ? props.row : undefined}>
{(row) => (
@@ -1072,11 +1074,95 @@ function SessionRowView(props: {
</Show>
)}
</Match>
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
{(row) => (
<TurnTokenUsage
messageIDs={row().messageIDs}
previousCacheRead={row().previousCacheRead}
message={props.message}
/>
)}
</Match>
</Switch>
</box>
)
}
function TurnTokenUsage(props: {
messageIDs: string[]
previousCacheRead?: number
message: (messageID: string) => SessionMessageInfo | undefined
}) {
const config = useConfig()
const { themeV2 } = useTheme()
const steps = createMemo(() => {
let previousCacheRead = props.previousCacheRead
return props.messageIDs.flatMap((messageID, index) => {
const message = props.message(messageID)
if (message?.type !== "assistant" || !message.tokens) return []
const total =
message.tokens.input +
message.tokens.output +
message.tokens.reasoning +
message.tokens.cache.read +
message.tokens.cache.write
if (total === 0) return []
const newTokens = total - message.tokens.cache.read
const breakdown = [
message.tokens.input > 0 ? `${message.tokens.input.toLocaleString()} input` : undefined,
message.tokens.output > 0 ? `${message.tokens.output.toLocaleString()} output` : undefined,
message.tokens.reasoning > 0 ? `${message.tokens.reasoning.toLocaleString()} reasoning` : undefined,
message.tokens.cache.write > 0 ? `${message.tokens.cache.write.toLocaleString()} cache write` : undefined,
]
.filter((value): value is string => value !== undefined)
.join(", ")
const cacheBust =
previousCacheRead !== undefined && message.tokens.cache.read < previousCacheRead
? previousCacheRead - message.tokens.cache.read
: undefined
previousCacheRead = message.tokens.cache.read
return [
{
label: `Step ${index + 1} [${message.finish ?? "unknown"}]`,
newTokens,
cached: message.tokens.cache.read,
total,
breakdown,
cacheBust,
},
]
})
})
return (
<Show when={config.data.debug?.turn_tokens === true && steps().length > 0}>
<box paddingLeft={3} flexDirection="column">
<box flexDirection="row">
<text width={INLINE_TOOL_ICON_WIDTH} fg={themeV2.text.subdued}>
</text>
<text fg={themeV2.text.subdued}>Turn token usage:</text>
</box>
<For each={steps()}>
{(item) => (
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
<text fg={themeV2.text.subdued}>
{item.label}:{" "}
<span style={{ attributes: TextAttributes.BOLD }}>New tokens: {item.newTokens.toLocaleString()}</span>
{` · Cached: ${item.cached.toLocaleString()} · Total: ${item.total.toLocaleString()}${item.breakdown ? ` [${item.breakdown}]` : ""}`}
</text>
<Show when={item.cacheBust !== undefined}>
<text fg={themeV2.text.feedback.error.default}>
! Cache bust: {item.cacheBust?.toLocaleString()} fewer cached tokens than the previous step
</text>
</Show>
</box>
)}
</For>
</box>
</Show>
)
}
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
const { themeV2 } = useTheme()
const shortcut = Keymap.useShortcut("session.background")
@@ -1414,11 +1500,8 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
const ctx = use()
const config = useConfig()
const { themeV2 } = useTheme()
const metadata = () => (props.message.type === "synthetic" ? props.message.metadata : undefined)
const turnTokenUsage = () => readTurnTokenUsage(metadata())
const visible = () => metadata()?.kind !== "turn-token-usage" || config.data.debug?.turn_tokens === true
const source = () => stringValue(metadata()?.source)
const completion = () => source() === "subagent" || source() === "shell"
const state = () => stringValue(metadata()?.state)
@@ -1442,101 +1525,24 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
return themeV2.text.feedback.info.default
}
return (
<Show when={visible()}>
<Show
when={turnTokenUsage()}
fallback={
<Show
when={completion()}
fallback={
<InlineToolRow icon="◈" color={themeV2.text.subdued} pending="Notice" complete={true}>
{text()}
</InlineToolRow>
}
>
<box marginLeft={3}>
<text wrapMode="none">
<span style={{ fg: color() }}>{heading()}</span>
<span style={{ fg: themeV2.text.subdued }}>{suffix()}</span>
</text>
</box>
</Show>
}
>
{(steps) => (
<box paddingLeft={3} flexDirection="column">
<box flexDirection="row">
<text width={INLINE_TOOL_ICON_WIDTH} fg={themeV2.text.subdued}>
</text>
<text fg={themeV2.text.subdued}>Turn token usage:</text>
</box>
<For each={steps()}>
{(item) => (
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
<text fg={themeV2.text.subdued}>
{item.label}:{" "}
<span style={{ attributes: TextAttributes.BOLD }}>
New tokens: {item.newTokens.toLocaleString()}
</span>
{` · Cached: ${item.cached.toLocaleString()} · Total: ${item.total.toLocaleString()}${item.breakdown ? ` [${item.breakdown}]` : ""}`}
</text>
<Show when={item.cacheBust !== undefined}>
<text fg={themeV2.text.feedback.error.default}>
! Cache bust: {item.cacheBust?.toLocaleString()} fewer cached tokens than the previous step
</text>
</Show>
</box>
)}
</For>
</box>
)}
</Show>
<Show
when={completion()}
fallback={
<InlineToolRow icon="◈" color={themeV2.text.subdued} pending="Notice" complete={true}>
{text()}
</InlineToolRow>
}
>
<box marginLeft={3}>
<text wrapMode="none">
<span style={{ fg: color() }}>{heading()}</span>
<span style={{ fg: themeV2.text.subdued }}>{suffix()}</span>
</text>
</box>
</Show>
)
}
type TurnTokenStep = {
readonly label: string
readonly newTokens: number
readonly cached: number
readonly total: number
readonly breakdown: string
readonly cacheBust?: number
}
function readTurnTokenUsage(metadata: Record<string, unknown> | undefined) {
if (metadata?.kind !== "turn-token-usage" || !Array.isArray(metadata.steps)) return
const steps = metadata.steps.flatMap((value): TurnTokenStep[] => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return []
const step = value as Record<string, unknown>
const newTokens = finiteNumber(step.newTokens)
const cached = finiteNumber(step.cached)
const total = finiteNumber(step.total)
const cacheBust = step.cacheBust === undefined ? undefined : finiteNumber(step.cacheBust)
if (
typeof step.label !== "string" ||
newTokens === undefined ||
cached === undefined ||
total === undefined ||
typeof step.breakdown !== "string" ||
(step.cacheBust !== undefined && cacheBust === undefined)
)
return []
return [
{
label: step.label,
newTokens,
cached,
total,
breakdown: step.breakdown,
...(cacheBust === undefined ? {} : { cacheBust }),
},
]
})
return steps.length === metadata.steps.length ? steps : undefined
}
function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { type: "skill" }> }) {
const { themeV2 } = useTheme()
return (
+50 -1
View File
@@ -27,6 +27,7 @@ export type SessionRow =
completed: boolean
}
| { type: "assistant-footer"; messageID: string }
| { type: "turn-usage"; messageIDs: string[]; previousCacheRead?: number }
export function createSessionRows(sessionID: Accessor<string>) {
const data = useData()
@@ -127,6 +128,26 @@ export function createSessionRows(sessionID: Accessor<string>) {
),
)
createEffect(
on(
() =>
data.session.message.list(sessionID()).flatMap((message) =>
message.type === "assistant"
? [
{
id: message.id,
finish: message.finish,
error: message.error,
retry: message.retry,
tokens: message.tokens,
},
]
: [],
),
() => setRows(reconcile(reduce())),
),
)
const appendMessage = (messageID: string) =>
setRows(
produce((draft) => {
@@ -260,6 +281,10 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
const isInput = (message: SessionMessageInfo) => inputs.has(message.id)
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
const steps: string[] = []
let previousCacheRead: number | undefined
let turnPreviousCacheRead: number | undefined
let measured = false
return [
...messages.filter((message) => !pending.has(message.id)),
...pendingCompactions,
@@ -271,20 +296,42 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
rows.push({ type: "message", messageID: message.id })
return rows
}
if (steps.length === 0) turnPreviousCacheRead = previousCacheRead
steps.push(message.id)
if (message.tokens && tokenTotal(message.tokens) > 0) {
previousCacheRead = message.tokens.cache.read
measured = true
}
const ordinals = { text: 0, reasoning: 0 }
message.content.forEach((part) => {
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
append(rows, { messageID: message.id, partID }, part)
})
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) {
const terminal = (message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error
if (terminal || message.retry) {
completePrevious(rows)
rows.push({ type: "assistant-footer", messageID: message.id })
}
if (terminal) {
if (measured)
rows.push({
type: "turn-usage",
messageIDs: [...steps],
...(turnPreviousCacheRead === undefined ? {} : { previousCacheRead: turnPreviousCacheRead }),
})
steps.length = 0
turnPreviousCacheRead = undefined
measured = false
}
return rows
}, [])
}
function tokenTotal(tokens: NonNullable<SessionMessageAssistant["tokens"]>) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) {
const byID = new Map(messages.map((message) => [message.id, message]))
const seen = new Set<string>()
@@ -309,6 +356,8 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMess
? row.refs[0]?.messageID
: row.type === "assistant-footer"
? row.messageID
: row.type === "turn-usage"
? row.messageIDs[0]
: undefined
if (!messageID) return undefined
const message = messages.get(messageID)
+35 -1
View File
@@ -255,6 +255,35 @@ test("renders synthetic messages with descriptions", () => {
])
})
test("derives turn usage rows from completed assistant steps", () => {
const usage = (read: number) => ({
input: 100,
output: 16,
reasoning: 0,
cache: { read, write: 0 },
})
const messages: SessionMessageInfo[] = [
{ type: "user", id: "user-1", text: "First", time: { created: 0 } },
assistant("assistant-1", [], { finish: "stop", tokens: usage(10) }),
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
assistant("assistant-2", [], { finish: "tool-calls", tokens: usage(8) }),
assistant("assistant-3", [], { finish: "stop", tokens: usage(12) }),
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "message", messageID: "user-1" },
{ type: "assistant-footer", messageID: "assistant-1" },
{ type: "turn-usage", messageIDs: ["assistant-1"] },
{ type: "message", messageID: "user-2" },
{ type: "assistant-footer", messageID: "assistant-3" },
{
type: "turn-usage",
messageIDs: ["assistant-2", "assistant-3"],
previousCacheRead: 10,
},
])
})
test("renders a footer for a pre-output retry assistant after replay", () => {
const message = assistant("assistant-retry", [])
message.retry = {
@@ -294,7 +323,11 @@ test("places a running compaction barrier before every queued user message", ()
])
})
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
function assistant(
id: string,
content: SessionMessageAssistant["content"],
info: Partial<SessionMessageAssistant> = {},
): SessionMessageAssistant {
return {
type: "assistant",
id,
@@ -302,6 +335,7 @@ function assistant(id: string, content: SessionMessageAssistant["content"]): Ses
model: { id: "model", providerID: "provider" },
content,
time: { created: 1 },
...info,
}
}