


















11// Run diagnostic event tests cover emitted diagnostics from isolated cron runs.
22import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3-import { onDiagnosticEvent, resetDiagnosticEventsForTest } from "../../infra/diagnostic-events.js";
3+import {
4+onDiagnosticEvent,
5+onInternalDiagnosticEvent,
6+resetDiagnosticEventsForTest,
7+} from "../../infra/diagnostic-events.js";
48import { resetDiagnosticStateForTest } from "../../logging/diagnostic.js";
59610vi.mock("../../agents/auth-profiles/source-check.js", () => ({
@@ -182,4 +186,196 @@ describe("runCronIsolatedAgentTurn diagnostic events", () => {
182186{ type: "message.processed", sessionId: "persisted-run-session" },
183187]);
184188});
189+190+it("emits model.usage for cron runs with billed aggregate usage", async () => {
191+const usageEvents: Array<{
192+type: string;
193+sessionKey?: string;
194+sessionId?: string;
195+channel?: string;
196+agentId?: string;
197+provider?: string;
198+model?: string;
199+usage?: {
200+input?: number;
201+output?: number;
202+cacheRead?: number;
203+cacheWrite?: number;
204+promptTokens?: number;
205+total?: number;
206+};
207+lastCallUsage?: {
208+input?: number;
209+output?: number;
210+cacheRead?: number;
211+cacheWrite?: number;
212+};
213+context?: { limit?: number; used?: number };
214+durationMs?: number;
215+}> = [];
216+const unsubscribe = onInternalDiagnosticEvent((evt) => {
217+if (evt.type === "model.usage") {
218+usageEvents.push(evt);
219+}
220+});
221+222+runWithModelFallbackMock.mockResolvedValue({
223+result: {
224+payloads: [{ text: "test output" }],
225+meta: {
226+agentMeta: {
227+sessionId: "cron-usage-session",
228+sessionFile: "/tmp/cron-usage-session.jsonl",
229+provider: "test-provider",
230+model: "test-model",
231+usage: { input: 50, output: 100, cacheRead: 7, cacheWrite: 3, total: 55 },
232+lastCallUsage: { input: 40, output: 5, cacheRead: 6, cacheWrite: 4 },
233+},
234+},
235+},
236+provider: "fallback-provider",
237+model: "fallback-model",
238+});
239+240+try {
241+const result = await runCronIsolatedAgentTurn(makeParams());
242+expect(result.status).toBe("ok");
243+} finally {
244+unsubscribe();
245+}
246+247+expect(usageEvents).toHaveLength(1);
248+expect(usageEvents[0]).toMatchObject({
249+type: "model.usage",
250+sessionKey: "agent:default:cron:diag-events:run:test-session-id",
251+sessionId: "cron-usage-session",
252+channel: "cron",
253+agentId: "default",
254+provider: "test-provider",
255+model: "test-model",
256+usage: {
257+input: 50,
258+output: 100,
259+cacheRead: 7,
260+cacheWrite: 3,
261+promptTokens: 60,
262+total: 160,
263+},
264+lastCallUsage: { input: 40, output: 5, cacheRead: 6, cacheWrite: 4 },
265+context: { limit: 128000, used: 50 },
266+});
267+expect(usageEvents[0]?.durationMs).toEqual(expect.any(Number));
268+expect(usageEvents[0]?.durationMs).toBeGreaterThanOrEqual(0);
269+});
270+271+it("does not emit model.usage when diagnostics are disabled", async () => {
272+const usageEvents: Array<{ type: string }> = [];
273+const unsubscribe = onInternalDiagnosticEvent((evt) => {
274+if (evt.type === "model.usage") {
275+usageEvents.push(evt);
276+}
277+});
278+279+try {
280+const params = makeParams();
281+params.cfg = { diagnostics: { enabled: false } } as never;
282+const result = await runCronIsolatedAgentTurn(params);
283+expect(result.status).toBe("ok");
284+} finally {
285+unsubscribe();
286+}
287+288+expect(usageEvents).toEqual([]);
289+});
290+291+it("emits billed model usage when the cron run is aborted before finalization", async () => {
292+const abortController = new AbortController();
293+const usageEvents: Array<{
294+type: string;
295+sessionId?: string;
296+usage?: { input?: number; output?: number; total?: number };
297+}> = [];
298+const unsubscribe = onInternalDiagnosticEvent((evt) => {
299+if (evt.type === "model.usage") {
300+usageEvents.push(evt);
301+}
302+});
303+304+runWithModelFallbackMock.mockImplementationOnce(async () => {
305+abortController.abort("cron: job execution timed out");
306+return {
307+result: {
308+payloads: [{ text: "late output" }],
309+meta: {
310+agentMeta: {
311+sessionId: "late-session",
312+usage: { input: 50, output: 10, total: 60 },
313+},
314+},
315+},
316+provider: "openai",
317+model: "gpt-5.4",
318+};
319+});
320+321+try {
322+const result = await runCronIsolatedAgentTurn({
323+ ...makeParams(),
324+abortSignal: abortController.signal,
325+});
326+expect(result.status).toBe("error");
327+expect(result.error).toBe("cron: job execution timed out");
328+} finally {
329+unsubscribe();
330+}
331+332+expect(usageEvents).toHaveLength(1);
333+expect(usageEvents[0]).toMatchObject({
334+type: "model.usage",
335+sessionId: "test-session-id",
336+usage: { input: 50, output: 10, total: 60 },
337+});
338+});
339+340+it("preserves total-only model usage in cron diagnostics", async () => {
341+const usageEvents: Array<{
342+type: string;
343+usage?: { input?: number; output?: number; promptTokens?: number; total?: number };
344+costUsd?: number;
345+}> = [];
346+const unsubscribe = onInternalDiagnosticEvent((evt) => {
347+if (evt.type === "model.usage") {
348+usageEvents.push(evt);
349+}
350+});
351+352+runWithModelFallbackMock.mockResolvedValue({
353+result: {
354+payloads: [{ text: "test output" }],
355+meta: {
356+agentMeta: {
357+usage: { total: 42 },
358+},
359+},
360+},
361+provider: "openai",
362+model: "gpt-5.4",
363+});
364+365+try {
366+const result = await runCronIsolatedAgentTurn(makeParams());
367+expect(result.status).toBe("ok");
368+} finally {
369+unsubscribe();
370+}
371+372+expect(usageEvents).toHaveLength(1);
373+expect(usageEvents[0]?.usage).toMatchObject({
374+input: 0,
375+output: 0,
376+promptTokens: 0,
377+total: 42,
378+});
379+expect(usageEvents[0]?.costUsd).toBeUndefined();
380+});
185381});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。