Compare commits

...
14 changed files with 198 additions and 19 deletions
+18
View File
@@ -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
}
}
| {
+56 -1
View File
@@ -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 }
}
}
)
>
+8
View File
@@ -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") {
+35 -2
View File
@@ -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,
},
])
}
},
+7 -8
View File
@@ -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 }
})
@@ -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)
@@ -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,
@@ -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 {
+6
View File
@@ -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
+8
View File
@@ -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<typeof CompactionRunning> {}
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<typeof CompactionFailed> {}
@@ -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(
@@ -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 (
<div data-component="session-compaction-message">
<div class="py-2">
<TimelineSeparator
label={i18n.t(
props.message.status === "completed" && props.message.providerContext
? "ui.messagePart.providerCompaction"
: "ui.messagePart.compaction",
)}
/>
<TimelineSeparator label={label()} />
</div>
<Show when={summary().trim()}>
<div data-component="text-part" data-timeline-part-id={props.message.id}>
@@ -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({
+12
View File
@@ -2098,6 +2098,15 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
props.message.status === "failed" ? (cancelled() ? "" : props.message.error.message) : props.message.summary
const content = createMemo(() => 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 (
<box>
<box flexDirection="row" alignItems="center">
@@ -2121,6 +2130,9 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
<Show when={cancelled()}>
<text fg={color()}>· cancelled</text>
</Show>
<Show when={usage()}>
<text fg={color()}>· {usage()}</text>
</Show>
</box>
<box border={["top"]} borderColor={color()} flexGrow={1} />
</box>
+1
View File
@@ -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",