| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318 |
- import { describe, expect, test } from "bun:test"
- import type { SessionNotification } from "@agentclientprotocol/sdk"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { ProviderV2 } from "@kirincode-ai/core/provider"
- import { ModelV2 } from "@kirincode-ai/core/model"
- import { UsageService } from "@/acp/usage"
- import { Provider } from "@/provider/provider"
- import { Effect, Layer } from "effect"
- import { it } from "../lib/effect"
- const assistant = (
- input: Partial<UsageService.AssistantMessage> & Pick<UsageService.AssistantMessage, "cost">,
- ): UsageService.SessionMessage => ({
- info: {
- role: "assistant",
- providerID: "anthropic",
- modelID: "claude-sonnet",
- tokens: {
- input: 10,
- output: 20,
- reasoning: 0,
- cache: { read: 0, write: 0 },
- },
- ...input,
- },
- })
- const user = (): UsageService.SessionMessage => ({
- info: { role: "user" },
- })
- const assistantWithoutProvider = (): UsageService.SessionMessage => ({
- info: {
- role: "assistant",
- modelID: "claude-sonnet",
- cost: 1,
- tokens: {
- input: 10,
- output: 20,
- reasoning: 0,
- cache: { read: 0, write: 0 },
- },
- },
- })
- const model = (providerID: ProviderV2.ID, modelID: ModelV2.ID, context: number): Provider.Model => ({
- id: modelID,
- providerID,
- api: {
- id: modelID,
- url: "https://example.com",
- npm: "@ai-sdk/openai-compatible",
- },
- name: modelID,
- family: "test",
- capabilities: {
- temperature: true,
- reasoning: false,
- attachment: false,
- toolcall: true,
- input: { text: true, audio: false, image: false, video: false, pdf: false },
- output: { text: true, audio: false, image: false, video: false, pdf: false },
- interleaved: false,
- },
- cost: {
- input: 0,
- output: 0,
- cache: { read: 0, write: 0 },
- },
- limit: {
- context,
- output: 4096,
- },
- status: "active",
- options: {},
- headers: {},
- release_date: "2026-01-01",
- })
- const providers = (context = 128_000): Record<ProviderV2.ID, Provider.Info> => {
- const providerID = ProviderV2.ID.make("anthropic")
- const modelID = ModelV2.ID.make("claude-sonnet")
- return {
- [providerID]: {
- id: providerID,
- name: "Anthropic",
- source: "config",
- env: [],
- options: {},
- models: {
- [modelID]: model(providerID, modelID, context),
- },
- },
- }
- }
- const fakeLayer = (input: {
- readonly messages?: Effect.Effect<readonly UsageService.SessionMessage[], unknown>
- readonly providers?: (directory: string) => Effect.Effect<Record<ProviderV2.ID, Provider.Info>, unknown>
- }) =>
- LayerNode.compile(UsageService.node, [
- [
- UsageService.messageLoaderNode,
- Layer.succeed(
- UsageService.MessageLoader,
- UsageService.MessageLoader.of({
- messages: () => input.messages ?? Effect.succeed([]),
- }),
- ),
- ],
- [
- UsageService.contextLimitLoaderNode,
- Layer.succeed(
- UsageService.ContextLimitLoader,
- UsageService.ContextLimitLoader.of({
- providers: input.providers ?? (() => Effect.succeed(providers())),
- }),
- ),
- ],
- ])
- const connection = (updates: SessionNotification[]) => ({
- sessionUpdate(params: SessionNotification) {
- updates.push(params)
- return Promise.resolve()
- },
- })
- describe("acp usage", () => {
- test("builds ACP Usage from assistant token shape", () => {
- expect(
- UsageService.buildUsage({
- cost: 0.02,
- tokens: {
- input: 100,
- output: 40,
- reasoning: 7,
- cache: { read: 11, write: 13 },
- },
- }),
- ).toEqual({
- inputTokens: 100,
- outputTokens: 40,
- thoughtTokens: 7,
- cachedReadTokens: 11,
- cachedWriteTokens: 13,
- totalTokens: 171,
- })
- })
- test("omits optional token fields when they are zero", () => {
- expect(
- UsageService.buildUsage({
- cost: 0,
- tokens: {
- input: 3,
- output: 4,
- reasoning: 0,
- cache: { read: 0, write: 0 },
- },
- }),
- ).toEqual({
- inputTokens: 3,
- outputTokens: 4,
- totalTokens: 7,
- })
- })
- test("finds the latest assistant message", () => {
- expect(
- UsageService.latestAssistantMessage([assistant({ cost: 1, modelID: "older" }), user(), assistant({ cost: 2 })]),
- ).toMatchObject({ cost: 2 })
- })
- test("calculates total session cost from assistant messages", () => {
- expect(UsageService.totalSessionCost([assistant({ cost: 1.25 }), user(), assistant({ cost: 2.5 })])).toBe(3.75)
- })
- it.effect("loads context limits from providers and caches by directory/provider/model", () => {
- const calls: string[] = []
- return Effect.gen(function* () {
- const usage = yield* UsageService.Service
- const first = yield* usage.contextLimit({
- directory: "/workspace",
- providerID: ProviderV2.ID.make("anthropic"),
- modelID: ModelV2.ID.make("claude-sonnet"),
- })
- const second = yield* usage.contextLimit({
- directory: "/workspace",
- providerID: ProviderV2.ID.make("anthropic"),
- modelID: ModelV2.ID.make("claude-sonnet"),
- })
- expect(first).toBe(200_000)
- expect(second).toBe(200_000)
- expect(calls).toEqual(["/workspace"])
- }).pipe(
- Effect.provide(
- fakeLayer({
- providers: (directory) =>
- Effect.sync(() => {
- calls.push(directory)
- return providers(200_000)
- }),
- }),
- ),
- )
- })
- it.effect("sends ACP usage_update with context size and cumulative assistant cost", () => {
- const updates: SessionNotification[] = []
- return Effect.gen(function* () {
- const usage = yield* UsageService.Service
- yield* usage.sendUpdate({
- connection: connection(updates),
- sessionID: "ses_1",
- directory: "/workspace",
- })
- expect(updates).toEqual([
- {
- sessionId: "ses_1",
- update: {
- sessionUpdate: "usage_update",
- used: 15,
- size: 128_000,
- cost: { amount: 3, currency: "USD" },
- },
- },
- ])
- }).pipe(
- Effect.provide(
- fakeLayer({
- messages: Effect.succeed([
- assistant({ cost: 1 }),
- assistant({
- cost: 2,
- tokens: {
- input: 10,
- output: 20,
- reasoning: 0,
- cache: { read: 5, write: 0 },
- },
- }),
- ]),
- }),
- ),
- )
- })
- it.effect("skips usage update when messages cannot be fetched", () => {
- const updates: SessionNotification[] = []
- return Effect.gen(function* () {
- const usage = yield* UsageService.Service
- yield* usage.sendUpdate({
- connection: connection(updates),
- sessionID: "ses_1",
- directory: "/workspace",
- })
- expect(updates).toEqual([])
- }).pipe(Effect.provide(fakeLayer({ messages: Effect.fail(new Error("boom")) })))
- })
- it.effect("skips usage update when no assistant message exists", () => {
- const updates: SessionNotification[] = []
- return Effect.gen(function* () {
- const usage = yield* UsageService.Service
- yield* usage.sendUpdate({
- connection: connection(updates),
- sessionID: "ses_1",
- directory: "/workspace",
- })
- expect(updates).toEqual([])
- }).pipe(Effect.provide(fakeLayer({ messages: Effect.succeed([user()]) })))
- })
- it.effect("skips usage update when assistant message has no provider or model", () => {
- const updates: SessionNotification[] = []
- return Effect.gen(function* () {
- const usage = yield* UsageService.Service
- yield* usage.sendUpdate({
- connection: connection(updates),
- sessionID: "ses_1",
- directory: "/workspace",
- })
- expect(updates).toEqual([])
- }).pipe(
- Effect.provide(
- fakeLayer({
- messages: Effect.succeed([assistantWithoutProvider()]),
- }),
- ),
- )
- })
- it.effect("skips usage update when context size is unknown", () => {
- const updates: SessionNotification[] = []
- return Effect.gen(function* () {
- const usage = yield* UsageService.Service
- yield* usage.sendUpdate({
- connection: connection(updates),
- sessionID: "ses_1",
- directory: "/workspace",
- })
- expect(updates).toEqual([])
- }).pipe(
- Effect.provide(
- fakeLayer({
- messages: Effect.succeed([assistant({ cost: 1, providerID: "missing" })]),
- }),
- ),
- )
- })
- })
|