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