mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 02:16:23 +00:00
Compare commits
2
Commits
v2
...
compaction-usage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c931d6cf5f | ||
|
|
7dc597494f |
@@ -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
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
>
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
])
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user