diff --git a/packages/junior/src/chat/agent/index.ts b/packages/junior/src/chat/agent/index.ts index 1d5885999..47b1b3a5d 100644 --- a/packages/junior/src/chat/agent/index.ts +++ b/packages/junior/src/chat/agent/index.ts @@ -13,6 +13,7 @@ import { Agent, type AgentLoopTurnUpdate } from "@earendil-works/pi-agent-core"; import type { FileUpload } from "chat"; import { botConfig } from "@/chat/config"; +import { getConversationStore } from "@/chat/db"; import { extractGenAiUsageAttributes, extractGenAiUsageSummary, @@ -74,6 +75,11 @@ import { } from "@/chat/services/turn-result"; import { isProviderRetryError } from "@/chat/services/provider-error"; import { nextProviderRetry } from "@/chat/services/provider-retry"; +import { + enforceTurnSpendLimit, + isTurnSpendLimitError, + TURN_SPEND_LIMIT_RESPONSE, +} from "@/chat/services/spend-limit"; import { annotateTurnDeadlineToolResult } from "@/chat/tool-support/turn-deadline-result"; import { configuredTurnRoute, @@ -269,8 +275,11 @@ async function executeAgentRunInPrivacyContext( let lastKnownSandboxRef = state.sandboxRef; let mcpToolManager: McpToolManager | undefined; let connectedMcpProviders = new Set(); + let agent: Agent | undefined; + let spendLimitPiMessages: PiMessage[] | undefined; let turnUsage: AgentTurnUsage | undefined; let priorPhaseUsage: AgentTurnUsage | undefined; + let retryUsage: AgentTurnUsage | undefined; const configuredReasoningLevel = policy.reasoningLevel ?? botConfig.reasoningLevel; let turnRoute: TurnRoute | undefined = configuredReasoningLevel @@ -379,6 +388,13 @@ async function executeAgentRunInPrivacyContext( conversationId, turnId, }); + const conversationUsage = ( + await getConversationStore().get({ conversationId }) + )?.usage; + enforceTurnSpendLimit({ + maxSpendUsd: botConfig.maxSpendUsd, + usage: conversationUsage, + }); // Mirror the committed provenance prefix the turn session record owns. A // fresh run may already include batched parked input committed before the // agent starts, then adds the current actor's turn-start instruction. @@ -566,7 +582,6 @@ async function executeAgentRunInPrivacyContext( const generatedFiles: FileUpload[] = []; const artifactStatePatch: Partial = {}; const toolCalls: string[] = []; - let agent: Agent | undefined; // Handoff becomes live only after its replacement epoch commits. This // pending value then drives the one-way model/context swap at Pi's boundary. let pendingHandoff: @@ -598,6 +613,26 @@ async function executeAgentRunInPrivacyContext( ); return hasAgentTurnUsage(usage) ? usage : undefined; }; + const enforceSpendLimit = ( + messages: PiMessage[], + latestUsage?: AgentTurnUsage, + ): void => { + const currentBoundaryUsage = usageSinceCurrentBoundary(messages); + spendLimitPiMessages = [...messages]; + turnUsage = addAgentTurnUsage( + priorPhaseUsage, + retryUsage, + currentBoundaryUsage, + ); + enforceTurnSpendLimit({ + maxSpendUsd: botConfig.maxSpendUsd, + usage: latestUsage, + }); + enforceTurnSpendLimit({ + maxSpendUsd: botConfig.maxSpendUsd, + usage: addAgentTurnUsage(conversationUsage, turnUsage), + }); + }; /** Commit the durable handoff epoch before staging its in-memory model swap. */ const scheduleHandoff = async (args: { profile: ModelProfile; @@ -850,6 +885,7 @@ async function executeAgentRunInPrivacyContext( let assistantMessageDeliveryError: | AssistantMessageDeliveryError | undefined; + let pendingTurnSpendLimitError: Error | undefined; /** Deliver one completed tool-free visible message before the agent advances. */ const deliverAssistantMessage = async ( message: Parameters[0], @@ -943,6 +979,11 @@ async function executeAgentRunInPrivacyContext( streamFn: createTracedStreamFn({ conversationPrivacy }), steeringMode: "all", beforeToolCall: async ({ assistantMessage }) => { + const usage = extractGenAiUsageSummary(assistantMessage); + enforceSpendLimit( + currentAgentMessages(), + hasAgentTurnUsage(usage) ? usage : undefined, + ); const toolCalls = assistantMessage.content.filter( (part) => part.type === "toolCall", ); @@ -964,6 +1005,7 @@ async function executeAgentRunInPrivacyContext( : undefined, prepareNextTurnWithContext: async (nextTurn, hookSignal) => { try { + enforceSpendLimit(nextTurn.context.messages as PiMessage[]); const handoffUpdate = applyPendingHandoff(); const pendingMessages = await drainSteeringMessages(); const capacityUpdate = await applyActiveContextCompaction( @@ -1016,10 +1058,17 @@ async function executeAgentRunInPrivacyContext( ) { return; } - const containsHandoff = event.message.content.some( - (part) => part.type === "toolCall" && part.name === HANDOFF_TOOL_NAME, - ); - if (containsHandoff) { + try { + const usage = extractGenAiUsageSummary(event.message); + enforceSpendLimit( + currentAgentMessages(), + hasAgentTurnUsage(usage) ? usage : undefined, + ); + } catch (error) { + if (!isTurnSpendLimitError(error)) { + throw error; + } + pendingTurnSpendLimitError = error; return; } return deliverAssistantMessage(event.message); @@ -1220,14 +1269,17 @@ async function executeAgentRunInPrivacyContext( freshPromptMessage, ]); } + enforceSpendLimit(currentAgentMessages()); run = shouldPromptAgent && !capacityUpdate ? agent!.prompt(freshPromptMessage) : agent!.continue(); - let retryUsage: AgentTurnUsage | undefined; try { for (let attempt = 0; ; attempt += 1) { await runAgentStep(run); + if (pendingTurnSpendLimitError) { + throw pendingTurnSpendLimitError; + } if (assistantMessageDeliveryError) { throw assistantMessageDeliveryError; } @@ -1247,6 +1299,9 @@ async function executeAgentRunInPrivacyContext( const currentUsage = hasAgentTurnUsage(usageSummary) ? usageSummary : undefined; + if (lastAssistant?.stopReason === "error") { + enforceSpendLimit(currentAgentMessages(), currentUsage); + } const currentPhaseUsage = addAgentTurnUsage( retryUsage, currentUsage, @@ -1367,6 +1422,35 @@ async function executeAgentRunInPrivacyContext( result, }; } catch (error) { + if (isTurnSpendLimitError(error)) { + if (delivery) { + await delivery.onAssistantMessage({ text: TURN_SPEND_LIMIT_RESPONSE }); + } + return { + status: "completed", + result: { + text: TURN_SPEND_LIMIT_RESPONSE, + sandboxRef: lastKnownSandboxRef, + piMessages: spendLimitPiMessages, + diagnostics: { + outcome: "success", + modelId: activeModelId, + assistantMessageCount: 1, + ...(turnRoute + ? { + reasoningLevel: turnRoute.reasoningLevel, + } + : {}), + toolCalls: [], + toolResultCount: 0, + toolErrorCount: 0, + usedPrimaryText: true, + durationMs: Date.now() - replyStartedAtMs, + usage: turnUsage, + }, + }, + }; + } if ( error instanceof AssistantMessageDeliveryError && !(error.originalError instanceof RetryableDeliveryError) diff --git a/packages/junior/src/chat/config.ts b/packages/junior/src/chat/config.ts index a35679e02..6b3301668 100644 --- a/packages/junior/src/chat/config.ts +++ b/packages/junior/src/chat/config.ts @@ -50,6 +50,7 @@ export interface BotConfig { fastModelId: string; imageGenerationModelId: string; loadingMessages: string[]; + maxSpendUsd?: number; profiles: Readonly>; reasoningLevel?: TurnReasoningLevel; visionModelId?: string; @@ -148,6 +149,22 @@ function parseLoadingMessages(rawValue: string | undefined): string[] { }); } +function parseOptionalPositiveNumber( + envName: string, + rawValue: string | undefined, +): number | undefined { + const trimmed = toOptionalTrimmed(rawValue); + if (trimmed === undefined) { + return undefined; + } + + const value = Number(trimmed); + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${envName} must be a positive number`); + } + return value; +} + function parseOptionalPositiveInteger( envName: string, rawValue: string | undefined, @@ -317,6 +334,7 @@ function readBotConfig( validateEmbeddingModelId(env.AI_EMBEDDING_MODEL) ?? DEFAULT_EMBEDDING_MODEL_ID, loadingMessages: parseLoadingMessages(env.JUNIOR_LOADING_MESSAGES), + maxSpendUsd: parseOptionalPositiveNumber("MAX_SPEND", env.MAX_SPEND), visionModelId: validateGatewayModelId(env.AI_VISION_MODEL), maxSlicesPerTurn: MAX_SLICES_PER_TURN, turnTimeoutMs: parseAgentTurnTimeoutMs( diff --git a/packages/junior/src/chat/conversations/sql/store.ts b/packages/junior/src/chat/conversations/sql/store.ts index 01062d26d..a4fc1b975 100644 --- a/packages/junior/src/chat/conversations/sql/store.ts +++ b/packages/junior/src/chat/conversations/sql/store.ts @@ -335,6 +335,7 @@ function conversationFromRow(readRow: ConversationReadRow): Conversation { ...(row.channelName ? { channelName: row.channelName } : {}), ...(source ? { source } : {}), ...(row.title ? { title: row.title } : {}), + ...(row.usage ? { usage: row.usage } : {}), ...(msFromDate(row.transcriptPurgedAt) !== undefined ? { transcriptPurgedAtMs: msFromDate(row.transcriptPurgedAt) } : {}), diff --git a/packages/junior/src/chat/conversations/store.ts b/packages/junior/src/chat/conversations/store.ts index c1b5d7078..9c715fa3c 100644 --- a/packages/junior/src/chat/conversations/store.ts +++ b/packages/junior/src/chat/conversations/store.ts @@ -46,6 +46,8 @@ export interface Conversation { source?: ConversationSource; title?: string; updatedAtMs: number; + /** Cumulative model usage used by reporting and runtime controls. */ + usage?: AgentTurnUsage; /** * When retention purged this conversation's content. Set means messages and * events were deleted wholesale; reporting presents the transcript as expired diff --git a/packages/junior/src/chat/services/spend-limit.ts b/packages/junior/src/chat/services/spend-limit.ts new file mode 100644 index 000000000..6b5132683 --- /dev/null +++ b/packages/junior/src/chat/services/spend-limit.ts @@ -0,0 +1,58 @@ +import type { AgentTurnUsage } from "@/chat/usage"; + +export const TURN_SPEND_LIMIT_RESPONSE = + "I stopped this conversation because it reached its configured spend limit."; + +/** Terminal failure raised when one agent turn reaches its configured USD cap. */ +export class TurnSpendLimitExceededError extends Error { + constructor(readonly maxSpendUsd: number) { + super(`Agent turn reached spend limit ($${maxSpendUsd.toFixed(6)})`); + this.name = "TurnSpendLimitExceededError"; + } +} + +/** Terminal failure raised when a configured cap cannot verify provider spend. */ +export class TurnSpendCostUnavailableError extends Error { + constructor() { + super("Agent turn provider usage omitted cost data"); + this.name = "TurnSpendCostUnavailableError"; + } +} + +/** Return whether an error should stop the turn with the static spend-limit response. */ +export function isTurnSpendLimitError( + error: unknown, +): error is TurnSpendLimitExceededError | TurnSpendCostUnavailableError { + return ( + error instanceof TurnSpendLimitExceededError || + error instanceof TurnSpendCostUnavailableError + ); +} + +/** Return the provider-reported total USD cost, deriving it from components when needed. */ +export function agentTurnCostUsd(usage: AgentTurnUsage | undefined): number { + if (!usage?.cost) return 0; + if (usage.cost.total !== undefined) return usage.cost.total; + return ( + (usage.cost.input ?? 0) + + (usage.cost.output ?? 0) + + (usage.cost.cacheRead ?? 0) + + (usage.cost.cacheWrite ?? 0) + ); +} + +/** Throw once reported turn cost reaches the cap or cannot be verified. */ +export function enforceTurnSpendLimit(args: { + maxSpendUsd: number | undefined; + usage: AgentTurnUsage | undefined; +}): void { + if (args.maxSpendUsd === undefined || args.usage === undefined) { + return; + } + if (!args.usage.cost || Object.keys(args.usage.cost).length === 0) { + throw new TurnSpendCostUnavailableError(); + } + if (agentTurnCostUsd(args.usage) >= args.maxSpendUsd) { + throw new TurnSpendLimitExceededError(args.maxSpendUsd); + } +} diff --git a/packages/junior/src/chat/usage.ts b/packages/junior/src/chat/usage.ts index 411ee3ad2..fc20e4b2b 100644 --- a/packages/junior/src/chat/usage.ts +++ b/packages/junior/src/chat/usage.ts @@ -91,10 +91,16 @@ export function addAgentTurnUsage( reasoningTokens = (reasoningTokens ?? 0) + reasoning; } if (usage.cost) { - for (const field of [...COST_COMPONENT_FIELDS, "total"] as const) { + let componentCostTotal: number | undefined; + for (const field of COST_COMPONENT_FIELDS) { const value = getFiniteCost(usage.cost[field]); if (value === undefined) continue; cost[field] = addCost(cost[field], value); + componentCostTotal = addCost(componentCostTotal, value); + } + const total = getFiniteCost(usage.cost.total) ?? componentCostTotal; + if (total !== undefined) { + cost.total = addCost(cost.total, total); } } const usageComponentTotal = getComponentTotal(usage); diff --git a/packages/junior/tests/component/config/chat-config.test.ts b/packages/junior/tests/component/config/chat-config.test.ts index 2c6ce14fe..1a4c470ac 100644 --- a/packages/junior/tests/component/config/chat-config.test.ts +++ b/packages/junior/tests/component/config/chat-config.test.ts @@ -380,6 +380,25 @@ describe("chat config", () => { expect(botConfig.maxSlicesPerTurn).toBe(100); }); + it("leaves the spend cap disabled when MAX_SPEND is unset", async () => { + delete process.env.MAX_SPEND; + const { botConfig } = await loadConfig(); + expect(botConfig.maxSpendUsd).toBeUndefined(); + }); + + it("parses MAX_SPEND as a positive USD amount", async () => { + process.env.MAX_SPEND = "1.25"; + const { botConfig } = await loadConfig(); + expect(botConfig.maxSpendUsd).toBe(1.25); + }); + + it("rejects an invalid MAX_SPEND", async () => { + process.env.MAX_SPEND = "0"; + await expect(loadConfig()).rejects.toThrow( + "MAX_SPEND must be a positive number", + ); + }); + it("uses default AGENT_TURN_TIMEOUT_MS when env var is unset", async () => { delete process.env.AGENT_TURN_TIMEOUT_MS; const { botConfig } = await loadConfig(); diff --git a/packages/junior/tests/integration/runtime/agent-run-spend-limit.test.ts b/packages/junior/tests/integration/runtime/agent-run-spend-limit.test.ts new file mode 100644 index 000000000..f73760bcb --- /dev/null +++ b/packages/junior/tests/integration/runtime/agent-run-spend-limit.test.ts @@ -0,0 +1,262 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createSlackSource, type Destination } from "@sentry/junior-plugin-api"; + +const provider = vi.hoisted(() => ({ + calls: 0, + includeCost: true, +})); + +vi.mock("@/chat/config", async (importOriginal) => { + const actual = await importOriginal(); + const config = actual.readChatConfig({ + ...process.env, + MAX_SPEND: "1", + }); + return { ...actual, botConfig: config.bot }; +}); + +vi.mock("@/chat/pi/client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + completeObject: async () => ({ + object: { + reasoning_level: "medium", + profile: "standard", + confidence: 1, + reason: "test", + }, + }), + }; +}); + +vi.mock("@/chat/pi/traced-stream", () => ({ + createTracedStreamFn: () => async (model: { id: string }) => { + provider.calls += 1; + const message = { + role: "assistant" as const, + content: [{ type: "text" as const, text: "This must not be delivered." }], + stopReason: "stop" as const, + api: "test", + provider: "test", + model: model.id, + timestamp: Date.now(), + usage: { + input: 10, + output: 5, + totalTokens: 15, + ...(provider.includeCost ? { cost: { total: 1 } } : {}), + }, + }; + return { + async *[Symbol.asyncIterator]() { + yield { type: "done" as const, reason: "stop", message }; + }, + result: async () => message, + }; + }, +})); + +vi.mock("@/chat/skills", async (importOriginal) => ({ + ...(await importOriginal()), + discoverSkills: async () => [], + findSkillByName: () => null, + parseSkillInvocation: () => null, +})); + +import { executeAgentRun } from "@/chat/agent"; +import { persistCompletedSessionRecord } from "@/chat/services/turn-session-record"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { TURN_SPEND_LIMIT_RESPONSE } from "@/chat/services/spend-limit"; + +const ORIGINAL_STATE_ADAPTER = process.env.JUNIOR_STATE_ADAPTER; + +/** Build a Slack route that the SQL-backed integration harness can persist. */ +function createTestRoute(channelId: string, threadTs: string) { + const destination = { + platform: "slack", + teamId: "TSPEND", + channelId, + } satisfies Destination; + return { + conversationId: `slack:${channelId}:${threadTs}`, + destination, + source: createSlackSource({ + teamId: destination.teamId, + channelId, + threadTs, + type: "priv", + }), + }; +} + +describe("executeAgentRun spend limit", () => { + beforeEach(async () => { + process.env.JUNIOR_STATE_ADAPTER = "memory"; + provider.calls = 0; + provider.includeCost = true; + await disconnectStateAdapter(); + }); + + afterAll(async () => { + await disconnectStateAdapter(); + if (ORIGINAL_STATE_ADAPTER === undefined) { + delete process.env.JUNIOR_STATE_ADAPTER; + } else { + process.env.JUNIOR_STATE_ADAPTER = ORIGINAL_STATE_ADAPTER; + } + }); + + it.each([ + ["reported spend reaches the cap", true], + ["provider omits cost data", false], + ])("delivers a static response when %s", async (_case, includeCost) => { + provider.includeCost = includeCost; + const channelId = includeCost ? "CSPENDCOST" : "CSPENDMISSING"; + const threadTs = includeCost ? "1712345.0001" : "1712345.0002"; + const { conversationId, destination, source } = createTestRoute( + channelId, + threadTs, + ); + const delivered: string[] = []; + + const outcome = await executeAgentRun({ + conversationId, + turnId: `turn-spend-limit-${includeCost}`, + input: { messageText: "keep working" }, + routing: { + destination, + source, + }, + delivery: { + onAssistantMessage: ({ text }) => { + delivered.push(text); + }, + }, + }); + + expect(provider.calls).toBe(1); + expect(delivered).toEqual([TURN_SPEND_LIMIT_RESPONSE]); + expect(outcome).toMatchObject({ + status: "completed", + result: { + text: TURN_SPEND_LIMIT_RESPONSE, + piMessages: [ + expect.objectContaining({ role: "user" }), + expect.objectContaining({ role: "assistant" }), + ], + diagnostics: { + outcome: "success", + usage: { + inputTokens: 10, + outputTokens: 5, + }, + }, + }, + }); + + if (outcome.status !== "completed") { + throw new Error("Expected a completed spend-limit result"); + } + await persistCompletedSessionRecord({ + conversationId, + sessionId: `turn-spend-limit-${includeCost}`, + sliceId: 1, + allMessages: outcome.result.piMessages ?? [], + modelId: outcome.result.diagnostics.modelId, + currentUsage: outcome.result.diagnostics.usage, + destination, + source, + }); + + const nextDelivered: string[] = []; + await executeAgentRun({ + conversationId, + turnId: `turn-spend-limit-next-${includeCost}`, + input: { messageText: "try again" }, + routing: { + destination, + source, + }, + delivery: { + onAssistantMessage: ({ text }) => { + nextDelivered.push(text); + }, + }, + }); + + expect(provider.calls).toBe(1); + expect(nextDelivered).toEqual([TURN_SPEND_LIMIT_RESPONSE]); + }); + + it("fails closed when new provider usage omits cost after prior spend", async () => { + const { conversationId, destination, source } = createTestRoute( + "CSPENDPRIOR", + "1712345.0003", + ); + await persistCompletedSessionRecord({ + conversationId, + sessionId: "turn-prior-cost", + sliceId: 1, + allMessages: [ + { + role: "user", + content: [{ type: "text", text: "previous request" }], + timestamp: 1, + }, + { + role: "assistant", + content: [{ type: "text", text: "previous response" }], + stopReason: "stop", + api: "test", + provider: "test", + model: "test-model", + timestamp: 2, + usage: { + input: 2, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 3, + cost: { + input: 0.2, + output: 0.05, + cacheRead: 0, + cacheWrite: 0, + total: 0.25, + }, + }, + }, + ], + modelId: "test-model", + destination, + source, + currentUsage: { + inputTokens: 2, + outputTokens: 1, + totalTokens: 3, + cost: { total: 0.25 }, + }, + }); + provider.includeCost = false; + const delivered: string[] = []; + + await executeAgentRun({ + conversationId, + turnId: "turn-missing-cost-after-prior-spend", + input: { messageText: "keep working" }, + routing: { + destination, + source, + }, + delivery: { + onAssistantMessage: ({ text }) => { + delivered.push(text); + }, + }, + }); + + expect(provider.calls).toBe(1); + expect(delivered).toEqual([TURN_SPEND_LIMIT_RESPONSE]); + }); +}); diff --git a/packages/junior/tests/unit/services/spend-limit.test.ts b/packages/junior/tests/unit/services/spend-limit.test.ts new file mode 100644 index 000000000..1e8d574e1 --- /dev/null +++ b/packages/junior/tests/unit/services/spend-limit.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + agentTurnCostUsd, + enforceTurnSpendLimit, + TurnSpendCostUnavailableError, + TurnSpendLimitExceededError, +} from "@/chat/services/spend-limit"; +import { addAgentTurnUsage } from "@/chat/usage"; + +describe("turn spend limit", () => { + it("uses the provider total when present", () => { + expect( + agentTurnCostUsd({ + cost: { input: 1, output: 2, total: 2.5 }, + }), + ).toBe(2.5); + }); + + it("derives cost from components when total is absent", () => { + expect( + agentTurnCostUsd({ + cost: { input: 1, output: 2, cacheRead: 0.25, cacheWrite: 0.5 }, + }), + ).toBe(3.75); + }); + + it("allows spend below the cap", () => { + expect(() => + enforceTurnSpendLimit({ + maxSpendUsd: 1, + usage: { cost: { total: 0.99 } }, + }), + ).not.toThrow(); + }); + + it("hard-stops when spend reaches the cap", () => { + expect(() => + enforceTurnSpendLimit({ + maxSpendUsd: 1, + usage: { cost: { total: 1 } }, + }), + ).toThrow(TurnSpendLimitExceededError); + }); + + it("counts total-only and component-only usage together", () => { + expect(() => + enforceTurnSpendLimit({ + maxSpendUsd: 1, + usage: addAgentTurnUsage( + { cost: { total: 0.75 } }, + { cost: { input: 0.15, output: 0.1 } }, + ), + }), + ).toThrow(TurnSpendLimitExceededError); + }); + + it("hard-stops when provider usage omits cost data", () => { + expect(() => + enforceTurnSpendLimit({ + maxSpendUsd: 1, + usage: { inputTokens: 10, outputTokens: 5 }, + }), + ).toThrow(TurnSpendCostUnavailableError); + }); + + it("allows an empty turn before provider usage exists", () => { + expect(() => + enforceTurnSpendLimit({ + maxSpendUsd: 1, + usage: undefined, + }), + ).not.toThrow(); + }); + + it("is disabled without a configured cap", () => { + expect(() => + enforceTurnSpendLimit({ + maxSpendUsd: undefined, + usage: { cost: { total: 10 } }, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/junior/tests/unit/usage.test.ts b/packages/junior/tests/unit/usage.test.ts index 5479b4fe4..282cdde00 100644 --- a/packages/junior/tests/unit/usage.test.ts +++ b/packages/junior/tests/unit/usage.test.ts @@ -56,6 +56,21 @@ describe("addAgentTurnUsage", () => { }); }); + it("derives missing slice totals before aggregating cost", () => { + expect( + addAgentTurnUsage( + { cost: { total: 1 } }, + { cost: { input: 0.2, output: 0.3 } }, + ), + ).toEqual({ + cost: { + input: 0.2, + output: 0.3, + total: 1.5, + }, + }); + }); + it("recognizes cost-only usage records", () => { expect(hasAgentTurnUsage({ cost: { total: 0.01 } })).toBe(true); });