Files
2026-08-24 19:39:04 +05:30

300 lines
12 KiB
TypeScript

import { describe, expect } from "bun:test"
import { Agent } from "@opencode-ai/schema/agent"
import { Event } from "@opencode-ai/schema/event"
import { Model } from "@opencode-ai/schema/model"
import { Money } from "@opencode-ai/schema/money"
import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Database } from "@opencode-ai/core/database/database"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStats } from "@opencode-ai/core/session/stats"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { DateTime, Effect, Schema } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(Database.node))
const projectID = Project.ID.make("stats-project")
const otherProjectID = Project.ID.make("stats-other-project")
const sessionID = Session.ID.make("ses_stats_root")
const childID = Session.ID.make("ses_stats_child")
const forkID = Session.ID.make("ses_stats_fork")
const usageOnlyID = Session.ID.make("ses_stats_usage_only")
const otherSessionID = Session.ID.make("ses_stats_other")
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const encodeUsage = Schema.encodeSync(SessionEvent.UsageRecorded.data)
describe("SessionStats", () => {
it.effect("aggregates activity and tool reliability without reading message payloads outside the range", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values([
{ id: projectID, worktree: AbsolutePath.make("/stats"), name: "stats", sandboxes: [] },
{ id: otherProjectID, worktree: AbsolutePath.make("/other"), name: "other", sandboxes: [] },
])
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values([
{ id: sessionID, project_id: projectID, slug: "root", directory: "/stats", version: "test" },
{
id: childID,
project_id: projectID,
parent_id: sessionID,
slug: "child",
directory: "/stats",
version: "test",
},
{
id: forkID,
project_id: projectID,
fork_session_id: sessionID,
slug: "fork",
directory: "/stats",
version: "test",
time_created: Date.UTC(2026, 0, 4),
},
{ id: usageOnlyID, project_id: projectID, slug: "usage", directory: "/stats", version: "test" },
{ id: otherSessionID, project_id: otherProjectID, slug: "other", directory: "/other", version: "test" },
])
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionMessageTable)
.values([
messageRow(
sessionID,
1,
SessionMessage.User.make({
id: SessionMessage.ID.make("msg_stats_user"),
type: "user",
text: "hello",
time: { created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 9)) },
}),
),
messageRow(
sessionID,
2,
assistant("msg_stats_assistant", Date.UTC(2026, 0, 2, 10), [
SessionMessage.AssistantTool.make({
type: "tool",
id: "call_read",
name: "read",
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: {},
content: [{ type: "text", text: "ok" }],
}),
time: {
created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)),
ran: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 1)),
completed: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 1, 250)),
},
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "call_edit",
name: "edit",
state: SessionMessage.ToolStateError.make({
status: "error",
input: {},
error: { type: "tool", message: "failed" },
}),
time: {
created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)),
completed: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 2)),
},
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "call_pending",
name: "pending",
state: SessionMessage.ToolStateRunning.make({ status: "running", input: {}, metadata: {} }),
time: { created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)) },
}),
]),
),
messageRow(childID, 1, assistant("msg_stats_child", Date.UTC(2026, 0, 3, 10), [], "large", 2)),
messageRow(
forkID,
1,
SessionMessage.User.make({
id: SessionMessage.ID.make("msg_stats_fork_copied_user"),
type: "user",
text: "copied",
time: { created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 9)) },
}),
),
messageRow(
forkID,
2,
assistant("msg_stats_fork_copied_assistant", Date.UTC(2026, 0, 2, 10), [
SessionMessage.AssistantTool.make({
type: "tool",
id: "call_copied",
name: "copied",
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: {},
content: [{ type: "text", text: "copied" }],
}),
time: {
created: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10)),
completed: DateTime.makeUnsafe(Date.UTC(2026, 0, 2, 10, 0, 1)),
},
}),
]),
),
messageRow(forkID, 3, assistant("msg_stats_fork_new", Date.UTC(2026, 0, 5, 10), [], "fork-new")),
messageRow(sessionID, 3, assistant("msg_stats_outside", Date.UTC(2025, 11, 31, 10), [])),
messageRow(usageOnlyID, 1, assistant("msg_stats_usage_only", Date.UTC(2025, 11, 30, 10), [])),
messageRow(otherSessionID, 1, assistant("msg_stats_other", Date.UTC(2020, 0, 1, 10), [])),
])
.run()
.pipe(Effect.orDie)
yield* db
.insert(EventSequenceTable)
.values([
{ aggregate_id: sessionID, seq: 0 },
{ aggregate_id: childID, seq: 0 },
{ aggregate_id: usageOnlyID, seq: 0 },
])
.run()
.pipe(Effect.orDie)
yield* db
.insert(EventTable)
.values([
{
id: Event.ID.make("evt_stats_usage"),
aggregate_id: sessionID,
seq: 0,
created: Date.UTC(2026, 0, 2, 10, 0, 3),
type: SessionEvent.UsageRecorded.type,
data: encodeUsage({
sessionID,
source: "title",
cost: Money.USD.make(0.5),
tokens: { input: 1, output: 1, reasoning: 1, cache: { read: 1, write: 1 } },
}),
},
{
id: Event.ID.make("evt_stats_usage_boundary"),
aggregate_id: usageOnlyID,
seq: 0,
created: Date.UTC(2026, 0, 2, 10, 0, 3),
type: SessionEvent.UsageRecorded.type,
data: encodeUsage({
sessionID: usageOnlyID,
source: "compaction",
cost: Money.USD.make(0.25),
tokens: { input: 2, output: 2, reasoning: 2, cache: { read: 2, write: 2 } },
}),
},
])
.run()
.pipe(Effect.orDie)
const stats = yield* SessionStats.get({
from: Date.UTC(2026, 0, 1),
to: Date.UTC(2026, 1, 1),
timezone: "UTC",
tools: "detail",
})
expect(stats.sessions).toBe(2)
expect(stats.subagents).toBe(1)
expect(stats.prompts).toBe(1)
expect(stats.steps).toBe(3)
expect(stats.tokens).toEqual({ input: 42, output: 22, reasoning: 10, cache: { read: 18, write: 6 } })
expect(stats.cost).toBe(Money.USD.make(6.25))
expect(stats.tools).toMatchObject({
mode: "detail",
totals: { calls: 3, succeeded: 1, failed: 1, unfinished: 1 },
})
expect(stats.activity).toEqual([
{ date: "2026-01-02", steps: 1 },
{ date: "2026-01-03", steps: 1 },
{ date: "2026-01-05", steps: 1 },
])
expect(stats.streak).toBe(2)
expect(stats.models.map((model) => String(model.model.id))).toEqual(["large", "sonnet", "fork-new"])
expect(stats.tools.mode).toBe("detail")
if (stats.tools.mode !== "detail") throw new Error("Expected detailed tool statistics")
expect(stats.tools.usage).toMatchObject([
{ name: "read", calls: 1, succeeded: 1, failed: 0, durationP50: 250 },
{ name: "edit", calls: 1, succeeded: 0, failed: 1, durationP50: 2_000 },
{ name: "pending", calls: 1, succeeded: 0, failed: 0, unfinished: 1 },
])
const summary = yield* SessionStats.get({
from: Date.UTC(2026, 0, 1),
to: Date.UTC(2026, 1, 1),
timezone: "UTC",
})
expect(summary.models.map((model) => String(model.model.id))).toEqual(["large", "sonnet", "fork-new"])
expect(summary.tools).toEqual({
mode: "summary",
totals: { calls: 3, succeeded: 1, failed: 1, unfinished: 1 },
})
const withoutTools = yield* SessionStats.get({
from: Date.UTC(2026, 0, 1),
to: Date.UTC(2026, 1, 1),
timezone: "UTC",
tools: "none",
})
expect(withoutTools.tools).toEqual({ mode: "none" })
const project = yield* SessionStats.get({ projectID, timezone: "UTC", tools: "none" })
expect(DateTime.toEpochMillis(project.range.from)).toBe(Date.UTC(2025, 11, 30, 10))
const error = yield* Effect.flip(
SessionStats.get({ from: Date.UTC(2026, 1, 1), to: Date.UTC(2026, 0, 1), tools: "none" }),
)
expect(error).toEqual(
new SessionStats.InvalidRangeError({ from: Date.UTC(2026, 1, 1), to: Date.UTC(2026, 0, 1) }),
)
const future = yield* Effect.flip(SessionStats.get({ from: Number.MAX_SAFE_INTEGER, tools: "none" }))
expect(future._tag).toBe("SessionStats.InvalidRangeError")
}),
)
})
function assistant(
id: string,
created: number,
content: SessionMessage.AssistantContent[],
model = "sonnet",
scale = 1,
) {
return SessionMessage.Assistant.make({
id: SessionMessage.ID.make(id),
type: "assistant",
agent: Agent.ID.make("build"),
model: { id: Model.ID.make(model), providerID: Provider.ID.make("anthropic") },
content,
cost: Money.USD.make(1.5 * scale),
tokens: { input: 10 * scale, output: 5 * scale, reasoning: 2 * scale, cache: { read: 4 * scale, write: scale } },
time: { created: DateTime.makeUnsafe(created), completed: DateTime.makeUnsafe(created + 2_000) },
})
}
function messageRow(
sessionID: Session.ID,
seq: number,
message: SessionMessage.Info,
): typeof SessionMessageTable.$inferInsert {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
return { id: SessionMessage.ID.make(id), session_id: sessionID, type, seq, time_created: encoded.time.created, data }
}