From bd75f9bc587fbe95727068417ea3a14b223d4dc6 Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 08:59:07 -0600 Subject: [PATCH 1/2] fix(backup,ai): order backup jobs by started_at; count cached tokens as input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two release-QA defects, both in "the number we show is derived from the wrong field". **A — backup dashboard picked the wrong "last job".** `created_at` is a row-INSERT timestamp. The profile fan-out writes an entire occurrence's jobs inside one transaction, so they share it to the microsecond and `ORDER BY created_at DESC` is not even a total order. QA hit that tie (two jobs at 06:32:11.829738), the planner returned the OLDER run, and the device Backup tab hid its VSS Status panel — that panel renders only when the chosen `lastJob` carries `vss_metadata`. New `services/backupJobOrdering.ts` states the rule once, in two shapes: - `latestBackupRunOrderBy` — `started_at DESC NULLS LAST, created_at DESC, id DESC`, for "which run is most recent". NULLS LAST because a `pending` job has not run: it has no snapshot, no VSS metadata and no error log, so it must not displace a real run. It only demotes, so a device whose every job is queued still reports one. - `backupJobHistoryOrderBy` — `created_at DESC` primary, then the same tiebreaks, for chronological feeds. Those filter on `created_at` windows and a just-queued job has to stay at the top of the operator's list; the tiebreaks only stop the fan-out reshuffling it per request. Applied to `/backup/status/:deviceId` (the reported defect), the attention-items `row_number()` window, the dashboard's latest-jobs feed and the `/backup/jobs` history list. The sweep also turned up the same defect mirrored in two AI tools: `aiToolsBackup` used `desc(startedAt)`, and DESC in Postgres is NULLS **FIRST**, so a single queued job floated above every real run and — under `limit(1)` — made `get_backup_status` answer "latest backup: pending" for a device that had just failed one. **B — AI session input tokens excluded cached tokens.** Real bug, not intent: no comment, column name or doc said otherwise, and the UI renders the column as "{{input}} in". QA saw 8 turns report 17 input tokens against 1029 output and $0.57 of spend, because prompt caching routes almost the whole prompt through `cache_read_input_tokens` on every turn after the first. `sumInputTokens()` now feeds `ai_sessions.total_input_tokens`, `ai_cost_usage.input_tokens`, the per-user `client_ai_usage` hook and the client `done` event. The three components stay split in the COST path, which was already cache-aware and correct — the sum is deliberately never fed back into `calculateCostCents`, and a test pins that (a 1M cache-read turn still prices at 30c, not 300c). Budget enforcement keys on `total_cost_cents`, so it was never affected either way. Historic rows are not backfilled: the split components were never persisted, so there is nothing to recover them from. Tests: 9 ordering-rule + 4 route cases for A (all four verified red against the old code), 7 token-accounting cases for B. --- apps/api/src/db/schema/ai.ts | 9 ++ apps/api/src/routes/backup/dashboard.test.ts | 114 +++++++++++++++ apps/api/src/routes/backup/dashboard.ts | 38 ++++- apps/api/src/routes/backup/jobs.ts | 10 +- apps/api/src/services/aiCostTracker.test.ts | 132 +++++++++++++++++- apps/api/src/services/aiCostTracker.ts | 40 +++++- apps/api/src/services/aiToolsBackup.ts | 12 +- .../src/services/backupJobOrdering.test.ts | 121 ++++++++++++++++ apps/api/src/services/backupJobOrdering.ts | 98 +++++++++++++ .../src/services/streamingSessionManager.ts | 11 +- .../streamingSessionManager.usage.test.ts | 48 +++++++ 11 files changed, 616 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/services/backupJobOrdering.test.ts create mode 100644 apps/api/src/services/backupJobOrdering.ts diff --git a/apps/api/src/db/schema/ai.ts b/apps/api/src/db/schema/ai.ts index 33d2fd394d..0d92e45520 100644 --- a/apps/api/src/db/schema/ai.ts +++ b/apps/api/src/db/schema/ai.ts @@ -34,8 +34,17 @@ export const aiSessions = pgTable('ai_sessions', { model: varchar('model', { length: 100 }).notNull().default('claude-sonnet-4-5-20250929'), systemPrompt: text('system_prompt'), contextSnapshot: jsonb('context_snapshot'), + // TOTAL input across the session — uncached + cache-read + cache-creation. + // The SDK splits those three apart because they are priced differently, not + // because only the first is "input"; see sumInputTokens in aiCostTracker. + // Rows written before that fix carry the uncached slice only and read + // implausibly low (an 8-turn session at 17 tokens); they are NOT backfilled, + // since the split components were never persisted anywhere to recover from. totalInputTokens: integer('total_input_tokens').notNull().default(0), totalOutputTokens: integer('total_output_tokens').notNull().default(0), + // Independent of the token columns above: cost comes from the SDK's own + // total_cost_usd, or from per-component pricing when that is 0. It was + // correct throughout — do not "reconcile" it against the token columns. totalCostCents: real('total_cost_cents').notNull().default(0), turnCount: integer('turn_count').notNull().default(0), maxTurns: integer('max_turns').notNull().default(50), diff --git a/apps/api/src/routes/backup/dashboard.test.ts b/apps/api/src/routes/backup/dashboard.test.ts index 500697bf55..6d6de71b3a 100644 --- a/apps/api/src/routes/backup/dashboard.test.ts +++ b/apps/api/src/routes/backup/dashboard.test.ts @@ -491,6 +491,120 @@ describe('backup dashboard routes', () => { expect(body.data.lastJob.errorLog).toContain('read from the live volume'); }); }); + + describe('GET /status/:deviceId — which run is "last"', () => { + // created_at is the row-INSERT clock. The profile fan-out writes an entire + // occurrence's jobs in one transaction, so they share it to the microsecond + // and `ORDER BY created_at DESC` is not a total order. Each case below feeds + // the route rows in the order a created_at-only sort could legitimately + // return them, and asserts the route still picks the right run. + function mockJobRows(rows: Array>) { + resolveBackupConfigForDeviceMock.mockResolvedValueOnce(null); + selectMock + .mockReturnValueOnce(chainMock([{ id: DEVICE_ID, siteId: SITE_A }])) + .mockReturnValueOnce(chainMock(rows)); + } + + function run(overrides: Record = {}) { + return { + id: 'job-run', + status: 'completed', + startedAt: new Date('2026-08-05T06:35:00.000Z'), + createdAt: new Date('2026-08-05T06:32:11.829738Z'), + completedAt: new Date('2026-08-05T06:50:00.000Z'), + errorLog: null, + vssMetadata: null, + ...overrides, + }; + } + + it('breaks an identical created_at tie in favour of the later started_at', async () => { + const sameCreatedAt = new Date('2026-08-05T06:32:11.829738Z'); + mockJobRows([ + run({ + id: 'job-older', + createdAt: sameCreatedAt, + startedAt: new Date('2026-08-05T06:32:20.000Z'), + completedAt: new Date('2026-08-05T06:33:00.000Z'), + }), + run({ + id: 'job-newer', + createdAt: sameCreatedAt, + startedAt: new Date('2026-08-05T06:40:00.000Z'), + completedAt: new Date('2026-08-05T06:55:00.000Z'), + vssMetadata: { shadowCopyId: 'set-9', writers: [] }, + }), + ]); + + const res = await app.request(`/backup/status/${DEVICE_ID}`); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.lastJob.id).toBe('job-newer'); + // The whole point of the defect: the VSS panel renders only off the + // chosen lastJob's metadata, so picking the loser blanks it. + expect(body.data.lastJob.vssMetadata).toEqual({ shadowCopyId: 'set-9', writers: [] }); + }); + + it('does not let a never-started pending job displace a real completed run', async () => { + mockJobRows([ + run({ + id: 'job-pending', + status: 'pending', + startedAt: null, + createdAt: new Date('2026-08-05T07:00:00.000Z'), + completedAt: null, + }), + run({ + id: 'job-completed', + startedAt: new Date('2026-08-05T06:35:00.000Z'), + createdAt: new Date('2026-08-05T06:32:00.000Z'), + completedAt: new Date('2026-08-05T06:50:00.000Z'), + vssMetadata: { shadowCopyId: 'set-1', writers: [] }, + }), + ]); + + const res = await app.request(`/backup/status/${DEVICE_ID}`); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.lastJob.id).toBe('job-completed'); + expect(body.data.lastJob.vssMetadata).toEqual({ shadowCopyId: 'set-1', writers: [] }); + expect(body.data.lastSuccessAt).toBe('2026-08-05T06:50:00.000Z'); + }); + + it('still reports a pending job when the device has never run one', async () => { + // NULLS LAST only demotes — it must never turn a queued-only device into + // "no backup jobs at all". + mockJobRows([ + run({ id: 'p1', status: 'pending', startedAt: null, createdAt: new Date('2026-08-05T06:00:00.000Z'), completedAt: null }), + run({ id: 'p2', status: 'pending', startedAt: null, createdAt: new Date('2026-08-05T07:00:00.000Z'), completedAt: null }), + ]); + + const res = await app.request(`/backup/status/${DEVICE_ID}`); + + const body = await res.json(); + expect(body.data.lastJob.id).toBe('p2'); + expect(body.data.lastSuccessAt).toBeNull(); + }); + + it('asks the database for the same order it applies in memory', async () => { + mockJobRows([run()]); + const jobsChain = selectMock.mock.results[1]?.value ?? null; + + await app.request(`/backup/status/${DEVICE_ID}`); + + const chain = jobsChain ?? (selectMock.mock.results[1]!.value as Record); + const orderTerms = chain.orderBy.mock.calls[0]; + expect(orderTerms).toHaveLength(3); + // started_at first, and explicitly NULLS LAST — a bare DESC would be + // NULLS FIRST in Postgres and float every queued job to the top. + expect(JSON.stringify(orderTerms[0])).toContain('nulls last'); + expect(JSON.stringify(orderTerms[0])).toContain('backup_jobs.started_at'); + expect(orderTerms[1]).toEqual({ op: 'desc', value: 'backup_jobs.created_at' }); + expect(orderTerms[2]).toEqual({ op: 'desc', value: 'backup_jobs.id' }); + }); + }); }); function makeRecentJob(overrides: Record = {}) { diff --git a/apps/api/src/routes/backup/dashboard.ts b/apps/api/src/routes/backup/dashboard.ts index 0ab004bd9f..bb6fb3b0b9 100644 --- a/apps/api/src/routes/backup/dashboard.ts +++ b/apps/api/src/routes/backup/dashboard.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { zValidator } from '../../lib/validation'; -import { eq, and, sql, gte, lte, desc, inArray } from 'drizzle-orm'; +import { eq, and, sql, gte, lte, inArray } from 'drizzle-orm'; import { db } from '../../db'; import { requirePermission } from '../../middleware/auth'; import { @@ -12,6 +12,12 @@ import { } from '../../db/schema'; import { PERMISSIONS, canAccessSite, type UserPermissions } from '../../services/permissions'; import { resolveBackupConfigForDevice, resolveAllBackupAssignedDevices } from '../../services/featureConfigResolver'; +import { + backupJobHistoryOrderBy, + compareBackupRunRecency, + latestBackupRunOrderBy, + latestBackupRunWindowOrder, +} from '../../services/backupJobOrdering'; import { getNextRun, resolveScopedOrgId } from './helpers'; import { usageHistoryQuerySchema } from './schemas'; @@ -71,7 +77,12 @@ async function resolveAttentionItems( errorLog: backupJobs.errorLog, completedAt: backupJobs.completedAt, createdAt: backupJobs.createdAt, - rn: sql`row_number() over (partition by ${backupJobs.deviceId} order by ${backupJobs.createdAt} desc)`.as('rn'), + // rn=1 must be the device's most recent RUN, not the most recently + // inserted row — see backupJobOrdering. Ordering this window by + // created_at alone let a queued job (or the loser of a same-transaction + // fan-out tie) take rn=1 and mask the failed run underneath it, so the + // device never showed up in attention items at all. + rn: sql`row_number() over (partition by ${backupJobs.deviceId} order by ${latestBackupRunWindowOrder})`.as('rn'), }) .from(backupJobs) .where(and(eq(backupJobs.orgId, orgId), jobDeviceScope)) @@ -340,7 +351,10 @@ dashboardRoutes.get('/dashboard', requirePermission(PERMISSIONS.ORGS_READ.resour .leftJoin(devices, eq(backupJobs.deviceId, devices.id)) .leftJoin(backupConfigs, eq(backupJobs.configId, backupConfigs.id)) .where(and(eq(backupJobs.orgId, orgId), jobDeviceScope)) - .orderBy(desc(backupJobs.createdAt)) + // Activity feed, so created_at stays primary (a job queued seconds ago + // belongs at the top) — but the order has to be TOTAL, or a + // same-transaction fan-out reshuffles the top 5 between refreshes. + .orderBy(...backupJobHistoryOrderBy) .limit(5), resolveAttentionItems(orgId, jobDeviceScope, allowedDeviceIds, noSiteAllowedDevices), ]); @@ -434,14 +448,26 @@ dashboardRoutes.get('/status/:deviceId', requirePermission(PERMISSIONS.ORGS_READ // Resolve backup config via configuration policy system const resolved = await resolveBackupConfigForDevice(deviceId); - // Get recent jobs for this device - const jobs = await db + // Get recent jobs for this device, most-recent RUN first. + // + // `created_at` is only the insert timestamp: the profile fan-out writes a + // whole occurrence's jobs in one transaction, so they share it exactly and + // `ORDER BY created_at DESC` is not a total order. QA hit that tie and the + // planner handed back the OLDER run, which silently hid the VSS Status panel + // (it renders only when the chosen lastJob carries vss_metadata). + const jobRows = await db .select() .from(backupJobs) .where( and(eq(backupJobs.orgId, orgId), eq(backupJobs.deviceId, deviceId)) ) - .orderBy(desc(backupJobs.createdAt)); + .orderBy(...latestBackupRunOrderBy); + + // This one array answers three questions below (last job / last success / + // last failure), so recency is applied once here through the comparator that + // mirrors latestBackupRunOrderBy — the three answers can't drift apart, and + // the rule is unit-testable without a live planner. + const jobs = [...jobRows].sort(compareBackupRunRecency); const lastJob = jobs[0] ?? null; // A partial run counts here for the same reason it counts for RPO: it left a diff --git a/apps/api/src/routes/backup/jobs.ts b/apps/api/src/routes/backup/jobs.ts index 5902165750..6f7f08efb1 100644 --- a/apps/api/src/routes/backup/jobs.ts +++ b/apps/api/src/routes/backup/jobs.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { zValidator } from '../../lib/validation'; -import { eq, and, desc, gte, lte, sql, inArray } from 'drizzle-orm'; +import { eq, and, gte, lte, sql, inArray } from 'drizzle-orm'; import { db } from '../../db'; import { backupJobs, backupConfigs, devices } from '../../db/schema'; import { requireMfa, requirePermission, requireScope } from '../../middleware/auth'; @@ -12,6 +12,7 @@ import { resolveBackupConfigForDevice, resolveAllBackupAssignedDevices } from '. import { queueBackupStopCommand } from '../../services/commandQueue'; import { canAccessSite, PERMISSIONS, type UserPermissions } from '../../services/permissions'; import { resolveScopedOrgId } from './helpers'; +import { backupJobHistoryOrderBy } from '../../services/backupJobOrdering'; import { jobListSchema } from './schemas'; // Legacy `?status=` spellings this API has always accepted, mapped to the real @@ -127,7 +128,12 @@ jobsRoutes.get('/jobs', requirePermission(PERMISSIONS.ORGS_READ.resource, PERMIS .leftJoin(devices, eq(backupJobs.deviceId, devices.id)) .leftJoin(backupConfigs, eq(backupJobs.configId, backupConfigs.id)) .where(and(...conditions)) - .orderBy(desc(backupJobs.createdAt)); + // History list: created_at stays primary because the ?from/?to/?date + // filters above are all on created_at, and a just-queued job must not sink + // to the bottom of the operator's list. started_at + id only make the order + // total, so a same-transaction profile fan-out stops reshuffling between + // requests. See services/backupJobOrdering. + .orderBy(...backupJobHistoryOrderBy); return c.json({ data: rows.map((r) => ({ diff --git a/apps/api/src/services/aiCostTracker.test.ts b/apps/api/src/services/aiCostTracker.test.ts index 7b856b412e..0b2f2405af 100644 --- a/apps/api/src/services/aiCostTracker.test.ts +++ b/apps/api/src/services/aiCostTracker.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { calculateCostCents, checkAiRateLimit, checkBudget, recordUsage, recordUsageFromSdkResult } from './aiCostTracker'; +import { calculateCostCents, checkAiRateLimit, checkBudget, recordUsage, recordUsageFromSdkResult, sumInputTokens } from './aiCostTracker'; import { db, withSystemDbAccessContext } from '../db'; import { getEffectiveAiBudget } from './effectiveSettings'; import { rateLimiter } from './rate-limit'; @@ -303,6 +303,136 @@ describe('recordUsageFromSdkResult', () => { }); }); +// ============================================ +// Input-token accounting — cache reads/creations ARE input +// ============================================ + +/** The number added to ai_sessions.total_input_tokens / total_output_tokens. */ +function recordedTokens(captured: Record | undefined, key: 'totalInputTokens' | 'totalOutputTokens'): number { + const expr = captured?.[key] as { values?: unknown[] } | undefined; + return Number(expr?.values?.[1]); +} + +describe('recordUsageFromSdkResult — input token accounting', () => { + // Release QA: an 8-turn session showed total_input_tokens = 17 against + // total_output_tokens = 1029 and $0.57 of spend. Prompt caching routes almost + // the entire prompt through cache_read_input_tokens on every turn after the + // first, and only the uncached remainder was being accumulated. + it('counts cache-read and cache-creation tokens as input', async () => { + const captured = setupDbMocks(null); + + await recordUsageFromSdkResult('sess-tokens', 'org-1', { + total_cost_usd: 0.5, + usage: { + input_tokens: 17, + output_tokens: 1_029, + cache_read_input_tokens: 120_000, + cache_creation_input_tokens: 4_500, + }, + num_turns: 1, + model: 'claude-sonnet-4-6', + }); + + expect(recordedTokens(captured.sessionSet, 'totalInputTokens')).toBe(17 + 120_000 + 4_500); + expect(recordedTokens(captured.sessionSet, 'totalOutputTokens')).toBe(1_029); + }); + + it('records the same total on the daily/monthly org aggregates', async () => { + const captured = setupDbMocks(null); + + await recordUsageFromSdkResult('sess-agg', 'org-1', { + total_cost_usd: 0.5, + usage: { + input_tokens: 17, + output_tokens: 1_029, + cache_read_input_tokens: 120_000, + cache_creation_input_tokens: 4_500, + }, + num_turns: 1, + model: 'claude-sonnet-4-6', + }); + + // One insert per period (daily + monthly); both carry the summed input. + expect(captured.aggregateValues).toHaveLength(2); + for (const values of captured.aggregateValues) { + expect(values.inputTokens).toBe(17 + 120_000 + 4_500); + expect(values.outputTokens).toBe(1_029); + } + }); + + it('records a fully-cached turn as real input, not zero', async () => { + const captured = setupDbMocks(null); + + await recordUsageFromSdkResult('sess-cached-only', 'org-1', { + total_cost_usd: 0, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 1_000_000, + cache_creation_input_tokens: 0, + }, + num_turns: 1, + model: 'claude-sonnet-4-6', + }); + + expect(recordedTokens(captured.sessionSet, 'totalInputTokens')).toBe(1_000_000); + // ...and the cost path is untouched by the summing: still priced off the + // SPLIT components (1M cache-read at 0.1x of 300 c/MTok = 30), never off the + // sum. Summing into the pricing call would have billed 300 here. + expect(recordedCostCents(captured.sessionSet)).toBe(30); + }); + + it('leaves cost alone when cache fields are present alongside real spend', async () => { + // Guards against the obvious mis-fix: feeding the summed input back into + // calculateCostCents would double-count cache tokens at the full input rate. + const captured = setupDbMocks(null); + + await recordUsageFromSdkResult('sess-cost-guard', 'org-1', { + total_cost_usd: 0, + usage: { + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cache_read_input_tokens: 1_000_000, + cache_creation_input_tokens: 1_000_000, + }, + num_turns: 1, + model: 'claude-sonnet-4-6', + }); + + expect(recordedTokens(captured.sessionSet, 'totalInputTokens')).toBe(3_000_000); + expect(recordedCostCents(captured.sessionSet)).toBe(2205); // unchanged from the pricing test above + }); + + it('is unaffected when the payload carries no cache fields at all', async () => { + // Older/partial usage payloads and the vLLM path have no prompt caching. + const captured = setupDbMocks(null); + + await recordUsageFromSdkResult('sess-nocache', 'org-1', { + total_cost_usd: 0.1, + usage: { input_tokens: 5_000, output_tokens: 2_000 }, + num_turns: 1, + model: 'claude-sonnet-4-6', + }); + + expect(recordedTokens(captured.sessionSet, 'totalInputTokens')).toBe(5_000); + }); +}); + +describe('sumInputTokens', () => { + it('sums the three disjoint slices the SDK splits input across', () => { + expect(sumInputTokens({ + input_tokens: 17, + cache_read_input_tokens: 120_000, + cache_creation_input_tokens: 4_500, + })).toBe(124_517); + }); + + it('treats missing and null components as 0', () => { + expect(sumInputTokens({})).toBe(0); + expect(sumInputTokens({ input_tokens: 10, cache_read_input_tokens: null })).toBe(10); + }); +}); + // ============================================ // recordUsage — sessionless org-budget path (issue #1949) // ============================================ diff --git a/apps/api/src/services/aiCostTracker.ts b/apps/api/src/services/aiCostTracker.ts index 6447cdf9ae..46c8803a9d 100644 --- a/apps/api/src/services/aiCostTracker.ts +++ b/apps/api/src/services/aiCostTracker.ts @@ -133,6 +133,36 @@ async function getSessionModel(sessionId: string): Promise { } } +/** The three components the Anthropic/SDK usage object splits input across. */ +export interface SdkInputTokenUsage { + input_tokens?: number | null; + cache_read_input_tokens?: number | null; + cache_creation_input_tokens?: number | null; +} + +/** + * Total input tokens for a turn — uncached + cache-read + cache-creation. + * + * The SDK reports these three separately because they are PRICED differently + * (see the multipliers above), not because only the first one is "input". They + * are three disjoint slices of one prompt: every token in the request lands in + * exactly one of them, so summing cannot double-count. + * + * The `*_input_tokens` columns store this sum. Recording only `input_tokens` + * made them worse than useless on any multi-turn session, where prompt caching + * routes almost the whole prompt through cache_read: release QA saw an 8-turn + * session report 17 input tokens against 1029 output tokens and $0.57 of spend. + * Cost was never affected — it is computed from the three split values, and + * still is (this sum is deliberately NOT fed back into the pricing call). + */ +export function sumInputTokens(usage: SdkInputTokenUsage): number { + return ( + (usage.input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0) + ); +} + export function calculateCostCents( model: string, inputTokens: number, @@ -415,6 +445,10 @@ export async function recordUsageFromSdkResult( cache_creation_input_tokens: cacheCreationTokens = 0, } = result.usage; + // What the `*_input_tokens` COLUMNS store. Kept distinct from the three + // variables above, which stay split because each is billed at its own rate. + const recordedInputTokens = sumInputTokens(result.usage); + // Prefer the SDK's self-reported cost. Fall back to token-based pricing only when // the SDK reports 0/missing cost but actually consumed tokens — this is the case // that was silently producing $0.00 sessions (the SDK can't price a model id newer @@ -458,7 +492,7 @@ export async function recordUsageFromSdkResult( await db .update(aiSessions) .set({ - totalInputTokens: sql`${aiSessions.totalInputTokens} + ${inputTokens}`, + totalInputTokens: sql`${aiSessions.totalInputTokens} + ${recordedInputTokens}`, totalOutputTokens: sql`${aiSessions.totalOutputTokens} + ${outputTokens}`, totalCostCents: sql`${aiSessions.totalCostCents} + ${costCents}`, turnCount: sql`${aiSessions.turnCount} + ${result.num_turns}`, @@ -480,7 +514,7 @@ export async function recordUsageFromSdkResult( orgId, period, periodKey, - inputTokens, + inputTokens: recordedInputTokens, outputTokens, totalCostCents: costCents, sessionCount: 0, @@ -490,7 +524,7 @@ export async function recordUsageFromSdkResult( .onConflictDoUpdate({ target: [aiCostUsage.orgId, aiCostUsage.period, aiCostUsage.periodKey], set: { - inputTokens: sql`${aiCostUsage.inputTokens} + ${inputTokens}`, + inputTokens: sql`${aiCostUsage.inputTokens} + ${recordedInputTokens}`, outputTokens: sql`${aiCostUsage.outputTokens} + ${outputTokens}`, totalCostCents: sql`${aiCostUsage.totalCostCents} + ${costCents}`, messageCount: sql`${aiCostUsage.messageCount} + 1`, diff --git a/apps/api/src/services/aiToolsBackup.ts b/apps/api/src/services/aiToolsBackup.ts index ea841233bc..e448028764 100644 --- a/apps/api/src/services/aiToolsBackup.ts +++ b/apps/api/src/services/aiToolsBackup.ts @@ -28,6 +28,7 @@ import { createManualBackupJobIfIdle } from './backupJobCreation'; import { enqueueBackupDispatch } from '../jobs/backupEnqueue'; import { deviceSiteDenied, resolveSiteAllowedDeviceIds } from './aiToolsSiteScope'; import { loadSnapshotWithSiteAccess } from './aiToolsBackupShared'; +import { backupJobHistoryOrderBy, latestBackupRunOrderBy } from './backupJobOrdering'; import { inArray } from 'drizzle-orm'; type BackupHandler = (input: Record, auth: AuthContext) => Promise; @@ -227,7 +228,10 @@ export function registerBackupTools(aiTools: Map): void { .leftJoin(devices, eq(backupJobs.deviceId, devices.id)) .leftJoin(backupConfigs, eq(backupJobs.configId, backupConfigs.id)) .where(conditions.length > 0 ? and(...conditions) : undefined) - .orderBy(desc(backupJobs.startedAt)) + // Was `desc(startedAt)`, which in Postgres means NULLS **FIRST** — so + // every queued job floated above the real runs and, under `limit`, + // pushed them out of the answer entirely. + .orderBy(...backupJobHistoryOrderBy) .limit(limit); return JSON.stringify({ jobs: rows, showing: rows.length }); @@ -317,7 +321,11 @@ export function registerBackupTools(aiTools: Map): void { errorCount: backupJobs.errorCount, }).from(backupJobs) .where(and(eq(backupJobs.deviceId, deviceId), ...(jobOrgCond ? [jobOrgCond] : []))) - .orderBy(desc(backupJobs.startedAt)) + // Same defect as the device Backup tab, mirrored: `desc(startedAt)` is + // NULLS FIRST in Postgres, so with limit(1) a single queued job made + // this tool report "latest backup: pending" for a device that had just + // finished (or just failed) a real run. + .orderBy(...latestBackupRunOrderBy) .limit(1); // Last successful backup time diff --git a/apps/api/src/services/backupJobOrdering.test.ts b/apps/api/src/services/backupJobOrdering.test.ts new file mode 100644 index 0000000000..d8686fa8bc --- /dev/null +++ b/apps/api/src/services/backupJobOrdering.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../db/schema', () => ({ + backupJobs: { + id: 'backup_jobs.id', + startedAt: 'backup_jobs.started_at', + createdAt: 'backup_jobs.created_at', + }, +})); + +vi.mock('drizzle-orm', () => ({ + desc: (value: unknown) => ({ op: 'desc', value }), + sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ op: 'sql', strings, values }), +})); + +const { + backupJobHistoryOrderBy, + compareBackupRunRecency, + latestBackupRunOrderBy, +} = await import('./backupJobOrdering'); + +/** Renders a mocked drizzle order term to a comparable string. */ +function render(term: any): string { + if (term.op === 'desc') return `${term.value} desc`; + return term.strings + .map((s: string, i: number) => s + (i < term.values.length ? String(term.values[i]) : '')) + .join('') + .trim(); +} + +function job(overrides: Partial<{ id: string; startedAt: Date | null; createdAt: Date }> = {}) { + return { + id: 'job-a', + startedAt: new Date('2026-08-05T06:35:00.000Z'), + createdAt: new Date('2026-08-05T06:32:11.829738Z'), + ...overrides, + }; +} + +describe('latestBackupRunOrderBy', () => { + it('orders by started_at desc NULLS LAST, then created_at, then id', () => { + expect(latestBackupRunOrderBy.map(render)).toEqual([ + 'backup_jobs.started_at desc nulls last', + 'backup_jobs.created_at desc', + 'backup_jobs.id desc', + ]); + }); + + it('does not order by created_at first — created_at is only the insert clock', () => { + expect(render(latestBackupRunOrderBy[0])).not.toContain('created_at'); + }); + + it('never uses a bare DESC on started_at (Postgres would put NULLs FIRST)', () => { + const startedTerm = render(latestBackupRunOrderBy[0]); + expect(startedTerm).toContain('nulls last'); + }); +}); + +describe('backupJobHistoryOrderBy', () => { + it('keeps created_at primary but makes the order total', () => { + expect(backupJobHistoryOrderBy.map(render)).toEqual([ + 'backup_jobs.created_at desc', + 'backup_jobs.started_at desc nulls last', + 'backup_jobs.id desc', + ]); + }); +}); + +describe('compareBackupRunRecency', () => { + it('breaks an identical created_at tie in favour of the later started_at', () => { + // The exact release-QA shape: two jobs written by one profile fan-out inside + // a single transaction, so created_at matches to the microsecond. Ordering by + // created_at alone is not a total order and the planner returned the OLDER + // run first, which hid the device tab's VSS Status panel. + const sameCreatedAt = new Date('2026-08-05T06:32:11.829738Z'); + const older = job({ id: 'job-older', createdAt: sameCreatedAt, startedAt: new Date('2026-08-05T06:32:20.000Z') }); + const newer = job({ id: 'job-newer', createdAt: sameCreatedAt, startedAt: new Date('2026-08-05T06:40:00.000Z') }); + + expect([older, newer].sort(compareBackupRunRecency)[0]).toBe(newer); + // ...and from the opposite input order, because a tie-break that depends on + // input order is exactly the bug. + expect([newer, older].sort(compareBackupRunRecency)[0]).toBe(newer); + }); + + it('is deterministic on id when started_at AND created_at both tie', () => { + const startedAt = new Date('2026-08-05T06:40:00.000Z'); + const createdAt = new Date('2026-08-05T06:32:11.829738Z'); + const a = job({ id: 'aaaa', startedAt, createdAt }); + const b = job({ id: 'bbbb', startedAt, createdAt }); + + expect([a, b].sort(compareBackupRunRecency).map((j) => j.id)).toEqual(['bbbb', 'aaaa']); + expect([b, a].sort(compareBackupRunRecency).map((j) => j.id)).toEqual(['bbbb', 'aaaa']); + }); + + it('does not let a never-started job displace a real completed run', () => { + // A `pending` job has no started_at: it has not run, so it carries no + // snapshot, no VSS metadata and no error log. Letting it win would blank the + // device tab every time a backup was queued. + const completed = job({ id: 'job-completed', startedAt: new Date('2026-08-05T06:35:00.000Z'), createdAt: new Date('2026-08-05T06:32:00.000Z') }); + const pending = job({ id: 'job-pending', startedAt: null, createdAt: new Date('2026-08-05T07:00:00.000Z') }); + + expect([completed, pending].sort(compareBackupRunRecency)[0]).toBe(completed); + expect([pending, completed].sort(compareBackupRunRecency)[0]).toBe(completed); + }); + + it('still surfaces a pending job when nothing has ever run', () => { + // NULLS LAST only demotes — a device whose every job is queued must not + // report "no backup jobs at all". + const p1 = job({ id: 'p1', startedAt: null, createdAt: new Date('2026-08-05T06:00:00.000Z') }); + const p2 = job({ id: 'p2', startedAt: null, createdAt: new Date('2026-08-05T07:00:00.000Z') }); + + expect([p1, p2].sort(compareBackupRunRecency)[0]).toBe(p2); + }); + + it('accepts ISO strings as well as Dates (JSON-decoded rows)', () => { + const older = { id: 'a', startedAt: '2026-08-05T06:00:00.000Z', createdAt: '2026-08-05T06:00:00.000Z' }; + const newer = { id: 'b', startedAt: '2026-08-05T08:00:00.000Z', createdAt: '2026-08-05T06:00:00.000Z' }; + + expect([older, newer].sort(compareBackupRunRecency)[0]).toBe(newer); + }); +}); diff --git a/apps/api/src/services/backupJobOrdering.ts b/apps/api/src/services/backupJobOrdering.ts new file mode 100644 index 0000000000..713b06a575 --- /dev/null +++ b/apps/api/src/services/backupJobOrdering.ts @@ -0,0 +1,98 @@ +import { desc, sql, type SQL } from 'drizzle-orm'; +import { backupJobs } from '../db/schema'; + +/** + * Ordering rules for backup_jobs. + * + * `created_at` is a row-INSERT timestamp, not the time the run happened. The + * profile fan-out (a profile with N enabled selections creates N jobs for one + * occurrence) inserts those jobs inside a single transaction, so they all share + * one `created_at` down to the microsecond. `ORDER BY created_at DESC` alone is + * therefore not even a total order: the tie resolves to whatever the planner + * hands back, which during release QA picked the older run of a same-transaction + * pair (both at 06:32:11.829738) and hid the device Backup tab's VSS Status + * panel, because that panel only renders when the chosen `lastJob` carries + * `vss_metadata`. + * + * Two distinct questions are asked of this table, and they want different keys: + */ + +/** + * "Which run is this device's most recent?" — `started_at` is the run's real + * clock, so it is primary. + * + * NULLS LAST is deliberate: `started_at` is NULL until the agent picks the job + * up, so a `pending` job has not run at all and must never displace a run that + * actually produced a restore point (and its VSS metadata / error log). A device + * whose only jobs are pending still surfaces one — NULLS LAST only demotes, it + * never hides. Note that plain `desc()` would be wrong here: Postgres defaults + * DESC to NULLS **FIRST**, which floats every queued job to the top. + * + * `created_at` then `id` make the order total, so equal rows never flap between + * requests. + */ +export const latestBackupRunOrderBy: SQL[] = [ + sql`${backupJobs.startedAt} desc nulls last`, + desc(backupJobs.createdAt), + desc(backupJobs.id), +]; + +/** + * "Show me this org's backup job history / recent activity." Chronological feeds + * keep `created_at` primary — they filter on `created_at` windows (`?from`, + * `?to`, `?date`), and a job queued seconds ago has to stay at the top of the + * list the operator is staring at rather than sinking to the bottom because it + * has not started yet. + * + * `started_at` only breaks `created_at` ties (the same-transaction fan-out + * above), and `id` makes the order total. + */ +export const backupJobHistoryOrderBy: SQL[] = [ + desc(backupJobs.createdAt), + sql`${backupJobs.startedAt} desc nulls last`, + desc(backupJobs.id), +]; + +/** The window-function spelling of {@link latestBackupRunOrderBy}. */ +export const latestBackupRunWindowOrder = sql`${backupJobs.startedAt} desc nulls last, ${backupJobs.createdAt} desc, ${backupJobs.id} desc`; + +type BackupRunRecencyKeys = { + startedAt?: Date | string | null; + createdAt?: Date | string | null; + id?: string | null; +}; + +function toEpoch(value: Date | string | null | undefined): number | null { + if (value == null) return null; + const time = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isNaN(time) ? null : time; +} + +/** + * In-memory twin of {@link latestBackupRunOrderBy}, for the (single) route that + * materialises a device's whole job list and then answers three different + * questions from it — last job, last success, last failure. Expressing recency + * once, as one comparator, keeps those three answers from drifting apart and + * makes the rule testable without a live planner. + * + * Sorts most-recent-first. NULL `started_at` sorts last, matching NULLS LAST. + */ +export function compareBackupRunRecency(a: BackupRunRecencyKeys, b: BackupRunRecencyKeys): number { + const aStarted = toEpoch(a.startedAt); + const bStarted = toEpoch(b.startedAt); + if (aStarted !== bStarted) { + if (aStarted === null) return 1; + if (bStarted === null) return -1; + return bStarted - aStarted; + } + + const aCreated = toEpoch(a.createdAt); + const bCreated = toEpoch(b.createdAt); + if (aCreated !== bCreated) { + if (aCreated === null) return 1; + if (bCreated === null) return -1; + return bCreated - aCreated; + } + + return (b.id ?? '').localeCompare(a.id ?? ''); +} diff --git a/apps/api/src/services/streamingSessionManager.ts b/apps/api/src/services/streamingSessionManager.ts index fb47eb5c8b..93c56f9775 100644 --- a/apps/api/src/services/streamingSessionManager.ts +++ b/apps/api/src/services/streamingSessionManager.ts @@ -21,7 +21,7 @@ import type { AuthContext } from '../middleware/auth'; import { buildOrgAccessClosures } from '../middleware/auth'; import type { AiStreamEvent, AiApprovalMode } from '@breeze/shared/types/ai'; import { AsyncEventQueue } from '../utils/asyncQueue'; -import { recordUsageFromSdkResult, calculateCostCents } from './aiCostTracker'; +import { recordUsageFromSdkResult, calculateCostCents, sumInputTokens } from './aiCostTracker'; import { sanitizeErrorForClient } from './aiAgent'; import { captureException } from './sentry'; import { createBreezeMcpServer, BREEZE_MCP_TOOL_NAMES } from './aiAgentSdkTools'; @@ -1093,10 +1093,15 @@ export class StreamingSessionManager { // Per-user usage hook (AI for Office): runs alongside the org-level // recordUsageFromSdkResult above, never instead of it. const turnCostCents = Math.round(usageData.total_cost_usd * 100 * 100) / 100; + // Cache-read and cache-creation tokens are input tokens — they are + // split out for PRICING only. Reporting the uncached slice alone made + // per-user ledgers and the client's turn summary read near-zero on + // any cached (i.e. any multi-turn) session. See sumInputTokens. + const turnInputTokens = sumInputTokens(usageData.usage); if (session.recordExtraUsage) { try { await session.recordExtraUsage({ - inputTokens: usageData.usage.input_tokens, + inputTokens: turnInputTokens, outputTokens: usageData.usage.output_tokens, costCents: turnCostCents, }); @@ -1112,7 +1117,7 @@ export class StreamingSessionManager { session.eventBus.publish({ type: 'done', usage: { - inputTokens: usageData.usage.input_tokens, + inputTokens: turnInputTokens, outputTokens: usageData.usage.output_tokens, costCents: turnCostCents, }, diff --git a/apps/api/src/services/streamingSessionManager.usage.test.ts b/apps/api/src/services/streamingSessionManager.usage.test.ts index 5e144f982e..dee11136c6 100644 --- a/apps/api/src/services/streamingSessionManager.usage.test.ts +++ b/apps/api/src/services/streamingSessionManager.usage.test.ts @@ -38,6 +38,9 @@ vi.mock('../db', () => ({ vi.mock('./aiCostTracker', () => ({ recordUsageFromSdkResult: recordUsageMock, calculateCostCents: calculateCostCentsMock, + // Pure helper — kept real so these tests exercise the actual summing rule. + sumInputTokens: (u: Record) => + (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0), })); vi.mock('./aiAgent', () => ({ sanitizeErrorForClient: (e: unknown) => String(e) })); vi.mock('./sentry', () => ({ captureException: vi.fn() })); @@ -240,6 +243,51 @@ describe('fallback accumulation from assistant messages', () => { expect(calculateCostCentsMock).toHaveBeenCalledWith('claude-sonnet-4-5-20250929', 500, 60, 0, 0); }); + it('reports cache tokens as input on the per-user hook and the done event', async () => { + // Release QA: an 8-turn session read 17 input tokens / 1029 output / $0.57. + // On every turn past the first, prompt caching moves nearly the whole prompt + // into cache_read, so the uncached slice alone is meaningless. + mockSdkQuery([ + resultMsg({ + total_cost_usd: 0.57, + usage: { + input_tokens: 17, + output_tokens: 1_029, + cache_read_input_tokens: 120_000, + cache_creation_input_tokens: 4_500, + }, + }), + ]); + + const session = await manager.getOrCreate('sess-cache-surfaces', DB_SESSION, PARTNER_AUTH, undefined, 'PROMPT', undefined); + const recordExtraUsage = vi.fn(() => Promise.resolve()); + session.recordExtraUsage = recordExtraUsage; + + await session.processorPromise; + + const done = session.eventBus.getReplayEvents().find((e: any) => e.type === 'done') as any; + expect(done?.usage).toEqual({ + inputTokens: 17 + 120_000 + 4_500, + outputTokens: 1_029, + costCents: 57, + }); + expect(recordExtraUsage).toHaveBeenCalledWith({ + inputTokens: 17 + 120_000 + 4_500, + outputTokens: 1_029, + costCents: 57, + }); + // The org-level recorder still receives the SPLIT components — it prices + // them at their different rates and does its own summing for the columns. + expect(recordUsageMock).toHaveBeenCalledWith('sess-cache-surfaces', ORG, expect.objectContaining({ + usage: { + input_tokens: 17, + output_tokens: 1_029, + cache_read_input_tokens: 120_000, + cache_creation_input_tokens: 4_500, + }, + })); + }); + it('does not double-record when a completed turn is followed by teardown', async () => { await runSession('sess-clean', [ assistantMsg({ input_tokens: 100, output_tokens: 10 }), From 873f2f04a62862fc38375d00cbaddbf989bf6edd Mon Sep 17 00:00:00 2001 From: Todd Hebebrand Date: Thu, 6 Aug 2026 09:38:40 -0600 Subject: [PATCH 2/2] fix(api): add sumInputTokens to the streamingSessionManager aiCostTracker mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI red on streamingSessionManager.clientLoop.test.ts: `recordExtraUsage` — "Number of calls: 0". Root cause is a test-double gap, not a product defect. That file mocks `./aiCostTracker` with an explicit factory listing only `recordUsageFromSdkResult`. Vitest does not return `undefined` for an export a factory omits — it THROWS on the access. So the new `sumInputTokens(usageData.usage)` call threw inside the `result` handler, the throw escaped to the processor's outer catch (visible in stderr as "[StreamingSessionManager] Query error: No 'sumInputTokens' export is defined on the './aiCostTracker' mock"), and everything after it was skipped — the per-user hook AND the `done` publish. Hence 0 calls rather than a wrong value. The assertion was NOT invalidated by the corrected accounting: that fixture carries no cache fields, so the correct answer is still inputTokens: 100. Assertions left exactly as they were. Fixed in all three streamingSessionManager suites that mock the module — droppedToolResult and deviceBoundAuth were green only because they never reach the result path, i.e. latent landmines on the same line. Also hardened `sumInputTokens` to accept a nullish usage object. It sits ahead of both the per-user hook and the `done` publish that returns the session to 'idle', so a throw there strands the turn and hangs the client — that line must not have a failure mode. This is the one real robustness point the failure exposed: the previous code did inline property reads, and hoisting a call there widened its blast radius. Ran the full requested surface this time (the miss that let this through): streamingSessionManager + aiCostTracker + routes/clientAi — 24 files, 248 tests, all passing. --- apps/api/src/services/aiCostTracker.test.ts | 7 +++++++ apps/api/src/services/aiCostTracker.ts | 14 ++++++++++---- .../streamingSessionManager.clientLoop.test.ts | 11 ++++++++++- ...streamingSessionManager.deviceBoundAuth.test.ts | 7 ++++++- ...reamingSessionManager.droppedToolResult.test.ts | 7 ++++++- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/api/src/services/aiCostTracker.test.ts b/apps/api/src/services/aiCostTracker.test.ts index 0b2f2405af..78c034e67a 100644 --- a/apps/api/src/services/aiCostTracker.test.ts +++ b/apps/api/src/services/aiCostTracker.test.ts @@ -431,6 +431,13 @@ describe('sumInputTokens', () => { expect(sumInputTokens({})).toBe(0); expect(sumInputTokens({ input_tokens: 10, cache_read_input_tokens: null })).toBe(10); }); + + it('never throws on a nullish usage object', () => { + // It sits ahead of the `done` publish that returns the session to 'idle'; + // a throw there strands the turn and hangs the client. + expect(sumInputTokens(null)).toBe(0); + expect(sumInputTokens(undefined)).toBe(0); + }); }); // ============================================ diff --git a/apps/api/src/services/aiCostTracker.ts b/apps/api/src/services/aiCostTracker.ts index 46c8803a9d..fd3cd8262a 100644 --- a/apps/api/src/services/aiCostTracker.ts +++ b/apps/api/src/services/aiCostTracker.ts @@ -154,12 +154,18 @@ export interface SdkInputTokenUsage { * session report 17 input tokens against 1029 output tokens and $0.57 of spend. * Cost was never affected — it is computed from the three split values, and * still is (this sum is deliberately NOT fed back into the pricing call). + * + * Total by construction: a nullish usage object or component yields 0 rather + * than throwing. This sits on the streaming `done` path, ahead of both the + * per-user usage hook and the `done` publish that returns the session to + * 'idle' — a throw there would strand the turn and hang the client, so it must + * not have a failure mode. */ -export function sumInputTokens(usage: SdkInputTokenUsage): number { +export function sumInputTokens(usage: SdkInputTokenUsage | null | undefined): number { return ( - (usage.input_tokens ?? 0) + - (usage.cache_read_input_tokens ?? 0) + - (usage.cache_creation_input_tokens ?? 0) + (usage?.input_tokens ?? 0) + + (usage?.cache_read_input_tokens ?? 0) + + (usage?.cache_creation_input_tokens ?? 0) ); } diff --git a/apps/api/src/services/streamingSessionManager.clientLoop.test.ts b/apps/api/src/services/streamingSessionManager.clientLoop.test.ts index 90026097f3..eb7add185b 100644 --- a/apps/api/src/services/streamingSessionManager.clientLoop.test.ts +++ b/apps/api/src/services/streamingSessionManager.clientLoop.test.ts @@ -28,7 +28,16 @@ vi.mock('../db', () => ({ runOutsideDbContext: vi.fn((fn: () => unknown) => fn()), })); -vi.mock('./aiCostTracker', () => ({ recordUsageFromSdkResult: recordUsageMock })); +vi.mock('./aiCostTracker', () => ({ + recordUsageFromSdkResult: recordUsageMock, + // Pure helper on the done path — kept real so these tests exercise the actual + // summing rule. A factory that omits it does NOT yield undefined: vitest + // throws on the access, the throw escapes the result handler, and both + // recordExtraUsage and the `done` publish are skipped. That surfaces as a + // baffling "Number of calls: 0" rather than a missing-export error. + sumInputTokens: (u: Record | null | undefined) => + (u?.input_tokens ?? 0) + (u?.cache_read_input_tokens ?? 0) + (u?.cache_creation_input_tokens ?? 0), +})); vi.mock('./aiAgent', () => ({ sanitizeErrorForClient: (e: unknown) => String(e) })); vi.mock('./sentry', () => ({ captureException: vi.fn() })); vi.mock('./aiAgentSdkTools', () => ({ diff --git a/apps/api/src/services/streamingSessionManager.deviceBoundAuth.test.ts b/apps/api/src/services/streamingSessionManager.deviceBoundAuth.test.ts index 70d9b56adf..3a577fdc7e 100644 --- a/apps/api/src/services/streamingSessionManager.deviceBoundAuth.test.ts +++ b/apps/api/src/services/streamingSessionManager.deviceBoundAuth.test.ts @@ -40,7 +40,12 @@ vi.mock('../db', () => ({ runOutsideDbContext: vi.fn((fn: () => unknown) => fn()), })); -vi.mock('./aiCostTracker', () => ({ recordUsageFromSdkResult: vi.fn(() => Promise.resolve()) })); +vi.mock('./aiCostTracker', () => ({ + recordUsageFromSdkResult: vi.fn(() => Promise.resolve()), + // Also consumed on the result/done path — see the note in clientLoop.test.ts. + sumInputTokens: (u: Record | null | undefined) => + (u?.input_tokens ?? 0) + (u?.cache_read_input_tokens ?? 0) + (u?.cache_creation_input_tokens ?? 0), +})); vi.mock('./aiAgent', () => ({ sanitizeErrorForClient: (e: unknown) => String(e) })); vi.mock('./sentry', () => ({ captureException: vi.fn() })); vi.mock('./aiAgentSdkTools', () => ({ diff --git a/apps/api/src/services/streamingSessionManager.droppedToolResult.test.ts b/apps/api/src/services/streamingSessionManager.droppedToolResult.test.ts index e55bd875ad..3513ab3566 100644 --- a/apps/api/src/services/streamingSessionManager.droppedToolResult.test.ts +++ b/apps/api/src/services/streamingSessionManager.droppedToolResult.test.ts @@ -46,7 +46,12 @@ vi.mock('../db', () => ({ runOutsideDbContext: vi.fn((fn: () => unknown) => fn()), })); -vi.mock('./aiCostTracker', () => ({ recordUsageFromSdkResult: vi.fn(() => Promise.resolve()) })); +vi.mock('./aiCostTracker', () => ({ + recordUsageFromSdkResult: vi.fn(() => Promise.resolve()), + // Also consumed on the result/done path — see the note in clientLoop.test.ts. + sumInputTokens: (u: Record | null | undefined) => + (u?.input_tokens ?? 0) + (u?.cache_read_input_tokens ?? 0) + (u?.cache_creation_input_tokens ?? 0), +})); vi.mock('./aiAgent', () => ({ sanitizeErrorForClient: (e: unknown) => String(e) })); vi.mock('./sentry', () => ({ captureException: vi.fn() })); vi.mock('./aiAgentSdkTools', () => ({