authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 07:54:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 07:54:23-07:00
log1587f248dd81ed45e214d476451ebf16dbfadb1a
tree0ed79d327287efa511f35963773e8515be16764b
parentdab5f6e6e02e78675655e69503aa89654e5b8050
signature Signed by PGP key B5690EEEBB952194

feat(server): measure provider turn token usage (#9132)


14 files changed, 3865 insertions(+), 20 deletions(-)

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+250-6
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24import { createModelSelection } from "@t3tools/shared/model";24import { createModelSelection } from "@t3tools/shared/model";
25import { assert, describe, it } from "@effect/vitest";25import { assert, describe, it } from "@effect/vitest";
26import * as Context from "effect/Context";26import * as Context from "effect/Context";
27import * as Deferred from "effect/Deferred";
27import * as Effect from "effect/Effect";28import * as Effect from "effect/Effect";
28import * as Fiber from "effect/Fiber";29import * as Fiber from "effect/Fiber";
29import * as Layer from "effect/Layer";30import * as Layer from "effect/Layer";
...@@ -1899,7 +1900,8 @@ describe("ClaudeAdapterLive", () => {...@@ -1899,7 +1900,8 @@ describe("ClaudeAdapterLive", () => {
1899 return Effect.gen(function* () {1900 return Effect.gen(function* () {
1900 const adapter = yield* ClaudeAdapter;1901 const adapter = yield* ClaudeAdapter;
19011902
1902 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe(1903 const runtimeEventsFiber = yield* adapter.streamEvents.pipe(
1904 Stream.takeUntil((event) => event.type === "turn.completed"),
1903 Stream.runCollect,1905 Stream.runCollect,
1904 Effect.forkChild,1906 Effect.forkChild,
1905 );1907 );
...@@ -1937,6 +1939,17 @@ describe("ClaudeAdapterLive", () => {...@@ -1937,6 +1939,17 @@ describe("ClaudeAdapterLive", () => {
1937 },1939 },
1938 } as unknown as SDKMessage);1940 } as unknown as SDKMessage);
19391941
1942 harness.query.emit({
1943 type: "system",
1944 subtype: "task_started",
1945 task_id: "task-agent-1",
1946 description: "Review the database layer",
1947 task_type: "local_agent",
1948 tool_use_id: "tool-task-1",
1949 uuid: "task-agent-1-uuid",
1950 session_id: "sdk-session-task",
1951 } as unknown as SDKMessage);
1952
1940 harness.query.emit({1953 harness.query.emit({
1941 type: "assistant",1954 type: "assistant",
1942 session_id: "sdk-session-task",1955 session_id: "sdk-session-task",
...@@ -1953,6 +1966,12 @@ describe("ClaudeAdapterLive", () => {...@@ -1953,6 +1966,12 @@ describe("ClaudeAdapterLive", () => {
1953 subtype: "success",1966 subtype: "success",
1954 is_error: false,1967 is_error: false,
1955 errors: [],1968 errors: [],
1969 usage: {
1970 input_tokens: 100,
1971 cache_read_input_tokens: 40,
1972 cache_creation_input_tokens: 10,
1973 output_tokens: 20,
1974 },
1956 session_id: "sdk-session-task",1975 session_id: "sdk-session-task",
1957 uuid: "result-task-1",1976 uuid: "result-task-1",
1958 } as unknown as SDKMessage);1977 } as unknown as SDKMessage);
...@@ -1964,6 +1983,11 @@ describe("ClaudeAdapterLive", () => {...@@ -1964,6 +1983,11 @@ describe("ClaudeAdapterLive", () => {
1964 assert.equal(toolStarted.payload.itemType, "collab_agent_tool_call");1983 assert.equal(toolStarted.payload.itemType, "collab_agent_tool_call");
1965 assert.equal(toolStarted.payload.title, "Subagent task");1984 assert.equal(toolStarted.payload.title, "Subagent task");
1966 }1985 }
1986 const completed = runtimeEvents.find((event) => event.type === "turn.completed");
1987 assert.equal(completed?.type, "turn.completed");
1988 if (completed?.type === "turn.completed") {
1989 assert.equal(completed.payload.tokenUsage?.hasSubagents, true);
1990 }
1967 }).pipe(1991 }).pipe(
1968 Effect.provideService(Random.Random, makeDeterministicRandomService()),1992 Effect.provideService(Random.Random, makeDeterministicRandomService()),
1969 Effect.provide(harness.layer),1993 Effect.provide(harness.layer),
...@@ -1975,7 +1999,8 @@ describe("ClaudeAdapterLive", () => {...@@ -1975,7 +1999,8 @@ describe("ClaudeAdapterLive", () => {
1975 return Effect.gen(function* () {1999 return Effect.gen(function* () {
1976 const adapter = yield* ClaudeAdapter;2000 const adapter = yield* ClaudeAdapter;
19772001
1978 const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe(2002 const runtimeEventsFiber = yield* adapter.streamEvents.pipe(
2003 Stream.takeUntil((event) => event.type === "turn.completed"),
1979 Stream.runCollect,2004 Stream.runCollect,
1980 Effect.forkChild,2005 Effect.forkChild,
1981 );2006 );
...@@ -1998,6 +2023,12 @@ describe("ClaudeAdapterLive", () => {...@@ -1998,6 +2023,12 @@ describe("ClaudeAdapterLive", () => {
1998 is_error: false,2023 is_error: false,
1999 errors: ["Error: Request was aborted."],2024 errors: ["Error: Request was aborted."],
2000 stop_reason: "tool_use",2025 stop_reason: "tool_use",
2026 usage: {
2027 input_tokens: 12,
2028 cache_read_input_tokens: 3,
2029 cache_creation_input_tokens: 1,
2030 output_tokens: 4,
2031 },
2001 session_id: "sdk-session-abort",2032 session_id: "sdk-session-abort",
2002 uuid: "result-abort",2033 uuid: "result-abort",
2003 } as unknown as SDKMessage);2034 } as unknown as SDKMessage);
...@@ -2011,6 +2042,7 @@ describe("ClaudeAdapterLive", () => {...@@ -2011,6 +2042,7 @@ describe("ClaudeAdapterLive", () => {
2011 "session.state.changed",2042 "session.state.changed",
2012 "turn.started",2043 "turn.started",
2013 "thread.started",2044 "thread.started",
2045 "thread.token-usage.updated",
2014 "turn.completed",2046 "turn.completed",
2015 ],2047 ],
2016 );2048 );
...@@ -2022,6 +2054,15 @@ describe("ClaudeAdapterLive", () => {...@@ -2022,6 +2054,15 @@ describe("ClaudeAdapterLive", () => {
2022 assert.equal(turnCompleted.payload.state, "interrupted");2054 assert.equal(turnCompleted.payload.state, "interrupted");
2023 assert.equal(turnCompleted.payload.errorMessage, "Error: Request was aborted.");2055 assert.equal(turnCompleted.payload.errorMessage, "Error: Request was aborted.");
2024 assert.equal(turnCompleted.payload.stopReason, "tool_use");2056 assert.equal(turnCompleted.payload.stopReason, "tool_use");
2057 assert.deepEqual(turnCompleted.payload.tokenUsage, {
2058 usageStatus: "partial",
2059 usageScope: "main_agent",
2060 inputTokens: 16,
2061 cachedInputTokens: 3,
2062 cacheCreationTokens: 1,
2063 outputTokens: 4,
2064 hasSubagents: false,
2065 });
2025 }2066 }
2026 }).pipe(2067 }).pipe(
2027 Effect.provideService(Random.Random, makeDeterministicRandomService()),2068 Effect.provideService(Random.Random, makeDeterministicRandomService()),
...@@ -2509,7 +2550,10 @@ describe("ClaudeAdapterLive", () => {...@@ -2509,7 +2550,10 @@ describe("ClaudeAdapterLive", () => {
2509 session_id: "sdk-session-result-usage",2550 session_id: "sdk-session-result-usage",
2510 usage: {2551 usage: {
2511 input_tokens: 400,2552 input_tokens: 400,
2553 cache_read_input_tokens: 90,
2554 cache_creation_input_tokens: 10,
2512 output_tokens: 50,2555 output_tokens: 50,
2556 output_tokens_details: { thinking_tokens: 30 },
2513 },2557 },
2514 modelUsage: {2558 modelUsage: {
2515 [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: {2559 [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: {
...@@ -2527,16 +2571,211 @@ describe("ClaudeAdapterLive", () => {...@@ -2527,16 +2571,211 @@ describe("ClaudeAdapterLive", () => {
2527 assert.deepEqual(usageEvent.payload.usage, {2571 assert.deepEqual(usageEvent.payload.usage, {
2528 usedTokens: 200,2572 usedTokens: 200,
2529 lastUsedTokens: 200,2573 lastUsedTokens: 200,
2530 totalProcessedTokens: 450,2574 totalProcessedTokens: 550,
2531 inputTokens: 180,2575 inputTokens: 180,
2532 outputTokens: 20,2576 outputTokens: 20,
2533 maxTokens: 200000,2577 maxTokens: 200000,
2534 });2578 });
2535 }2579 }
2536 assert.equal(2580 const completed = runtimeEvents.find((event) => event.type === "turn.completed");
2537 runtimeEvents.find((event) => event.type === "turn.completed")?.type,2581 assert.equal(completed?.type, "turn.completed");
2538 "turn.completed",2582 if (completed?.type === "turn.completed") {
2583 assert.deepEqual(completed.payload.tokenUsage, {
2584 usageStatus: "complete",
2585 usageScope: "main_agent",
2586 inputTokens: 500,
2587 cachedInputTokens: 90,
2588 cacheCreationTokens: 10,
2589 reasoningTokens: 30,
2590 outputTokens: 50,
2591 hasSubagents: false,
2592 });
2593 }
2594 }).pipe(
2595 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2596 Effect.provide(harness.layer),
2597 );
2598 });
2599
2600 it.effect("treats omitted Claude cache counters as zero contributions", () => {
2601 const harness = makeHarness();
2602 return Effect.gen(function* () {
2603 const adapter = yield* ClaudeAdapter;
2604 const completedFiber = yield* adapter.streamEvents.pipe(
2605 Stream.filter((event) => event.type === "turn.completed"),
2606 Stream.runHead,
2607 Effect.forkChild,
2539 );2608 );
2609 yield* adapter.startSession({
2610 threadId: THREAD_ID,
2611 provider: ProviderDriverKind.make("claudeAgent"),
2612 runtimeMode: "full-access",
2613 });
2614 yield* adapter.sendTurn({
2615 threadId: THREAD_ID,
2616 input: "uncached turn",
2617 attachments: [],
2618 });
2619 harness.query.emit({
2620 type: "result",
2621 subtype: "success",
2622 is_error: false,
2623 duration_ms: 100,
2624 duration_api_ms: 90,
2625 num_turns: 1,
2626 result: "done",
2627 stop_reason: "end_turn",
2628 session_id: "sdk-session-uncached-usage",
2629 usage: {
2630 input_tokens: 42,
2631 output_tokens: 9,
2632 },
2633 } as unknown as SDKMessage);
2634
2635 const completed = yield* Fiber.join(completedFiber);
2636 assert.equal(completed._tag, "Some");
2637 if (completed._tag === "Some" && completed.value.type === "turn.completed") {
2638 assert.deepEqual(completed.value.payload.tokenUsage, {
2639 usageStatus: "complete",
2640 usageScope: "main_agent",
2641 inputTokens: 42,
2642 outputTokens: 9,
2643 hasSubagents: false,
2644 });
2645 }
2646 }).pipe(
2647 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2648 Effect.provide(harness.layer),
2649 );
2650 });
2651
2652 it.effect("uses per-turn result usage across consecutive Claude turns", () => {
2653 const harness = makeHarness();
2654 return Effect.gen(function* () {
2655 const adapter = yield* ClaudeAdapter;
2656 const firstTurnSettled = yield* Deferred.make<void>();
2657 const completedFiber = yield* adapter.streamEvents.pipe(
2658 Stream.tap((event) =>
2659 event.type === "session.state.changed" && event.payload.reason === "api_retry:1/2"
2660 ? Deferred.succeed(firstTurnSettled, undefined).pipe(Effect.asVoid)
2661 : Effect.void,
2662 ),
2663 Stream.filter((event) => event.type === "turn.completed"),
2664 Stream.take(2),
2665 Stream.runCollect,
2666 Effect.forkChild,
2667 );
2668
2669 yield* adapter.startSession({
2670 threadId: THREAD_ID,
2671 provider: ProviderDriverKind.make("claudeAgent"),
2672 runtimeMode: "full-access",
2673 });
2674 yield* adapter.sendTurn({
2675 threadId: THREAD_ID,
2676 input: "first turn",
2677 attachments: [],
2678 });
2679 harness.query.emit({
2680 type: "result",
2681 subtype: "success",
2682 is_error: false,
2683 duration_ms: 100,
2684 duration_api_ms: 90,
2685 num_turns: 1,
2686 result: "first",
2687 stop_reason: "end_turn",
2688 session_id: "sdk-session-consecutive-usage",
2689 usage: {
2690 input_tokens: 100,
2691 cache_read_input_tokens: 20,
2692 cache_creation_input_tokens: 5,
2693 output_tokens: 10,
2694 },
2695 modelUsage: {
2696 "claude-opus-4-6": {
2697 inputTokens: 10_000,
2698 outputTokens: 1_000,
2699 cacheReadInputTokens: 2_000,
2700 cacheCreationInputTokens: 500,
2701 webSearchRequests: 0,
2702 costUSD: 1,
2703 contextWindow: 200_000,
2704 maxOutputTokens: 64_000,
2705 },
2706 },
2707 } as unknown as SDKMessage);
2708 harness.query.emit({
2709 type: "system",
2710 subtype: "api_retry",
2711 attempt: 1,
2712 max_retries: 2,
2713 retry_delay_ms: 1,
2714 error_status: 502,
2715 error: { type: "api_error" },
2716 session_id: "sdk-session-consecutive-usage",
2717 uuid: "consecutive-usage-barrier",
2718 } as unknown as SDKMessage);
2719 yield* Deferred.await(firstTurnSettled);
2720
2721 yield* adapter.sendTurn({
2722 threadId: THREAD_ID,
2723 input: "second turn",
2724 attachments: [],
2725 });
2726 harness.query.emit({
2727 type: "result",
2728 subtype: "success",
2729 is_error: false,
2730 duration_ms: 80,
2731 duration_api_ms: 70,
2732 num_turns: 2,
2733 result: "second",
2734 stop_reason: "end_turn",
2735 session_id: "sdk-session-consecutive-usage",
2736 usage: {
2737 input_tokens: 30,
2738 cache_read_input_tokens: 2,
2739 cache_creation_input_tokens: 3,
2740 output_tokens: 7,
2741 },
2742 modelUsage: {
2743 "claude-opus-4-6": {
2744 inputTokens: 20_000,
2745 outputTokens: 2_000,
2746 cacheReadInputTokens: 4_000,
2747 cacheCreationInputTokens: 1_000,
2748 webSearchRequests: 0,
2749 costUSD: 2,
2750 contextWindow: 200_000,
2751 maxOutputTokens: 64_000,
2752 },
2753 },
2754 } as unknown as SDKMessage);
2755
2756 const completed = Array.from(yield* Fiber.join(completedFiber));
2757 assert.equal(completed[0]?.type, "turn.completed");
2758 assert.equal(completed[1]?.type, "turn.completed");
2759 if (completed[0]?.type === "turn.completed" && completed[1]?.type === "turn.completed") {
2760 assert.deepEqual(completed[0].payload.tokenUsage, {
2761 usageStatus: "complete",
2762 usageScope: "main_agent",
2763 inputTokens: 125,
2764 cachedInputTokens: 20,
2765 cacheCreationTokens: 5,
2766 outputTokens: 10,
2767 hasSubagents: false,
2768 });
2769 assert.deepEqual(completed[1].payload.tokenUsage, {
2770 usageStatus: "complete",
2771 usageScope: "main_agent",
2772 inputTokens: 35,
2773 cachedInputTokens: 2,
2774 cacheCreationTokens: 3,
2775 outputTokens: 7,
2776 hasSubagents: false,
2777 });
2778 }
2540 }).pipe(2779 }).pipe(
2541 Effect.provideService(Random.Random, makeDeterministicRandomService()),2780 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2542 Effect.provide(harness.layer),2781 Effect.provide(harness.layer),
...@@ -2941,6 +3180,11 @@ describe("ClaudeAdapterLive", () => {...@@ -2941,6 +3180,11 @@ describe("ClaudeAdapterLive", () => {
2941 assert.equal(String(turnCompleted.turnId), String(turn.turnId));3180 assert.equal(String(turnCompleted.turnId), String(turn.turnId));
2942 assert.equal(turnCompleted.payload.state, "interrupted");3181 assert.equal(turnCompleted.payload.state, "interrupted");
2943 assert.equal(turnCompleted.payload.errorMessage, "Claude runtime interrupted.");3182 assert.equal(turnCompleted.payload.errorMessage, "Claude runtime interrupted.");
3183 assert.deepEqual(turnCompleted.payload.tokenUsage, {
3184 usageStatus: "unavailable",
3185 usageScope: "main_agent",
3186 hasSubagents: false,
3187 });
2944 }3188 }
29453189
2946 const sessionExited = runtimeEvents[5];3190 const sessionExited = runtimeEvents[5];
apps/server/src/provider/Layers/ClaudeAdapter.ts+94
...@@ -24,6 +24,7 @@ import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";...@@ -24,6 +24,7 @@ import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
24import { type ClaudeScopedLimitNames, claudeRateLimitEventToUpdate } from "./claudeUsageLimits.ts";24import { type ClaudeScopedLimitNames, claudeRateLimitEventToUpdate } from "./claudeUsageLimits.ts";
25import {25import {
26 ApprovalRequestId,26 ApprovalRequestId,
27 classifyTaskAgentKind,
27 type CanonicalItemType,28 type CanonicalItemType,
28 type CanonicalRequestType,29 type CanonicalRequestType,
29 type ClaudeSettings,30 type ClaudeSettings,
...@@ -38,6 +39,7 @@ import {...@@ -38,6 +39,7 @@ import {
38 type ProviderSendTurnInput,39 type ProviderSendTurnInput,
39 type ProviderSession,40 type ProviderSession,
40 type ThreadTokenUsageSnapshot,41 type ThreadTokenUsageSnapshot,
42 type TurnTokenUsage,
41 type ProviderUserInputAnswers,43 type ProviderUserInputAnswers,
42 type RuntimeContentStreamKind,44 type RuntimeContentStreamKind,
43 RuntimeItemId,45 RuntimeItemId,
...@@ -154,6 +156,7 @@ interface ClaudeTurnState {...@@ -154,6 +156,7 @@ interface ClaudeTurnState {
154 readonly capturedProposedPlanKeys: Set<string>;156 readonly capturedProposedPlanKeys: Set<string>;
155 latestAssistantUsage: unknown | undefined;157 latestAssistantUsage: unknown | undefined;
156 compactedSinceLatestAssistantUsage: boolean;158 compactedSinceLatestAssistantUsage: boolean;
159 hasSubagents: boolean;
157 nextSyntheticAssistantBlockIndex: number;160 nextSyntheticAssistantBlockIndex: number;
158}161}
159162
...@@ -643,6 +646,85 @@ function normalizeClaudeActiveTokenUsage(...@@ -643,6 +646,85 @@ function normalizeClaudeActiveTokenUsage(
643 });646 });
644}647}
645648
649function normalizeClaudeTurnTokenUsage(
650 result: SDKResultMessage | undefined,
651 hasSubagents: boolean,
652 terminalStatus: ProviderRuntimeTurnStatus,
653): TurnTokenUsage {
654 const usage = result?.usage as Record<string, unknown> | undefined;
655 if (!usage) {
656 return {
657 usageStatus: "unavailable",
658 usageScope: "main_agent",
659 hasSubagents,
660 };
661 }
662
663 const uncachedInputTokens = finiteNonNegativeInteger(usage.input_tokens);
664 const cachedInputTokens = finiteNonNegativeInteger(usage.cache_read_input_tokens);
665 const cacheCreationTokens = finiteNonNegativeInteger(usage.cache_creation_input_tokens);
666 const rawOutputTokens = finiteNonNegativeInteger(usage.output_tokens);
667 const outputDetails = usage.output_tokens_details as Record<string, unknown> | undefined;
668 const thinkingTokens = finiteNonNegativeInteger(outputDetails?.thinking_tokens);
669 const cachedInputContribution = usage.cache_read_input_tokens == null ? 0 : cachedInputTokens;
670 const cacheCreationContribution =
671 usage.cache_creation_input_tokens == null ? 0 : cacheCreationTokens;
672 const inputTokens =
673 uncachedInputTokens !== undefined &&
674 cachedInputContribution !== undefined &&
675 cacheCreationContribution !== undefined
676 ? uncachedInputTokens + cachedInputContribution + cacheCreationContribution
677 : undefined;
678 const hasKnownUsage =
679 uncachedInputTokens !== undefined ||
680 cachedInputTokens !== undefined ||
681 cacheCreationTokens !== undefined ||
682 rawOutputTokens !== undefined;
683 const hasPositiveUsage =
684 (uncachedInputTokens ?? 0) +
685 (cachedInputTokens ?? 0) +
686 (cacheCreationTokens ?? 0) +
687 (rawOutputTokens ?? 0) >
688 0;
689
690 if (!hasKnownUsage || (result?.subtype !== "success" && !hasPositiveUsage)) {
691 return {
692 usageStatus: "unavailable",
693 usageScope: "main_agent",
694 hasSubagents,
695 };
696 }
697
698 const commonUsage = {
699 usageScope: "main_agent",
700 ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
701 ...(cacheCreationTokens !== undefined ? { cacheCreationTokens } : {}),
702 ...(thinkingTokens !== undefined && rawOutputTokens !== undefined
703 ? { reasoningTokens: Math.min(rawOutputTokens, thinkingTokens) }
704 : {}),
705 hasSubagents,
706 } as const;
707 if (
708 terminalStatus === "completed" &&
709 result?.subtype === "success" &&
710 inputTokens !== undefined &&
711 rawOutputTokens !== undefined
712 ) {
713 return {
714 ...commonUsage,
715 usageStatus: "complete",
716 inputTokens,
717 outputTokens: rawOutputTokens,
718 };
719 }
720 return {
721 ...commonUsage,
722 usageStatus: "partial",
723 ...(inputTokens !== undefined ? { inputTokens } : {}),
724 ...(rawOutputTokens !== undefined ? { outputTokens: rawOutputTokens } : {}),
725 };
726}
727
646function compactBoundaryTokenUsageSnapshot(728function compactBoundaryTokenUsageSnapshot(
647 message: Record<string, unknown>,729 message: Record<string, unknown>,
648 contextWindow?: number,730 contextWindow?: number,
...@@ -2503,6 +2585,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -2503,6 +2585,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
2503 ? { totalCostUsd: result.total_cost_usd }2585 ? { totalCostUsd: result.total_cost_usd }
2504 : {}),2586 : {}),
2505 ...(errorMessage ? { errorMessage } : {}),2587 ...(errorMessage ? { errorMessage } : {}),
2588 tokenUsage: normalizeClaudeTurnTokenUsage(result, turnState.hasSubagents, status),
2506 },2589 },
2507 providerRefs: nativeProviderRefs(context),2590 providerRefs: nativeProviderRefs(context),
2508 });2591 });
...@@ -3047,6 +3130,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3047,6 +3130,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3047 capturedProposedPlanKeys: new Set(),3130 capturedProposedPlanKeys: new Set(),
3048 latestAssistantUsage: undefined,3131 latestAssistantUsage: undefined,
3049 compactedSinceLatestAssistantUsage: false,3132 compactedSinceLatestAssistantUsage: false,
3133 hasSubagents: false,
3050 nextSyntheticAssistantBlockIndex: -1,3134 nextSyntheticAssistantBlockIndex: -1,
3051 };3135 };
3052 context.session = {3136 context.session = {
...@@ -3353,6 +3437,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3353,6 +3437,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3353 )3437 )
3354 : undefined;3438 : undefined;
3355 const owningAgentId = launchingTool?.agentId;3439 const owningAgentId = launchingTool?.agentId;
3440 if (
3441 context.turnState &&
3442 classifyTaskAgentKind({
3443 taskType: message.task_type,
3444 ...(owningAgentId ? { agentId: owningAgentId } : {}),
3445 }) === "agent"
3446 ) {
3447 context.turnState.hasSubagents = true;
3448 }
3356 // Model/effort: the Agent tool's input carries explicit overrides;3449 // Model/effort: the Agent tool's input carries explicit overrides;
3357 // absent ones inherit the session's selection (SDK behavior).3450 // absent ones inherit the session's selection (SDK behavior).
3358 // Subagent assistant snapshots refine model with the authoritative API3451 // Subagent assistant snapshots refine model with the authoritative API
...@@ -4746,6 +4839,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4746,6 +4839,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4746 capturedProposedPlanKeys: new Set(),4839 capturedProposedPlanKeys: new Set(),
4747 latestAssistantUsage: undefined,4840 latestAssistantUsage: undefined,
4748 compactedSinceLatestAssistantUsage: false,4841 compactedSinceLatestAssistantUsage: false,
4842 hasSubagents: false,
4749 nextSyntheticAssistantBlockIndex: -1,4843 nextSyntheticAssistantBlockIndex: -1,
4750 };4844 };
47514845
apps/server/src/provider/Layers/CodexAdapter.test.ts+446
...@@ -597,7 +597,453 @@ function startLifecycleRuntime() {...@@ -597,7 +597,453 @@ function startLifecycleRuntime() {
597 });597 });
598}598}
599599
600function codexTokenUsageEvent(input: {
601 readonly id: string;
602 readonly turnId: string;
603 readonly inputTokens: number;
604 readonly cachedInputTokens: number;
605 readonly cacheCreationTokens: number;
606 readonly outputTokens: number;
607 readonly reasoningTokens: number;
608 readonly last?: {
609 readonly inputTokens: number;
610 readonly cachedInputTokens: number;
611 readonly cacheCreationTokens: number;
612 readonly outputTokens: number;
613 readonly reasoningTokens: number;
614 };
615}): ProviderEvent {
616 const totalTokens = input.inputTokens + input.outputTokens;
617 const last = input.last ?? input;
618 return {
619 id: asEventId(input.id),
620 kind: "notification",
621 provider: ProviderDriverKind.make("codex"),
622 threadId: asThreadId("thread-1"),
623 turnId: asTurnId(input.turnId),
624 createdAt: "2026-01-01T00:00:00.000Z",
625 method: "thread/tokenUsage/updated",
626 payload: {
627 threadId: "thread-1",
628 turnId: input.turnId,
629 tokenUsage: {
630 total: {
631 inputTokens: input.inputTokens,
632 cachedInputTokens: input.cachedInputTokens,
633 cacheWriteInputTokens: input.cacheCreationTokens,
634 outputTokens: input.outputTokens,
635 reasoningOutputTokens: input.reasoningTokens,
636 totalTokens,
637 },
638 last: {
639 inputTokens: last.inputTokens,
640 cachedInputTokens: last.cachedInputTokens,
641 cacheWriteInputTokens: last.cacheCreationTokens,
642 outputTokens: last.outputTokens,
643 reasoningOutputTokens: last.reasoningTokens,
644 totalTokens: last.inputTokens + last.outputTokens,
645 },
646 },
647 },
648 };
649}
650
651function codexTurnEvent(method: "turn/started" | "turn/completed", turnId: string): ProviderEvent {
652 return {
653 id: asEventId(`evt-${method}-${turnId}`),
654 kind: "notification",
655 provider: ProviderDriverKind.make("codex"),
656 threadId: asThreadId("thread-1"),
657 turnId: asTurnId(turnId),
658 createdAt: "2026-01-01T00:00:00.000Z",
659 method,
660 payload:
661 method === "turn/started"
662 ? {}
663 : {
664 threadId: "thread-1",
665 turn: { id: turnId, items: [], status: "completed" },
666 },
667 };
668}
669
600lifecycleLayer("CodexAdapterLive lifecycle", (it) => {670lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
671 it.effect("calculates one Codex turn total from cumulative counters", () =>
672 Effect.gen(function* () {
673 const { adapter, runtime } = yield* startLifecycleRuntime();
674 const completedFiber = yield* adapter.streamEvents.pipe(
675 Stream.filter((event) => event.type === "turn.completed"),
676 Stream.runHead,
677 Effect.forkChild,
678 );
679
680 yield* runtime.emit(codexTurnEvent("turn/started", "turn-usage"));
681 yield* runtime.emit(
682 codexTokenUsageEvent({
683 id: "evt-usage-1",
684 turnId: "turn-usage",
685 inputTokens: 100,
686 cachedInputTokens: 40,
687 cacheCreationTokens: 10,
688 outputTokens: 20,
689 reasoningTokens: 8,
690 }),
691 );
692 // Codex can repeat both notifications without new work.
693 yield* runtime.emit(codexTurnEvent("turn/started", "turn-usage"));
694 yield* runtime.emit(
695 codexTokenUsageEvent({
696 id: "evt-usage-duplicate",
697 turnId: "turn-usage",
698 inputTokens: 100,
699 cachedInputTokens: 40,
700 cacheCreationTokens: 10,
701 outputTokens: 20,
702 reasoningTokens: 8,
703 }),
704 );
705 yield* runtime.emit({
706 id: asEventId("evt-collab-activity"),
707 kind: "notification",
708 provider: ProviderDriverKind.make("codex"),
709 threadId: asThreadId("thread-1"),
710 turnId: asTurnId("turn-usage"),
711 createdAt: "2026-01-01T00:00:00.000Z",
712 method: "collabAgent/activity",
713 payload: {
714 agentThreadId: "child-1",
715 agentPath: "/root/child-1",
716 activityKind: "started",
717 },
718 });
719 yield* runtime.emit(
720 codexTokenUsageEvent({
721 id: "evt-usage-2",
722 turnId: "turn-usage",
723 inputTokens: 150,
724 cachedInputTokens: 60,
725 cacheCreationTokens: 15,
726 outputTokens: 30,
727 reasoningTokens: 12,
728 }),
729 );
730 yield* runtime.emit(codexTurnEvent("turn/completed", "turn-usage"));
731
732 const completed = yield* Fiber.join(completedFiber);
733 NodeAssert.equal(completed._tag, "Some");
734 if (completed._tag === "Some" && completed.value.type === "turn.completed") {
735 NodeAssert.deepStrictEqual(completed.value.payload.tokenUsage, {
736 usageStatus: "complete",
737 usageScope: "main_agent",
738 inputTokens: 150,
739 cachedInputTokens: 60,
740 cacheCreationTokens: 15,
741 outputTokens: 30,
742 reasoningTokens: 12,
743 hasSubagents: true,
744 });
745 }
746 }),
747 );
748
749 it.effect("does not charge a late prior-turn update to the next Codex turn", () =>
750 Effect.gen(function* () {
751 const { adapter, runtime } = yield* startLifecycleRuntime();
752 const completedFiber = yield* adapter.streamEvents.pipe(
753 Stream.filter((event) => event.type === "turn.completed"),
754 Stream.take(2),
755 Stream.runCollect,
756 Effect.forkChild,
757 );
758
759 yield* runtime.emit(codexTurnEvent("turn/started", "turn-first"));
760 yield* runtime.emit(
761 codexTokenUsageEvent({
762 id: "evt-late-1",
763 turnId: "turn-first",
764 inputTokens: 100,
765 cachedInputTokens: 40,
766 cacheCreationTokens: 10,
767 outputTokens: 20,
768 reasoningTokens: 8,
769 }),
770 );
771 yield* runtime.emit(codexTurnEvent("turn/completed", "turn-first"));
772 yield* runtime.emit(codexTurnEvent("turn/started", "turn-second"));
773 // A late update for the finished turn lands after the next turn starts.
774 yield* runtime.emit(
775 codexTokenUsageEvent({
776 id: "evt-late-2",
777 turnId: "turn-first",
778 inputTokens: 150,
779 cachedInputTokens: 60,
780 cacheCreationTokens: 15,
781 outputTokens: 30,
782 reasoningTokens: 12,
783 }),
784 );
785 yield* runtime.emit(
786 codexTokenUsageEvent({
787 id: "evt-late-3",
788 turnId: "turn-second",
789 inputTokens: 170,
790 cachedInputTokens: 65,
791 cacheCreationTokens: 16,
792 outputTokens: 35,
793 reasoningTokens: 14,
794 }),
795 );
796 yield* runtime.emit(codexTurnEvent("turn/completed", "turn-second"));
797
798 const completed = Array.from(yield* Fiber.join(completedFiber));
799 const second = completed[1];
800 NodeAssert.equal(second?.type, "turn.completed");
801 if (second?.type === "turn.completed") {
802 NodeAssert.deepStrictEqual(second.payload.tokenUsage, {
803 usageStatus: "complete",
804 usageScope: "main_agent",
805 inputTokens: 20,
806 cachedInputTokens: 5,
807 cacheCreationTokens: 1,
808 outputTokens: 5,
809 reasoningTokens: 2,
810 hasSubagents: false,
811 });
812 }
813 }),
814 );
815
816 it.effect("clamps Codex cache and reasoning subsets to their totals", () =>
817 Effect.gen(function* () {
818 const { adapter, runtime } = yield* startLifecycleRuntime();
819 const completedFiber = yield* adapter.streamEvents.pipe(
820 Stream.filter((event) => event.type === "turn.completed"),
821 Stream.runHead,
822 Effect.forkChild,
823 );
824
825 yield* runtime.emit(codexTurnEvent("turn/started", "turn-clamp"));
826 yield* runtime.emit(
827 codexTokenUsageEvent({
828 id: "evt-clamp-1",
829 turnId: "turn-clamp",
830 inputTokens: 100,
831 cachedInputTokens: 140,
832 cacheCreationTokens: 120,
833 outputTokens: 20,
834 reasoningTokens: 30,
835 }),
836 );
837 yield* runtime.emit(codexTurnEvent("turn/completed", "turn-clamp"));
838
839 const completed = yield* Fiber.join(completedFiber);
840 NodeAssert.equal(completed._tag, "Some");
841 if (completed._tag === "Some" && completed.value.type === "turn.completed") {
842 NodeAssert.deepStrictEqual(completed.value.payload.tokenUsage, {
843 usageStatus: "complete",
844 usageScope: "main_agent",
845 inputTokens: 100,
846 cachedInputTokens: 100,
847 cacheCreationTokens: 100,
848 outputTokens: 20,
849 reasoningTokens: 20,
850 hasSubagents: false,
851 });
852 }
853 }),
854 );
855
856 it.effect("counts the last response when Codex resets its running total mid-turn", () =>
857 Effect.gen(function* () {
858 const { adapter, runtime } = yield* startLifecycleRuntime();
859 const completedFiber = yield* adapter.streamEvents.pipe(
860 Stream.filter((event) => event.type === "turn.completed"),
861 Stream.runHead,
862 Effect.forkChild,
863 );
864
865 yield* runtime.emit(codexTurnEvent("turn/started", "turn-reset"));
866 yield* runtime.emit(
867 codexTokenUsageEvent({
868 id: "evt-reset-1",
869 turnId: "turn-reset",
870 inputTokens: 5_000,
871 cachedInputTokens: 4_000,
872 cacheCreationTokens: 100,
873 outputTokens: 500,
874 reasoningTokens: 200,
875 last: {
876 inputTokens: 100,
877 cachedInputTokens: 80,
878 cacheCreationTokens: 10,
879 outputTokens: 20,
880 reasoningTokens: 8,
881 },
882 }),
883 );
884 // Codex restarted its cumulative total. The new total is smaller than
885 // the previous one, so only `last` is counted for this update.
886 yield* runtime.emit(
887 codexTokenUsageEvent({
888 id: "evt-reset-2",
889 turnId: "turn-reset",
890 inputTokens: 150,
891 cachedInputTokens: 90,
892 cacheCreationTokens: 5,
893 outputTokens: 30,
894 reasoningTokens: 12,
895 }),
896 );
897 yield* runtime.emit(codexTurnEvent("turn/completed", "turn-reset"));
898
899 const completed = yield* Fiber.join(completedFiber);
900 NodeAssert.equal(completed._tag, "Some");
901 if (completed._tag === "Some" && completed.value.type === "turn.completed") {
902 NodeAssert.deepStrictEqual(completed.value.payload.tokenUsage, {
903 usageStatus: "complete",
904 usageScope: "main_agent",
905 inputTokens: 250,
906 cachedInputTokens: 170,
907 cacheCreationTokens: 15,
908 outputTokens: 50,
909 reasoningTokens: 20,
910 hasSubagents: false,
911 });
912 }
913 }),
914 );
915
916 it.effect("uses the last response usage when no prior Codex total exists", () =>
917 Effect.gen(function* () {
918 const adapter = yield* CodexAdapter;
919 yield* adapter.startSession({
920 provider: ProviderDriverKind.make("codex"),
921 threadId: asThreadId("thread-1"),
922 resumeCursor: { threadId: "provider-thread-1" },
923 runtimeMode: "full-access",
924 });
925 const runtime = lifecycleRuntimeFactory.lastRuntime;
926 NodeAssert.ok(runtime);
927 const firstCompletionsFiber = yield* adapter.streamEvents.pipe(
928 Stream.filter((event) => event.type === "turn.completed"),
929 Stream.take(2),
930 Stream.runCollect,
931 Effect.forkChild,
932 );
933
934 // Resumed thread: the cumulative total already holds old history, so the
935 // first update must count only `last`.
936 yield* runtime.emit(codexTurnEvent("turn/started", "turn-resumed"));
937 yield* runtime.emit(
938 codexTokenUsageEvent({
939 id: "evt-resume-baseline",
940 turnId: "turn-resumed",
941 inputTokens: 1_000,
942 cachedInputTokens: 400,
943 cacheCreationTokens: 100,
944 outputTokens: 200,
945 reasoningTokens: 80,
946 last: {
947 inputTokens: 300,
948 cachedInputTokens: 120,
949 cacheCreationTokens: 30,
950 outputTokens: 60,
951 reasoningTokens: 24,
952 },
953 }),
954 );
955 yield* runtime.emit(codexTurnEvent("turn/completed", "turn-resumed"));
956
957 yield* runtime.emit(codexTurnEvent("turn/started", "turn-after-resume"));
958 yield* runtime.emit(
959 codexTokenUsageEvent({
960 id: "evt-after-resume",
961 turnId: "turn-after-resume",
962 inputTokens: 1_100,
963 cachedInputTokens: 440,
964 cacheCreationTokens: 110,
965 outputTokens: 220,
966 reasoningTokens: 88,
967 }),
968 );
969 yield* runtime.emit(codexTurnEvent("turn/completed", "turn-after-resume"));
970
971 const firstCompletions = Array.from(yield* Fiber.join(firstCompletionsFiber));
972
973 yield* adapter.rollbackThread(asThreadId("thread-1"), 1);
974 const rollbackCompletionFiber = yield* adapter.streamEvents.pipe(
975 Stream.filter((event) => event.type === "turn.completed"),
976 Stream.runHead,
977 Effect.forkChild,
978 );
979 // Rollback drops the baseline and Codex shrinks its total, so the first
980 // update after it counts only `last` again.
981 yield* runtime.emit(codexTurnEvent("turn/started", "turn-after-rollback"));
982 yield* runtime.emit(
983 codexTokenUsageEvent({
984 id: "evt-after-rollback",
985 turnId: "turn-after-rollback",
986 inputTokens: 1_050,
987 cachedInputTokens: 420,
988 cacheCreationTokens: 105,
989 outputTokens: 210,
990 reasoningTokens: 84,
991 last: {
992 inputTokens: 50,
993 cachedInputTokens: 20,
994 cacheCreationTokens: 5,
995 outputTokens: 10,
996 reasoningTokens: 4,
997 },
998 }),
999 );
1000 yield* runtime.emit(codexTurnEvent("turn/completed", "turn-after-rollback"));
1001
1002 const rollbackCompletion = yield* Fiber.join(rollbackCompletionFiber);
1003 const completions = [
1004 ...firstCompletions,
1005 ...(rollbackCompletion._tag === "Some" ? [rollbackCompletion.value] : []),
1006 ];
1007 NodeAssert.deepStrictEqual(
1008 completions.map((event) =>
1009 event.type === "turn.completed" ? event.payload.tokenUsage : undefined,
1010 ),
1011 [
1012 {
1013 usageStatus: "complete",
1014 usageScope: "main_agent",
1015 inputTokens: 300,
1016 cachedInputTokens: 120,
1017 cacheCreationTokens: 30,
1018 outputTokens: 60,
1019 reasoningTokens: 24,
1020 hasSubagents: false,
1021 },
1022 {
1023 usageStatus: "complete",
1024 usageScope: "main_agent",
1025 inputTokens: 100,
1026 cachedInputTokens: 40,
1027 cacheCreationTokens: 10,
1028 outputTokens: 20,
1029 reasoningTokens: 8,
1030 hasSubagents: false,
1031 },
1032 {
1033 usageStatus: "complete",
1034 usageScope: "main_agent",
1035 inputTokens: 50,
1036 cachedInputTokens: 20,
1037 cacheCreationTokens: 5,
1038 outputTokens: 10,
1039 reasoningTokens: 4,
1040 hasSubagents: false,
1041 },
1042 ],
1043 );
1044 }),
1045 );
1046
601 it.effect("carries child model metadata through every task event", () =>1047 it.effect("carries child model metadata through every task event", () =>
602 Effect.gen(function* () {1048 Effect.gen(function* () {
603 const { adapter, runtime } = yield* startLifecycleRuntime();1049 const { adapter, runtime } = yield* startLifecycleRuntime();
apps/server/src/provider/Layers/CodexAdapter.ts+253-2
...@@ -26,6 +26,7 @@ import {...@@ -26,6 +26,7 @@ import {
26 RuntimeRequestId,26 RuntimeRequestId,
27 RuntimeTaskId,27 RuntimeTaskId,
28 type RuntimeTaskUsage,28 type RuntimeTaskUsage,
29 type TurnTokenUsage,
29 ProviderApprovalDecision,30 ProviderApprovalDecision,
30 ThreadId,31 ThreadId,
31 ProviderSendTurnInput,32 ProviderSendTurnInput,
...@@ -99,9 +100,34 @@ interface CodexAdapterSessionContext {...@@ -99,9 +100,34 @@ interface CodexAdapterSessionContext {
99 readonly scope: Scope.Closeable;100 readonly scope: Scope.Closeable;
100 readonly runtime: CodexSessionRuntimeShape;101 readonly runtime: CodexSessionRuntimeShape;
101 readonly eventFiber: Fiber.Fiber<void, never>;102 readonly eventFiber: Fiber.Fiber<void, never>;
103 readonly turnTokenUsage: CodexTurnTokenUsageState;
102 stopped: boolean;104 stopped: boolean;
103}105}
104106
107type CodexCumulativeTokenUsage = {
108 readonly inputTokens: number;
109 readonly cachedInputTokens: number;
110 readonly cacheCreationTokens?: number;
111 readonly outputTokens: number;
112 readonly reasoningTokens: number;
113};
114
115interface CodexTurnTokenUsageAccumulator {
116 inputTokens: number;
117 cachedInputTokens: number;
118 cacheCreationTokens: number | undefined;
119 outputTokens: number;
120 reasoningTokens: number;
121 observed: boolean;
122 hasSubagents: boolean;
123}
124
125interface CodexTurnTokenUsageState {
126 baseline: CodexCumulativeTokenUsage | undefined;
127 activeTurnId: string | undefined;
128 readonly byTurnId: Map<string, CodexTurnTokenUsageAccumulator>;
129}
130
105function mapCodexRuntimeError(131function mapCodexRuntimeError(
106 threadId: ThreadId,132 threadId: ThreadId,
107 method: string,133 method: string,
...@@ -429,6 +455,162 @@ function normalizeCodexTokenUsage(...@@ -429,6 +455,162 @@ function normalizeCodexTokenUsage(
429 };455 };
430}456}
431457
458function codexTokenUsageBreakdown(
459 usage: EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification__TokenUsageBreakdown,
460): CodexCumulativeTokenUsage {
461 return {
462 inputTokens: usage.inputTokens,
463 cachedInputTokens: usage.cachedInputTokens,
464 ...(usage.cacheWriteInputTokens !== undefined
465 ? { cacheCreationTokens: usage.cacheWriteInputTokens }
466 : {}),
467 outputTokens: usage.outputTokens,
468 reasoningTokens: usage.reasoningOutputTokens,
469 };
470}
471
472function makeCodexTurnTokenUsageState(): CodexTurnTokenUsageState {
473 return {
474 baseline: undefined,
475 activeTurnId: undefined,
476 byTurnId: new Map(),
477 };
478}
479
480function getCodexTurnAccumulator(
481 state: CodexTurnTokenUsageState,
482 turnId: string,
483): CodexTurnTokenUsageAccumulator {
484 const existing = state.byTurnId.get(turnId);
485 if (existing) return existing;
486 const created: CodexTurnTokenUsageAccumulator = {
487 inputTokens: 0,
488 cachedInputTokens: 0,
489 cacheCreationTokens: 0,
490 outputTokens: 0,
491 reasoningTokens: 0,
492 observed: false,
493 hasSubagents: false,
494 };
495 state.byTurnId.set(turnId, created);
496 return created;
497}
498
499/**
500 * Usage added by one `thread/tokenUsage/updated` notification. Codex reports a
501 * running `total` for the thread and `last`, the usage of the newest model
502 * response. Within a turn the growth of `total` equals `last`. Without a prior
503 * total (first update after resume or rollback), or when Codex reset the
504 * running total, `last` is the delta.
505 */
506function codexTurnTokenUsageDelta(
507 previous: CodexCumulativeTokenUsage | undefined,
508 current: CodexCumulativeTokenUsage,
509 last: CodexCumulativeTokenUsage,
510): CodexCumulativeTokenUsage {
511 if (
512 previous === undefined ||
513 current.inputTokens < previous.inputTokens ||
514 current.cachedInputTokens < previous.cachedInputTokens ||
515 current.outputTokens < previous.outputTokens ||
516 current.reasoningTokens < previous.reasoningTokens
517 ) {
518 return last;
519 }
520 return {
521 inputTokens: current.inputTokens - previous.inputTokens,
522 cachedInputTokens: current.cachedInputTokens - previous.cachedInputTokens,
523 ...(current.cacheCreationTokens !== undefined &&
524 previous.cacheCreationTokens !== undefined &&
525 current.cacheCreationTokens >= previous.cacheCreationTokens
526 ? { cacheCreationTokens: current.cacheCreationTokens - previous.cacheCreationTokens }
527 : {}),
528 outputTokens: current.outputTokens - previous.outputTokens,
529 reasoningTokens: current.reasoningTokens - previous.reasoningTokens,
530 };
531}
532
533function accumulateCodexTurnTokenUsage(
534 state: CodexTurnTokenUsageState,
535 turnId: string,
536 usage: EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification["tokenUsage"],
537): void {
538 const current = codexTokenUsageBreakdown(usage.total);
539 if (state.activeTurnId !== turnId) {
540 // The total is thread-wide, so every update moves the baseline. A late
541 // update for a finished turn is not counted toward the live turn.
542 state.baseline = current;
543 return;
544 }
545
546 const accumulator = getCodexTurnAccumulator(state, turnId);
547 const delta = codexTurnTokenUsageDelta(
548 state.baseline,
549 current,
550 codexTokenUsageBreakdown(usage.last),
551 );
552 state.baseline = current;
553
554 if (
555 delta.inputTokens > 0 ||
556 delta.cachedInputTokens > 0 ||
557 delta.outputTokens > 0 ||
558 delta.reasoningTokens > 0
559 ) {
560 accumulator.observed = true;
561 }
562 accumulator.inputTokens += delta.inputTokens;
563 accumulator.cachedInputTokens += delta.cachedInputTokens;
564 accumulator.outputTokens += delta.outputTokens;
565 accumulator.reasoningTokens += delta.reasoningTokens;
566 if (delta.cacheCreationTokens === undefined) {
567 accumulator.cacheCreationTokens = undefined;
568 } else if (accumulator.cacheCreationTokens !== undefined) {
569 accumulator.cacheCreationTokens += delta.cacheCreationTokens;
570 }
571}
572
573function completeCodexTurnTokenUsage(
574 state: CodexTurnTokenUsageState,
575 turnId: string,
576 completed: boolean,
577): TurnTokenUsage {
578 const usage = state.byTurnId.get(turnId);
579 state.byTurnId.delete(turnId);
580 if (state.activeTurnId === turnId) state.activeTurnId = undefined;
581 if (!usage) {
582 return {
583 usageStatus: "unavailable",
584 usageScope: "main_agent",
585 hasSubagents: false,
586 };
587 }
588
589 if (!usage.observed) {
590 return {
591 usageStatus: "unavailable",
592 usageScope: "main_agent",
593 hasSubagents: usage.hasSubagents,
594 };
595 }
596
597 // Codex counts cache reads and writes inside inputTokens. Clamp the
598 // subsets so the record keeps the documented relationships even if a
599 // counter drifts.
600 return {
601 usageStatus: completed ? "complete" : "partial",
602 usageScope: "main_agent",
603 inputTokens: usage.inputTokens,
604 cachedInputTokens: Math.min(usage.inputTokens, usage.cachedInputTokens),
605 ...(usage.cacheCreationTokens !== undefined
606 ? { cacheCreationTokens: Math.min(usage.inputTokens, usage.cacheCreationTokens) }
607 : {}),
608 outputTokens: usage.outputTokens,
609 reasoningTokens: Math.min(usage.outputTokens, usage.reasoningTokens),
610 hasSubagents: usage.hasSubagents,
611 };
612}
613
432function toTurnStatus(614function toTurnStatus(
433 value: EffectCodexSchema.V2TurnCompletedNotification["turn"]["status"] | "cancelled",615 value: EffectCodexSchema.V2TurnCompletedNotification["turn"]["status"] | "cancelled",
434): "completed" | "failed" | "cancelled" | "interrupted" {616): "completed" | "failed" | "cancelled" | "interrupted" {
...@@ -2063,6 +2245,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2063,6 +2245,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2063 }2245 }
2064 : {}),2246 : {}),
2065 };2247 };
2248 const turnTokenUsage = makeCodexTurnTokenUsageState();
2066 const sessionScope = yield* Scope.make("sequential");2249 const sessionScope = yield* Scope.make("sequential");
2067 let sessionScopeTransferred = false;2250 let sessionScopeTransferred = false;
2068 yield* Effect.addFinalizer(() =>2251 yield* Effect.addFinalizer(() =>
...@@ -2091,7 +2274,64 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2091,7 +2274,64 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2091 const eventFiber = yield* Stream.runForEach(runtime.events, (event) =>2274 const eventFiber = yield* Stream.runForEach(runtime.events, (event) =>
2092 Effect.gen(function* () {2275 Effect.gen(function* () {
2093 yield* writeNativeEvent(event);2276 yield* writeNativeEvent(event);
2094 const runtimeEvents = mapToRuntimeEvents(event, event.threadId);2277 if (event.method === "turn/started" && event.turnId) {
2278 if (turnTokenUsage.activeTurnId !== event.turnId) {
2279 turnTokenUsage.byTurnId.clear();
2280 turnTokenUsage.activeTurnId = event.turnId;
2281 getCodexTurnAccumulator(turnTokenUsage, event.turnId);
2282 }
2283 } else if (event.method === "thread/tokenUsage/updated") {
2284 const payload = readPayload(
2285 EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification,
2286 event.payload,
2287 );
2288 if (payload) {
2289 accumulateCodexTurnTokenUsage(turnTokenUsage, payload.turnId, payload.tokenUsage);
2290 }
2291 } else if (turnTokenUsage.activeTurnId) {
2292 const collabPayload =
2293 typeof event.payload === "object" && event.payload !== null
2294 ? (event.payload as Record<string, unknown>)
2295 : undefined;
2296 const isCollabSpawn =
2297 event.method === "collabAgent/started" ||
2298 (event.method === "collabAgent/activity" &&
2299 collabPayload?.activityKind === "started");
2300 if (isCollabSpawn && event.turnId === turnTokenUsage.activeTurnId) {
2301 getCodexTurnAccumulator(turnTokenUsage, turnTokenUsage.activeTurnId).hasSubagents =
2302 true;
2303 }
2304 }
2305
2306 const runtimeEvents = mapToRuntimeEvents(event, event.threadId).map((runtimeEvent) => {
2307 if (runtimeEvent.type === "turn.completed" && runtimeEvent.turnId) {
2308 return {
2309 ...runtimeEvent,
2310 payload: {
2311 ...runtimeEvent.payload,
2312 tokenUsage: completeCodexTurnTokenUsage(
2313 turnTokenUsage,
2314 String(runtimeEvent.turnId),
2315 runtimeEvent.payload.state === "completed",
2316 ),
2317 },
2318 } satisfies ProviderRuntimeEvent;
2319 }
2320 if (runtimeEvent.type === "turn.aborted" && runtimeEvent.turnId) {
2321 return {
2322 ...runtimeEvent,
2323 payload: {
2324 ...runtimeEvent.payload,
2325 tokenUsage: completeCodexTurnTokenUsage(
2326 turnTokenUsage,
2327 String(runtimeEvent.turnId),
2328 false,
2329 ),
2330 },
2331 } satisfies ProviderRuntimeEvent;
2332 }
2333 return runtimeEvent;
2334 });
2095 if (runtimeEvents.length === 0) {2335 if (runtimeEvents.length === 0) {
2096 yield* Effect.logDebug("ignoring unhandled Codex provider event", {2336 yield* Effect.logDebug("ignoring unhandled Codex provider event", {
2097 method: event.method,2337 method: event.method,
...@@ -2129,6 +2369,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2129,6 +2369,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2129 scope: sessionScope,2369 scope: sessionScope,
2130 runtime,2370 runtime,
2131 eventFiber,2371 eventFiber,
2372 turnTokenUsage,
2132 stopped: false,2373 stopped: false,
2133 });2374 });
2134 sessionScopeTransferred = true;2375 sessionScopeTransferred = true;
...@@ -2262,7 +2503,17 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (...@@ -2262,7 +2503,17 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
2262 }2503 }
22632504
2264 return requireSession(threadId).pipe(2505 return requireSession(threadId).pipe(
2265 Effect.flatMap((session) => session.runtime.rollbackThread(numTurns)),2506 Effect.flatMap((session) =>
2507 session.runtime.rollbackThread(numTurns).pipe(
2508 Effect.tap(() =>
2509 Effect.sync(() => {
2510 session.turnTokenUsage.baseline = undefined;
2511 session.turnTokenUsage.activeTurnId = undefined;
2512 session.turnTokenUsage.byTurnId.clear();
2513 }),
2514 ),
2515 ),
2516 ),
2266 Effect.mapError((cause) =>2517 Effect.mapError((cause) =>
2267 cause._tag === "ProviderAdapterSessionNotFoundError"2518 cause._tag === "ProviderAdapterSessionNotFoundError"
2268 ? cause2519 ? cause
apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+745
...@@ -3,6 +3,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -3,6 +3,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
3import { it } from "@effect/vitest";3import { it } from "@effect/vitest";
4import * as Cause from "effect/Cause";4import * as Cause from "effect/Cause";
5import * as Context from "effect/Context";5import * as Context from "effect/Context";
6import * as Crypto from "effect/Crypto";
7import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";9import * as Exit from "effect/Exit";
8import * as Fiber from "effect/Fiber";10import * as Fiber from "effect/Fiber";
...@@ -2086,6 +2088,557 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -2086,6 +2088,557 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
2086 }),2088 }),
2087 );2089 );
20882090
2091 it.effect("marks subagents when a child is proven related by ancestry lookup", () =>
2092 Effect.gen(function* () {
2093 const adapter = yield* OpenCodeAdapter;
2094 const threadId = asThreadId("thread-child-ancestry-usage");
2095 // The child's `session.created` event was missed. Only the ancestry
2096 // lookup can prove that `ses_child` belongs to this thread.
2097 runtimeMock.state.sessionParentById.set("ses_child", "http://127.0.0.1:9999/session");
2098 runtimeMock.state.sessionStatus = "busy";
2099 const busy = promiseWithResolvers<unknown>();
2100 const childPermission = promiseWithResolvers<unknown>();
2101 const idle = promiseWithResolvers<unknown>();
2102 runtimeMock.state.subscribedEvents = [busy.promise, childPermission.promise, idle.promise];
2103
2104 const eventsFiber = yield* adapter.streamEvents.pipe(
2105 Stream.filter(
2106 (event) =>
2107 event.threadId === threadId &&
2108 (event.type === "request.opened" || event.type === "turn.completed"),
2109 ),
2110 Stream.take(2),
2111 Stream.runCollect,
2112 Effect.forkChild,
2113 );
2114 yield* adapter.startSession({
2115 provider: ProviderDriverKind.make("opencode"),
2116 threadId,
2117 runtimeMode: "approval-required",
2118 });
2119 const sendFiber = yield* adapter
2120 .sendTurn({
2121 threadId,
2122 input: "Delegate to a child",
2123 modelSelection: createModelSelection(
2124 ProviderInstanceId.make("opencode"),
2125 "opencode/kimi-k3",
2126 ),
2127 })
2128 .pipe(Effect.forkChild);
2129 busy.resolve({
2130 id: "evt-child-ancestry-busy",
2131 type: "session.status",
2132 properties: {
2133 sessionID: "http://127.0.0.1:9999/session",
2134 status: { type: "busy" },
2135 },
2136 });
2137 yield* Fiber.join(sendFiber);
2138
2139 const requestOpened = promiseWithResolvers<void>();
2140 runtimeMock.state.sessionGetObserved = (sessionID) => {
2141 if (sessionID === "ses_child") requestOpened.resolve(undefined);
2142 };
2143 childPermission.resolve({
2144 id: "evt-child-ancestry-permission",
2145 type: "permission.asked",
2146 properties: permissionRequest("per_child_ancestry", "ses_child"),
2147 });
2148 yield* Effect.promise(() => requestOpened.promise);
2149 yield* Effect.yieldNow;
2150 runtimeMock.state.sessionStatus = "idle";
2151 idle.resolve({
2152 id: "evt-child-ancestry-idle",
2153 type: "session.status",
2154 properties: {
2155 sessionID: "http://127.0.0.1:9999/session",
2156 status: { type: "idle" },
2157 },
2158 });
2159
2160 const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
2161 NodeAssert.deepEqual(
2162 events.map((event) => event.type),
2163 ["request.opened", "turn.completed"],
2164 );
2165 const completed = events[1];
2166 if (completed?.type === "turn.completed") {
2167 NodeAssert.deepEqual(completed.payload.tokenUsage, {
2168 usageStatus: "unavailable",
2169 usageScope: "main_agent",
2170 hasSubagents: true,
2171 });
2172 }
2173 yield* adapter.stopSession(threadId);
2174 }),
2175 );
2176
2177 it.effect("sums owned OpenCode step usage and marks unresolved usage partial", () =>
2178 Effect.gen(function* () {
2179 const adapter = yield* OpenCodeAdapter;
2180 const threadId = asThreadId("thread-step-usage");
2181 const busy = promiseWithResolvers<unknown>();
2182 const firstStep = promiseWithResolvers<unknown>();
2183 const assistantMessage = promiseWithResolvers<unknown>();
2184 const duplicateStep = promiseWithResolvers<unknown>();
2185 const secondStep = promiseWithResolvers<unknown>();
2186 const unresolvedHeader = promiseWithResolvers<unknown>();
2187 const unresolvedStep = promiseWithResolvers<unknown>();
2188 const recoveredStep = promiseWithResolvers<unknown>();
2189 const recoveredIncompleteHeader = promiseWithResolvers<unknown>();
2190 const recoveredCompleteHeader = promiseWithResolvers<unknown>();
2191 const childSession = promiseWithResolvers<unknown>();
2192 const idle = promiseWithResolvers<unknown>();
2193 runtimeMock.state.subscribedEvents = [
2194 busy.promise,
2195 firstStep.promise,
2196 assistantMessage.promise,
2197 duplicateStep.promise,
2198 secondStep.promise,
2199 unresolvedHeader.promise,
2200 unresolvedStep.promise,
2201 recoveredStep.promise,
2202 recoveredIncompleteHeader.promise,
2203 recoveredCompleteHeader.promise,
2204 childSession.promise,
2205 idle.promise,
2206 ];
2207
2208 const completedFiber = yield* adapter.streamEvents.pipe(
2209 Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
2210 Stream.runHead,
2211 Effect.forkChild,
2212 );
2213 yield* adapter.startSession({
2214 provider: ProviderDriverKind.make("opencode"),
2215 threadId,
2216 runtimeMode: "full-access",
2217 });
2218 const sendFiber = yield* adapter
2219 .sendTurn({
2220 threadId,
2221 input: "Use two model steps",
2222 modelSelection: createModelSelection(
2223 ProviderInstanceId.make("opencode"),
2224 "opencode/kimi-k3",
2225 ),
2226 })
2227 .pipe(Effect.forkChild);
2228 busy.resolve({
2229 id: "evt-step-usage-busy",
2230 type: "session.status",
2231 properties: {
2232 sessionID: "http://127.0.0.1:9999/session",
2233 status: { type: "busy" },
2234 },
2235 });
2236 yield* Fiber.join(sendFiber);
2237 const promptMessageId = (runtimeMock.state.promptCalls[0] as { messageID: string }).messageID;
2238
2239 const stepPart = {
2240 id: "step-usage-1",
2241 sessionID: "http://127.0.0.1:9999/session",
2242 messageID: "assistant-step-usage-1",
2243 type: "step-finish",
2244 reason: "tool-calls",
2245 cost: 0,
2246 tokens: {
2247 input: 100,
2248 output: 20,
2249 reasoning: 5,
2250 cache: { read: 40, write: 10 },
2251 },
2252 } as const;
2253 firstStep.resolve({
2254 id: "evt-step-usage-1",
2255 type: "message.part.updated",
2256 properties: {
2257 sessionID: "http://127.0.0.1:9999/session",
2258 part: stepPart,
2259 },
2260 });
2261 assistantMessage.resolve({
2262 id: "evt-step-usage-assistant",
2263 type: "message.updated",
2264 properties: {
2265 sessionID: "http://127.0.0.1:9999/session",
2266 info: {
2267 id: "assistant-step-usage-1",
2268 role: "assistant",
2269 parentID: promptMessageId,
2270 },
2271 },
2272 });
2273 duplicateStep.resolve({
2274 id: "evt-step-usage-1-duplicate",
2275 type: "message.part.updated",
2276 properties: {
2277 sessionID: "http://127.0.0.1:9999/session",
2278 part: stepPart,
2279 },
2280 });
2281 secondStep.resolve({
2282 id: "evt-step-usage-2",
2283 type: "message.part.updated",
2284 properties: {
2285 sessionID: "http://127.0.0.1:9999/session",
2286 part: {
2287 ...stepPart,
2288 id: "step-usage-2",
2289 tokens: {
2290 input: 50,
2291 output: 10,
2292 reasoning: 2,
2293 cache: { read: 10, write: 0 },
2294 },
2295 },
2296 },
2297 });
2298 unresolvedHeader.resolve({
2299 id: "evt-step-usage-unresolved-header",
2300 type: "message.updated",
2301 properties: {
2302 sessionID: "http://127.0.0.1:9999/session",
2303 info: {
2304 id: "assistant-step-usage-without-parent",
2305 role: "assistant",
2306 },
2307 },
2308 });
2309 unresolvedStep.resolve({
2310 id: "evt-step-usage-unresolved",
2311 type: "message.part.updated",
2312 properties: {
2313 sessionID: "http://127.0.0.1:9999/session",
2314 part: {
2315 ...stepPart,
2316 id: "step-usage-unresolved",
2317 messageID: "assistant-step-usage-without-parent",
2318 tokens: {
2319 input: 1_000,
2320 output: 1_000,
2321 reasoning: 0,
2322 cache: { read: 0, write: 0 },
2323 },
2324 },
2325 },
2326 });
2327 recoveredStep.resolve({
2328 id: "evt-step-usage-recovered",
2329 type: "message.part.updated",
2330 properties: {
2331 sessionID: "http://127.0.0.1:9999/session",
2332 part: {
2333 ...stepPart,
2334 id: "step-usage-recovered",
2335 messageID: "assistant-step-usage-recovered",
2336 tokens: {
2337 input: 30,
2338 output: 10,
2339 reasoning: 2,
2340 cache: { read: 5, write: 1 },
2341 },
2342 },
2343 },
2344 });
2345 recoveredIncompleteHeader.resolve({
2346 id: "evt-step-usage-recovered-incomplete-header",
2347 type: "message.updated",
2348 properties: {
2349 sessionID: "http://127.0.0.1:9999/session",
2350 info: {
2351 id: "assistant-step-usage-recovered",
2352 role: "assistant",
2353 parentID: "",
2354 },
2355 },
2356 });
2357 recoveredCompleteHeader.resolve({
2358 id: "evt-step-usage-recovered-complete-header",
2359 type: "message.updated",
2360 properties: {
2361 sessionID: "http://127.0.0.1:9999/session",
2362 info: {
2363 id: "assistant-step-usage-recovered",
2364 role: "assistant",
2365 parentID: promptMessageId,
2366 },
2367 },
2368 });
2369 childSession.resolve({
2370 id: "evt-step-usage-child",
2371 type: "session.created",
2372 properties: {
2373 info: {
2374 id: "child-step-usage",
2375 parentID: "http://127.0.0.1:9999/session",
2376 },
2377 },
2378 });
2379 idle.resolve({
2380 id: "evt-step-usage-idle",
2381 type: "session.status",
2382 properties: {
2383 sessionID: "http://127.0.0.1:9999/session",
2384 status: { type: "idle" },
2385 },
2386 });
2387
2388 const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second"));
2389 NodeAssert.equal(completed._tag, "Some");
2390 if (completed._tag === "Some" && completed.value.type === "turn.completed") {
2391 NodeAssert.deepStrictEqual(completed.value.payload.tokenUsage, {
2392 usageStatus: "partial",
2393 usageScope: "main_agent",
2394 inputTokens: 246,
2395 cachedInputTokens: 55,
2396 cacheCreationTokens: 11,
2397 outputTokens: 49,
2398 reasoningTokens: 9,
2399 hasSubagents: true,
2400 });
2401 }
2402
2403 yield* adapter.stopSession(threadId);
2404 }),
2405 );
2406
2407 it.effect("keeps the next turn usage while the prior completion is delayed", () =>
2408 Effect.gen(function* () {
2409 const threadId = asThreadId("thread-token-usage-terminal-handoff");
2410 const firstBusy = promiseWithResolvers<unknown>();
2411 const firstAssistantMessage = promiseWithResolvers<unknown>();
2412 const firstStep = promiseWithResolvers<unknown>();
2413 const firstIdle = promiseWithResolvers<unknown>();
2414 const secondBusy = promiseWithResolvers<unknown>();
2415 const secondAssistantMessage = promiseWithResolvers<unknown>();
2416 const secondStep = promiseWithResolvers<unknown>();
2417 const secondIdle = promiseWithResolvers<unknown>();
2418 runtimeMock.state.subscribedEvents = [
2419 firstBusy.promise,
2420 firstAssistantMessage.promise,
2421 firstStep.promise,
2422 firstIdle.promise,
2423 secondBusy.promise,
2424 secondAssistantMessage.promise,
2425 secondStep.promise,
2426 secondIdle.promise,
2427 ];
2428
2429 const firstStepWriteStarted = yield* Deferred.make<void>();
2430 const firstStepWriteRelease = yield* Deferred.make<void>();
2431 const terminalUuidStarted = yield* Deferred.make<void>();
2432 const terminalUuidRelease = yield* Deferred.make<void>();
2433 let blockFirstStepWrite = true;
2434 let blockNextUuid = false;
2435 const nodeCrypto = yield* Crypto.Crypto;
2436 const gatedCrypto = {
2437 ...nodeCrypto,
2438 randomUUIDv4: Effect.suspend(() => {
2439 if (!blockNextUuid) return nodeCrypto.randomUUIDv4;
2440 blockNextUuid = false;
2441 return Deferred.succeed(terminalUuidStarted, undefined).pipe(
2442 Effect.andThen(Deferred.await(terminalUuidRelease)),
2443 Effect.andThen(nodeCrypto.randomUUIDv4),
2444 );
2445 }),
2446 } satisfies Crypto.Crypto;
2447 const adapter = yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, {
2448 nativeEventLogger: {
2449 filePath: "memory://opencode-token-usage-terminal-handoff",
2450 write: (record) => {
2451 const eventType = (record as { event?: { type?: unknown } }).event?.type;
2452 if (blockFirstStepWrite && eventType === "message.part.updated") {
2453 blockFirstStepWrite = false;
2454 return Deferred.succeed(firstStepWriteStarted, undefined).pipe(
2455 Effect.andThen(Deferred.await(firstStepWriteRelease)),
2456 );
2457 }
2458 return Effect.void;
2459 },
2460 close: () => Effect.void,
2461 },
2462 }).pipe(Effect.provideService(Crypto.Crypto, gatedCrypto));
2463
2464 const completedFiber = yield* adapter.streamEvents.pipe(
2465 Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"),
2466 Stream.take(2),
2467 Stream.runCollect,
2468 Effect.forkChild,
2469 );
2470 yield* adapter.startSession({
2471 provider: ProviderDriverKind.make("opencode"),
2472 threadId,
2473 runtimeMode: "full-access",
2474 });
2475
2476 const firstSend = yield* adapter
2477 .sendTurn({
2478 threadId,
2479 input: "Run the first token handoff turn",
2480 modelSelection: createModelSelection(
2481 ProviderInstanceId.make("opencode"),
2482 "opencode/kimi-k3",
2483 ),
2484 })
2485 .pipe(Effect.forkChild);
2486 firstBusy.resolve({
2487 id: "evt-token-handoff-first-busy",
2488 type: "session.status",
2489 properties: {
2490 sessionID: "http://127.0.0.1:9999/session",
2491 status: { type: "busy" },
2492 },
2493 });
2494 const firstTurn = yield* Fiber.join(firstSend);
2495 const firstPromptMessageId = (runtimeMock.state.promptCalls[0] as { messageID: string })
2496 .messageID;
2497 firstAssistantMessage.resolve({
2498 id: "evt-token-handoff-first-assistant",
2499 type: "message.updated",
2500 properties: {
2501 sessionID: "http://127.0.0.1:9999/session",
2502 info: {
2503 id: "assistant-token-handoff-first",
2504 role: "assistant",
2505 parentID: firstPromptMessageId,
2506 },
2507 },
2508 });
2509 firstStep.resolve({
2510 id: "evt-token-handoff-first-step",
2511 type: "message.part.updated",
2512 properties: {
2513 sessionID: "http://127.0.0.1:9999/session",
2514 part: {
2515 id: "step-token-handoff-first",
2516 sessionID: "http://127.0.0.1:9999/session",
2517 messageID: "assistant-token-handoff-first",
2518 type: "step-finish",
2519 reason: "stop",
2520 cost: 0,
2521 tokens: {
2522 input: 100,
2523 output: 20,
2524 reasoning: 5,
2525 cache: { read: 40, write: 10 },
2526 },
2527 },
2528 },
2529 });
2530 yield* Deferred.await(firstStepWriteStarted);
2531 blockNextUuid = true;
2532 firstIdle.resolve({
2533 id: "evt-token-handoff-first-idle",
2534 type: "session.status",
2535 properties: {
2536 sessionID: "http://127.0.0.1:9999/session",
2537 status: { type: "idle" },
2538 },
2539 });
2540 yield* Deferred.succeed(firstStepWriteRelease, undefined);
2541 yield* Deferred.await(terminalUuidStarted);
2542
2543 runtimeMock.state.sessionStatus = "busy";
2544 const secondTurn = yield* adapter.sendTurn({
2545 threadId,
2546 input: "Run the second token handoff turn",
2547 modelSelection: createModelSelection(
2548 ProviderInstanceId.make("opencode"),
2549 "opencode/kimi-k3",
2550 ),
2551 });
2552 NodeAssert.notEqual(secondTurn.turnId, firstTurn.turnId);
2553 const secondPromptMessageId = (runtimeMock.state.promptCalls[1] as { messageID: string })
2554 .messageID;
2555 yield* Deferred.succeed(terminalUuidRelease, undefined);
2556
2557 secondBusy.resolve({
2558 id: "evt-token-handoff-second-busy",
2559 type: "session.status",
2560 properties: {
2561 sessionID: "http://127.0.0.1:9999/session",
2562 status: { type: "busy" },
2563 },
2564 });
2565 secondAssistantMessage.resolve({
2566 id: "evt-token-handoff-second-assistant",
2567 type: "message.updated",
2568 properties: {
2569 sessionID: "http://127.0.0.1:9999/session",
2570 info: {
2571 id: "assistant-token-handoff-second",
2572 role: "assistant",
2573 parentID: secondPromptMessageId,
2574 },
2575 },
2576 });
2577 secondStep.resolve({
2578 id: "evt-token-handoff-second-step",
2579 type: "message.part.updated",
2580 properties: {
2581 sessionID: "http://127.0.0.1:9999/session",
2582 part: {
2583 id: "step-token-handoff-second",
2584 sessionID: "http://127.0.0.1:9999/session",
2585 messageID: "assistant-token-handoff-second",
2586 type: "step-finish",
2587 reason: "stop",
2588 cost: 0,
2589 tokens: {
2590 input: 90,
2591 output: 13,
2592 reasoning: 3,
2593 cache: { read: 20, write: 5 },
2594 },
2595 },
2596 },
2597 });
2598 secondIdle.resolve({
2599 id: "evt-token-handoff-second-idle",
2600 type: "session.status",
2601 properties: {
2602 sessionID: "http://127.0.0.1:9999/session",
2603 status: { type: "idle" },
2604 },
2605 });
2606
2607 const completed = Array.from(
2608 yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")),
2609 );
2610 NodeAssert.deepStrictEqual(
2611 completed.map((event) =>
2612 event.type === "turn.completed" ? event.payload.tokenUsage : undefined,
2613 ),
2614 [
2615 {
2616 usageStatus: "complete",
2617 usageScope: "main_agent",
2618 inputTokens: 150,
2619 cachedInputTokens: 40,
2620 cacheCreationTokens: 10,
2621 outputTokens: 25,
2622 reasoningTokens: 5,
2623 hasSubagents: false,
2624 },
2625 {
2626 usageStatus: "complete",
2627 usageScope: "main_agent",
2628 inputTokens: 115,
2629 cachedInputTokens: 20,
2630 cacheCreationTokens: 5,
2631 outputTokens: 16,
2632 reasoningTokens: 3,
2633 hasSubagents: false,
2634 },
2635 ],
2636 );
2637
2638 yield* adapter.stopSession(threadId);
2639 }),
2640 );
2641
2089 it.effect("ignores a stale admission status response after the next turn starts", () =>2642 it.effect("ignores a stale admission status response after the next turn starts", () =>
2090 Effect.gen(function* () {2643 Effect.gen(function* () {
2091 const adapter = yield* OpenCodeAdapter;2644 const adapter = yield* OpenCodeAdapter;
...@@ -2203,6 +2756,188 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -2203,6 +2756,188 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
2203 }),2756 }),
2204 );2757 );
22052758
2759 it.effect("ignores a late prior-turn step after the next prompt is admitted", () =>
2760 Effect.gen(function* () {
2761 const adapter = yield* OpenCodeAdapter;
2762 const threadId = asThreadId("thread-token-usage-late-prior-step");
2763 const firstBusy = promiseWithResolvers<unknown>();
2764 const firstAssistant = promiseWithResolvers<unknown>();
2765 const secondBusy = promiseWithResolvers<unknown>();
2766 const secondAssistant = promiseWithResolvers<unknown>();
2767 const lateFirstStep = promiseWithResolvers<unknown>();
2768 const secondStep = promiseWithResolvers<unknown>();
2769 const secondIdle = promiseWithResolvers<unknown>();
2770 runtimeMock.state.subscribedEvents = [
2771 firstBusy.promise,
2772 firstAssistant.promise,
2773 secondBusy.promise,
2774 secondAssistant.promise,
2775 lateFirstStep.promise,
2776 secondStep.promise,
2777 secondIdle.promise,
2778 ];
2779
2780 const terminalsFiber = yield* adapter.streamEvents.pipe(
2781 Stream.filter(
2782 (event) =>
2783 event.threadId === threadId &&
2784 (event.type === "turn.aborted" || event.type === "turn.completed"),
2785 ),
2786 Stream.take(2),
2787 Stream.runCollect,
2788 Effect.forkChild,
2789 );
2790 yield* adapter.startSession({
2791 provider: ProviderDriverKind.make("opencode"),
2792 threadId,
2793 runtimeMode: "full-access",
2794 });
2795
2796 const firstSend = yield* adapter
2797 .sendTurn({
2798 threadId,
2799 input: "Start the interrupted turn",
2800 modelSelection: createModelSelection(
2801 ProviderInstanceId.make("opencode"),
2802 "opencode/kimi-k3",
2803 ),
2804 })
2805 .pipe(Effect.forkChild);
2806 firstBusy.resolve({
2807 id: "evt-token-late-first-busy",
2808 type: "session.status",
2809 properties: {
2810 sessionID: "http://127.0.0.1:9999/session",
2811 status: { type: "busy" },
2812 },
2813 });
2814 yield* Fiber.join(firstSend);
2815 const firstPromptMessageId = (runtimeMock.state.promptCalls[0] as { messageID: string })
2816 .messageID;
2817 firstAssistant.resolve({
2818 id: "evt-token-late-first-assistant",
2819 type: "message.updated",
2820 properties: {
2821 sessionID: "http://127.0.0.1:9999/session",
2822 info: {
2823 id: "assistant-token-late-first",
2824 role: "assistant",
2825 parentID: firstPromptMessageId,
2826 },
2827 },
2828 });
2829 yield* Effect.yieldNow;
2830 yield* adapter.interruptTurn(threadId);
2831
2832 const secondSend = yield* adapter
2833 .sendTurn({
2834 threadId,
2835 input: "Start the next turn",
2836 modelSelection: createModelSelection(
2837 ProviderInstanceId.make("opencode"),
2838 "opencode/kimi-k3",
2839 ),
2840 })
2841 .pipe(Effect.forkChild);
2842 while (runtimeMock.state.promptCalls.length < 2) {
2843 yield* Effect.yieldNow;
2844 }
2845 secondBusy.resolve({
2846 id: "evt-token-late-second-busy",
2847 type: "session.status",
2848 properties: {
2849 sessionID: "http://127.0.0.1:9999/session",
2850 status: { type: "busy" },
2851 },
2852 });
2853 yield* Fiber.join(secondSend);
2854 const secondPromptMessageId = (runtimeMock.state.promptCalls[1] as { messageID: string })
2855 .messageID;
2856 secondAssistant.resolve({
2857 id: "evt-token-late-second-assistant",
2858 type: "message.updated",
2859 properties: {
2860 sessionID: "http://127.0.0.1:9999/session",
2861 info: {
2862 id: "assistant-token-late-second",
2863 role: "assistant",
2864 parentID: secondPromptMessageId,
2865 },
2866 },
2867 });
2868 lateFirstStep.resolve({
2869 id: "evt-token-late-first-step",
2870 type: "message.part.updated",
2871 properties: {
2872 sessionID: "http://127.0.0.1:9999/session",
2873 part: {
2874 id: "step-token-late-first",
2875 sessionID: "http://127.0.0.1:9999/session",
2876 messageID: "assistant-token-late-first",
2877 type: "step-finish",
2878 reason: "stop",
2879 cost: 0,
2880 tokens: {
2881 input: 1_000,
2882 output: 1_000,
2883 reasoning: 0,
2884 cache: { read: 0, write: 0 },
2885 },
2886 },
2887 },
2888 });
2889 secondStep.resolve({
2890 id: "evt-token-late-second-step",
2891 type: "message.part.updated",
2892 properties: {
2893 sessionID: "http://127.0.0.1:9999/session",
2894 part: {
2895 id: "step-token-late-second",
2896 sessionID: "http://127.0.0.1:9999/session",
2897 messageID: "assistant-token-late-second",
2898 type: "step-finish",
2899 reason: "stop",
2900 cost: 0,
2901 tokens: {
2902 input: 40,
2903 output: 10,
2904 reasoning: 2,
2905 cache: { read: 5, write: 1 },
2906 },
2907 },
2908 },
2909 });
2910 secondIdle.resolve({
2911 id: "evt-token-late-second-idle",
2912 type: "session.status",
2913 properties: {
2914 sessionID: "http://127.0.0.1:9999/session",
2915 status: { type: "idle" },
2916 },
2917 });
2918
2919 const terminals = Array.from(
2920 yield* Fiber.join(terminalsFiber).pipe(Effect.timeout("1 second")),
2921 );
2922 const secondCompleted = terminals.find((event) => event.type === "turn.completed");
2923 NodeAssert.equal(secondCompleted?.type, "turn.completed");
2924 if (secondCompleted?.type === "turn.completed") {
2925 NodeAssert.deepStrictEqual(secondCompleted.payload.tokenUsage, {
2926 usageStatus: "complete",
2927 usageScope: "main_agent",
2928 inputTokens: 46,
2929 cachedInputTokens: 5,
2930 cacheCreationTokens: 1,
2931 outputTokens: 12,
2932 reasoningTokens: 2,
2933 hasSubagents: false,
2934 });
2935 }
2936
2937 yield* adapter.stopSession(threadId);
2938 }),
2939 );
2940
2206 it.effect("reconciles a sole idle when the matching prompt echo arrives later", () =>2941 it.effect("reconciles a sole idle when the matching prompt echo arrives later", () =>
2207 Effect.gen(function* () {2942 Effect.gen(function* () {
2208 const adapter = yield* OpenCodeAdapter;2943 const adapter = yield* OpenCodeAdapter;
...@@ -5432,6 +6167,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -5432,6 +6167,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
54326167
5433 return Effect.gen(function* () {6168 return Effect.gen(function* () {
5434 const adapter = yield* OpenCodeAdapter;6169 const adapter = yield* OpenCodeAdapter;
6170 const startedFiber = yield* adapter.streamEvents.pipe(
6171 Stream.filter((event) => event.type === "turn.started"),
6172 Stream.runHead,
6173 Effect.forkChild,
6174 );
5435 yield* adapter.startSession({6175 yield* adapter.startSession({
5436 provider: ProviderDriverKind.make("opencode"),6176 provider: ProviderDriverKind.make("opencode"),
5437 threadId: asThreadId("thread-custom-instance"),6177 threadId: asThreadId("thread-custom-instance"),
...@@ -5470,6 +6210,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -5470,6 +6210,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
5470 }),6210 }),
5471 parts: [{ type: "text", text: "Fix it" }],6211 parts: [{ type: "text", text: "Fix it" }],
5472 });6212 });
6213 const started = yield* Fiber.join(startedFiber);
6214 NodeAssert.equal(started._tag, "Some");
6215 if (started._tag === "Some" && started.value.type === "turn.started") {
6216 NodeAssert.equal(started.value.payload.effort, undefined);
6217 }
5473 }).pipe(Effect.provide(adapterLayer));6218 }).pipe(Effect.provide(adapterLayer));
5474 });6219 });
54756220
apps/server/src/provider/Layers/OpenCodeAdapter.ts+152-4
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 RuntimeRequestId,9 RuntimeRequestId,
10 ThreadId,10 ThreadId,
11 type ToolLifecycleItemType,11 type ToolLifecycleItemType,
12 type TurnTokenUsage,
12 TurnId,13 TurnId,
13 type UserInputQuestion,14 type UserInputQuestion,
14} from "@t3tools/contracts";15} from "@t3tools/contracts";
...@@ -338,6 +339,7 @@ interface OpenCodeSessionContext {...@@ -338,6 +339,7 @@ interface OpenCodeSessionContext {
338 readonly partById: Map<string, Part>;339 readonly partById: Map<string, Part>;
339 readonly emittedTextByPartId: Map<string, string>;340 readonly emittedTextByPartId: Map<string, string>;
340 readonly completedAssistantPartIds: Set<string>;341 readonly completedAssistantPartIds: Set<string>;
342 turnTokenUsage: OpenCodeTurnTokenUsageAccumulator | undefined;
341 activeTurnId: TurnId | undefined;343 activeTurnId: TurnId | undefined;
342 activeAgent: string | undefined;344 activeAgent: string | undefined;
343 activeVariant: string | undefined;345 activeVariant: string | undefined;
...@@ -368,6 +370,75 @@ interface OpenCodeSessionContext {...@@ -368,6 +370,75 @@ interface OpenCodeSessionContext {
368 readonly sessionScope: Scope.Closeable;370 readonly sessionScope: Scope.Closeable;
369}371}
370372
373interface OpenCodeTurnTokenUsageAccumulator {
374 readonly partIds: Set<string>;
375 readonly promptMessageIds: Set<string>;
376 readonly assistantOwnershipByMessageId: Map<string, "owned" | "other" | "unknown">;
377 readonly unresolvedStepPartIds: Set<string>;
378 inputTokens: number;
379 cachedInputTokens: number;
380 cacheCreationTokens: number;
381 outputTokens: number;
382 reasoningTokens: number;
383 complete: boolean;
384 hasSubagents: boolean;
385}
386
387function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumulator {
388 return {
389 partIds: new Set(),
390 promptMessageIds: new Set(),
391 assistantOwnershipByMessageId: new Map(),
392 unresolvedStepPartIds: new Set(),
393 inputTokens: 0,
394 cachedInputTokens: 0,
395 cacheCreationTokens: 0,
396 outputTokens: 0,
397 reasoningTokens: 0,
398 complete: true,
399 hasSubagents: false,
400 };
401}
402
403function accumulateOpenCodeStepUsage(
404 accumulator: OpenCodeTurnTokenUsageAccumulator,
405 part: Extract<Part, { readonly type: "step-finish" }>,
406): void {
407 if (accumulator.partIds.has(part.id)) return;
408 accumulator.partIds.add(part.id);
409 accumulator.inputTokens += part.tokens.input + part.tokens.cache.read + part.tokens.cache.write;
410 accumulator.cachedInputTokens += part.tokens.cache.read;
411 accumulator.cacheCreationTokens += part.tokens.cache.write;
412 accumulator.outputTokens += part.tokens.output + part.tokens.reasoning;
413 accumulator.reasoningTokens += part.tokens.reasoning;
414}
415
416function takeOpenCodeTurnTokenUsage(
417 context: OpenCodeSessionContext,
418 complete: boolean,
419): TurnTokenUsage {
420 const usage = context.turnTokenUsage;
421 context.turnTokenUsage = undefined;
422 if (!usage || usage.partIds.size === 0) {
423 return {
424 usageStatus: "unavailable",
425 usageScope: "main_agent",
426 hasSubagents: usage?.hasSubagents ?? false,
427 };
428 }
429 return {
430 usageStatus:
431 complete && usage.complete && usage.unresolvedStepPartIds.size === 0 ? "complete" : "partial",
432 usageScope: "main_agent",
433 inputTokens: usage.inputTokens,
434 cachedInputTokens: usage.cachedInputTokens,
435 cacheCreationTokens: usage.cacheCreationTokens,
436 outputTokens: usage.outputTokens,
437 reasoningTokens: Math.min(usage.outputTokens, usage.reasoningTokens),
438 hasSubagents: usage.hasSubagents,
439 };
440}
441
371export interface OpenCodeAdapterLiveOptions {442export interface OpenCodeAdapterLiveOptions {
372 readonly instanceId?: ProviderInstanceId;443 readonly instanceId?: ProviderInstanceId;
373 readonly environment?: NodeJS.ProcessEnv;444 readonly environment?: NodeJS.ProcessEnv;
...@@ -1032,6 +1103,7 @@ export function makeOpenCodeAdapter(...@@ -1032,6 +1103,7 @@ export function makeOpenCodeAdapter(
1032 ) {1103 ) {
1033 context.pendingIdleReconciliation = undefined;1104 context.pendingIdleReconciliation = undefined;
1034 }1105 }
1106 const tokenUsage = takeOpenCodeTurnTokenUsage(context, true);
1035 context.activeTurnId = undefined;1107 context.activeTurnId = undefined;
1036 context.activeAgent = undefined;1108 context.activeAgent = undefined;
1037 context.activeVariant = undefined;1109 context.activeVariant = undefined;
...@@ -1061,6 +1133,7 @@ export function makeOpenCodeAdapter(...@@ -1061,6 +1133,7 @@ export function makeOpenCodeAdapter(
1061 type: "turn.completed",1133 type: "turn.completed",
1062 payload: {1134 payload: {
1063 state: "completed",1135 state: "completed",
1136 tokenUsage,
1064 },1137 },
1065 });1138 });
1066 });1139 });
...@@ -1198,6 +1271,7 @@ export function makeOpenCodeAdapter(...@@ -1198,6 +1271,7 @@ export function makeOpenCodeAdapter(
1198 deleteContextIfCurrent(context);1271 deleteContextIfCurrent(context);
1199 return;1272 return;
1200 }1273 }
1274 const tokenUsage = takeOpenCodeTurnTokenUsage(context, false);
1201 context.promptAdmission = undefined;1275 context.promptAdmission = undefined;
1202 context.activeTurnId = undefined;1276 context.activeTurnId = undefined;
1203 context.activeAgent = undefined;1277 context.activeAgent = undefined;
...@@ -1219,6 +1293,7 @@ export function makeOpenCodeAdapter(...@@ -1219,6 +1293,7 @@ export function makeOpenCodeAdapter(
1219 payload: {1293 payload: {
1220 state: "failed",1294 state: "failed",
1221 errorMessage: detail,1295 errorMessage: detail,
1296 tokenUsage,
1222 },1297 },
1223 });1298 });
1224 yield* emit({1299 yield* emit({
...@@ -1396,7 +1471,13 @@ export function makeOpenCodeAdapter(...@@ -1396,7 +1471,13 @@ export function makeOpenCodeAdapter(
1396 if (cancellation) {1471 if (cancellation) {
1397 context.cancellation = undefined;1472 context.cancellation = undefined;
1398 }1473 }
1474 let tokenUsage: TurnTokenUsage = {
1475 usageStatus: "unavailable",
1476 usageScope: "main_agent",
1477 hasSubagents: false,
1478 };
1399 if (context.activeTurnId === turnId) {1479 if (context.activeTurnId === turnId) {
1480 tokenUsage = takeOpenCodeTurnTokenUsage(context, false);
1400 context.activeTurnId = undefined;1481 context.activeTurnId = undefined;
1401 context.activeAgent = undefined;1482 context.activeAgent = undefined;
1402 context.activeVariant = undefined;1483 context.activeVariant = undefined;
...@@ -1416,6 +1497,7 @@ export function makeOpenCodeAdapter(...@@ -1416,6 +1497,7 @@ export function makeOpenCodeAdapter(
1416 type: "turn.aborted",1497 type: "turn.aborted",
1417 payload: {1498 payload: {
1418 reason: "Interrupted by user.",1499 reason: "Interrupted by user.",
1500 tokenUsage,
1419 },1501 },
1420 });1502 });
1421 if (cancellation) {1503 if (cancellation) {
...@@ -1550,6 +1632,16 @@ export function makeOpenCodeAdapter(...@@ -1550,6 +1632,16 @@ export function makeOpenCodeAdapter(
1550 }1632 }
1551 });1633 });
15521634
1635 // Records a child session of this thread. A child seen during a live turn
1636 // means that turn used subagents, whether the relation came from a
1637 // `session.created` event or a later ancestry lookup after reconnect.
1638 const addRelatedOpenCodeSession = (context: OpenCodeSessionContext, sessionId: string) => {
1639 context.relatedSessionIds.add(sessionId);
1640 if (context.activeTurnId && context.turnTokenUsage) {
1641 context.turnTokenUsage.hasSubagents = true;
1642 }
1643 };
1644
1553 const isRelatedOpenCodeSession = Effect.fn("isRelatedOpenCodeSession")(function* (1645 const isRelatedOpenCodeSession = Effect.fn("isRelatedOpenCodeSession")(function* (
1554 context: OpenCodeSessionContext,1646 context: OpenCodeSessionContext,
1555 candidateSessionId: string,1647 candidateSessionId: string,
...@@ -1581,7 +1673,7 @@ export function makeOpenCodeAdapter(...@@ -1581,7 +1673,7 @@ export function makeOpenCodeAdapter(
1581 let sessionId: string | undefined = candidateSessionId;1673 let sessionId: string | undefined = candidateSessionId;
1582 for (let depth = 0; sessionId !== undefined && depth < 32; depth += 1) {1674 for (let depth = 0; sessionId !== undefined && depth < 32; depth += 1) {
1583 if (context.relatedSessionIds.has(sessionId)) {1675 if (context.relatedSessionIds.has(sessionId)) {
1584 context.relatedSessionIds.add(candidateSessionId);1676 addRelatedOpenCodeSession(context, candidateSessionId);
1585 return true;1677 return true;
1586 }1678 }
1587 if (seen.has(sessionId)) {1679 if (seen.has(sessionId)) {
...@@ -2078,6 +2170,9 @@ export function makeOpenCodeAdapter(...@@ -2078,6 +2170,9 @@ export function makeOpenCodeAdapter(
2078 }2170 }
2079 yield* schedulePendingRequestRecovery(context);2171 yield* schedulePendingRequestRecovery(context);
2080 if (!isFirstConnection) {2172 if (!isFirstConnection) {
2173 if (context.turnTokenUsage) {
2174 context.turnTokenUsage.complete = false;
2175 }
2081 yield* schedulePromptAdmissionRecovery(context, event);2176 yield* schedulePromptAdmissionRecovery(context, event);
2082 if (context.activeTurnId !== undefined && context.promptAdmission === undefined) {2177 if (context.activeTurnId !== undefined && context.promptAdmission === undefined) {
2083 yield* scheduleIdleReconciliation(context, context.activeTurnId, event);2178 yield* scheduleIdleReconciliation(context, context.activeTurnId, event);
...@@ -2097,7 +2192,7 @@ export function makeOpenCodeAdapter(...@@ -2097,7 +2192,7 @@ export function makeOpenCodeAdapter(
2097 if (event.type === "session.created" || event.type === "session.updated") {2192 if (event.type === "session.created" || event.type === "session.updated") {
2098 const session = event.properties.info;2193 const session = event.properties.info;
2099 if (session.parentID && context.relatedSessionIds.has(session.parentID)) {2194 if (session.parentID && context.relatedSessionIds.has(session.parentID)) {
2100 context.relatedSessionIds.add(session.id);2195 addRelatedOpenCodeSession(context, session.id);
2101 }2196 }
2102 } else if (event.type === "session.deleted") {2197 } else if (event.type === "session.deleted") {
2103 context.relatedSessionIds.delete(event.properties.info.id);2198 context.relatedSessionIds.delete(event.properties.info.id);
...@@ -2221,10 +2316,36 @@ export function makeOpenCodeAdapter(...@@ -2221,10 +2316,36 @@ export function makeOpenCodeAdapter(
2221 }2316 }
2222 context.messageRoleById.set(event.properties.info.id, event.properties.info.role);2317 context.messageRoleById.set(event.properties.info.id, event.properties.info.role);
2223 if (event.properties.info.role === "assistant") {2318 if (event.properties.info.role === "assistant") {
2319 const usage = context.turnTokenUsage;
2320 const parentMessageId =
2321 typeof event.properties.info.parentID === "string" &&
2322 event.properties.info.parentID.trim().length > 0
2323 ? event.properties.info.parentID
2324 : undefined;
2325 const observedOwnership =
2326 parentMessageId === undefined
2327 ? "unknown"
2328 : usage?.promptMessageIds.has(parentMessageId)
2329 ? "owned"
2330 : "other";
2331 const priorOwnership = usage?.assistantOwnershipByMessageId.get(
2332 event.properties.info.id,
2333 );
2334 const ownership =
2335 priorOwnership === undefined || priorOwnership === "unknown"
2336 ? observedOwnership
2337 : priorOwnership;
2338 if (usage) {
2339 usage.assistantOwnershipByMessageId.set(event.properties.info.id, ownership);
2340 }
2224 for (const part of context.partById.values()) {2341 for (const part of context.partById.values()) {
2225 if (part.messageID !== event.properties.info.id) {2342 if (part.messageID !== event.properties.info.id) {
2226 continue;2343 continue;
2227 }2344 }
2345 if (usage && part.type === "step-finish") {
2346 if (ownership !== "unknown") usage.unresolvedStepPartIds.delete(part.id);
2347 if (ownership === "owned") accumulateOpenCodeStepUsage(usage, part);
2348 }
2228 yield* emitAssistantTextDelta(context, part, turnId, event);2349 yield* emitAssistantTextDelta(context, part, turnId, event);
2229 }2350 }
2230 }2351 }
...@@ -2290,6 +2411,21 @@ export function makeOpenCodeAdapter(...@@ -2290,6 +2411,21 @@ export function makeOpenCodeAdapter(
2290 context.partById.set(part.id, part);2411 context.partById.set(part.id, part);
2291 const messageRole = messageRoleForPart(context, part);2412 const messageRole = messageRoleForPart(context, part);
22922413
2414 if (turnId && part.type === "step-finish" && context.turnTokenUsage) {
2415 const ownership = context.turnTokenUsage.assistantOwnershipByMessageId.get(
2416 part.messageID,
2417 );
2418 if (ownership === "owned") {
2419 accumulateOpenCodeStepUsage(context.turnTokenUsage, part);
2420 } else if (
2421 ownership === "unknown" ||
2422 (ownership === undefined &&
2423 context.messageRoleById.get(part.messageID) !== "assistant")
2424 ) {
2425 context.turnTokenUsage.unresolvedStepPartIds.add(part.id);
2426 }
2427 }
2428
2293 if (messageRole === "assistant") {2429 if (messageRole === "assistant") {
2294 yield* emitAssistantTextDelta(context, part, turnId, event);2430 yield* emitAssistantTextDelta(context, part, turnId, event);
2295 }2431 }
...@@ -2483,6 +2619,7 @@ export function makeOpenCodeAdapter(...@@ -2483,6 +2619,7 @@ export function makeOpenCodeAdapter(
2483 terminalCancellation.turnSettled = true;2619 terminalCancellation.turnSettled = true;
2484 terminalCancellation.acknowledged = true;2620 terminalCancellation.acknowledged = true;
2485 }2621 }
2622 const tokenUsage = activeTurnId ? takeOpenCodeTurnTokenUsage(context, false) : undefined;
2486 context.activeTurnId = undefined;2623 context.activeTurnId = undefined;
2487 context.activeAgent = undefined;2624 context.activeAgent = undefined;
2488 context.activeVariant = undefined;2625 context.activeVariant = undefined;
...@@ -2507,6 +2644,7 @@ export function makeOpenCodeAdapter(...@@ -2507,6 +2644,7 @@ export function makeOpenCodeAdapter(
2507 payload: {2644 payload: {
2508 state: "failed",2645 state: "failed",
2509 errorMessage: message,2646 errorMessage: message,
2647 tokenUsage,
2510 },2648 },
2511 });2649 });
2512 }2650 }
...@@ -2826,6 +2964,7 @@ export function makeOpenCodeAdapter(...@@ -2826,6 +2964,7 @@ export function makeOpenCodeAdapter(
2826 emittedTextByPartId: new Map(),2964 emittedTextByPartId: new Map(),
2827 messageRoleById: new Map(),2965 messageRoleById: new Map(),
2828 completedAssistantPartIds: new Set(),2966 completedAssistantPartIds: new Set(),
2967 turnTokenUsage: undefined,
2829 activeTurnId: undefined,2968 activeTurnId: undefined,
2830 activeAgent: undefined,2969 activeAgent: undefined,
2831 activeVariant: undefined,2970 activeVariant: undefined,
...@@ -2998,6 +3137,10 @@ export function makeOpenCodeAdapter(...@@ -2998,6 +3137,10 @@ export function makeOpenCodeAdapter(
2998 context.promptAdmission = promptAdmission;3137 context.promptAdmission = promptAdmission;
29993138
3000 context.activeTurnId = turnId;3139 context.activeTurnId = turnId;
3140 if (steeringTurnId === undefined) {
3141 context.turnTokenUsage = makeOpenCodeTurnTokenUsageAccumulator();
3142 }
3143 context.turnTokenUsage?.promptMessageIds.add(messageId);
3001 context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined);3144 context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined);
3002 context.activeVariant = variant;3145 context.activeVariant = variant;
3003 if (steeringTurnId === undefined) {3146 if (steeringTurnId === undefined) {
...@@ -3022,7 +3165,6 @@ export function makeOpenCodeAdapter(...@@ -3022,7 +3165,6 @@ export function makeOpenCodeAdapter(
3022 type: "turn.started",3165 type: "turn.started",
3023 payload: {3166 payload: {
3024 model: modelSelection?.model ?? context.session.model,3167 model: modelSelection?.model ?? context.session.model,
3025 ...(variant ? { effort: variant } : {}),
3026 },3168 },
3027 });3169 });
3028 }3170 }
...@@ -3087,6 +3229,7 @@ export function makeOpenCodeAdapter(...@@ -3087,6 +3229,7 @@ export function makeOpenCodeAdapter(
3087 }3229 }
3088 return;3230 return;
3089 }3231 }
3232 const tokenUsage = takeOpenCodeTurnTokenUsage(context, false);
3090 context.promptAdmission = undefined;3233 context.promptAdmission = undefined;
3091 context.activeTurnId = undefined;3234 context.activeTurnId = undefined;
3092 context.activeAgent = undefined;3235 context.activeAgent = undefined;
...@@ -3103,7 +3246,10 @@ export function makeOpenCodeAdapter(...@@ -3103,7 +3246,10 @@ export function makeOpenCodeAdapter(
3103 yield* emit({3246 yield* emit({
3104 ...(yield* buildEventBase({ threadId: input.threadId, turnId })),3247 ...(yield* buildEventBase({ threadId: input.threadId, turnId })),
3105 type: "turn.aborted",3248 type: "turn.aborted",
3106 payload: { reason: requestError.detail },3249 payload: {
3250 reason: requestError.detail,
3251 tokenUsage,
3252 },
3107 });3253 });
3108 return;3254 return;
3109 }3255 }
...@@ -3131,6 +3277,7 @@ export function makeOpenCodeAdapter(...@@ -3131,6 +3277,7 @@ export function makeOpenCodeAdapter(
3131 });3277 });
3132 return;3278 return;
3133 }3279 }
3280 const tokenUsage = takeOpenCodeTurnTokenUsage(context, false);
3134 context.promptAdmission = undefined;3281 context.promptAdmission = undefined;
3135 context.activeTurnId = undefined;3282 context.activeTurnId = undefined;
3136 context.activeAgent = undefined;3283 context.activeAgent = undefined;
...@@ -3154,6 +3301,7 @@ export function makeOpenCodeAdapter(...@@ -3154,6 +3301,7 @@ export function makeOpenCodeAdapter(
3154 type: "turn.aborted",3301 type: "turn.aborted",
3155 payload: {3302 payload: {
3156 reason: requestError.detail,3303 reason: requestError.detail,
3304 tokenUsage,
3157 },3305 },
3158 });3306 });
3159 }),3307 }),
apps/server/src/provider/Layers/ProviderService.test.ts+1237-4
...@@ -314,6 +314,69 @@ function makeFakeCodexAdapter(...@@ -314,6 +314,69 @@ function makeFakeCodexAdapter(
314 };314 };
315}315}
316316
317interface RecordedAnalyticsEvent {
318 readonly event: string;
319 readonly properties?: Readonly<Record<string, unknown>>;
320}
321
322function makeRecordingAnalytics() {
323 const events: Array<RecordedAnalyticsEvent> = [];
324 const layer = Layer.succeed(
325 AnalyticsService.AnalyticsService,
326 AnalyticsService.AnalyticsService.of({
327 record: (event, properties) =>
328 Effect.sync(() => {
329 events.push({ event, ...(properties ? { properties } : {}) });
330 }),
331 flush: Effect.void,
332 }),
333 );
334
335 return {
336 layer,
337 reset: () => {
338 events.length = 0;
339 },
340 eventsByName: (event: string) => events.filter((entry) => entry.event === event),
341 };
342}
343
344function makeStaticInstanceRegistry(
345 entries: ReadonlyArray<readonly [ProviderInstanceId, ProviderAdapterShape<ProviderAdapterError>]>,
346): ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] {
347 const adapters = new Map(entries);
348 const unsupported = (instanceId: ProviderInstanceId) =>
349 new ProviderUnsupportedError({
350 provider: ProviderDriverKind.make(instanceId),
351 });
352
353 return {
354 getByInstance: (instanceId) => {
355 const adapter = adapters.get(instanceId);
356 return adapter ? Effect.succeed(adapter) : Effect.fail(unsupported(instanceId));
357 },
358 getInstanceInfo: (instanceId) => {
359 const adapter = adapters.get(instanceId);
360 return adapter
361 ? Effect.succeed({
362 instanceId,
363 driverKind: adapter.provider,
364 displayName: undefined,
365 enabled: true,
366 continuationIdentity: {
367 driverKind: adapter.provider,
368 continuationKey: `${adapter.provider}:instance:${instanceId}`,
369 },
370 })
371 : Effect.fail(unsupported(instanceId));
372 },
373 listInstances: () => Effect.succeed(Array.from(adapters.keys())),
374 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) =>
375 PubSub.subscribe(pubsub),
376 ),
377 };
378}
379
317const advanceTestClock = (ms: number) =>380const advanceTestClock = (ms: number) =>
318 TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow));381 TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow));
319382
...@@ -332,6 +395,7 @@ function makeProviderServiceLayer(...@@ -332,6 +395,7 @@ function makeProviderServiceLayer(
332 input: {395 input: {
333 readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"];396 readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"];
334 readonly supportsConversationRollback?: boolean;397 readonly supportsConversationRollback?: boolean;
398 readonly analyticsLayer?: Layer.Layer<AnalyticsService.AnalyticsService>;
335 readonly registry?: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"];399 readonly registry?: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"];
336 } = {},400 } = {},
337) {401) {
...@@ -365,7 +429,7 @@ function makeProviderServiceLayer(...@@ -365,7 +429,7 @@ function makeProviderServiceLayer(
365 Layer.provide(directoryLayer),429 Layer.provide(directoryLayer),
366 Layer.provide(defaultServerSettingsLayer),430 Layer.provide(defaultServerSettingsLayer),
367 Layer.provide(serverConfigTestLayer),431 Layer.provide(serverConfigTestLayer),
368 Layer.provideMerge(AnalyticsService.layerTest),432 Layer.provideMerge(input.analyticsLayer ?? AnalyticsService.layerTest),
369 Layer.provide(433 Layer.provide(
370 Layer.succeed(434 Layer.succeed(
371 ProviderEventLoggers.ProviderEventLoggers,435 ProviderEventLoggers.ProviderEventLoggers,
...@@ -440,6 +504,124 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>...@@ -440,6 +504,124 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () =>
440 }),504 }),
441);505);
442506
507it.effect("ProviderServiceLive flushes deferred completions during shutdown", () =>
508 Effect.gen(function* () {
509 const recordedAnalytics = makeRecordingAnalytics();
510 const codex = makeFakeCodexAdapter();
511 const registry = makeStaticInstanceRegistry([[codexInstanceId, codex.adapter]]);
512 const providerAdapterLayer = Layer.succeed(
513 ProviderAdapterRegistry.ProviderAdapterRegistry,
514 registry,
515 );
516 const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
517 Layer.provide(SqlitePersistenceMemory),
518 );
519 const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer));
520 const providerLayer = Layer.mergeAll(
521 makeProviderServiceLive().pipe(
522 Layer.provide(providerAdapterLayer),
523 Layer.provide(directoryLayer),
524 Layer.provide(defaultServerSettingsLayer),
525 Layer.provide(serverConfigTestLayer),
526 Layer.provide(recordedAnalytics.layer),
527 Layer.provide(
528 Layer.succeed(
529 ProviderEventLoggers.ProviderEventLoggers,
530 ProviderEventLoggers.NoOpProviderEventLoggers,
531 ),
532 ),
533 ),
534 directoryLayer,
535 runtimeRepositoryLayer,
536 NodeServices.layer,
537 );
538 const scope = yield* Scope.make();
539 const runtimeServices = yield* Layer.build(providerLayer).pipe(Scope.provide(scope));
540 const provider = yield* ProviderService.ProviderService.pipe(Effect.provide(runtimeServices));
541 const threadId = asThreadId("thread-turn-analytics-stop-all-deferred");
542 const firstStarted = yield* Deferred.make<void>();
543 const secondStarted = yield* Deferred.make<void>();
544 const sendRelease = yield* Deferred.make<void>();
545 const turnId = asTurnId("turn-analytics-stop-all-deferred");
546 yield* provider.startSession(threadId, {
547 provider: CODEX_DRIVER,
548 providerInstanceId: codexInstanceId,
549 threadId,
550 runtimeMode: "full-access",
551 });
552 codex.sendTurn
553 .mockImplementationOnce(() =>
554 Effect.gen(function* () {
555 yield* Deferred.succeed(firstStarted, undefined);
556 yield* Deferred.await(sendRelease);
557 return { threadId, turnId };
558 }),
559 )
560 .mockImplementationOnce(() =>
561 Effect.gen(function* () {
562 yield* Deferred.succeed(secondStarted, undefined);
563 yield* Deferred.await(sendRelease);
564 return { threadId, turnId: asTurnId("turn-analytics-stop-all-other") };
565 }),
566 );
567
568 const firstSend = yield* provider
569 .sendTurn({ threadId, input: "first", attachments: [] })
570 .pipe(Effect.forkChild);
571 yield* Deferred.await(firstStarted);
572 const secondSend = yield* provider
573 .sendTurn({ threadId, input: "second", attachments: [] })
574 .pipe(Effect.forkChild);
575 yield* Deferred.await(secondStarted);
576
577 const runtimeEvents = yield* Stream.take(provider.streamEvents, 2).pipe(
578 Stream.runDrain,
579 Effect.forkChild,
580 );
581 yield* Effect.yieldNow;
582 codex.emit({
583 type: "turn.started",
584 eventId: asEventId("evt-turn-analytics-stop-all-deferred-start"),
585 provider: CODEX_DRIVER,
586 createdAt: "2026-01-01T00:00:00.000Z",
587 threadId,
588 turnId,
589 payload: { model: "native-stop-all" },
590 });
591 codex.emit({
592 type: "turn.completed",
593 eventId: asEventId("evt-turn-analytics-stop-all-deferred-complete"),
594 provider: CODEX_DRIVER,
595 createdAt: "2026-01-01T00:00:00.000Z",
596 threadId,
597 turnId,
598 payload: {
599 state: "completed",
600 tokenUsage: {
601 usageStatus: "complete",
602 usageScope: "main_agent",
603 inputTokens: 1_200,
604 outputTokens: 300,
605 hasSubagents: false,
606 },
607 },
608 });
609 yield* Fiber.join(runtimeEvents);
610 assert.equal(recordedAnalytics.eventsByName("provider.turn.completed").length, 0);
611
612 const closeExit = yield* Scope.close(scope, Exit.void).pipe(Effect.exit);
613 const completed = recordedAnalytics.eventsByName("provider.turn.completed");
614 assert.equal(Exit.isSuccess(closeExit), true);
615 assert.equal(completed.length, 1);
616 assert.equal(completed[0]?.properties?.model, "native-stop-all");
617 assert.equal(completed[0]?.properties?.inputTokens, 1_200);
618 assert.equal(completed[0]?.properties?.outputTokens, 300);
619 yield* Fiber.interrupt(firstSend);
620 yield* Fiber.interrupt(secondSend);
621 assert.equal(recordedAnalytics.eventsByName("provider.turn.completed").length, 1);
622 }),
623);
624
443it.effect("ProviderServiceLive rejects new sessions for disabled providers", () =>625it.effect("ProviderServiceLive rejects new sessions for disabled providers", () =>
444 Effect.gen(function* () {626 Effect.gen(function* () {
445 const codex = makeFakeCodexAdapter();627 const codex = makeFakeCodexAdapter();
...@@ -2282,7 +2464,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -2282,7 +2464,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
2282 createdAt: "2026-01-01T00:00:00.000Z",2464 createdAt: "2026-01-01T00:00:00.000Z",
2283 threadId: session.threadId,2465 threadId: session.threadId,
2284 turnId: asTurnId("turn-1"),2466 turnId: asTurnId("turn-1"),
2285 status: "completed",2467 payload: { state: "completed" },
2286 };2468 };
22872469
2288 fanout.codex.emit(completedEvent);2470 fanout.codex.emit(completedEvent);
...@@ -2349,7 +2531,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -2349,7 +2531,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
2349 createdAt: "2026-01-01T00:00:00.000Z",2531 createdAt: "2026-01-01T00:00:00.000Z",
2350 threadId: session.threadId,2532 threadId: session.threadId,
2351 turnId: asTurnId("turn-1"),2533 turnId: asTurnId("turn-1"),
2352 status: "completed",2534 payload: { state: "completed" },
2353 });2535 });
23542536
2355 yield* Fiber.join(consumer);2537 yield* Fiber.join(consumer);
...@@ -2415,7 +2597,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {...@@ -2415,7 +2597,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => {
2415 createdAt: "2026-01-01T00:00:00.000Z",2597 createdAt: "2026-01-01T00:00:00.000Z",
2416 threadId: session.threadId,2598 threadId: session.threadId,
2417 turnId: asTurnId("turn-1"),2599 turnId: asTurnId("turn-1"),
2418 status: "completed",2600 payload: { state: "completed" },
2419 },2601 },
2420 ];2602 ];
24212603
...@@ -2659,6 +2841,1057 @@ citations.layer("ProviderServiceLive assistant citations", (it) => {...@@ -2659,6 +2841,1057 @@ citations.layer("ProviderServiceLive assistant citations", (it) => {
2659 );2841 );
2660});2842});
26612843
2844const recordedTurnAnalytics = makeRecordingAnalytics();
2845const secondaryCodexInstanceId = ProviderInstanceId.make("codex_work");
2846const primaryAnalyticsCodex = makeFakeCodexAdapter();
2847const secondaryAnalyticsCodex = makeFakeCodexAdapter();
2848const turnAnalytics = makeProviderServiceLayer({
2849 analyticsLayer: recordedTurnAnalytics.layer,
2850 registry: makeStaticInstanceRegistry([
2851 [codexInstanceId, primaryAnalyticsCodex.adapter],
2852 [secondaryCodexInstanceId, secondaryAnalyticsCodex.adapter],
2853 ]),
2854});
2855
2856turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => {
2857 it.effect("records one completed-turn event with the allowed properties", () =>
2858 Effect.gen(function* () {
2859 recordedTurnAnalytics.reset();
2860 const provider = yield* ProviderService.ProviderService;
2861 const threadId = asThreadId("thread-turn-analytics-complete");
2862 yield* provider.startSession(threadId, {
2863 provider: CODEX_DRIVER,
2864 providerInstanceId: codexInstanceId,
2865 threadId,
2866 runtimeMode: "full-access",
2867 });
2868 const turn = yield* provider.sendTurn({
2869 threadId,
2870 input: "measure this turn",
2871 attachments: [],
2872 interactionMode: "plan",
2873 modelSelection: createModelSelection(codexInstanceId, "gpt-5.6-sol", [
2874 { id: "reasoningEffort", value: "high" },
2875 ]),
2876 });
2877 yield* advanceTestClock(40);
2878
2879 const runtimeEvents = yield* Stream.take(provider.streamEvents, 2).pipe(
2880 Stream.runDrain,
2881 Effect.forkChild,
2882 );
2883 yield* Effect.yieldNow;
2884 const completedEvent: LegacyProviderRuntimeEvent = {
2885 type: "turn.completed",
2886 eventId: asEventId("evt-turn-analytics-complete"),
2887 provider: CODEX_DRIVER,
2888 createdAt: "2026-01-01T00:00:00.000Z",
2889 threadId,
2890 turnId: turn.turnId,
2891 payload: {
2892 state: "completed",
2893 tokenUsage: {
2894 usageStatus: "complete",
2895 usageScope: "main_agent",
2896 inputTokens: 1_200,
2897 cachedInputTokens: 800,
2898 cacheCreationTokens: 100,
2899 outputTokens: 300,
2900 reasoningTokens: 120,
2901 hasSubagents: false,
2902 },
2903 },
2904 };
2905 primaryAnalyticsCodex.emit(completedEvent);
2906 primaryAnalyticsCodex.emit({
2907 ...completedEvent,
2908 eventId: asEventId("evt-turn-analytics-complete-duplicate"),
2909 });
2910 yield* Fiber.join(runtimeEvents);
2911
2912 const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
2913 assert.equal(completed.length, 1);
2914 assert.deepEqual(completed[0]?.properties, {
2915 provider: CODEX_DRIVER,
2916 model: "gpt-5.6-sol",
2917 effort: "high",
2918 interactionMode: "plan",
2919 runtimeMode: "full-access",
2920 mixedModels: false,
2921 durationMs: 40,
2922 terminalStatus: "completed",
2923 usageStatus: "complete",
2924 usageScope: "main_agent",
2925 hasSubagents: false,
2926 inputTokens: 1_200,
2927 cachedInputTokens: 800,
2928 cacheCreationTokens: 100,
2929 outputTokens: 300,
2930 reasoningTokens: 120,
2931 });
2932 }),
2933 );
2934
2935 it.effect("does not report a generic model variant as reasoning effort", () =>
2936 Effect.gen(function* () {
2937 recordedTurnAnalytics.reset();
2938 const provider = yield* ProviderService.ProviderService;
2939 const threadId = asThreadId("thread-turn-analytics-generic-variant");
2940 yield* provider.startSession(threadId, {
2941 provider: CODEX_DRIVER,
2942 providerInstanceId: codexInstanceId,
2943 threadId,
2944 runtimeMode: "full-access",
2945 });
2946 const turn = yield* provider.sendTurn({
2947 threadId,
2948 input: "use the provider preset",
2949 attachments: [],
2950 modelSelection: createModelSelection(codexInstanceId, "provider/model", [
2951 { id: "variant", value: "high" },
2952 ]),
2953 });
2954
2955 const runtimeEvent = yield* Stream.take(provider.streamEvents, 1).pipe(
2956 Stream.runDrain,
2957 Effect.forkChild,
2958 );
2959 yield* Effect.yieldNow;
2960 primaryAnalyticsCodex.emit({
2961 type: "turn.completed",
2962 eventId: asEventId("evt-turn-analytics-generic-variant"),
2963 provider: CODEX_DRIVER,
2964 createdAt: "2026-01-01T00:00:00.000Z",
2965 threadId,
2966 turnId: turn.turnId,
2967 payload: { state: "completed" },
2968 });
2969 yield* Fiber.join(runtimeEvent);
2970
2971 const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
2972 assert.equal(completed.length, 1);
2973 assert.notProperty(completed[0]?.properties ?? {}, "effort");
2974 }),
2975 );
2976
2977 it.effect("ignores model metadata bound to another provider instance", () =>
2978 Effect.gen(function* () {
2979 recordedTurnAnalytics.reset();
2980 const provider = yield* ProviderService.ProviderService;
2981 const threadId = asThreadId("thread-turn-analytics-mismatched-model-instance");
2982 yield* provider.startSession(threadId, {
2983 provider: CODEX_DRIVER,
2984 providerInstanceId: codexInstanceId,
2985 threadId,
2986 runtimeMode: "full-access",
2987 });
2988 const turn = yield* provider.sendTurn({
2989 threadId,
2990 input: "ignore this mismatched selection",
2991 attachments: [],
2992 modelSelection: createModelSelection(secondaryCodexInstanceId, "wrong-model", [
2993 { id: "reasoningEffort", value: "high" },
2994 ]),
2995 });
2996
2997 const runtimeEvent = yield* Stream.take(provider.streamEvents, 1).pipe(
2998 Stream.runDrain,
2999 Effect.forkChild,
3000 );
3001 yield* Effect.yieldNow;
3002 primaryAnalyticsCodex.emit({
3003 type: "turn.completed",
3004 eventId: asEventId("evt-turn-analytics-mismatched-model-instance"),
3005 provider: CODEX_DRIVER,
3006 createdAt: "2026-01-01T00:00:00.000Z",
3007 threadId,
3008 turnId: turn.turnId,
3009 payload: { state: "completed" },
3010 });
3011 yield* Fiber.join(runtimeEvent);
3012
3013 const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
3014 assert.equal(completed.length, 1);
3015 assert.notProperty(completed[0]?.properties ?? {}, "model");
3016 assert.notProperty(completed[0]?.properties ?? {}, "effort");
3017 }),
3018 );
3019
3020 it.effect("keeps overlapping request metadata with out-of-order adapter responses", () =>
3021 Effect.gen(function* () {
3022 recordedTurnAnalytics.reset();
3023 const provider = yield* ProviderService.ProviderService;
3024 const threadId = asThreadId("thread-turn-analytics-overlap");
3025 yield* provider.startSession(threadId, {
3026 provider: CODEX_DRIVER,
3027 providerInstanceId: codexInstanceId,
3028 threadId,
3029 runtimeMode: "full-access",
3030 });
3031
3032 const firstStarted = yield* Deferred.make<void>();
3033 const secondStarted = yield* Deferred.make<void>();
3034 const firstRelease = yield* Deferred.make<void>();
3035 const secondRelease = yield* Deferred.make<void>();
3036 const initialStartsObserved = yield* Deferred.make<void>();
3037 let initialStartCount = 0;
3038 const firstTurnId = asTurnId("turn-analytics-overlap-first");
3039 const secondTurnId = asTurnId("turn-analytics-overlap-second");
3040 primaryAnalyticsCodex.sendTurn
3041 .mockImplementationOnce((input) =>
3042 Effect.gen(function* () {
3043 yield* Deferred.succeed(firstStarted, undefined);
3044 yield* Deferred.await(firstRelease);
3045 return { threadId: input.threadId, turnId: firstTurnId };
3046 }),
3047 )
3048 .mockImplementationOnce((input) =>
3049 Effect.gen(function* () {
3050 yield* Deferred.succeed(secondStarted, undefined);
3051 yield* Deferred.await(secondRelease);
3052 return { threadId: input.threadId, turnId: secondTurnId };
3053 }),
3054 );
3055
3056 const runtimeEvents = yield* Stream.take(provider.streamEvents, 5).pipe(
3057 Stream.tap((event) => {
3058 if (event.type !== "turn.started" || initialStartCount >= 2) return Effect.void;
3059 initialStartCount += 1;
3060 return initialStartCount === 2
3061 ? Deferred.succeed(initialStartsObserved, undefined).pipe(Effect.asVoid)
3062 : Effect.void;
3063 }),
3064 Stream.runDrain,
3065 Effect.forkChild,
3066 );
3067 const firstSend = yield* provider
3068 .sendTurn({
3069 threadId,
3070 input: "first",
3071 attachments: [],
3072 interactionMode: "default",
3073 modelSelection: createModelSelection(codexInstanceId, "requested-first"),
3074 })
3075 .pipe(Effect.forkChild);
3076 yield* Deferred.await(firstStarted);
3077 const secondSend = yield* provider
3078 .sendTurn({
3079 threadId,
3080 input: "second",
3081 attachments: [],
3082 interactionMode: "plan",
3083 modelSelection: createModelSelection(codexInstanceId, "requested-second"),
3084 })
3085 .pipe(Effect.forkChild);
3086 yield* Deferred.await(secondStarted);
3087
3088 for (const [turnId, suffix] of [
3089 [firstTurnId, "first"],
3090 [secondTurnId, "second"],
3091 ] as const) {
3092 primaryAnalyticsCodex.emit({
3093 type: "turn.started",
3094 eventId: asEventId(`evt-turn-analytics-overlap-start-${suffix}`),
3095 provider: CODEX_DRIVER,
3096 createdAt: "2026-01-01T00:00:00.000Z",
3097 threadId,
3098 turnId,
3099 payload: { model: `native-${suffix}`, effort: `native-effort-${suffix}` },
3100 });
3101 }
3102 yield* Deferred.await(initialStartsObserved);
3103 yield* Deferred.succeed(secondRelease, undefined);
3104 yield* Fiber.join(secondSend);
3105 primaryAnalyticsCodex.emit({
3106 type: "turn.started",
3107 eventId: asEventId("evt-turn-analytics-overlap-start-second-duplicate"),
3108 provider: CODEX_DRIVER,
3109 createdAt: "2026-01-01T00:00:00.000Z",
3110 threadId,
3111 turnId: secondTurnId,
3112 payload: { model: "native-second", effort: "native-effort-second" },
3113 });
3114 yield* Deferred.succeed(firstRelease, undefined);
3115 yield* Fiber.join(firstSend);
3116 for (const [turnId, suffix] of [
3117 [secondTurnId, "second"],
3118 [firstTurnId, "first"],
3119 ] as const) {
3120 primaryAnalyticsCodex.emit({
3121 type: "turn.completed",
3122 eventId: asEventId(`evt-turn-analytics-overlap-complete-${suffix}`),
3123 provider: CODEX_DRIVER,
3124 createdAt: "2026-01-01T00:00:00.000Z",
3125 threadId,
3126 turnId,
3127 payload: { state: "completed" },
3128 });
3129 }
3130 yield* Fiber.join(runtimeEvents);
3131
3132 const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
3133 assert.equal(completed.length, 2);
3134 assert.deepInclude(completed[0]?.properties ?? {}, {
3135 model: "native-second",
3136 effort: "native-effort-second",
3137 interactionMode: "plan",
3138 });
3139 assert.deepInclude(completed[1]?.properties ?? {}, {
3140 model: "native-first",
3141 effort: "native-effort-first",
3142 interactionMode: "default",
3143 });
3144 }),
3145 );
3146
3147 it.effect("waits for the adapter response when a turn completes before sendTurn returns", () =>
3148 Effect.gen(function* () {
3149 recordedTurnAnalytics.reset();
3150 const provider = yield* ProviderService.ProviderService;
3151 const threadId = asThreadId("thread-turn-analytics-fast-completion");
3152 const turnId = asTurnId("turn-analytics-fast-completion");
3153 const returnRelease = yield* Deferred.make<void>();
3154 yield* provider.startSession(threadId, {
3155 provider: CODEX_DRIVER,
3156 providerInstanceId: codexInstanceId,
3157 threadId,
3158 runtimeMode: "full-access",
3159 });
3160 yield* Effect.yieldNow;
3161 primaryAnalyticsCodex.sendTurn.mockImplementationOnce((input) =>
3162 Effect.gen(function* () {
3163 primaryAnalyticsCodex.emit({
3164 type: "turn.started",
3165 eventId: asEventId("evt-turn-analytics-fast-start"),
3166 provider: CODEX_DRIVER,
3167 createdAt: "2026-01-01T00:00:00.000Z",
3168 threadId,
3169 turnId,
3170 payload: { model: "native-fast", effort: "high" },
3171 });
3172 primaryAnalyticsCodex.emit({
3173 type: "turn.completed",
3174 eventId: asEventId("evt-turn-analytics-fast-complete"),
3175 provider: CODEX_DRIVER,
3176 createdAt: "2026-01-01T00:00:00.000Z",
3177 threadId,
3178 turnId,
3179 payload: { state: "completed" },
3180 });
3181 yield* Deferred.await(returnRelease);
3182 return { threadId: input.threadId, turnId };
3183 }),
3184 );
3185
3186 const terminalReceipt = yield* provider.streamEvents.pipe(
3187 Stream.filter((event) => event.type === "turn.completed"),
3188 Stream.runHead,
3189 Effect.forkChild,
3190 );
3191 yield* Effect.yieldNow;
3192 const sendFiber = yield* provider
3193 .sendTurn({
3194 threadId,
3195 input: "finish immediately",
3196 attachments: [],
3197 interactionMode: "plan",
3198 modelSelection: createModelSelection(codexInstanceId, "requested-fast"),
3199 })
3200 .pipe(Effect.forkChild);
3201 const terminal = yield* Fiber.join(terminalReceipt);
3202 assert.equal(terminal._tag, "Some");
3203 assert.equal(sendFiber.pollUnsafe(), undefined);
3204 assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 0);
3205
3206 yield* Deferred.succeed(returnRelease, undefined);
3207 yield* Fiber.join(sendFiber);
3208 const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
3209 assert.equal(completed.length, 1);
3210 assert.deepInclude(completed[0]?.properties ?? {}, {
3211 model: "native-fast",
3212 effort: "high",
3213 interactionMode: "plan",
3214 });
3215 }),
3216 );
3217
3218 it.effect("does not give a synthetic turn the metadata of an in-flight send", () =>
3219 Effect.gen(function* () {
3220 recordedTurnAnalytics.reset();
3221 const provider = yield* ProviderService.ProviderService;
3222 const threadId = asThreadId("thread-turn-analytics-synthetic-start");
3223 const syntheticTurnId = asTurnId("turn-analytics-synthetic");
3224 const realTurnId = asTurnId("turn-analytics-real");
3225 yield* provider.startSession(threadId, {
3226 provider: CODEX_DRIVER,
3227 providerInstanceId: codexInstanceId,
3228 threadId,
3229 runtimeMode: "full-access",
3230 });
3231 yield* Effect.yieldNow;
3232 // Claude closes a leftover synthetic turn while it prepares the real
3233 // turn, so both events arrive before sendTurn returns the real turn ID.
3234 primaryAnalyticsCodex.sendTurn.mockImplementationOnce((input) =>
3235 Effect.gen(function* () {
3236 primaryAnalyticsCodex.emit({
3237 type: "turn.started",
3238 eventId: asEventId("evt-turn-analytics-synthetic-start"),
3239 provider: CODEX_DRIVER,
3240 createdAt: "2026-01-01T00:00:00.000Z",
3241 threadId,
3242 turnId: syntheticTurnId,
3243 payload: {},
3244 });
3245 primaryAnalyticsCodex.emit({
3246 type: "turn.completed",
3247 eventId: asEventId("evt-turn-analytics-synthetic-complete"),
3248 provider: CODEX_DRIVER,
3249 createdAt: "2026-01-01T00:00:00.000Z",
3250 threadId,
3251 turnId: syntheticTurnId,
3252 payload: { state: "completed" },
3253 });
3254 primaryAnalyticsCodex.emit({
3255 type: "turn.started",
3256 eventId: asEventId("evt-turn-analytics-real-start"),
3257 provider: CODEX_DRIVER,
3258 createdAt: "2026-01-01T00:00:00.000Z",
3259 threadId,
3260 turnId: realTurnId,
3261 payload: { model: "native-real" },
3262 });
3263 yield* Effect.yieldNow;
3264 return { threadId: input.threadId, turnId: realTurnId };
3265 }),
3266 );
3267
3268 yield* provider.sendTurn({
3269 threadId,
3270 input: "start the real turn",
3271 attachments: [],
3272 interactionMode: "plan",
3273 modelSelection: createModelSelection(codexInstanceId, "requested-real"),
3274 });
3275 const realCompletion = yield* provider.streamEvents.pipe(
3276 Stream.filter((event) => event.type === "turn.completed" && event.turnId === realTurnId),
3277 Stream.runHead,
3278 Effect.forkChild,
3279 );
3280 yield* Effect.yieldNow;
3281 primaryAnalyticsCodex.emit({
3282 type: "turn.completed",
3283 eventId: asEventId("evt-turn-analytics-real-complete"),
3284 provider: CODEX_DRIVER,
3285 createdAt: "2026-01-01T00:00:00.000Z",
3286 threadId,
3287 turnId: realTurnId,
3288 payload: { state: "completed" },
3289 });
3290 yield* Fiber.join(realCompletion);
3291
3292 const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
3293 assert.equal(completed.length, 2);
3294 assert.equal(completed[0]?.properties?.interactionMode, undefined);
3295 assert.equal(completed[0]?.properties?.model, undefined);
3296 assert.deepInclude(completed[1]?.properties ?? {}, {
3297 model: "native-real",
3298 interactionMode: "plan",
3299 });
3300 }),
3301 );
3302
3303 it.effect("defers overlapping terminal analytics until exact request association", () =>
3304 Effect.gen(function* () {
3305 recordedTurnAnalytics.reset();
3306 const provider = yield* ProviderService.ProviderService;
3307 const threadId = asThreadId("thread-turn-analytics-overlap-fast-completion");
3308 const firstStarted = yield* Deferred.make<void>();
3309 const secondStarted = yield* Deferred.make<void>();
3310 const firstRelease = yield* Deferred.make<void>();
3311 const secondRelease = yield* Deferred.make<void>();
3312 const firstTurnId = asTurnId("turn-analytics-overlap-fast-first");
3313 const secondTurnId = asTurnId("turn-analytics-overlap-fast-second");
3314 yield* provider.startSession(threadId, {
3315 provider: CODEX_DRIVER,
3316 providerInstanceId: codexInstanceId,
3317 threadId,
3318 runtimeMode: "full-access",
3319 });
3320 primaryAnalyticsCodex.sendTurn
3321 .mockImplementationOnce(() =>
3322 Effect.gen(function* () {
3323 yield* Deferred.succeed(firstStarted, undefined);
3324 yield* Deferred.await(firstRelease);
3325 return { threadId, turnId: firstTurnId };
3326 }),
3327 )
3328 .mockImplementationOnce(() =>
3329 Effect.gen(function* () {
3330 yield* Deferred.succeed(secondStarted, undefined);
3331 yield* Deferred.await(secondRelease);
3332 return { threadId, turnId: secondTurnId };
3333 }),
3334 );
3335
3336 const runtimeEvents = yield* Stream.take(provider.streamEvents, 4).pipe(
3337 Stream.runDrain,
3338 Effect.forkChild,
3339 );
3340 const firstSend = yield* provider
3341 .sendTurn({
3342 threadId,
3343 input: "first fast completion",
3344 attachments: [],
3345 interactionMode: "default",
3346 modelSelection: createModelSelection(codexInstanceId, "requested-first"),
3347 })
3348 .pipe(Effect.forkChild);
3349 yield* Deferred.await(firstStarted);
3350 yield* advanceTestClock(10);
3351 const secondSend = yield* provider
3352 .sendTurn({
3353 threadId,
3354 input: "second fast completion",
3355 attachments: [],
3356 interactionMode: "plan",
3357 modelSelection: createModelSelection(codexInstanceId, "requested-second"),
3358 })
3359 .pipe(Effect.forkChild);
3360 yield* Deferred.await(secondStarted);
3361 yield* advanceTestClock(20);
3362
3363 for (const [turnId, suffix] of [
3364 [firstTurnId, "first"],
3365 [secondTurnId, "second"],
3366 ] as const) {
3367 primaryAnalyticsCodex.emit({
3368 type: "turn.started",
3369 eventId: asEventId(`evt-turn-analytics-overlap-fast-start-${suffix}`),
3370 provider: CODEX_DRIVER,
3371 createdAt: "2026-01-01T00:00:00.000Z",
3372 threadId,
3373 turnId,
3374 payload: { model: `native-${suffix}`, effort: `native-effort-${suffix}` },
3375 });
3376 primaryAnalyticsCodex.emit({
3377 type: "turn.completed",
3378 eventId: asEventId(`evt-turn-analytics-overlap-fast-complete-${suffix}`),
3379 provider: CODEX_DRIVER,
3380 createdAt: "2026-01-01T00:00:00.000Z",
3381 threadId,
3382 turnId,
3383 payload: { state: "completed" },
3384 });
3385 }
3386 yield* Fiber.join(runtimeEvents);
3387 assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 0);
3388 yield* advanceTestClock(40);
3389
3390 yield* Deferred.succeed(secondRelease, undefined);
3391 yield* Fiber.join(secondSend);
3392 let completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
3393 assert.equal(completed.length, 1);
3394 assert.deepInclude(completed[0]?.properties ?? {}, {
3395 model: "native-second",
3396 effort: "native-effort-second",
3397 interactionMode: "plan",
3398 durationMs: 20,
3399 });
3400
3401 yield* Deferred.succeed(firstRelease, undefined);
3402 yield* Fiber.join(firstSend);
3403 completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
3404 assert.equal(completed.length, 2);
3405 assert.deepInclude(completed[1]?.properties ?? {}, {
3406 model: "native-first",
3407 effort: "native-effort-first",
3408 interactionMode: "default",
3409 durationMs: 30,
3410 });
3411 }),
3412 );
3413
3414 it.effect("cleans pending metadata when a send is canceled", () =>
3415 Effect.gen(function* () {
3416 recordedTurnAnalytics.reset();
3417 const provider = yield* ProviderService.ProviderService;
3418 const threadId = asThreadId("thread-turn-analytics-canceled-send");
3419 const canceledStarted = yield* Deferred.make<void>();
3420 const canceledRelease = yield* Deferred.make<void>();
3421 const nextReturnRelease = yield* Deferred.make<void>();
3422 const nextTurnId = asTurnId("turn-analytics-after-canceled-send");
3423 yield* provider.startSession(threadId, {
3424 provider: CODEX_DRIVER,
3425 providerInstanceId: codexInstanceId,
3426 threadId,
3427 runtimeMode: "full-access",
3428 });
3429 primaryAnalyticsCodex.sendTurn
3430 .mockImplementationOnce(() =>
3431 Effect.gen(function* () {
3432 yield* Deferred.succeed(canceledStarted, undefined);
3433 yield* Deferred.await(canceledRelease);
3434 return { threadId, turnId: asTurnId("turn-analytics-canceled") };
3435 }),
3436 )
3437 .mockImplementationOnce(() =>
3438 Effect.gen(function* () {
3439 primaryAnalyticsCodex.emit({
3440 type: "turn.started",
3441 eventId: asEventId("evt-turn-analytics-after-canceled-start"),
3442 provider: CODEX_DRIVER,
3443 createdAt: "2026-01-01T00:00:00.000Z",
3444 threadId,
3445 turnId: nextTurnId,
3446 payload: { model: "native-next", effort: "high" },
3447 });
3448 primaryAnalyticsCodex.emit({
3449 type: "turn.completed",
3450 eventId: asEventId("evt-turn-analytics-after-canceled-complete"),
3451 provider: CODEX_DRIVER,
3452 createdAt: "2026-01-01T00:00:00.000Z",
3453 threadId,
3454 turnId: nextTurnId,
3455 payload: { state: "completed" },
3456 });
3457 yield* Deferred.await(nextReturnRelease);
3458 return { threadId, turnId: nextTurnId };
3459 }),
3460 );
3461
3462 const canceledSend = yield* provider
3463 .sendTurn({
3464 threadId,
3465 input: "cancel this request",
3466 attachments: [],
3467 interactionMode: "default",
3468 })
3469 .pipe(Effect.forkChild);
3470 yield* Deferred.await(canceledStarted);
3471 yield* Fiber.interrupt(canceledSend);
3472
3473 const terminalReceipt = yield* provider.streamEvents.pipe(
3474 Stream.filter((event) => event.type === "turn.completed"),
3475 Stream.runHead,
3476 Effect.forkChild,
3477 );
3478 yield* Effect.yieldNow;
3479 const nextSend = yield* provider
3480 .sendTurn({
3481 threadId,
3482 input: "measure the next request",
3483 attachments: [],
3484 interactionMode: "plan",
3485 })
3486 .pipe(Effect.forkChild);
3487 const terminal = yield* Fiber.join(terminalReceipt);
3488 assert.equal(terminal._tag, "Some");
3489 assert.equal(nextSend.pollUnsafe(), undefined);
3490 // The canceled request must not hold the completion. The live request
3491 // still does, until its adapter response links it to the turn.
3492 assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 0);
3493 yield* Deferred.succeed(nextReturnRelease, undefined);
3494 const nextTurn = yield* Fiber.join(nextSend);
3495 assert.equal(nextTurn.turnId, nextTurnId);
3496
3497 const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
3498 assert.equal(completed.length, 1);
3499 assert.deepInclude(completed[0]?.properties ?? {}, {
3500 model: "native-next",
3501 effort: "high",
3502 interactionMode: "plan",
3503 });
3504 }),
3505 );
3506
3507 it.effect("bounds deferred completions and drains them after sends are canceled", () =>
3508 Effect.gen(function* () {
3509 recordedTurnAnalytics.reset();
3510 const provider = yield* ProviderService.ProviderService;
3511 const threadId = asThreadId("thread-turn-analytics-bounded-deferred");
3512 const allStarted = yield* Deferred.make<void>();
3513 const sendRelease = yield* Deferred.make<void>();
3514 const turnIds = Array.from({ length: 9 }, (_, index) =>
3515 asTurnId(`turn-analytics-bounded-deferred-${index + 1}`),
3516 );
3517 let startedCount = 0;
3518 yield* provider.startSession(threadId, {
3519 provider: CODEX_DRIVER,
3520 providerInstanceId: codexInstanceId,
3521 threadId,
3522 runtimeMode: "full-access",
3523 });
3524 for (const turnId of turnIds) {
3525 primaryAnalyticsCodex.sendTurn.mockImplementationOnce((input) =>
3526 Effect.gen(function* () {
3527 startedCount += 1;
3528 if (startedCount === turnIds.length) {
3529 yield* Deferred.succeed(allStarted, undefined);
3530 }
3531 yield* Deferred.await(sendRelease);
3532 return { threadId: input.threadId, turnId };
3533 }),
3534 );
3535 }
3536
3537 const runtimeEvents = yield* Stream.take(provider.streamEvents, turnIds.length * 2).pipe(
3538 Stream.runDrain,
3539 Effect.forkChild,
3540 );
3541 yield* Effect.yieldNow;
3542 const sends = [];
3543 for (let index = 0; index < turnIds.length; index += 1) {
3544 sends.push(
3545 yield* provider
3546 .sendTurn({
3547 threadId,
3548 input: `bounded deferred ${index + 1}`,
3549 attachments: [],
3550 interactionMode: index % 2 === 0 ? "default" : "plan",
3551 })
3552 .pipe(Effect.forkChild),
3553 );
3554 }
3555 yield* Deferred.await(allStarted);
3556
3557 for (const [index, turnId] of turnIds.entries()) {
3558 primaryAnalyticsCodex.emit({
3559 type: "turn.started",
3560 eventId: asEventId(`evt-turn-analytics-bounded-deferred-start-${index + 1}`),
3561 provider: CODEX_DRIVER,
3562 createdAt: "2026-01-01T00:00:00.000Z",
3563 threadId,
3564 turnId,
3565 payload: { model: `native-bounded-${index + 1}` },
3566 });
3567 primaryAnalyticsCodex.emit({
3568 type: "turn.completed",
3569 eventId: asEventId(`evt-turn-analytics-bounded-deferred-complete-${index + 1}`),
3570 provider: CODEX_DRIVER,
3571 createdAt: "2026-01-01T00:00:00.000Z",
3572 threadId,
3573 turnId,
3574 payload: { state: "completed" },
3575 });
3576 }
3577 yield* Fiber.join(runtimeEvents);
3578 assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 1);
3579
3580 for (const send of sends) {
3581 yield* Fiber.interrupt(send);
3582 }
3583 assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 9);
3584 }),
3585 );
3586
3587 it.effect("flushes a deferred completion when the session stops", () =>
3588 Effect.gen(function* () {
3589 recordedTurnAnalytics.reset();
3590 const provider = yield* ProviderService.ProviderService;
3591 const threadId = asThreadId("thread-turn-analytics-stop-deferred");
3592 const firstStarted = yield* Deferred.make<void>();
3593 const secondStarted = yield* Deferred.make<void>();
3594 const sendRelease = yield* Deferred.make<void>();
3595 const turnId = asTurnId("turn-analytics-stop-deferred");
3596 yield* provider.startSession(threadId, {
3597 provider: CODEX_DRIVER,
3598 providerInstanceId: codexInstanceId,
3599 threadId,
3600 runtimeMode: "full-access",
3601 });
3602 primaryAnalyticsCodex.sendTurn
3603 .mockImplementationOnce(() =>
3604 Effect.gen(function* () {
3605 yield* Deferred.succeed(firstStarted, undefined);
3606 yield* Deferred.await(sendRelease);
3607 return { threadId, turnId };
3608 }),
3609 )
3610 .mockImplementationOnce(() =>
3611 Effect.gen(function* () {
3612 yield* Deferred.succeed(secondStarted, undefined);
3613 yield* Deferred.await(sendRelease);
3614 return { threadId, turnId: asTurnId("turn-analytics-stop-other") };
3615 }),
3616 );
3617
3618 const firstSend = yield* provider
3619 .sendTurn({ threadId, input: "first", attachments: [] })
3620 .pipe(Effect.forkChild);
3621 yield* Deferred.await(firstStarted);
3622 const secondSend = yield* provider
3623 .sendTurn({ threadId, input: "second", attachments: [] })
3624 .pipe(Effect.forkChild);
3625 yield* Deferred.await(secondStarted);
3626
3627 const runtimeEvents = yield* Stream.take(provider.streamEvents, 2).pipe(
3628 Stream.runDrain,
3629 Effect.forkChild,
3630 );
3631 yield* Effect.yieldNow;
3632 primaryAnalyticsCodex.emit({
3633 type: "turn.started",
3634 eventId: asEventId("evt-turn-analytics-stop-deferred-start"),
3635 provider: CODEX_DRIVER,
3636 createdAt: "2026-01-01T00:00:00.000Z",
3637 threadId,
3638 turnId,
3639 payload: { model: "native-stop" },
3640 });
3641 primaryAnalyticsCodex.emit({
3642 type: "turn.completed",
3643 eventId: asEventId("evt-turn-analytics-stop-deferred-complete"),
3644 provider: CODEX_DRIVER,
3645 createdAt: "2026-01-01T00:00:00.000Z",
3646 threadId,
3647 turnId,
3648 payload: { state: "completed" },
3649 });
3650 yield* Fiber.join(runtimeEvents);
3651 assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 0);
3652
3653 yield* provider.stopSession({ threadId });
3654 const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
3655 assert.equal(completed.length, 1);
3656 assert.equal(completed[0]?.properties?.model, "native-stop");
3657 yield* Fiber.interrupt(firstSend);
3658 yield* Fiber.interrupt(secondSend);
3659 }),
3660 );
3661
3662 it.effect("keeps the first metadata when steering reuses a rerouted turn", () =>
3663 Effect.gen(function* () {
3664 recordedTurnAnalytics.reset();
3665 const provider = yield* ProviderService.ProviderService;
3666 const threadId = asThreadId("thread-turn-analytics-steering");
3667 yield* provider.startSession(threadId, {
3668 provider: CODEX_DRIVER,
3669 providerInstanceId: codexInstanceId,
3670 threadId,
3671 runtimeMode: "auto",
3672 });
3673 const firstTurn = yield* provider.sendTurn({
3674 threadId,
3675 input: "start",
3676 attachments: [],
3677 interactionMode: "default",
3678 modelSelection: createModelSelection(codexInstanceId, "gpt-5.6-sol", [
3679 { id: "reasoningEffort", value: "high" },
3680 ]),
3681 });
3682 yield* advanceTestClock(10);
3683
3684 const reroutedEvent = yield* Stream.take(provider.streamEvents, 1).pipe(
3685 Stream.runDrain,
3686 Effect.forkChild,
3687 );
3688 yield* Effect.yieldNow;
3689 primaryAnalyticsCodex.emit({
3690 type: "model.rerouted",
3691 eventId: asEventId("evt-turn-analytics-rerouted"),
3692 provider: CODEX_DRIVER,
3693 createdAt: "2026-01-01T00:00:00.000Z",
3694 threadId,
3695 turnId: firstTurn.turnId,
3696 payload: {
3697 fromModel: "gpt-5.6-sol",
3698 toModel: "gpt-5.6-terra",
3699 reason: "capacity",
3700 },
3701 });
3702 yield* Fiber.join(reroutedEvent);
3703 yield* advanceTestClock(15);
3704
3705 const steeredTurn = yield* provider.sendTurn({
3706 threadId,
3707 input: "steer",
3708 attachments: [],
3709 interactionMode: "plan",
3710 modelSelection: createModelSelection(codexInstanceId, "gpt-5.6-terra", [
3711 { id: "reasoningEffort", value: "low" },
3712 ]),
3713 });
3714 assert.equal(steeredTurn.turnId, firstTurn.turnId);
3715 yield* advanceTestClock(20);
3716
3717 const completedEvent = yield* Stream.take(provider.streamEvents, 1).pipe(
3718 Stream.runDrain,
3719 Effect.forkChild,
3720 );
3721 yield* Effect.yieldNow;
3722 primaryAnalyticsCodex.emit({
3723 type: "turn.completed",
3724 eventId: asEventId("evt-turn-analytics-steered-complete"),
3725 provider: CODEX_DRIVER,
3726 createdAt: "2026-01-01T00:00:00.000Z",
3727 threadId,
3728 turnId: firstTurn.turnId,
3729 payload: {
3730 state: "completed",
3731 tokenUsage: {
3732 usageStatus: "complete",
3733 usageScope: "main_agent",
3734 inputTokens: 500,
3735 outputTokens: 100,
3736 hasSubagents: false,
3737 },
3738 },
3739 });
3740 yield* Fiber.join(completedEvent);
3741
3742 const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
3743 assert.equal(completed.length, 1);
3744 assert.equal(completed[0]?.properties?.model, "gpt-5.6-sol");
3745 assert.equal(completed[0]?.properties?.effort, "high");
3746 assert.equal(completed[0]?.properties?.interactionMode, "default");
3747 assert.equal(completed[0]?.properties?.mixedModels, true);
3748 assert.equal(completed[0]?.properties?.durationMs, 45);
3749 }),
3750 );
3751
3752 it.effect("bounds active metadata while preserving recent delayed completions", () =>
3753 Effect.gen(function* () {
3754 recordedTurnAnalytics.reset();
3755 const provider = yield* ProviderService.ProviderService;
3756 const threadId = asThreadId("thread-turn-analytics-bounded-active");
3757 const runtimeEvents = yield* Stream.take(provider.streamEvents, 12).pipe(
3758 Stream.runDrain,
3759 Effect.forkChild,
3760 );
3761 yield* Effect.yieldNow;
3762
3763 for (let index = 1; index <= 10; index += 1) {
3764 primaryAnalyticsCodex.emit({
3765 type: "turn.started",
3766 eventId: asEventId(`evt-turn-analytics-bounded-start-${index}`),
3767 provider: CODEX_DRIVER,
3768 createdAt: "2026-01-01T00:00:00.000Z",
3769 threadId,
3770 turnId: asTurnId(`turn-analytics-bounded-${index}`),
3771 payload: { model: `model-${index}` },
3772 });
3773 }
3774 for (const index of [3, 1]) {
3775 primaryAnalyticsCodex.emit({
3776 type: "turn.completed",
3777 eventId: asEventId(`evt-turn-analytics-bounded-complete-${index}`),
3778 provider: CODEX_DRIVER,
3779 createdAt: "2026-01-01T00:00:00.000Z",
3780 threadId,
3781 turnId: asTurnId(`turn-analytics-bounded-${index}`),
3782 payload: { state: "completed" },
3783 });
3784 }
3785 yield* Fiber.join(runtimeEvents);
3786
3787 const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
3788 assert.equal(completed.length, 2);
3789 assert.equal(completed[0]?.properties?.model, "model-3");
3790 assert.notProperty(completed[1]?.properties ?? {}, "model");
3791 }),
3792 );
3793
3794 it.effect("separates provider instances and omits unavailable counts", () =>
3795 Effect.gen(function* () {
3796 recordedTurnAnalytics.reset();
3797 const provider = yield* ProviderService.ProviderService;
3798 const threadId = asThreadId("thread-turn-analytics-instances");
3799 const turnId = asTurnId("turn-shared-between-instances");
3800 const runtimeEvents = yield* Stream.take(provider.streamEvents, 2).pipe(
3801 Stream.runDrain,
3802 Effect.forkChild,
3803 );
3804 yield* Effect.yieldNow;
3805 const event: LegacyProviderRuntimeEvent = {
3806 type: "turn.completed",
3807 eventId: asEventId("evt-turn-analytics-primary-instance"),
3808 provider: CODEX_DRIVER,
3809 createdAt: "2026-01-01T00:00:00.000Z",
3810 threadId,
3811 turnId,
3812 payload: {
3813 state: "completed",
3814 tokenUsage: {
3815 usageStatus: "unavailable",
3816 usageScope: "main_agent",
3817 hasSubagents: false,
3818 },
3819 },
3820 };
3821 primaryAnalyticsCodex.emit(event);
3822 secondaryAnalyticsCodex.emit({
3823 ...event,
3824 eventId: asEventId("evt-turn-analytics-secondary-instance"),
3825 });
3826 yield* Fiber.join(runtimeEvents);
3827
3828 const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
3829 assert.equal(completed.length, 2);
3830 for (const entry of completed) {
3831 assert.deepEqual(entry.properties, {
3832 provider: CODEX_DRIVER,
3833 terminalStatus: "completed",
3834 usageStatus: "unavailable",
3835 usageScope: "main_agent",
3836 hasSubagents: false,
3837 });
3838 }
3839 }),
3840 );
3841
3842 it.effect("records known token counts for an interrupted turn", () =>
3843 Effect.gen(function* () {
3844 recordedTurnAnalytics.reset();
3845 const provider = yield* ProviderService.ProviderService;
3846 const threadId = asThreadId("thread-turn-analytics-interrupted");
3847 yield* provider.startSession(threadId, {
3848 provider: CODEX_DRIVER,
3849 providerInstanceId: codexInstanceId,
3850 threadId,
3851 runtimeMode: "approval-required",
3852 });
3853 const turn = yield* provider.sendTurn({
3854 threadId,
3855 input: "stop after some work",
3856 attachments: [],
3857 });
3858
3859 const runtimeEvent = yield* Stream.take(provider.streamEvents, 1).pipe(
3860 Stream.runDrain,
3861 Effect.forkChild,
3862 );
3863 yield* Effect.yieldNow;
3864 primaryAnalyticsCodex.emit({
3865 type: "turn.aborted",
3866 eventId: asEventId("evt-turn-analytics-interrupted"),
3867 provider: CODEX_DRIVER,
3868 createdAt: "2026-01-01T00:00:00.000Z",
3869 threadId,
3870 turnId: turn.turnId,
3871 payload: {
3872 reason: "Interrupted by user",
3873 tokenUsage: {
3874 usageStatus: "partial",
3875 usageScope: "main_agent",
3876 inputTokens: 120,
3877 outputTokens: 30,
3878 hasSubagents: true,
3879 },
3880 },
3881 });
3882 yield* Fiber.join(runtimeEvent);
3883
3884 const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed");
3885 assert.equal(completed.length, 1);
3886 assert.equal(completed[0]?.properties?.terminalStatus, "interrupted");
3887 assert.equal(completed[0]?.properties?.usageStatus, "partial");
3888 assert.equal(completed[0]?.properties?.inputTokens, 120);
3889 assert.equal(completed[0]?.properties?.outputTokens, 30);
3890 assert.equal(completed[0]?.properties?.hasSubagents, true);
3891 }),
3892 );
3893});
3894
2662const validation = makeProviderServiceLayer();3895const validation = makeProviderServiceLayer();
2663validation.layer("ProviderServiceLive validation", (it) => {3896validation.layer("ProviderServiceLive validation", (it) => {
2664 it.effect("rejects citation-expanded input over the provider character limit", () =>3897 it.effect("rejects citation-expanded input over the provider character limit", () =>
apps/server/src/provider/Layers/ProviderService.ts+484-1
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31} from "@t3tools/contracts";31} from "@t3tools/contracts";
32import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";32import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations";
33import { causeErrorTag } from "@t3tools/shared/observability";33import { causeErrorTag } from "@t3tools/shared/observability";
34import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
34import * as DateTime from "effect/DateTime";35import * as DateTime from "effect/DateTime";
35import * as Deferred from "effect/Deferred";36import * as Deferred from "effect/Deferred";
36import * as Effect from "effect/Effect";37import * as Effect from "effect/Effect";
...@@ -96,6 +97,75 @@ export interface ProviderServiceLiveOptions {...@@ -96,6 +97,75 @@ export interface ProviderServiceLiveOptions {
96 readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread;97 readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread;
97}98}
9899
100interface TurnAnalyticsMetadata {
101 readonly requestId: number;
102 readonly provider: ProviderDriverKind;
103 readonly startedAtMs: number;
104 readonly mixedModels: boolean;
105 readonly model?: string;
106 readonly effort?: string;
107 readonly interactionMode?: string;
108 readonly runtimeMode?: string;
109}
110
111interface ActiveTurnAnalytics {
112 readonly metadata: TurnAnalyticsMetadata;
113 readonly requestAssociated: boolean;
114}
115
116interface DeferredTurnAnalyticsCompletion {
117 readonly completionKey: string;
118 readonly completedAtMs: number;
119 readonly terminalProperties: Readonly<Record<string, unknown>>;
120}
121
122interface TurnAnalyticsSessionState {
123 readonly pendingByRequestId: Map<number, TurnAnalyticsMetadata>;
124 readonly activeByTurnId: Map<string, ActiveTurnAnalytics>;
125 readonly deferredCompletionsByTurnId: Map<string, DeferredTurnAnalyticsCompletion>;
126}
127
128interface TurnAnalyticsState {
129 readonly sessions: Map<string, TurnAnalyticsSessionState>;
130 readonly completedKeys: Set<string>;
131 readonly completedOrder: Array<string>;
132}
133
134const MAX_COMPLETED_TURN_ANALYTICS_KEYS = 512;
135const MAX_ACTIVE_TURN_ANALYTICS_PER_SESSION = 8;
136
137function setActiveTurnAnalytics(
138 session: TurnAnalyticsSessionState,
139 turnId: string,
140 active: ActiveTurnAnalytics,
141): void {
142 session.activeByTurnId.set(turnId, active);
143 while (session.activeByTurnId.size > MAX_ACTIVE_TURN_ANALYTICS_PER_SESSION) {
144 const oldestTurnId = session.activeByTurnId.keys().next().value;
145 if (oldestTurnId === undefined) return;
146 session.activeByTurnId.delete(oldestTurnId);
147 }
148}
149
150function turnAnalyticsSessionKey(instanceId: ProviderInstanceId, threadId: ThreadId): string {
151 return `${String(instanceId)}\u0000${String(threadId)}`;
152}
153
154function turnAnalyticsCompletionKey(
155 instanceId: ProviderInstanceId,
156 threadId: ThreadId,
157 turnId: string,
158): string {
159 return `${turnAnalyticsSessionKey(instanceId, threadId)}\u0000${turnId}`;
160}
161
162function turnEffort(modelSelection: ProviderSendTurnInput["modelSelection"]): string | undefined {
163 return (
164 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
165 getModelSelectionStringOptionValue(modelSelection, "effort")
166 );
167}
168
99type ProviderServiceMethod<Name extends keyof ProviderService.ProviderService["Service"]> =169type ProviderServiceMethod<Name extends keyof ProviderService.ProviderService["Service"]> =
100 ProviderService.ProviderService["Service"][Name];170 ProviderService.ProviderService["Service"][Name];
101171
...@@ -258,7 +328,366 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -258,7 +328,366 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
258 yield* Deferred.succeed(pending.completion, terminal);328 yield* Deferred.succeed(pending.completion, terminal);
259 return true;329 return true;
260 });330 });
331 const turnAnalytics = yield* Ref.make<TurnAnalyticsState>({
332 sessions: new Map(),
333 completedKeys: new Set(),
334 completedOrder: [],
335 });
336 let turnAnalyticsRequestId = 0;
261 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);337 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
338
339 const finishTurnAnalytics = (
340 state: TurnAnalyticsState,
341 input: {
342 readonly sessionKey: string;
343 readonly turnId: string;
344 readonly completion: DeferredTurnAnalyticsCompletion;
345 },
346 ): Readonly<Record<string, unknown>> | undefined => {
347 if (state.completedKeys.has(input.completion.completionKey)) return undefined;
348 state.completedKeys.add(input.completion.completionKey);
349 state.completedOrder.push(input.completion.completionKey);
350 while (state.completedOrder.length > MAX_COMPLETED_TURN_ANALYTICS_KEYS) {
351 const expired = state.completedOrder.shift();
352 if (expired) state.completedKeys.delete(expired);
353 }
354
355 const session = state.sessions.get(input.sessionKey);
356 const metadata = session?.activeByTurnId.get(input.turnId)?.metadata;
357 session?.activeByTurnId.delete(input.turnId);
358 session?.deferredCompletionsByTurnId.delete(input.turnId);
359 if (
360 session &&
361 session.activeByTurnId.size === 0 &&
362 session.pendingByRequestId.size === 0 &&
363 session.deferredCompletionsByTurnId.size === 0
364 ) {
365 state.sessions.delete(input.sessionKey);
366 }
367
368 return {
369 ...input.completion.terminalProperties,
370 ...(metadata?.model ? { model: metadata.model } : {}),
371 ...(metadata?.effort ? { effort: metadata.effort } : {}),
372 ...(metadata?.interactionMode ? { interactionMode: metadata.interactionMode } : {}),
373 ...(metadata?.runtimeMode ? { runtimeMode: metadata.runtimeMode } : {}),
374 ...(metadata ? { mixedModels: metadata.mixedModels } : {}),
375 ...(metadata
376 ? { durationMs: Math.max(0, input.completion.completedAtMs - metadata.startedAtMs) }
377 : {}),
378 };
379 };
380
381 const recordCompletedTurnProperties = (
382 properties: ReadonlyArray<Readonly<Record<string, unknown>>>,
383 ) =>
384 Effect.forEach(properties, (entry) => analytics.record("provider.turn.completed", entry), {
385 discard: true,
386 });
387
388 const clearTurnAnalyticsSession = (providerInstanceId: ProviderInstanceId, threadId: ThreadId) =>
389 Effect.gen(function* () {
390 const properties = yield* Ref.modify(turnAnalytics, (state) => {
391 const sessionKey = turnAnalyticsSessionKey(providerInstanceId, threadId);
392 const session = state.sessions.get(sessionKey);
393 const completed: Array<Readonly<Record<string, unknown>>> = [];
394 if (session) {
395 for (const [turnId, completion] of session.deferredCompletionsByTurnId) {
396 const entry = finishTurnAnalytics(state, { sessionKey, turnId, completion });
397 if (entry) completed.push(entry);
398 }
399 }
400 state.sessions.delete(sessionKey);
401 return [completed, state] as const;
402 });
403 yield* recordCompletedTurnProperties(properties);
404 });
405
406 const beginTurnAnalytics = Effect.fn("beginTurnAnalytics")(function* (input: {
407 readonly providerInstanceId: ProviderInstanceId;
408 readonly provider: ProviderDriverKind;
409 readonly threadId: ThreadId;
410 readonly modelSelection: ProviderSendTurnInput["modelSelection"];
411 readonly interactionMode: ProviderSendTurnInput["interactionMode"];
412 readonly runtimeMode: string | undefined;
413 }) {
414 const startedAtMs = DateTime.toEpochMillis(yield* DateTime.now);
415 turnAnalyticsRequestId += 1;
416 const requestId = turnAnalyticsRequestId;
417 const effort = turnEffort(input.modelSelection);
418 return yield* Ref.modify(turnAnalytics, (state) => {
419 const key = turnAnalyticsSessionKey(input.providerInstanceId, input.threadId);
420 const session = state.sessions.get(key) ?? {
421 pendingByRequestId: new Map(),
422 activeByTurnId: new Map(),
423 deferredCompletionsByTurnId: new Map(),
424 };
425 const metadata: TurnAnalyticsMetadata = {
426 provider: input.provider,
427 startedAtMs,
428 mixedModels: false,
429 requestId,
430 ...(input.modelSelection?.model ? { model: input.modelSelection.model } : {}),
431 ...(effort ? { effort } : {}),
432 ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
433 ...(input.runtimeMode ? { runtimeMode: input.runtimeMode } : {}),
434 };
435 session.pendingByRequestId.set(requestId, metadata);
436 state.sessions.set(key, session);
437 return [metadata, state] as const;
438 });
439 });
440
441 const clearPendingTurnAnalytics = (input: {
442 readonly providerInstanceId: ProviderInstanceId;
443 readonly threadId: ThreadId;
444 readonly requestId: number;
445 }) =>
446 Effect.gen(function* () {
447 const properties = yield* Ref.modify(turnAnalytics, (state) => {
448 const sessionKey = turnAnalyticsSessionKey(input.providerInstanceId, input.threadId);
449 const session = state.sessions.get(sessionKey);
450 if (!session)
451 return [[] as ReadonlyArray<Readonly<Record<string, unknown>>>, state] as const;
452 session.pendingByRequestId.delete(input.requestId);
453 const completed: Array<Readonly<Record<string, unknown>>> = [];
454 if (session.pendingByRequestId.size === 0) {
455 for (const [turnId, completion] of session.deferredCompletionsByTurnId) {
456 const entry = finishTurnAnalytics(state, { sessionKey, turnId, completion });
457 if (entry) completed.push(entry);
458 }
459 }
460 if (
461 session.activeByTurnId.size === 0 &&
462 session.pendingByRequestId.size === 0 &&
463 session.deferredCompletionsByTurnId.size === 0
464 ) {
465 state.sessions.delete(sessionKey);
466 }
467 return [completed, state] as const;
468 });
469 yield* recordCompletedTurnProperties(properties);
470 });
471
472 const associateTurnAnalytics = (input: {
473 readonly providerInstanceId: ProviderInstanceId;
474 readonly threadId: ThreadId;
475 readonly turnId: string;
476 readonly metadata: TurnAnalyticsMetadata;
477 }) =>
478 Effect.gen(function* () {
479 const properties = yield* Ref.modify(turnAnalytics, (state) => {
480 const completionKey = turnAnalyticsCompletionKey(
481 input.providerInstanceId,
482 input.threadId,
483 input.turnId,
484 );
485 const sessionKey = turnAnalyticsSessionKey(input.providerInstanceId, input.threadId);
486 const session = state.sessions.get(sessionKey);
487 if (!session || state.completedKeys.has(completionKey)) {
488 if (session) {
489 session.pendingByRequestId.delete(input.metadata.requestId);
490 if (
491 session.activeByTurnId.size === 0 &&
492 session.pendingByRequestId.size === 0 &&
493 session.deferredCompletionsByTurnId.size === 0
494 ) {
495 state.sessions.delete(sessionKey);
496 }
497 }
498 return [[] as ReadonlyArray<Readonly<Record<string, unknown>>>, state] as const;
499 }
500 const existing = session.activeByTurnId.get(input.turnId);
501 const existingMetadata = existing?.metadata;
502 const base = existing?.requestAssociated ? existing.metadata : input.metadata;
503 setActiveTurnAnalytics(session, input.turnId, {
504 requestAssociated: true,
505 metadata: {
506 ...base,
507 ...(existingMetadata?.model
508 ? { model: existingMetadata.model }
509 : input.metadata.model
510 ? { model: input.metadata.model }
511 : {}),
512 ...(existingMetadata?.effort
513 ? { effort: existingMetadata.effort }
514 : input.metadata.effort
515 ? { effort: input.metadata.effort }
516 : {}),
517 ...(base?.interactionMode
518 ? {}
519 : input.metadata.interactionMode
520 ? { interactionMode: input.metadata.interactionMode }
521 : {}),
522 ...(base?.runtimeMode
523 ? {}
524 : input.metadata.runtimeMode
525 ? { runtimeMode: input.metadata.runtimeMode }
526 : {}),
527 mixedModels: existingMetadata?.mixedModels ?? input.metadata.mixedModels,
528 },
529 });
530 session.pendingByRequestId.delete(input.metadata.requestId);
531 const completion = session.deferredCompletionsByTurnId.get(input.turnId);
532 const completed = completion
533 ? finishTurnAnalytics(state, {
534 sessionKey,
535 turnId: input.turnId,
536 completion,
537 })
538 : undefined;
539 return [completed ? [completed] : [], state] as const;
540 });
541 yield* recordCompletedTurnProperties(properties);
542 });
543
544 const observeTurnStartedForAnalytics = Effect.fn("observeTurnStartedForAnalytics")(function* (
545 source: { readonly instanceId: ProviderInstanceId; readonly provider: ProviderDriverKind },
546 event: Extract<ProviderRuntimeEvent, { readonly type: "turn.started" }>,
547 ) {
548 if (!event.turnId) return;
549 const observedAtMs = DateTime.toEpochMillis(yield* DateTime.now);
550 yield* Ref.update(turnAnalytics, (state) => {
551 const completionKey = turnAnalyticsCompletionKey(
552 source.instanceId,
553 event.threadId,
554 String(event.turnId),
555 );
556 if (state.completedKeys.has(completionKey)) return state;
557 const sessionKey = turnAnalyticsSessionKey(source.instanceId, event.threadId);
558 const session = state.sessions.get(sessionKey) ?? {
559 pendingByRequestId: new Map(),
560 activeByTurnId: new Map(),
561 deferredCompletionsByTurnId: new Map(),
562 };
563 // A start never binds send metadata on its own. Claude can start a
564 // synthetic turn for leftover agent output while sendTurn is still
565 // preparing the real turn, so only the adapter's sendTurn response
566 // links a request to its turn. Completions that land before that
567 // response wait in deferredCompletionsByTurnId.
568 const current = session.activeByTurnId.get(String(event.turnId));
569 const metadata: TurnAnalyticsMetadata = {
570 ...(current?.metadata ?? {
571 requestId: ++turnAnalyticsRequestId,
572 provider: source.provider,
573 startedAtMs: observedAtMs,
574 mixedModels: false,
575 }),
576 ...(event.payload.model ? { model: event.payload.model } : {}),
577 ...(event.payload.effort ? { effort: event.payload.effort } : {}),
578 };
579 setActiveTurnAnalytics(session, String(event.turnId), {
580 metadata,
581 requestAssociated: current?.requestAssociated ?? false,
582 });
583 state.sessions.set(sessionKey, session);
584 return state;
585 });
586 });
587
588 const observeModelReroutedForAnalytics = (
589 source: { readonly instanceId: ProviderInstanceId },
590 event: Extract<ProviderRuntimeEvent, { readonly type: "model.rerouted" }>,
591 ) =>
592 Ref.update(turnAnalytics, (state) => {
593 const session = state.sessions.get(
594 turnAnalyticsSessionKey(source.instanceId, event.threadId),
595 );
596 if (!session) return state;
597 if (event.turnId) {
598 const current = session.activeByTurnId.get(String(event.turnId));
599 if (current) {
600 session.activeByTurnId.set(String(event.turnId), {
601 ...current,
602 metadata: { ...current.metadata, mixedModels: true },
603 });
604 }
605 } else {
606 for (const [turnId, current] of session.activeByTurnId) {
607 session.activeByTurnId.set(turnId, {
608 ...current,
609 metadata: { ...current.metadata, mixedModels: true },
610 });
611 }
612 }
613 return state;
614 });
615
616 const recordTurnCompletedAnalytics = Effect.fn("recordTurnCompletedAnalytics")(function* (
617 source: { readonly instanceId: ProviderInstanceId; readonly provider: ProviderDriverKind },
618 event: Extract<ProviderRuntimeEvent, { readonly type: "turn.completed" | "turn.aborted" }>,
619 ) {
620 if (!event.turnId) return;
621 const completedAtMs = DateTime.toEpochMillis(yield* DateTime.now);
622 const tokenUsage = event.payload.tokenUsage;
623 const completion: DeferredTurnAnalyticsCompletion = {
624 completionKey: turnAnalyticsCompletionKey(
625 source.instanceId,
626 event.threadId,
627 String(event.turnId),
628 ),
629 completedAtMs,
630 terminalProperties: {
631 provider: source.provider,
632 terminalStatus:
633 event.type === "turn.completed"
634 ? event.payload.state
635 : event.payload.reason.toLowerCase().includes("interrupt")
636 ? "interrupted"
637 : "cancelled",
638 usageStatus: tokenUsage?.usageStatus ?? "unavailable",
639 usageScope: tokenUsage?.usageScope ?? "main_agent",
640 ...(tokenUsage ? { hasSubagents: tokenUsage.hasSubagents } : {}),
641 ...(tokenUsage?.inputTokens !== undefined ? { inputTokens: tokenUsage.inputTokens } : {}),
642 ...(tokenUsage?.cachedInputTokens !== undefined
643 ? { cachedInputTokens: tokenUsage.cachedInputTokens }
644 : {}),
645 ...(tokenUsage?.cacheCreationTokens !== undefined
646 ? { cacheCreationTokens: tokenUsage.cacheCreationTokens }
647 : {}),
648 ...(tokenUsage?.outputTokens !== undefined
649 ? { outputTokens: tokenUsage.outputTokens }
650 : {}),
651 ...(tokenUsage?.reasoningTokens !== undefined
652 ? { reasoningTokens: tokenUsage.reasoningTokens }
653 : {}),
654 },
655 };
656 const properties = yield* Ref.modify(turnAnalytics, (state) => {
657 if (state.completedKeys.has(completion.completionKey)) {
658 return [[] as ReadonlyArray<Readonly<Record<string, unknown>>>, state] as const;
659 }
660 const turnId = String(event.turnId);
661 const sessionKey = turnAnalyticsSessionKey(source.instanceId, event.threadId);
662 const session = state.sessions.get(sessionKey);
663 if (session?.deferredCompletionsByTurnId.has(turnId)) {
664 return [[] as ReadonlyArray<Readonly<Record<string, unknown>>>, state] as const;
665 }
666 const active = session?.activeByTurnId.get(turnId);
667 const needsAssociation =
668 (session?.pendingByRequestId.size ?? 0) > 0 && active?.requestAssociated !== true;
669 if (!session || !needsAssociation) {
670 const completed = finishTurnAnalytics(state, { sessionKey, turnId, completion });
671 return [completed ? [completed] : [], state] as const;
672 }
673
674 session.deferredCompletionsByTurnId.set(turnId, completion);
675 const completed: Array<Readonly<Record<string, unknown>>> = [];
676 while (session.deferredCompletionsByTurnId.size > MAX_ACTIVE_TURN_ANALYTICS_PER_SESSION) {
677 const oldest = session.deferredCompletionsByTurnId.entries().next().value;
678 if (!oldest) break;
679 const [oldestTurnId, oldestCompletion] = oldest;
680 const entry = finishTurnAnalytics(state, {
681 sessionKey,
682 turnId: oldestTurnId,
683 completion: oldestCompletion,
684 });
685 if (entry) completed.push(entry);
686 }
687 return [completed, state] as const;
688 });
689 yield* recordCompletedTurnProperties(properties);
690 });
262 /**691 /**
263 * Attach the `t3-code` MCP server to the session that is about to start.692 * Attach the `t3-code` MCP server to the session that is about to start.
264 *693 *
...@@ -437,6 +866,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -437,6 +866,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
437 provider: canonicalEvent.provider,866 provider: canonicalEvent.provider,
438 eventType: canonicalEvent.type,867 eventType: canonicalEvent.type,
439 });868 });
869 if (canonicalEvent.type === "turn.started") {
870 yield* observeTurnStartedForAnalytics(source, canonicalEvent);
871 } else if (canonicalEvent.type === "model.rerouted") {
872 yield* observeModelReroutedForAnalytics(source, canonicalEvent);
873 } else if (
874 canonicalEvent.type === "turn.completed" ||
875 canonicalEvent.type === "turn.aborted"
876 ) {
877 yield* recordTurnCompletedAnalytics(source, canonicalEvent);
878 } else if (canonicalEvent.type === "session.exited") {
879 yield* clearTurnAnalyticsSession(source.instanceId, canonicalEvent.threadId);
880 }
440 if (881 if (
441 isCompactedEvent(canonicalEvent) &&882 isCompactedEvent(canonicalEvent) &&
442 timedOutNativeCompactions.delete(canonicalEvent.threadId)883 timedOutNativeCompactions.delete(canonicalEvent.threadId)
...@@ -790,6 +1231,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -790,6 +1231,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
790 "provider.cwd.effective": effectiveCwd ?? "",1231 "provider.cwd.effective": effectiveCwd ?? "",
791 });1232 });
792 const adapter = yield* registry.getByInstance(resolvedInstanceId);1233 const adapter = yield* registry.getByInstance(resolvedInstanceId);
1234 yield* clearTurnAnalyticsSession(resolvedInstanceId, threadId);
793 yield* prepareMcpSession(threadId, resolvedInstanceId);1235 yield* prepareMcpSession(threadId, resolvedInstanceId);
794 const session = yield* adapter1236 const session = yield* adapter
795 .startSession({1237 .startSession({
...@@ -953,7 +1395,35 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -953,7 +1395,35 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
953 // rather than issuing a new one: sessions that go a long time between1395 // rather than issuing a new one: sessions that go a long time between
954 // browser tool calls used to lose the toolkit outright.1396 // browser tool calls used to lose the toolkit outright.
955 yield* McpSessionRegistry.touchActiveMcpThread(input.threadId);1397 yield* McpSessionRegistry.touchActiveMcpThread(input.threadId);
956 const turn = yield* routed.adapter.sendTurn(input);1398 const analyticsModelSelection =
1399 input.modelSelection?.instanceId === routed.instanceId ? input.modelSelection : undefined;
1400 const turn = yield* Effect.acquireUseRelease(
1401 beginTurnAnalytics({
1402 providerInstanceId: routed.instanceId,
1403 provider: routed.adapter.provider,
1404 threadId: input.threadId,
1405 modelSelection: analyticsModelSelection,
1406 interactionMode: input.interactionMode,
1407 runtimeMode: routed.runtimeMode,
1408 }),
1409 (turnMetadata) =>
1410 Effect.gen(function* () {
1411 const turn = yield* routed.adapter.sendTurn(input);
1412 yield* associateTurnAnalytics({
1413 providerInstanceId: routed.instanceId,
1414 threadId: input.threadId,
1415 turnId: String(turn.turnId),
1416 metadata: turnMetadata,
1417 });
1418 return turn;
1419 }),
1420 (turnMetadata) =>
1421 clearPendingTurnAnalytics({
1422 providerInstanceId: routed.instanceId,
1423 threadId: input.threadId,
1424 requestId: turnMetadata.requestId,
1425 }),
1426 );
957 yield* directory.upsert({1427 yield* directory.upsert({
958 threadId: input.threadId,1428 threadId: input.threadId,
959 provider: routed.adapter.provider,1429 provider: routed.adapter.provider,
...@@ -1254,6 +1724,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1254,6 +1724,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1254 yield* settleCompaction(input.threadId, pendingCompaction, "turn.aborted");1724 yield* settleCompaction(input.threadId, pendingCompaction, "turn.aborted");
1255 }1725 }
1256 timedOutNativeCompactions.delete(input.threadId);1726 timedOutNativeCompactions.delete(input.threadId);
1727 yield* clearTurnAnalyticsSession(routed.instanceId, input.threadId);
1257 yield* clearMcpSession(input.threadId);1728 yield* clearMcpSession(input.threadId);
1258 yield* directory.upsert({1729 yield* directory.upsert({
1259 threadId: input.threadId,1730 threadId: input.threadId,
...@@ -1471,6 +1942,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1471,6 +1942,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1471 );1942 );
14721943
1473 const runStopAll = Effect.fn("runStopAll")(function* () {1944 const runStopAll = Effect.fn("runStopAll")(function* () {
1945 const properties = yield* Ref.modify(turnAnalytics, (state) => {
1946 const completed: Array<Readonly<Record<string, unknown>>> = [];
1947 for (const [sessionKey, session] of state.sessions) {
1948 for (const [turnId, completion] of session.deferredCompletionsByTurnId) {
1949 const entry = finishTurnAnalytics(state, { sessionKey, turnId, completion });
1950 if (entry) completed.push(entry);
1951 }
1952 }
1953 state.sessions.clear();
1954 return [completed, state] as const;
1955 });
1956 yield* recordCompletedTurnProperties(properties);
1474 const threadIds = yield* directory.listThreadIds();1957 const threadIds = yield* directory.listThreadIds();
1475 const currentAdapters = yield* getAdapterEntries;1958 const currentAdapters = yield* getAdapterEntries;
1476 const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) =>1959 const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) =>
apps/server/src/telemetry/AnalyticsService.test.ts+43
...@@ -148,4 +148,47 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {...@@ -148,4 +148,47 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
148 );148 );
149 }),149 }),
150 );150 );
151
152 it.effect("does not send batch requests when telemetry is disabled", () =>
153 Effect.gen(function* () {
154 const capturedPaths: Array<string> = [];
155 const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), {
156 prefix: "t3-telemetry-disabled-",
157 });
158 const telemetryLayer = AnalyticsService.layer.pipe(Layer.provideMerge(serverConfigLayer));
159 const configLayer = ConfigProvider.layer(
160 ConfigProvider.fromUnknown({
161 T3CODE_TELEMETRY_ENABLED: false,
162 T3CODE_POSTHOG_KEY: "phc_test_key",
163 T3CODE_POSTHOG_HOST: "http://localhost",
164 }),
165 );
166 const batchServerLayer = HttpServer.serve(
167 Effect.gen(function* () {
168 const request = yield* HttpServerRequest.HttpServerRequest;
169 capturedPaths.push(request.url);
170 return HttpServerResponse.jsonUnsafe({});
171 }),
172 );
173 const runtimeLayer = telemetryLayer.pipe(
174 Layer.provide(configLayer),
175 Layer.provide(
176 Layer.mergeAll(
177 Layer.succeed(HostProcessPlatform, "linux"),
178 Layer.succeed(HostProcessArchitecture, "arm64"),
179 ),
180 ),
181 Layer.provideMerge(NodeHttpServer.layerTest),
182 );
183
184 yield* Effect.gen(function* () {
185 yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped);
186 const analytics = yield* AnalyticsService.AnalyticsService;
187 yield* analytics.record("test.disabled", { index: 1 });
188 yield* analytics.flush;
189 }).pipe(Effect.provide(runtimeLayer));
190
191 assert.deepEqual(capturedPaths, []);
192 }),
193 );
151});194});
docs/README.md+1
...@@ -7,6 +7,7 @@...@@ -7,6 +7,7 @@
7- [Keyboard shortcuts](./user/keybindings.md)7- [Keyboard shortcuts](./user/keybindings.md)
8- [Organizing threads](./user/thread-sidebar.md)8- [Organizing threads](./user/thread-sidebar.md)
9- [Review usage](./user/usage.md)9- [Review usage](./user/usage.md)
10- [Anonymous usage data](./user/telemetry.md)
10- [Customize a project icon](./user/project-settings.md)11- [Customize a project icon](./user/project-settings.md)
11- [Mobile appearance](./user/mobile-appearance.md)12- [Mobile appearance](./user/mobile-appearance.md)
12- [Environment themes](./user/environment-theme.md)13- [Environment themes](./user/environment-theme.md)
docs/internals/product-analytics.md+68-3
...@@ -23,6 +23,54 @@ time....@@ -23,6 +23,54 @@ time.
23client metadata because a provider turn can continue after the requesting23client metadata because a provider turn can continue after the requesting
24client disconnects.24client disconnects.
2525
26## Provider turn events
27
28`provider.turn.completed` records one anonymous event when a provider emits a
29canonical completed or aborted turn. The event reports normalized main-agent
30token counts when the provider supplies enough data.
31
32| Property | Values and meaning |
33| --------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
34| `provider` | Provider driver, such as `codex`, `claudeAgent`, or `opencode`. Use this as the harness breakdown. |
35| `model` | Selected or provider-reported model when known. This is metadata, not proof that one model produced every token. |
36| `effort` | Selected or provider-reported reasoning effort when known. |
37| `interactionMode` | Turn interaction mode, `default` or `plan`, when known. |
38| `runtimeMode` | Session permission mode when known. |
39| `mixedModels` | `true` when T3 Code observed `model.rerouted` during the turn. This does not split token counts by model. |
40| `durationMs` | Time from the observed turn start or accepted send request to the terminal event. Omitted when start metadata is not available. |
41| `terminalStatus` | `completed`, `failed`, `interrupted`, or `cancelled`. |
42| `usageStatus` | `complete`, `partial`, or `unavailable`. |
43| `usageScope` | `main_agent`. Child-agent token use is not included. |
44| `hasSubagents` | Whether the adapter observed child agents during the turn. Omitted when the adapter does not provide a normalized usage report. |
45| `inputTokens` | Total main-agent input tokens. This includes uncached input, cache reads, and cache creation or writes. |
46| `cachedInputTokens` | Cache-read tokens. This is a subset of `inputTokens`. |
47| `cacheCreationTokens` | Cache creation or write tokens. This is a subset of `inputTokens`. |
48| `outputTokens` | Total main-agent output tokens, including reasoning tokens. |
49| `reasoningTokens` | Reasoning tokens when the provider reports them separately. This is a subset of `outputTokens`. |
50
51`complete` means the provider supplied full input and output totals for the
52turn. `partial` means each included count is valid, but T3 Code did not observe
53the full turn. This can happen after an interruption, provider failure, or
54reconnect. `unavailable` means the provider did not supply trustworthy counts.
55Numeric properties are omitted when their values are unknown. They are not set
56to zero.
57
58Codex uses deltas from its cumulative token totals, and falls back to the
59newest response's `last` usage when no prior total exists or the total was
60reset. Claude uses the final result's per-turn `usage` value and does not use
61cumulative `modelUsage`.
62OpenCode sums unique main-session step totals. Cursor, Grok, and Antigravity
63usage stays `unavailable` until their ACP token fields and scope are verified.
64
65The completed event count does not have to match `provider.turn.sent`. For
66example, Claude can emit synthetic turns without a matching send request.
67Duplicate terminal events for the same provider instance, thread, and turn
68produce one analytics event.
69
70Collection is best effort and starts with the first server release that
71contains this event. T3 Code does not scan provider history or backfill older
72turns.
73
26## Recommended properties74## Recommended properties
2775
28Client properties appear on the three client events when the connected client76Client properties appear on the three client events when the connected client
...@@ -94,6 +142,20 @@ In PostHog Data management, use the event and property descriptions from this...@@ -94,6 +142,20 @@ In PostHog Data management, use the event and property descriptions from this
94document. Mark the recommended properties as verified. Keep `clientType`142document. Mark the recommended properties as verified. Keep `clientType`
95visible with its legacy description so old reports remain understandable.143visible with its legacy description so old reports remain understandable.
96144
145Create a second saved dashboard named `Provider token efficiency`. Start each
146token insight with these filters:
147
148- `usageStatus = complete`
149- `hasSubagents = false`
150- `mixedModels = false`
151
152Break down input tokens, output tokens, and their ratio by `provider`. Compare
153the same `model`, `effort`, `interactionMode`, and `terminalStatus`. Without
154these filters, model choice, reasoning level, plan mode, failures, and child
155agents can look like harness differences. Keep separate views for median turn
156usage and aggregate `sum(outputTokens) / sum(inputTokens)`. Do not average each
157turn's ratio because small-input turns can dominate that result.
158
97## Collection and release boundary159## Collection and release boundary
98160
99Client values are best effort. Invalid values are ignored and never reject a161Client values are best effort. Invalid values are ignored and never reject a
...@@ -101,9 +163,12 @@ connection. Browser clients use user-agent data for broad OS, browser, phone,...@@ -101,9 +163,12 @@ connection. Browser clients use user-agent data for broad OS, browser, phone,
101and tablet groups. They do not infer CPU architecture or an exact OS version163and tablet groups. They do not infer CPU architecture or an exact OS version
102from `navigator.platform`.164from `navigator.platform`.
103165
104This change does not collect URLs, tokens, prompts, IP addresses, or166This change does not collect URLs, authentication tokens, prompts, IP
105user-assigned device names. It measures authenticated product use. It does not167addresses, or user-assigned device names. The token event does not include
106measure a person who visits the hosted app without connecting to a server.168thread IDs, turn IDs, provider instance IDs, event IDs, request IDs, raw
169provider payloads, or child-agent output. It does not scan provider history on
170disk or start external work. It measures authenticated product use. It does
171not measure a person who visits the hosted app without connecting to a server.
107172
108The new fields start with the first client and server release that contains173The new fields start with the first client and server release that contains
109this metadata path. Historical events cannot reliably identify the client OS,174this metadata path. Historical events cannot reliably identify the client OS,
docs/user/telemetry.md created+15
...@@ -0,0 +1,15 @@
1# Anonymous usage data
2
3The T3 server sends anonymous product events to PostHog. These events include
4the provider, model, reasoning effort, permission mode, turn result, duration,
5and normalized main-agent token totals when the provider reports them.
6
7T3 Code does not send prompts, responses, file contents, raw provider events,
8thread IDs, turn IDs, provider instance IDs, authentication tokens, or child
9agent output. Token totals can be complete, partial, or unavailable. Child
10agent token use is not included.
11
12Set `T3CODE_TELEMETRY_ENABLED=false` before you start the T3 server to stop
13product events from being recorded or sent. The server still derives an
14anonymous identifier during startup and can create its local fallback
15identifier file.
packages/contracts/src/providerRuntime.test.ts+46
...@@ -6,6 +6,52 @@ import { classifyTaskAgentKind, ProviderRuntimeEvent } from "./providerRuntime.t...@@ -6,6 +6,52 @@ import { classifyTaskAgentKind, ProviderRuntimeEvent } from "./providerRuntime.t
6const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent);6const decodeRuntimeEvent = Schema.decodeUnknownSync(ProviderRuntimeEvent);
77
8describe("ProviderRuntimeEvent", () => {8describe("ProviderRuntimeEvent", () => {
9 it("requires input and output totals for complete turn usage", () => {
10 const completeEvent = {
11 type: "turn.completed",
12 eventId: "event-complete-usage",
13 provider: "codex",
14 createdAt: "2026-02-28T00:00:00.000Z",
15 threadId: "thread-1",
16 turnId: "turn-1",
17 payload: {
18 state: "completed",
19 tokenUsage: {
20 usageStatus: "complete",
21 usageScope: "main_agent",
22 hasSubagents: false,
23 },
24 },
25 };
26
27 expect(() => decodeRuntimeEvent(completeEvent)).toThrow();
28 expect(
29 decodeRuntimeEvent({
30 ...completeEvent,
31 payload: {
32 ...completeEvent.payload,
33 tokenUsage: {
34 ...completeEvent.payload.tokenUsage,
35 inputTokens: 10,
36 outputTokens: 2,
37 },
38 },
39 }).type,
40 ).toBe("turn.completed");
41 expect(
42 decodeRuntimeEvent({
43 ...completeEvent,
44 payload: {
45 ...completeEvent.payload,
46 tokenUsage: {
47 ...completeEvent.payload.tokenUsage,
48 usageStatus: "partial",
49 },
50 },
51 }).type,
52 ).toBe("turn.completed");
53 });
54
9 it("accepts fork-provided driver kinds as branded slugs", () => {55 it("accepts fork-provided driver kinds as branded slugs", () => {
10 const parsed = decodeRuntimeEvent({56 const parsed = decodeRuntimeEvent({
11 type: "session.started",57 type: "session.started",
packages/contracts/src/providerRuntime.ts+31
...@@ -367,6 +367,35 @@ const TurnStartedPayload = Schema.Struct({...@@ -367,6 +367,35 @@ const TurnStartedPayload = Schema.Struct({
367});367});
368export type TurnStartedPayload = typeof TurnStartedPayload.Type;368export type TurnStartedPayload = typeof TurnStartedPayload.Type;
369369
370/**
371 * Normalized main-agent usage for one turn. Input includes cache reads and
372 * writes. Output includes reasoning, and reasoningTokens is an optional subset.
373 * Complete means the provider supplied full input and output totals. Partial
374 * means every included count is valid, but the full turn total is not known.
375 */
376const TurnTokenUsageCommonFields = {
377 usageScope: Schema.Literal("main_agent"),
378 cachedInputTokens: Schema.optional(NonNegativeInt),
379 cacheCreationTokens: Schema.optional(NonNegativeInt),
380 reasoningTokens: Schema.optional(NonNegativeInt),
381 hasSubagents: Schema.Boolean,
382};
383export const TurnTokenUsage = Schema.Union([
384 Schema.Struct({
385 ...TurnTokenUsageCommonFields,
386 usageStatus: Schema.Literal("complete"),
387 inputTokens: NonNegativeInt,
388 outputTokens: NonNegativeInt,
389 }),
390 Schema.Struct({
391 ...TurnTokenUsageCommonFields,
392 usageStatus: Schema.Literals(["partial", "unavailable"]),
393 inputTokens: Schema.optional(NonNegativeInt),
394 outputTokens: Schema.optional(NonNegativeInt),
395 }),
396]);
397export type TurnTokenUsage = typeof TurnTokenUsage.Type;
398
370const TurnCompletedPayload = Schema.Struct({399const TurnCompletedPayload = Schema.Struct({
371 state: RuntimeTurnState,400 state: RuntimeTurnState,
372 stopReason: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)),401 stopReason: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)),
...@@ -374,11 +403,13 @@ const TurnCompletedPayload = Schema.Struct({...@@ -374,11 +403,13 @@ const TurnCompletedPayload = Schema.Struct({
374 modelUsage: Schema.optional(UnknownRecordSchema),403 modelUsage: Schema.optional(UnknownRecordSchema),
375 totalCostUsd: Schema.optional(Schema.Number),404 totalCostUsd: Schema.optional(Schema.Number),
376 errorMessage: Schema.optional(TrimmedNonEmptyStringSchema),405 errorMessage: Schema.optional(TrimmedNonEmptyStringSchema),
406 tokenUsage: Schema.optional(TurnTokenUsage),
377});407});
378export type TurnCompletedPayload = typeof TurnCompletedPayload.Type;408export type TurnCompletedPayload = typeof TurnCompletedPayload.Type;
379409
380const TurnAbortedPayload = Schema.Struct({410const TurnAbortedPayload = Schema.Struct({
381 reason: TrimmedNonEmptyStringSchema,411 reason: TrimmedNonEmptyStringSchema,
412 tokenUsage: Schema.optional(TurnTokenUsage),
382});413});
383export type TurnAbortedPayload = typeof TurnAbortedPayload.Type;414export type TurnAbortedPayload = typeof TurnAbortedPayload.Type;
384415