From 65152b79366beedb84c830a0082ca8877b1f4cf3 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 9 Sep 2026 17:07:50 +0530 Subject: [PATCH] feat(core): carry compaction usage on the compaction message (#47974) --- packages/client/src/effect/api/api.ts | 18 ++++++ .../client/src/promise/generated/types.ts | 57 ++++++++++++++++++- packages/client/src/solid/data.ts | 8 +++ packages/client/test/solid-compaction.test.ts | 37 +++++++++++- packages/core/src/session/compaction.ts | 15 +++-- packages/core/src/session/message-updater.ts | 6 ++ packages/core/test/session-compaction.test.ts | 10 +++- .../test/session-native-compaction.test.ts | 2 + packages/schema/src/session-event.ts | 6 ++ packages/schema/src/session-message.ts | 8 +++ .../src/message/message-content.tsx | 35 +++++++++--- .../src/storybook/current-session-fixtures.ts | 2 + packages/tui/src/routes/session/index.tsx | 12 ++++ packages/ui/src/i18n/en.ts | 1 + 14 files changed, 198 insertions(+), 19 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index e7898b17ccb..a548a85e6a4 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -986,6 +986,15 @@ export type SessionLogOutput = | undefined readonly text: string readonly recent: string + readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined + readonly tokens?: + | { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + | undefined } } | { @@ -1000,6 +1009,15 @@ export type SessionLogOutput = readonly reason: "auto" | "manual" readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined } readonly inputID?: SessionMessage.ID | undefined + readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined + readonly tokens?: + | { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + | undefined } } | { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index b071b164086..46b74aaa13d 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -523,6 +523,8 @@ export type SessionMessageCompactionFailed = { status: "failed" reason: "auto" | "manual" error: SessionStructuredError + cost?: MoneyUSD + tokens?: TokenUsageInfo } export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue } @@ -808,7 +810,14 @@ export type SessionCompactionFailed = { type: "session.compaction.failed" durable: { aggregateID: string; seq: number; version: 1 } location?: LocationRef - data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string } + data: { + sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string + cost?: MoneyUSD + tokens?: TokenUsageInfo + } } export type SessionRevertCleared = { @@ -1738,6 +1747,8 @@ export type SessionMessageCompactionCompleted = { summary: string recent: string providerContext?: SessionProviderContext + cost?: MoneyUSD + tokens?: TokenUsageInfo } export type SessionCompactionEnded = { @@ -1755,6 +1766,8 @@ export type SessionCompactionEnded = { providerContext?: SessionProviderContext text: string recent: string + cost?: MoneyUSD + tokens?: TokenUsageInfo } } @@ -3110,6 +3123,13 @@ export type SessionImportInput = { } readonly messages: JsonValue } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } } | { readonly type: "compaction" @@ -3119,6 +3139,13 @@ export type SessionImportInput = { readonly status: "failed" readonly reason: "auto" | "manual" readonly error: { readonly type: string; readonly message: string; readonly status?: number } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } } ) > @@ -3401,6 +3428,13 @@ export type SessionImportInput = { } readonly messages: JsonValue } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } } | { readonly type: "compaction" @@ -3410,6 +3444,13 @@ export type SessionImportInput = { readonly status: "failed" readonly reason: "auto" | "manual" readonly error: { readonly type: string; readonly message: string; readonly status?: number } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } } ) > @@ -3692,6 +3733,13 @@ export type SessionImportInput = { } readonly messages: JsonValue } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } } | { readonly type: "compaction" @@ -3701,6 +3749,13 @@ export type SessionImportInput = { readonly status: "failed" readonly reason: "auto" | "manual" readonly error: { readonly type: string; readonly message: string; readonly status?: number } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } } ) > diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index bf4465f7889..5071b0368f6 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -1073,8 +1073,11 @@ export function createData(config: CreateDataInput) { reason: event.data.reason, model: event.data.model, providerState: event.data.providerState, + providerContext: event.data.providerContext, summary: event.data.text, recent: event.data.recent, + cost: event.data.cost, + tokens: event.data.tokens, }) return } @@ -1085,8 +1088,11 @@ export function createData(config: CreateDataInput) { reason: event.data.reason, model: event.data.model, providerState: event.data.providerState, + providerContext: event.data.providerContext, summary: event.data.text, recent: event.data.recent, + cost: event.data.cost, + tokens: event.data.tokens, time: { created: event.created }, }) }) @@ -1106,6 +1112,8 @@ export function createData(config: CreateDataInput) { message: "Compaction failed before recording an error", }, metadata: current?.type === "compaction" ? current.metadata : event.metadata, + cost: event.data.cost, + tokens: event.data.tokens, time: current?.type === "compaction" ? current.time : { created: event.created }, } if (current?.type === "compaction") { diff --git a/packages/client/test/solid-compaction.test.ts b/packages/client/test/solid-compaction.test.ts index f33596cc564..1ea40d5d047 100644 --- a/packages/client/test/solid-compaction.test.ts +++ b/packages/client/test/solid-compaction.test.ts @@ -100,13 +100,46 @@ test.each(["started", "cancelled", "failed"])( expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }]) const model = { providerID: "demo", id: "model" } const providerState = { responseId: "summary-response" } + const tokens = { input: 10, output: 4, reasoning: 0, cache: { read: 3, write: 0 } } + const providerContext = { + version: 1 as const, + provenance: { + providerID: "demo", + provider: "demo", + modelID: "model", + route: "demo-responses", + protocol: "demo", + endpoint: "digest", + }, + messages: [], + } fixture.emit({ ...event, type: "session.compaction.ended", - data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" }, + data: { + sessionID, + reason: "manual", + model, + providerState, + providerContext, + text: "Summary", + recent: "Recent", + cost: 0.01, + tokens, + }, }) + // The live fold carries the provider window and request usage so the label matches a reloaded session. expect(fixture.data.session.message.list(sessionID)).toMatchObject([ - { type: "compaction", status: "completed", summary: "Summary", model, providerState }, + { + type: "compaction", + status: "completed", + summary: "Summary", + model, + providerState, + providerContext, + cost: 0.01, + tokens, + }, ]) } }, diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 8ee111bd542..b45292d9b5a 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -390,12 +390,7 @@ export const layer = Layer.effect( }, }), }) - const failed = Effect.fnUntraced(function* (input: { - readonly sessionID: SessionSchema.ID - readonly reason: SessionMessage.Compaction["reason"] - readonly error: SessionError.Error - readonly inputID?: SessionMessage.ID - }) { + const failed = Effect.fnUntraced(function* (input: SessionEvent.Compaction.Failed["data"]) { yield* bus.publish(SessionEvent.Compaction.Failed, input) return { status: "failed" as const, error: input.error } }) @@ -511,11 +506,12 @@ export const layer = Layer.effect( ) }), ) - if (result.usage) + const usage = result.usage ? SessionUsage.record(result.usage, context.model.cost) : undefined + if (usage) yield* bus.publish(SessionEvent.UsageRecorded, { sessionID: context.session.id, source: "compaction" as const, - ...SessionUsage.record(result.usage, context.model.cost), + ...usage, }) yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID: context.session.id, @@ -524,6 +520,7 @@ export const layer = Layer.effect( text: "", recent: "", providerContext: SessionProviderContext.encode(provenance, result.replacement), + ...usage, }) return { status: "completed" as const } }), @@ -671,6 +668,7 @@ export const layer = Layer.effect( reason: input.reason, error, inputID: input.inputID, + ...usage, }) } yield* bus.publish(SessionEvent.Compaction.Ended, { @@ -680,6 +678,7 @@ export const layer = Layer.effect( providerState, text: summary, recent: history.recent, + ...usage, }) return { status: "completed" as const } }) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 8675690f33b..0177c095a84 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -415,6 +415,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { summary: event.data.text, providerContext: event.data.providerContext, recent: event.data.recent, + cost: event.data.cost, + tokens: event.data.tokens, }) return } @@ -430,6 +432,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { summary: event.data.text, providerContext: event.data.providerContext, recent: event.data.recent, + cost: event.data.cost, + tokens: event.data.tokens, time: { created }, }), ) @@ -444,6 +448,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { metadata: current?.metadata ?? event.metadata, reason: event.data.reason, error: event.data.error, + cost: event.data.cost, + tokens: event.data.tokens, time: current?.time ?? { created }, }) if (current?.status === "running") return yield* adapter.updateCompaction(failed) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 4e7b6c56c05..4ed46bca0d9 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -409,8 +409,16 @@ it.effect("manual compaction summarizes short context instead of no-op", () => expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.") expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project") expect(JSON.stringify(requests[0]?.messages)).not.toContain("display-only-output") + // The compaction message carries its own request usage so clients can show what compacting cost. expect(yield* store.context(sessionID)).toMatchObject([ - { type: "compaction", reason: "manual", summary: "## Objective\n- manual summary", recent: "" }, + { + type: "compaction", + reason: "manual", + summary: "## Objective\n- manual summary", + recent: "", + cost: 0.0000233, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } }, + }, ]) expect(yield* store.get(sessionID)).toMatchObject({ cost: 0.0000233, diff --git a/packages/core/test/session-native-compaction.test.ts b/packages/core/test/session-native-compaction.test.ts index 7a68439fff1..872a1212e55 100644 --- a/packages/core/test/session-native-compaction.test.ts +++ b/packages/core/test/session-native-compaction.test.ts @@ -240,6 +240,8 @@ const setup = Effect.fnUntraced(function* (endpoint = false) { return yield* Effect.die("Missing native checkpoint") expect(last.summary).toBe("") expect(last.recent).toBe("") + // Provider compaction has no summary, so the request usage is the only visible cost of the operation. + expect(last.tokens).toMatchObject({ input: 20, output: 4 }) return last.providerContext }) return { diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 3235f08ac5e..c22a901ce40 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -591,6 +591,10 @@ export namespace Compaction { providerContext: SessionMessage.CompactionCompleted.fields.providerContext, text: Schema.String, recent: Schema.String, + // Repeats the internal `session.usage.recorded` figures: that event never reaches clients, and it + // stays the accounting source for session totals and stats. + cost: SessionMessage.CompactionCompleted.fields.cost, + tokens: SessionMessage.CompactionCompleted.fields.tokens, }, }) export type Ended = typeof Ended.Type @@ -603,6 +607,8 @@ export namespace Compaction { reason: Started.data.fields.reason, error: SessionError.Error, inputID: SessionMessage.ID.pipe(optional), + cost: SessionMessage.CompactionFailed.fields.cost, + tokens: SessionMessage.CompactionFailed.fields.tokens, }, }) export type Failed = typeof Failed.Type diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 6bd37eb5174..7e8ce0482ae 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -237,6 +237,12 @@ export const Assistant = Schema.Struct({ const CompactionBase = { type: Schema.tag("compaction"), ...Base } +/** Usage of the compaction request itself, not the size of the resulting context. */ +const CompactionUsage = { + cost: Money.USD.pipe(optional), + tokens: TokenUsage.Info.pipe(optional), +} + export interface CompactionRunning extends Schema.Schema.Type {} export const CompactionRunning = Schema.Struct({ ...CompactionBase, @@ -256,6 +262,7 @@ export const CompactionCompleted = Schema.Struct({ summary: Schema.String, recent: Schema.String, providerContext: SessionProviderContext.Info.pipe(optional), + ...CompactionUsage, }).annotate({ identifier: "Session.Message.Compaction.Completed" }) export interface CompactionFailed extends Schema.Schema.Type {} @@ -264,6 +271,7 @@ export const CompactionFailed = Schema.Struct({ status: Schema.tag("failed"), reason: Schema.Literals(["auto", "manual"]), error: SessionError.Error, + ...CompactionUsage, }).annotate({ identifier: "Session.Message.Compaction.Failed" }) export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted, CompactionFailed]).pipe( diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index 4b215179d7e..297248e07e3 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -395,17 +395,38 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact if (props.message.status !== "failed" || props.message.error.type === "aborted") return "" return props.error } + const compact = createMemo( + () => new Intl.NumberFormat(i18n.locale(), { notation: "compact", maximumFractionDigits: 1 }), + ) + // Usage of the compaction request itself; the resulting context size only shows on the next assistant step. + const usage = () => { + if (props.message.status === "running" || !props.message.tokens) return "" + const tokens = props.message.tokens + const input = tokens.input + tokens.cache.read + tokens.cache.write + const output = tokens.output + tokens.reasoning + if (input + output <= 0) return "" + return i18n.t("ui.messagePart.compaction.usage", { + input: compact().format(input), + output: compact().format(output), + }) + } + const label = createMemo(() => + [ + i18n.t( + props.message.status === "completed" && props.message.providerContext + ? "ui.messagePart.providerCompaction" + : "ui.messagePart.compaction", + ), + usage(), + ] + .filter(Boolean) + .join(" · "), + ) return (
- +
diff --git a/packages/session-ui/src/storybook/current-session-fixtures.ts b/packages/session-ui/src/storybook/current-session-fixtures.ts index 8b52c4e0b62..f29e37a461b 100644 --- a/packages/session-ui/src/storybook/current-session-fixtures.ts +++ b/packages/session-ui/src/storybook/current-session-fixtures.ts @@ -998,6 +998,8 @@ export const compactionDocument = document([ reason: "auto", summary: "The Session timeline now consumes current nested assistant content.", recent: "Add deterministic stories and verify Storybook.", + cost: 0.0142, + tokens: { input: 3_180, output: 412, reasoning: 96, cache: { read: 8_704, write: 0 } }, time: { created: STORY_TIME + 63_000 }, }, assistant({ diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index dd34e40459c..f2fa83c68a5 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2098,6 +2098,15 @@ function CompactionMessage(props: { message: Extract text().trim()) const color = () => (status() === "failed" && !cancelled() ? theme.text.feedback.error.default : theme.text.subdued) + // Usage of the compaction request itself; the resulting context size only shows on the next assistant step. + const usage = () => { + if (props.message.status === "running" || !props.message.tokens) return + const tokens = props.message.tokens + const input = tokens.input + tokens.cache.read + tokens.cache.write + const output = tokens.output + tokens.reasoning + if (input + output <= 0) return + return `${Locale.number(input)} in · ${Locale.number(output)} out` + } return ( @@ -2121,6 +2130,9 @@ function CompactionMessage(props: { message: Extract · cancelled + + · {usage()} + diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 05ecfd9f6d7..6adc4960464 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -105,6 +105,7 @@ const source = { "ui.messagePart.questions.dismissed": "Questions dismissed", "ui.messagePart.compaction": "Session compacted", "ui.messagePart.providerCompaction": "Session compacted by provider", + "ui.messagePart.compaction.usage": "{{input}} in · {{output}} out", "ui.messagePart.context.details": "Details", "ui.messagePart.context.read.one": "{{count}} read", "ui.messagePart.context.read.other": "{{count}} reads",