diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 38758b0a7997..5ca4c5463bd2 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 7f8c229e9ff8..fac9f7659cda 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 } } @@ -3111,6 +3124,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" @@ -3120,6 +3140,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 } + } } ) > @@ -3402,6 +3429,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" @@ -3411,6 +3445,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 } + } } ) > @@ -3693,6 +3734,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" @@ -3702,6 +3750,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 7fb8d710fbb2..7c08fe73a251 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -1083,8 +1083,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 } @@ -1095,8 +1098,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 }, }) }) @@ -1116,6 +1122,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 f33596cc5641..1ea40d5d0476 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 d3d44ee49656..22139a0eaccf 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -383,12 +383,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 } }) @@ -504,11 +499,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, @@ -517,6 +513,7 @@ export const layer = Layer.effect( text: "", recent: "", providerContext: SessionProviderContext.encode(provenance, result.replacement), + ...usage, }) return { status: "completed" as const } }), @@ -662,6 +659,7 @@ export const layer = Layer.effect( reason: input.reason, error, inputID: input.inputID, + ...usage, }) } yield* bus.publish(SessionEvent.Compaction.Ended, { @@ -671,6 +669,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 8675690f33b2..0177c095a845 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 5384eb99efe8..0d4c21991eb8 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -401,8 +401,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 7a68439fff1f..872a1212e557 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 d82ad08a0aa1..ca1b9a7ec381 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -590,6 +590,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 @@ -602,6 +606,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 6bd37eb51746..7e8ce0482ae9 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 149e0a60869a..8cc61e80a8c7 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -384,17 +384,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 8b52c4e0b62f..f29e37a461bb 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 dd34e40459cf..f2fa83c68a5e 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 24e180694ac6..0f64567f9ecc 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",