mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 15:03:43 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e27dd7b3e |
@@ -595,6 +595,7 @@ export type Endpoint5_31Output =
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly generated?: DateTime.Utc | undefined
|
||||
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
|
||||
readonly files?: ReadonlyArray<RelativePath> | undefined
|
||||
}
|
||||
@@ -619,6 +620,7 @@ export type Endpoint5_31Output =
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
| undefined
|
||||
readonly generated?: DateTime.Utc | undefined
|
||||
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
|
||||
readonly files?: ReadonlyArray<RelativePath> | undefined
|
||||
}
|
||||
|
||||
@@ -624,6 +624,7 @@ export type SessionStepEnded = {
|
||||
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
generated?: number
|
||||
snapshot?: string
|
||||
files?: Array<string>
|
||||
}
|
||||
@@ -1137,6 +1138,7 @@ export type SessionStepFailed = {
|
||||
error: SessionStructuredError
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
generated?: number
|
||||
snapshot?: string
|
||||
files?: Array<string>
|
||||
}
|
||||
@@ -1921,7 +1923,7 @@ export type SessionPendingMessage = SessionPendingUserMessage | SessionPendingSy
|
||||
export type SessionMessageAssistant = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number; completed?: number }
|
||||
time: { created: number; started?: number; generated?: number; completed?: number }
|
||||
type: "assistant"
|
||||
agent: string
|
||||
model: ModelRef
|
||||
@@ -2642,7 +2644,12 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly started?: number
|
||||
readonly generated?: number
|
||||
readonly completed?: number
|
||||
}
|
||||
readonly type: "assistant"
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
@@ -2909,7 +2916,12 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly started?: number
|
||||
readonly generated?: number
|
||||
readonly completed?: number
|
||||
}
|
||||
readonly type: "assistant"
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
@@ -3176,7 +3188,12 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number; readonly completed?: number }
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly started?: number
|
||||
readonly generated?: number
|
||||
readonly completed?: number
|
||||
}
|
||||
readonly type: "assistant"
|
||||
readonly agent: string
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
|
||||
@@ -195,6 +195,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
draft.retry = undefined
|
||||
draft.error = undefined
|
||||
draft.finish = undefined
|
||||
draft.time.started = undefined
|
||||
draft.time.generated = undefined
|
||||
draft.time.completed = undefined
|
||||
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
|
||||
}),
|
||||
@@ -230,6 +232,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
draft.finish = event.data.finish
|
||||
draft.cost = event.data.cost
|
||||
draft.tokens = event.data.tokens
|
||||
draft.time.generated = event.data.generated
|
||||
if (event.data.snapshot || event.data.files)
|
||||
draft.snapshot = {
|
||||
...draft.snapshot,
|
||||
@@ -248,6 +251,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
draft.cost = event.data.cost
|
||||
draft.tokens = castDraft(event.data.tokens)
|
||||
}
|
||||
draft.time.generated = event.data.generated
|
||||
if (event.data.snapshot || event.data.files)
|
||||
draft.snapshot = {
|
||||
...draft.snapshot,
|
||||
@@ -258,6 +262,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
},
|
||||
"session.text.started": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
if (draft.time.completed === undefined) draft.time.started ??= event.created
|
||||
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
|
||||
})
|
||||
},
|
||||
|
||||
@@ -275,6 +275,7 @@ const layer = Layer.effect(
|
||||
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
|
||||
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
|
||||
tokens: finish.tokens,
|
||||
generated: finish.generated,
|
||||
})
|
||||
|
||||
const captureStepEnd = Effect.fnUntraced(function* () {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import { Effect } from "effect"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
@@ -36,6 +36,7 @@ export interface StepRecord {
|
||||
readonly finish?: {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
readonly generated: DateTime.Utc
|
||||
}
|
||||
readonly calls: ReadonlyArray<{
|
||||
readonly id: string
|
||||
@@ -310,6 +311,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
const publishStepFailure = Effect.fnUntraced(function* (details?: {
|
||||
readonly cost?: Money.USD
|
||||
readonly tokens?: ReturnType<typeof SessionUsage.tokens>
|
||||
readonly generated?: DateTime.Utc
|
||||
readonly snapshot?: Snapshot.ID
|
||||
readonly files?: readonly RelativePath[]
|
||||
}) {
|
||||
@@ -493,9 +495,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
return
|
||||
}
|
||||
case "step-finish":
|
||||
const generated = yield* DateTime.now
|
||||
yield* flush()
|
||||
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
||||
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
|
||||
stepSettlement = {
|
||||
finish: event.reason.normalized,
|
||||
tokens: SessionUsage.tokens(event.usage),
|
||||
generated,
|
||||
}
|
||||
if (event.reason.normalized === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||
|
||||
@@ -47,7 +47,9 @@ const build = Agent.defaultID
|
||||
const assistantRow = (
|
||||
id: SessionMessage.ID,
|
||||
seq: number,
|
||||
time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
|
||||
time: { created: DateTime.Utc; started?: DateTime.Utc; generated?: DateTime.Utc; completed?: DateTime.Utc } = {
|
||||
created,
|
||||
},
|
||||
usage?: Pick<SessionMessage.Assistant, "cost" | "tokens">,
|
||||
) => {
|
||||
const {
|
||||
@@ -667,12 +669,18 @@ describe("SessionProjector", () => {
|
||||
const usageUpdated = yield* service
|
||||
.subscribe(SessionEvent.UsageUpdated)
|
||||
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* service.publish(SessionEvent.Text.Started, {
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
|
||||
ordinal: 0,
|
||||
})
|
||||
yield* service.publish(SessionEvent.Step.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
|
||||
finish: "stop",
|
||||
cost: Money.USD.make(1.25),
|
||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
||||
generated: DateTime.makeUnsafe(0),
|
||||
})
|
||||
|
||||
const rows = yield* db
|
||||
@@ -691,7 +699,11 @@ describe("SessionProjector", () => {
|
||||
finish: "stop",
|
||||
cost: Money.USD.make(1.25),
|
||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
||||
time: { completed: DateTime.makeUnsafe(0) },
|
||||
time: {
|
||||
started: DateTime.makeUnsafe(0),
|
||||
generated: DateTime.makeUnsafe(0),
|
||||
completed: DateTime.makeUnsafe(0),
|
||||
},
|
||||
})
|
||||
expect(
|
||||
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
|
||||
|
||||
@@ -266,6 +266,7 @@ test("step finish records settlement without publishing step ended", async () =>
|
||||
|
||||
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
|
||||
expect(publisher.record().finish).toMatchObject({ finish: "stop" })
|
||||
expect(publisher.record().finish).toHaveProperty("generated")
|
||||
})
|
||||
|
||||
test("content-filter finish retains failure evidence until step closeout", async () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Event } from "./event.js"
|
||||
import { FinishReason } from "./llm.js"
|
||||
import { Content } from "./tool.js"
|
||||
import { Model } from "./model.js"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
|
||||
import { DateTimeUtcFromMillis, NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
|
||||
import { FileAttachment } from "./prompt.js"
|
||||
import { SessionID } from "./session-id.js"
|
||||
import { Location } from "./location.js"
|
||||
@@ -309,6 +309,7 @@ export namespace Step {
|
||||
finish: FinishReason,
|
||||
cost: Money.USD,
|
||||
tokens: TokenUsage.Info,
|
||||
generated: DateTimeUtcFromMillis.pipe(optional),
|
||||
snapshot: Snapshot.ID.pipe(optional),
|
||||
files: Schema.Array(RelativePath).pipe(optional),
|
||||
},
|
||||
@@ -324,6 +325,7 @@ export namespace Step {
|
||||
error: SessionError.Error,
|
||||
cost: Money.USD.pipe(optional),
|
||||
tokens: TokenUsage.Info.pipe(optional),
|
||||
generated: DateTimeUtcFromMillis.pipe(optional),
|
||||
snapshot: Snapshot.ID.pipe(optional),
|
||||
files: Schema.Array(RelativePath).pipe(optional),
|
||||
},
|
||||
|
||||
@@ -221,6 +221,8 @@ export const Assistant = Schema.Struct({
|
||||
retry: AssistantRetry.pipe(optional),
|
||||
time: Schema.Struct({
|
||||
created: DateTimeUtcFromMillis,
|
||||
started: DateTimeUtcFromMillis.pipe(optional),
|
||||
generated: DateTimeUtcFromMillis.pipe(optional),
|
||||
completed: DateTimeUtcFromMillis.pipe(optional),
|
||||
}),
|
||||
}).annotate({ identifier: "Session.Message.Assistant" })
|
||||
|
||||
@@ -584,6 +584,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
existing.retry = undefined
|
||||
existing.error = undefined
|
||||
existing.finish = undefined
|
||||
existing.time.started = undefined
|
||||
existing.time.generated = undefined
|
||||
existing.time.completed = undefined
|
||||
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
|
||||
return
|
||||
@@ -613,6 +615,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
currentAssistant.finish = event.data.finish
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
currentAssistant.time.generated = event.data.generated
|
||||
if (event.data.snapshot)
|
||||
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
|
||||
})
|
||||
@@ -630,11 +633,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
}
|
||||
currentAssistant.time.generated = event.data.generated
|
||||
})
|
||||
break
|
||||
case "session.text.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!assistant) return
|
||||
if (assistant.time.completed === undefined) assistant.time.started ??= event.created
|
||||
assistant.content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
@@ -654,7 +661,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
break
|
||||
case "session.tool.input.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!assistant) return
|
||||
assistant.content.push({
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
name: event.data.name,
|
||||
@@ -743,7 +752,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
break
|
||||
case "session.reasoning.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
const assistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!assistant) return
|
||||
assistant.content.push({
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
createSessionRows,
|
||||
messageBoundaryIDs,
|
||||
resolvePart,
|
||||
tokenThroughput,
|
||||
turnDuration,
|
||||
type CacheUsage,
|
||||
type PartRef,
|
||||
@@ -1243,6 +1244,7 @@ function TurnTokenUsage(props: {
|
||||
const newTokens = total - message.tokens.cache.read
|
||||
const currentCache = { read: message.tokens.cache.read, model: message.model }
|
||||
const reuseDrop = cacheReuseDrop(previousCache, currentCache)
|
||||
const throughput = tokenThroughput(message)
|
||||
previousCache = currentCache
|
||||
return [
|
||||
{
|
||||
@@ -1252,6 +1254,7 @@ function TurnTokenUsage(props: {
|
||||
cached: message.tokens.cache.read,
|
||||
total,
|
||||
reuseDrop,
|
||||
throughput,
|
||||
},
|
||||
]
|
||||
})
|
||||
@@ -1261,16 +1264,25 @@ function TurnTokenUsage(props: {
|
||||
newTokens: Math.max("New".length, ...steps().map((item) => item.newTokens.toLocaleString().length)),
|
||||
cached: Math.max("Cached".length, ...steps().map((item) => item.cached.toLocaleString().length)),
|
||||
total: Math.max("Total".length, ...steps().map((item) => item.total.toLocaleString().length)),
|
||||
throughput: Math.max("Tok/s".length, ...steps().map((item) => (item.throughput?.rate.toFixed(1) ?? "-").length)),
|
||||
}))
|
||||
const summary = createMemo(() => {
|
||||
const items = steps()
|
||||
const last = items[items.length - 1]
|
||||
const throughput = items.reduce(
|
||||
(result, item) => ({
|
||||
tokens: result.tokens + (item.throughput?.tokens ?? 0),
|
||||
duration: result.duration + (item.throughput?.duration ?? 0),
|
||||
}),
|
||||
{ tokens: 0, duration: 0 },
|
||||
)
|
||||
return {
|
||||
count: items.length,
|
||||
newTokens: items.reduce((sum, item) => sum + item.newTokens, 0),
|
||||
cached: last?.cached ?? 0,
|
||||
total: last?.total ?? 0,
|
||||
reuseDrops: items.filter((item) => item.reuseDrop !== undefined).length,
|
||||
throughput: throughput.duration > 0 ? (throughput.tokens * 1_000) / throughput.duration : undefined,
|
||||
}
|
||||
})
|
||||
return (
|
||||
@@ -1292,6 +1304,7 @@ function TurnTokenUsage(props: {
|
||||
: {summary().count} {summary().count === 1 ? "step" : "steps"} · {summary().newTokens.toLocaleString()}{" "}
|
||||
new · {summary().cached.toLocaleString()} cached · {summary().total.toLocaleString()} total
|
||||
</span>
|
||||
<Show when={summary().throughput}>{(value) => <span> · {value().toFixed(1)} tok/s</span>}</Show>
|
||||
<Show when={summary().reuseDrops > 0}>
|
||||
<span style={{ fg: theme.text.feedback.warning.default }}>
|
||||
{" "}
|
||||
@@ -1309,6 +1322,8 @@ function TurnTokenUsage(props: {
|
||||
{"Cached".padStart(columns().cached)}
|
||||
{" "}
|
||||
{"Total".padStart(columns().total)}
|
||||
{" "}
|
||||
{"Tok/s".padStart(columns().throughput)}
|
||||
</text>
|
||||
</box>
|
||||
<For each={steps()}>
|
||||
@@ -1323,6 +1338,8 @@ function TurnTokenUsage(props: {
|
||||
{item.cached.toLocaleString().padStart(columns().cached)}
|
||||
{" "}
|
||||
{item.total.toLocaleString().padStart(columns().total)}
|
||||
{" "}
|
||||
{(item.throughput?.rate.toFixed(1) ?? "-").padStart(columns().throughput)}
|
||||
</text>
|
||||
<TurnTokenToolCalls tools={item.tools} />
|
||||
<Show when={item.reuseDrop !== undefined}>
|
||||
@@ -1669,6 +1686,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
|
||||
)
|
||||
const duration = createMemo(() => turnDuration(props.message, data.session.message.list(ctx.sessionID)))
|
||||
const throughput = createMemo(() => tokenThroughput(props.message)?.rate)
|
||||
const interrupted = createMemo(() => props.message.error?.message === "Step interrupted")
|
||||
return (
|
||||
<>
|
||||
@@ -1689,6 +1707,9 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
<Show when={duration() && (dimensions().width < 28 || dimensions().width >= 36)}>
|
||||
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
<Show when={dimensions().width >= 48 ? throughput() : undefined}>
|
||||
{(value) => <span style={{ fg: theme.text.subdued }}> · {value().toFixed(1)} tok/s</span>}
|
||||
</Show>
|
||||
<Show when={interrupted()}>
|
||||
<span style={{ fg: theme.text.subdued }}> · interrupted</span>
|
||||
</Show>
|
||||
|
||||
@@ -357,6 +357,16 @@ export function turnDuration(message: SessionMessageAssistant, messages: Session
|
||||
return Math.max(0, message.time.completed - (input?.time.created ?? message.time.created))
|
||||
}
|
||||
|
||||
export function tokenThroughput(message: SessionMessageAssistant) {
|
||||
if (message.finish !== "stop" && message.finish !== "length") return undefined
|
||||
if (message.tokens === undefined || message.time.started === undefined || message.time.generated === undefined)
|
||||
return undefined
|
||||
const duration = message.time.generated - message.time.started
|
||||
const tokens = message.tokens.output - 1
|
||||
if (duration < 250 || tokens <= 0) return undefined
|
||||
return { tokens, duration, rate: (tokens * 1_000) / duration }
|
||||
}
|
||||
|
||||
function hasTokenUsage(
|
||||
message: SessionMessageAssistant,
|
||||
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } {
|
||||
|
||||
@@ -513,19 +513,31 @@ test("truncates committed revert messages without changing lifetime usage", asyn
|
||||
model: { providerID: "provider", id: "model" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_revert_boundary_text_started",
|
||||
created: 1.25,
|
||||
type: "session.text.started",
|
||||
durable: durable(sessionID, 2),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_revert_boundary",
|
||||
ordinal: 0,
|
||||
},
|
||||
})
|
||||
cost = 0.5
|
||||
tokens = { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }
|
||||
emitEvent(events, {
|
||||
id: "evt_revert_boundary_ended",
|
||||
created: 2,
|
||||
type: "session.step.ended",
|
||||
durable: durable(sessionID, 2),
|
||||
durable: durable(sessionID, 3),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_revert_boundary",
|
||||
finish: "stop",
|
||||
cost: 0.5,
|
||||
tokens,
|
||||
generated: 1.75,
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
@@ -535,6 +547,12 @@ test("truncates committed revert messages without changing lifetime usage", asyn
|
||||
data: { sessionID, cost, tokens },
|
||||
})
|
||||
await wait(() => data.session.get(sessionID)?.cost === 0.5)
|
||||
expect(data.session.message.get(sessionID, "msg_revert_boundary")?.time).toEqual({
|
||||
created: 1,
|
||||
started: 1.25,
|
||||
generated: 1.75,
|
||||
completed: 2,
|
||||
})
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_revert_later_started",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows, turnDuration } from "../../../src/routes/session/rows"
|
||||
import {
|
||||
cacheReuseDrop,
|
||||
messageBoundaryIDs,
|
||||
reduceSessionRows,
|
||||
tokenThroughput,
|
||||
turnDuration,
|
||||
} from "../../../src/routes/session/rows"
|
||||
|
||||
test("measures turn duration from the user prompt across assistant steps", () => {
|
||||
const first = assistant("assistant-1", [])
|
||||
@@ -16,6 +22,39 @@ test("measures turn duration from the user prompt across assistant steps", () =>
|
||||
expect(turnDuration(final, messages)).toBe(29_000)
|
||||
})
|
||||
|
||||
test("measures visible output throughput after the first token", () => {
|
||||
const message = assistant("assistant-1", [])
|
||||
message.finish = "stop"
|
||||
message.time = { created: 1_000, started: 2_000, generated: 4_000, completed: 8_000 }
|
||||
message.tokens = { input: 100, output: 80, reasoning: 20, cache: { read: 50, write: 0 } }
|
||||
|
||||
expect(tokenThroughput(message)).toEqual({ tokens: 79, duration: 2_000, rate: 39.5 })
|
||||
})
|
||||
|
||||
test("does not report throughput without a stable generation window", () => {
|
||||
const message = assistant("assistant-1", [])
|
||||
message.finish = "stop"
|
||||
message.time = { created: 1_000, completed: 4_000 }
|
||||
message.tokens = { input: 100, output: 80, reasoning: 20, cache: { read: 0, write: 0 } }
|
||||
expect(tokenThroughput(message)).toBeUndefined()
|
||||
|
||||
message.time.started = 4_000
|
||||
message.time.generated = 4_000
|
||||
expect(tokenThroughput(message)).toBeUndefined()
|
||||
|
||||
message.time.started = 3_751
|
||||
expect(tokenThroughput(message)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not report throughput for tool-call steps", () => {
|
||||
const message = assistant("assistant-1", [])
|
||||
message.finish = "tool-calls"
|
||||
message.time = { created: 1_000, started: 2_000, generated: 4_000, completed: 8_000 }
|
||||
message.tokens = { input: 100, output: 80, reasoning: 20, cache: { read: 0, write: 0 } }
|
||||
|
||||
expect(tokenThroughput(message)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("filters OpenAI cache quantization from cache reuse drops", () => {
|
||||
const openai = { id: "gpt", providerID: "openai" }
|
||||
expect(cacheReuseDrop(undefined, { read: 10_000, model: openai })).toBeUndefined()
|
||||
|
||||
Reference in New Issue
Block a user