From 7963e388b2f3c7758680b4a15e5ee00df850e7f2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 07:54:23 -0700 Subject: [PATCH 01/12] feat(server): measure provider turn token usage Adapters now attach normalized main-agent token usage to terminal turn events, and ProviderService records one provider.turn.completed analytics event per provider instance, thread, and turn. AnalyticsService stays inert unless a Pylon PostHog key is configured. Pylon adaptations: - Codex splices usage tracking into the incarnation stamping and pending admission correlation mapper. - OpenCode uses the finished upstream design from #10116 (unresolvedStepsByMessageId) because Pylon already retains only text parts. - ProviderService observes analytics after the runtime generation and session incarnation fences, flushes held completions on start, stop, session exit, and shutdown. - Prime, Cursor, Grok, and Antigravity report usage as unavailable. - Upstream tests adapted to Pylon session fencing and its rejection of model selections for another provider instance. - Documented in new docs/internals/product-analytics.md and docs/user/telemetry.md in Pylon voice. Adopted from 1587f248dd81ed45e214d476451ebf16dbfadb1a (#9132) --- .../src/provider/Layers/ClaudeAdapter.test.ts | 255 +++- .../src/provider/Layers/ClaudeAdapter.ts | 94 ++ .../src/provider/Layers/CodexAdapter.test.ts | 446 ++++++ .../src/provider/Layers/CodexAdapter.ts | 251 +++- .../provider/Layers/OpenCodeAdapter.test.ts | 755 ++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 169 ++- .../provider/Layers/ProviderService.test.ts | 1255 ++++++++++++++++- .../src/provider/Layers/ProviderService.ts | 485 ++++++- .../src/telemetry/AnalyticsService.test.ts | 37 + docs/README.md | 2 + docs/internals/product-analytics.md | 63 + docs/user/telemetry.md | 18 + .../contracts/src/providerRuntime.test.ts | 46 + packages/contracts/src/providerRuntime.ts | 31 + 14 files changed, 3891 insertions(+), 16 deletions(-) create mode 100644 docs/internals/product-analytics.md create mode 100644 docs/user/telemetry.md diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 2d54cfe78..1f1c3f286 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2210,7 +2210,8 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), Stream.runCollect, Effect.forkChild, ); @@ -2248,6 +2249,17 @@ describe("ClaudeAdapterLive", () => { }, } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-agent-1", + description: "Review the database layer", + task_type: "local_agent", + tool_use_id: "tool-task-1", + uuid: "task-agent-1-uuid", + session_id: "sdk-session-task", + } as unknown as SDKMessage); + harness.query.emit({ type: "assistant", session_id: "sdk-session-task", @@ -2264,6 +2276,12 @@ describe("ClaudeAdapterLive", () => { subtype: "success", is_error: false, errors: [], + usage: { + input_tokens: 100, + cache_read_input_tokens: 40, + cache_creation_input_tokens: 10, + output_tokens: 20, + }, session_id: "sdk-session-task", uuid: "result-task-1", } as unknown as SDKMessage); @@ -2275,6 +2293,11 @@ describe("ClaudeAdapterLive", () => { assert.equal(toolStarted.payload.itemType, "collab_agent_tool_call"); assert.equal(toolStarted.payload.title, "Subagent task"); } + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.equal(completed.payload.tokenUsage?.hasSubagents, true); + } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -2286,7 +2309,8 @@ describe("ClaudeAdapterLive", () => { return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), Stream.runCollect, Effect.forkChild, ); @@ -2309,6 +2333,12 @@ describe("ClaudeAdapterLive", () => { is_error: false, errors: ["Error: Request was aborted."], stop_reason: "tool_use", + usage: { + input_tokens: 12, + cache_read_input_tokens: 3, + cache_creation_input_tokens: 1, + output_tokens: 4, + }, session_id: "sdk-session-abort", uuid: "result-abort", } as unknown as SDKMessage); @@ -2322,6 +2352,7 @@ describe("ClaudeAdapterLive", () => { "session.state.changed", "turn.started", "thread.started", + "thread.token-usage.updated", "turn.completed", ], ); @@ -2333,6 +2364,15 @@ describe("ClaudeAdapterLive", () => { assert.equal(turnCompleted.payload.state, "interrupted"); assert.equal(turnCompleted.payload.errorMessage, "Error: Request was aborted."); assert.equal(turnCompleted.payload.stopReason, "tool_use"); + assert.deepEqual(turnCompleted.payload.tokenUsage, { + usageStatus: "partial", + usageScope: "main_agent", + inputTokens: 16, + cachedInputTokens: 3, + cacheCreationTokens: 1, + outputTokens: 4, + hasSubagents: false, + }); } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), @@ -3552,7 +3592,10 @@ describe("ClaudeAdapterLive", () => { session_id: "sdk-session-result-usage", usage: { input_tokens: 400, + cache_read_input_tokens: 90, + cache_creation_input_tokens: 10, output_tokens: 50, + output_tokens_details: { thinking_tokens: 30 }, }, modelUsage: { [SYNTHETIC_CLAUDE_CAPABLE_MODEL]: { @@ -3571,7 +3614,7 @@ describe("ClaudeAdapterLive", () => { assert.deepEqual(usageEvent.payload.usage, { usedTokens: 200, lastUsedTokens: 200, - totalProcessedTokens: 450, + totalProcessedTokens: 550, inputTokens: 180, outputTokens: 20, maxTokens: 200000, @@ -3579,10 +3622,205 @@ describe("ClaudeAdapterLive", () => { autoCompactThreshold: 160_000, }); } - assert.equal( - runtimeEvents.find((event) => event.type === "turn.completed")?.type, - "turn.completed", + const completed = runtimeEvents.find((event) => event.type === "turn.completed"); + assert.equal(completed?.type, "turn.completed"); + if (completed?.type === "turn.completed") { + assert.deepEqual(completed.payload.tokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 500, + cachedInputTokens: 90, + cacheCreationTokens: 10, + reasoningTokens: 30, + outputTokens: 50, + hasSubagents: false, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("treats omitted Claude cache counters as zero contributions", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, ); + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "uncached turn", + attachments: [], + }); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 100, + duration_api_ms: 90, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-uncached-usage", + usage: { + input_tokens: 42, + output_tokens: 9, + }, + } as unknown as SDKMessage); + + const completed = yield* Fiber.join(completedFiber); + assert.equal(completed._tag, "Some"); + if (completed._tag === "Some" && completed.value.type === "turn.completed") { + assert.deepEqual(completed.value.payload.tokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 42, + outputTokens: 9, + hasSubagents: false, + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("uses per-turn result usage across consecutive Claude turns", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const firstTurnSettled = yield* Deferred.make(); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.tap((event) => + event.type === "session.state.changed" && event.payload.reason === "api_retry:1/2" + ? Deferred.succeed(firstTurnSettled, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + Stream.filter((event) => event.type === "turn.completed"), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "first turn", + attachments: [], + }); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 100, + duration_api_ms: 90, + num_turns: 1, + result: "first", + stop_reason: "end_turn", + session_id: "sdk-session-consecutive-usage", + usage: { + input_tokens: 100, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 5, + output_tokens: 10, + }, + modelUsage: { + "claude-opus-4-6": { + inputTokens: 10_000, + outputTokens: 1_000, + cacheReadInputTokens: 2_000, + cacheCreationInputTokens: 500, + webSearchRequests: 0, + costUSD: 1, + contextWindow: 200_000, + maxOutputTokens: 64_000, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "api_retry", + attempt: 1, + max_retries: 2, + retry_delay_ms: 1, + error_status: 502, + error: { type: "api_error" }, + session_id: "sdk-session-consecutive-usage", + uuid: "consecutive-usage-barrier", + } as unknown as SDKMessage); + yield* Deferred.await(firstTurnSettled); + + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "second turn", + attachments: [], + }); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 80, + duration_api_ms: 70, + num_turns: 2, + result: "second", + stop_reason: "end_turn", + session_id: "sdk-session-consecutive-usage", + usage: { + input_tokens: 30, + cache_read_input_tokens: 2, + cache_creation_input_tokens: 3, + output_tokens: 7, + }, + modelUsage: { + "claude-opus-4-6": { + inputTokens: 20_000, + outputTokens: 2_000, + cacheReadInputTokens: 4_000, + cacheCreationInputTokens: 1_000, + webSearchRequests: 0, + costUSD: 2, + contextWindow: 200_000, + maxOutputTokens: 64_000, + }, + }, + } as unknown as SDKMessage); + + const completed = Array.from(yield* Fiber.join(completedFiber)); + assert.equal(completed[0]?.type, "turn.completed"); + assert.equal(completed[1]?.type, "turn.completed"); + if (completed[0]?.type === "turn.completed" && completed[1]?.type === "turn.completed") { + assert.deepEqual(completed[0].payload.tokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 125, + cachedInputTokens: 20, + cacheCreationTokens: 5, + outputTokens: 10, + hasSubagents: false, + }); + assert.deepEqual(completed[1].payload.tokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 35, + cachedInputTokens: 2, + cacheCreationTokens: 3, + outputTokens: 7, + hasSubagents: false, + }); + } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), @@ -4328,6 +4566,11 @@ describe("ClaudeAdapterLive", () => { assert.equal(String(turnCompleted.turnId), String(turn.turnId)); assert.equal(turnCompleted.payload.state, "interrupted"); assert.equal(turnCompleted.payload.errorMessage, "Claude runtime interrupted."); + assert.deepEqual(turnCompleted.payload.tokenUsage, { + usageStatus: "unavailable", + usageScope: "main_agent", + hasSubagents: false, + }); } const sessionExited = runtimeEvents[5]; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b632303d0..d44ff8d10 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -25,6 +25,7 @@ import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { ApprovalRequestId, + classifyTaskAgentKind, type CanonicalItemType, type CanonicalRequestType, type ClaudeSettings, @@ -39,6 +40,7 @@ import { type ProviderSendTurnInput, type ProviderSession, type ThreadTokenUsageSnapshot, + type TurnTokenUsage, type ProviderUserInputAnswers, type RuntimeContentStreamKind, RuntimeItemId, @@ -157,6 +159,7 @@ interface ClaudeTurnState { readonly capturedProposedPlanKeys: Set; latestAssistantUsage: unknown | undefined; compactedSinceLatestAssistantUsage: boolean; + hasSubagents: boolean; nextSyntheticAssistantBlockIndex: number; authenticationFailureMessage: string | undefined; rejectedRateLimitTypes: Set; @@ -708,6 +711,85 @@ function normalizeClaudeAutoCompactSettings( }; } +function normalizeClaudeTurnTokenUsage( + result: SDKResultMessage | undefined, + hasSubagents: boolean, + terminalStatus: ProviderRuntimeTurnStatus, +): TurnTokenUsage { + const usage = result?.usage as Record | undefined; + if (!usage) { + return { + usageStatus: "unavailable", + usageScope: "main_agent", + hasSubagents, + }; + } + + const uncachedInputTokens = finiteNonNegativeInteger(usage.input_tokens); + const cachedInputTokens = finiteNonNegativeInteger(usage.cache_read_input_tokens); + const cacheCreationTokens = finiteNonNegativeInteger(usage.cache_creation_input_tokens); + const rawOutputTokens = finiteNonNegativeInteger(usage.output_tokens); + const outputDetails = usage.output_tokens_details as Record | undefined; + const thinkingTokens = finiteNonNegativeInteger(outputDetails?.thinking_tokens); + const cachedInputContribution = usage.cache_read_input_tokens == null ? 0 : cachedInputTokens; + const cacheCreationContribution = + usage.cache_creation_input_tokens == null ? 0 : cacheCreationTokens; + const inputTokens = + uncachedInputTokens !== undefined && + cachedInputContribution !== undefined && + cacheCreationContribution !== undefined + ? uncachedInputTokens + cachedInputContribution + cacheCreationContribution + : undefined; + const hasKnownUsage = + uncachedInputTokens !== undefined || + cachedInputTokens !== undefined || + cacheCreationTokens !== undefined || + rawOutputTokens !== undefined; + const hasPositiveUsage = + (uncachedInputTokens ?? 0) + + (cachedInputTokens ?? 0) + + (cacheCreationTokens ?? 0) + + (rawOutputTokens ?? 0) > + 0; + + if (!hasKnownUsage || (result?.subtype !== "success" && !hasPositiveUsage)) { + return { + usageStatus: "unavailable", + usageScope: "main_agent", + hasSubagents, + }; + } + + const commonUsage = { + usageScope: "main_agent", + ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), + ...(cacheCreationTokens !== undefined ? { cacheCreationTokens } : {}), + ...(thinkingTokens !== undefined && rawOutputTokens !== undefined + ? { reasoningTokens: Math.min(rawOutputTokens, thinkingTokens) } + : {}), + hasSubagents, + } as const; + if ( + terminalStatus === "completed" && + result?.subtype === "success" && + inputTokens !== undefined && + rawOutputTokens !== undefined + ) { + return { + ...commonUsage, + usageStatus: "complete", + inputTokens, + outputTokens: rawOutputTokens, + }; + } + return { + ...commonUsage, + usageStatus: "partial", + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(rawOutputTokens !== undefined ? { outputTokens: rawOutputTokens } : {}), + }; +} + function compactBoundaryTokenUsageSnapshot( message: Record, contextWindow?: number, @@ -2620,6 +2702,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ? { totalCostUsd: result.total_cost_usd } : {}), ...(errorMessage ? { errorMessage } : {}), + tokenUsage: normalizeClaudeTurnTokenUsage(result, turnState.hasSubagents, status), }, providerRefs: nativeProviderRefs(context), }); @@ -3164,6 +3247,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( capturedProposedPlanKeys: new Set(), latestAssistantUsage: undefined, compactedSinceLatestAssistantUsage: false, + hasSubagents: false, nextSyntheticAssistantBlockIndex: -1, authenticationFailureMessage: undefined, rejectedRateLimitTypes: new Set(), @@ -3519,6 +3603,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) : undefined; const owningAgentId = launchingTool?.agentId; + if ( + context.turnState && + classifyTaskAgentKind({ + taskType: message.task_type, + ...(owningAgentId ? { agentId: owningAgentId } : {}), + }) === "agent" + ) { + context.turnState.hasSubagents = true; + } // Model/effort: the Agent tool's input carries explicit overrides; // absent ones inherit the session's selection (SDK behavior). // Subagent assistant snapshots refine model with the authoritative API @@ -5003,6 +5096,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( capturedProposedPlanKeys: new Set(), latestAssistantUsage: undefined, compactedSinceLatestAssistantUsage: false, + hasSubagents: false, nextSyntheticAssistantBlockIndex: -1, authenticationFailureMessage: undefined, rejectedRateLimitTypes: new Set(), diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 5830f7315..724f5b2a2 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -605,6 +605,76 @@ function startLifecycleRuntime() { }); } +function codexTokenUsageEvent(input: { + readonly id: string; + readonly turnId: string; + readonly inputTokens: number; + readonly cachedInputTokens: number; + readonly cacheCreationTokens: number; + readonly outputTokens: number; + readonly reasoningTokens: number; + readonly last?: { + readonly inputTokens: number; + readonly cachedInputTokens: number; + readonly cacheCreationTokens: number; + readonly outputTokens: number; + readonly reasoningTokens: number; + }; +}): ProviderEvent { + const totalTokens = input.inputTokens + input.outputTokens; + const last = input.last ?? input; + return { + id: asEventId(input.id), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + turnId: asTurnId(input.turnId), + createdAt: "2026-01-01T00:00:00.000Z", + method: "thread/tokenUsage/updated", + payload: { + threadId: "thread-1", + turnId: input.turnId, + tokenUsage: { + total: { + inputTokens: input.inputTokens, + cachedInputTokens: input.cachedInputTokens, + cacheWriteInputTokens: input.cacheCreationTokens, + outputTokens: input.outputTokens, + reasoningOutputTokens: input.reasoningTokens, + totalTokens, + }, + last: { + inputTokens: last.inputTokens, + cachedInputTokens: last.cachedInputTokens, + cacheWriteInputTokens: last.cacheCreationTokens, + outputTokens: last.outputTokens, + reasoningOutputTokens: last.reasoningTokens, + totalTokens: last.inputTokens + last.outputTokens, + }, + }, + }, + }; +} + +function codexTurnEvent(method: "turn/started" | "turn/completed", turnId: string): ProviderEvent { + return { + id: asEventId(`evt-${method}-${turnId}`), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + turnId: asTurnId(turnId), + createdAt: "2026-01-01T00:00:00.000Z", + method, + payload: + method === "turn/started" + ? {} + : { + threadId: "thread-1", + turn: { id: turnId, items: [], status: "completed" }, + }, + }; +} + lifecycleLayer("CodexAdapterLive lifecycle", (it) => { it.effect("correlates the next native turn start to the exact admission", () => Effect.gen(function* () { @@ -726,6 +796,382 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("calculates one Codex turn total from cumulative counters", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + + yield* runtime.emit(codexTurnEvent("turn/started", "turn-usage")); + yield* runtime.emit( + codexTokenUsageEvent({ + id: "evt-usage-1", + turnId: "turn-usage", + inputTokens: 100, + cachedInputTokens: 40, + cacheCreationTokens: 10, + outputTokens: 20, + reasoningTokens: 8, + }), + ); + // Codex can repeat both notifications without new work. + yield* runtime.emit(codexTurnEvent("turn/started", "turn-usage")); + yield* runtime.emit( + codexTokenUsageEvent({ + id: "evt-usage-duplicate", + turnId: "turn-usage", + inputTokens: 100, + cachedInputTokens: 40, + cacheCreationTokens: 10, + outputTokens: 20, + reasoningTokens: 8, + }), + ); + yield* runtime.emit({ + id: asEventId("evt-collab-activity"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-usage"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "collabAgent/activity", + payload: { + agentThreadId: "child-1", + agentPath: "/root/child-1", + activityKind: "started", + }, + }); + yield* runtime.emit( + codexTokenUsageEvent({ + id: "evt-usage-2", + turnId: "turn-usage", + inputTokens: 150, + cachedInputTokens: 60, + cacheCreationTokens: 15, + outputTokens: 30, + reasoningTokens: 12, + }), + ); + yield* runtime.emit(codexTurnEvent("turn/completed", "turn-usage")); + + const completed = yield* Fiber.join(completedFiber); + NodeAssert.equal(completed._tag, "Some"); + if (completed._tag === "Some" && completed.value.type === "turn.completed") { + NodeAssert.deepStrictEqual(completed.value.payload.tokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 150, + cachedInputTokens: 60, + cacheCreationTokens: 15, + outputTokens: 30, + reasoningTokens: 12, + hasSubagents: true, + }); + } + }), + ); + + it.effect("does not charge a late prior-turn update to the next Codex turn", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + yield* runtime.emit(codexTurnEvent("turn/started", "turn-first")); + yield* runtime.emit( + codexTokenUsageEvent({ + id: "evt-late-1", + turnId: "turn-first", + inputTokens: 100, + cachedInputTokens: 40, + cacheCreationTokens: 10, + outputTokens: 20, + reasoningTokens: 8, + }), + ); + yield* runtime.emit(codexTurnEvent("turn/completed", "turn-first")); + yield* runtime.emit(codexTurnEvent("turn/started", "turn-second")); + // A late update for the finished turn lands after the next turn starts. + yield* runtime.emit( + codexTokenUsageEvent({ + id: "evt-late-2", + turnId: "turn-first", + inputTokens: 150, + cachedInputTokens: 60, + cacheCreationTokens: 15, + outputTokens: 30, + reasoningTokens: 12, + }), + ); + yield* runtime.emit( + codexTokenUsageEvent({ + id: "evt-late-3", + turnId: "turn-second", + inputTokens: 170, + cachedInputTokens: 65, + cacheCreationTokens: 16, + outputTokens: 35, + reasoningTokens: 14, + }), + ); + yield* runtime.emit(codexTurnEvent("turn/completed", "turn-second")); + + const completed = Array.from(yield* Fiber.join(completedFiber)); + const second = completed[1]; + NodeAssert.equal(second?.type, "turn.completed"); + if (second?.type === "turn.completed") { + NodeAssert.deepStrictEqual(second.payload.tokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 20, + cachedInputTokens: 5, + cacheCreationTokens: 1, + outputTokens: 5, + reasoningTokens: 2, + hasSubagents: false, + }); + } + }), + ); + + it.effect("clamps Codex cache and reasoning subsets to their totals", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + + yield* runtime.emit(codexTurnEvent("turn/started", "turn-clamp")); + yield* runtime.emit( + codexTokenUsageEvent({ + id: "evt-clamp-1", + turnId: "turn-clamp", + inputTokens: 100, + cachedInputTokens: 140, + cacheCreationTokens: 120, + outputTokens: 20, + reasoningTokens: 30, + }), + ); + yield* runtime.emit(codexTurnEvent("turn/completed", "turn-clamp")); + + const completed = yield* Fiber.join(completedFiber); + NodeAssert.equal(completed._tag, "Some"); + if (completed._tag === "Some" && completed.value.type === "turn.completed") { + NodeAssert.deepStrictEqual(completed.value.payload.tokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 100, + cachedInputTokens: 100, + cacheCreationTokens: 100, + outputTokens: 20, + reasoningTokens: 20, + hasSubagents: false, + }); + } + }), + ); + + it.effect("counts the last response when Codex resets its running total mid-turn", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + + yield* runtime.emit(codexTurnEvent("turn/started", "turn-reset")); + yield* runtime.emit( + codexTokenUsageEvent({ + id: "evt-reset-1", + turnId: "turn-reset", + inputTokens: 5_000, + cachedInputTokens: 4_000, + cacheCreationTokens: 100, + outputTokens: 500, + reasoningTokens: 200, + last: { + inputTokens: 100, + cachedInputTokens: 80, + cacheCreationTokens: 10, + outputTokens: 20, + reasoningTokens: 8, + }, + }), + ); + // Codex restarted its cumulative total. The new total is smaller than + // the previous one, so only `last` is counted for this update. + yield* runtime.emit( + codexTokenUsageEvent({ + id: "evt-reset-2", + turnId: "turn-reset", + inputTokens: 150, + cachedInputTokens: 90, + cacheCreationTokens: 5, + outputTokens: 30, + reasoningTokens: 12, + }), + ); + yield* runtime.emit(codexTurnEvent("turn/completed", "turn-reset")); + + const completed = yield* Fiber.join(completedFiber); + NodeAssert.equal(completed._tag, "Some"); + if (completed._tag === "Some" && completed.value.type === "turn.completed") { + NodeAssert.deepStrictEqual(completed.value.payload.tokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 250, + cachedInputTokens: 170, + cacheCreationTokens: 15, + outputTokens: 50, + reasoningTokens: 20, + hasSubagents: false, + }); + } + }), + ); + + it.effect("uses the last response usage when no prior Codex total exists", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + resumeCursor: { threadId: "provider-thread-1" }, + runtimeMode: "full-access", + }); + const runtime = lifecycleRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + const firstCompletionsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + // Resumed thread: the cumulative total already holds old history, so the + // first update must count only `last`. + yield* runtime.emit(codexTurnEvent("turn/started", "turn-resumed")); + yield* runtime.emit( + codexTokenUsageEvent({ + id: "evt-resume-baseline", + turnId: "turn-resumed", + inputTokens: 1_000, + cachedInputTokens: 400, + cacheCreationTokens: 100, + outputTokens: 200, + reasoningTokens: 80, + last: { + inputTokens: 300, + cachedInputTokens: 120, + cacheCreationTokens: 30, + outputTokens: 60, + reasoningTokens: 24, + }, + }), + ); + yield* runtime.emit(codexTurnEvent("turn/completed", "turn-resumed")); + + yield* runtime.emit(codexTurnEvent("turn/started", "turn-after-resume")); + yield* runtime.emit( + codexTokenUsageEvent({ + id: "evt-after-resume", + turnId: "turn-after-resume", + inputTokens: 1_100, + cachedInputTokens: 440, + cacheCreationTokens: 110, + outputTokens: 220, + reasoningTokens: 88, + }), + ); + yield* runtime.emit(codexTurnEvent("turn/completed", "turn-after-resume")); + + const firstCompletions = Array.from(yield* Fiber.join(firstCompletionsFiber)); + + yield* adapter.rollbackThread(asThreadId("thread-1"), 1); + const rollbackCompletionFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + // Rollback drops the baseline and Codex shrinks its total, so the first + // update after it counts only `last` again. + yield* runtime.emit(codexTurnEvent("turn/started", "turn-after-rollback")); + yield* runtime.emit( + codexTokenUsageEvent({ + id: "evt-after-rollback", + turnId: "turn-after-rollback", + inputTokens: 1_050, + cachedInputTokens: 420, + cacheCreationTokens: 105, + outputTokens: 210, + reasoningTokens: 84, + last: { + inputTokens: 50, + cachedInputTokens: 20, + cacheCreationTokens: 5, + outputTokens: 10, + reasoningTokens: 4, + }, + }), + ); + yield* runtime.emit(codexTurnEvent("turn/completed", "turn-after-rollback")); + + const rollbackCompletion = yield* Fiber.join(rollbackCompletionFiber); + const completions = [ + ...firstCompletions, + ...(rollbackCompletion._tag === "Some" ? [rollbackCompletion.value] : []), + ]; + NodeAssert.deepStrictEqual( + completions.map((event) => + event.type === "turn.completed" ? event.payload.tokenUsage : undefined, + ), + [ + { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 300, + cachedInputTokens: 120, + cacheCreationTokens: 30, + outputTokens: 60, + reasoningTokens: 24, + hasSubagents: false, + }, + { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 100, + cachedInputTokens: 40, + cacheCreationTokens: 10, + outputTokens: 20, + reasoningTokens: 8, + hasSubagents: false, + }, + { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 50, + cachedInputTokens: 20, + cacheCreationTokens: 5, + outputTokens: 10, + reasoningTokens: 4, + hasSubagents: false, + }, + ], + ); + }), + ); + it.effect("carries child model metadata through every task event", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 4dceb085f..2dc45614d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -26,6 +26,7 @@ import { RuntimeRequestId, RuntimeTaskId, type RuntimeTaskUsage, + type TurnTokenUsage, ProviderApprovalDecision, ThreadId, type TurnId, @@ -107,9 +108,34 @@ interface CodexAdapterSessionContext { readonly sessionIncarnationId: NonNullable; turnId?: TurnId; }>; + readonly turnTokenUsage: CodexTurnTokenUsageState; stopped: boolean; } +type CodexCumulativeTokenUsage = { + readonly inputTokens: number; + readonly cachedInputTokens: number; + readonly cacheCreationTokens?: number; + readonly outputTokens: number; + readonly reasoningTokens: number; +}; + +interface CodexTurnTokenUsageAccumulator { + inputTokens: number; + cachedInputTokens: number; + cacheCreationTokens: number | undefined; + outputTokens: number; + reasoningTokens: number; + observed: boolean; + hasSubagents: boolean; +} + +interface CodexTurnTokenUsageState { + baseline: CodexCumulativeTokenUsage | undefined; + activeTurnId: string | undefined; + readonly byTurnId: Map; +} + function mapCodexRuntimeError( threadId: ThreadId, method: string, @@ -446,6 +472,162 @@ function normalizeCodexTokenUsage( }; } +function codexTokenUsageBreakdown( + usage: EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification__TokenUsageBreakdown, +): CodexCumulativeTokenUsage { + return { + inputTokens: usage.inputTokens, + cachedInputTokens: usage.cachedInputTokens, + ...(usage.cacheWriteInputTokens !== undefined + ? { cacheCreationTokens: usage.cacheWriteInputTokens } + : {}), + outputTokens: usage.outputTokens, + reasoningTokens: usage.reasoningOutputTokens, + }; +} + +function makeCodexTurnTokenUsageState(): CodexTurnTokenUsageState { + return { + baseline: undefined, + activeTurnId: undefined, + byTurnId: new Map(), + }; +} + +function getCodexTurnAccumulator( + state: CodexTurnTokenUsageState, + turnId: string, +): CodexTurnTokenUsageAccumulator { + const existing = state.byTurnId.get(turnId); + if (existing) return existing; + const created: CodexTurnTokenUsageAccumulator = { + inputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + observed: false, + hasSubagents: false, + }; + state.byTurnId.set(turnId, created); + return created; +} + +/** + * Usage added by one `thread/tokenUsage/updated` notification. Codex reports a + * running `total` for the thread and `last`, the usage of the newest model + * response. Within a turn the growth of `total` equals `last`. Without a prior + * total (first update after resume or rollback), or when Codex reset the + * running total, `last` is the delta. + */ +function codexTurnTokenUsageDelta( + previous: CodexCumulativeTokenUsage | undefined, + current: CodexCumulativeTokenUsage, + last: CodexCumulativeTokenUsage, +): CodexCumulativeTokenUsage { + if ( + previous === undefined || + current.inputTokens < previous.inputTokens || + current.cachedInputTokens < previous.cachedInputTokens || + current.outputTokens < previous.outputTokens || + current.reasoningTokens < previous.reasoningTokens + ) { + return last; + } + return { + inputTokens: current.inputTokens - previous.inputTokens, + cachedInputTokens: current.cachedInputTokens - previous.cachedInputTokens, + ...(current.cacheCreationTokens !== undefined && + previous.cacheCreationTokens !== undefined && + current.cacheCreationTokens >= previous.cacheCreationTokens + ? { cacheCreationTokens: current.cacheCreationTokens - previous.cacheCreationTokens } + : {}), + outputTokens: current.outputTokens - previous.outputTokens, + reasoningTokens: current.reasoningTokens - previous.reasoningTokens, + }; +} + +function accumulateCodexTurnTokenUsage( + state: CodexTurnTokenUsageState, + turnId: string, + usage: EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification["tokenUsage"], +): void { + const current = codexTokenUsageBreakdown(usage.total); + if (state.activeTurnId !== turnId) { + // The total is thread-wide, so every update moves the baseline. A late + // update for a finished turn is not counted toward the live turn. + state.baseline = current; + return; + } + + const accumulator = getCodexTurnAccumulator(state, turnId); + const delta = codexTurnTokenUsageDelta( + state.baseline, + current, + codexTokenUsageBreakdown(usage.last), + ); + state.baseline = current; + + if ( + delta.inputTokens > 0 || + delta.cachedInputTokens > 0 || + delta.outputTokens > 0 || + delta.reasoningTokens > 0 + ) { + accumulator.observed = true; + } + accumulator.inputTokens += delta.inputTokens; + accumulator.cachedInputTokens += delta.cachedInputTokens; + accumulator.outputTokens += delta.outputTokens; + accumulator.reasoningTokens += delta.reasoningTokens; + if (delta.cacheCreationTokens === undefined) { + accumulator.cacheCreationTokens = undefined; + } else if (accumulator.cacheCreationTokens !== undefined) { + accumulator.cacheCreationTokens += delta.cacheCreationTokens; + } +} + +function completeCodexTurnTokenUsage( + state: CodexTurnTokenUsageState, + turnId: string, + completed: boolean, +): TurnTokenUsage { + const usage = state.byTurnId.get(turnId); + state.byTurnId.delete(turnId); + if (state.activeTurnId === turnId) state.activeTurnId = undefined; + if (!usage) { + return { + usageStatus: "unavailable", + usageScope: "main_agent", + hasSubagents: false, + }; + } + + if (!usage.observed) { + return { + usageStatus: "unavailable", + usageScope: "main_agent", + hasSubagents: usage.hasSubagents, + }; + } + + // Codex counts cache reads and writes inside inputTokens. Clamp the + // subsets so the record keeps the documented relationships even if a + // counter drifts. + return { + usageStatus: completed ? "complete" : "partial", + usageScope: "main_agent", + inputTokens: usage.inputTokens, + cachedInputTokens: Math.min(usage.inputTokens, usage.cachedInputTokens), + ...(usage.cacheCreationTokens !== undefined + ? { cacheCreationTokens: Math.min(usage.inputTokens, usage.cacheCreationTokens) } + : {}), + outputTokens: usage.outputTokens, + reasoningTokens: Math.min(usage.outputTokens, usage.reasoningTokens), + hasSubagents: usage.hasSubagents, + }; +} + function toTurnStatus( value: EffectCodexSchema.V2TurnCompletedNotification["turn"]["status"] | "cancelled", ): "completed" | "failed" | "cancelled" | "interrupted" { @@ -2115,6 +2297,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } : {}), }; + const turnTokenUsage = makeCodexTurnTokenUsageState(); const sessionScope = yield* Scope.make("sequential"); let sessionScopeTransferred = false; yield* Effect.addFinalizer(() => @@ -2145,12 +2328,67 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const eventFiber = yield* Stream.runForEach(runtime.events, (event) => Effect.gen(function* () { yield* writeNativeEvent(event); + if (event.method === "turn/started" && event.turnId) { + if (turnTokenUsage.activeTurnId !== event.turnId) { + turnTokenUsage.byTurnId.clear(); + turnTokenUsage.activeTurnId = event.turnId; + getCodexTurnAccumulator(turnTokenUsage, event.turnId); + } + } else if (event.method === "thread/tokenUsage/updated") { + const payload = readPayload( + EffectCodexSchema.V2ThreadTokenUsageUpdatedNotification, + event.payload, + ); + if (payload) { + accumulateCodexTurnTokenUsage(turnTokenUsage, payload.turnId, payload.tokenUsage); + } + } else if (turnTokenUsage.activeTurnId) { + const collabPayload = + typeof event.payload === "object" && event.payload !== null + ? (event.payload as Record) + : undefined; + const isCollabSpawn = + event.method === "collabAgent/started" || + (event.method === "collabAgent/activity" && + collabPayload?.activityKind === "started"); + if (isCollabSpawn && event.turnId === turnTokenUsage.activeTurnId) { + getCodexTurnAccumulator(turnTokenUsage, turnTokenUsage.activeTurnId).hasSubagents = + true; + } + } + const runtimeEvents = mapToRuntimeEvents(event, event.threadId).map((runtimeEvent) => { const stampedRuntimeEvent = runtimeEvent.sessionIncarnationId !== undefined || input.sessionIncarnationId === undefined ? runtimeEvent : { ...runtimeEvent, sessionIncarnationId: input.sessionIncarnationId }; + if (stampedRuntimeEvent.type === "turn.completed" && stampedRuntimeEvent.turnId) { + return { + ...stampedRuntimeEvent, + payload: { + ...stampedRuntimeEvent.payload, + tokenUsage: completeCodexTurnTokenUsage( + turnTokenUsage, + String(stampedRuntimeEvent.turnId), + stampedRuntimeEvent.payload.state === "completed", + ), + }, + } satisfies ProviderRuntimeEvent; + } + if (stampedRuntimeEvent.type === "turn.aborted" && stampedRuntimeEvent.turnId) { + return { + ...stampedRuntimeEvent, + payload: { + ...stampedRuntimeEvent.payload, + tokenUsage: completeCodexTurnTokenUsage( + turnTokenUsage, + String(stampedRuntimeEvent.turnId), + false, + ), + }, + } satisfies ProviderRuntimeEvent; + } if (stampedRuntimeEvent.type !== "turn.started") return stampedRuntimeEvent; const exactIndex = pendingAdmissions.findIndex( (admission) => admission.turnId === stampedRuntimeEvent.turnId, @@ -2204,6 +2442,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( eventFiber, admissionSemaphore: yield* Semaphore.make(1), pendingAdmissions, + turnTokenUsage, stopped: false, }); sessionScopeTransferred = true; @@ -2362,7 +2601,17 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } return requireSession(threadId).pipe( - Effect.flatMap((session) => session.runtime.rollbackThread(numTurns)), + Effect.flatMap((session) => + session.runtime.rollbackThread(numTurns).pipe( + Effect.tap(() => + Effect.sync(() => { + session.turnTokenUsage.baseline = undefined; + session.turnTokenUsage.activeTurnId = undefined; + session.turnTokenUsage.byTurnId.clear(); + }), + ), + ), + ), Effect.mapError((cause) => cause._tag === "ProviderAdapterSessionNotFoundError" ? cause diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index cbf3a96a9..2569dc688 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -2115,6 +2116,557 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("marks subagents when a child is proven related by ancestry lookup", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-child-ancestry-usage"); + // The child's `session.created` event was missed. Only the ancestry + // lookup can prove that `ses_child` belongs to this thread. + runtimeMock.state.sessionParentById.set("ses_child", "http://127.0.0.1:9999/session"); + runtimeMock.state.sessionStatus = "busy"; + const busy = promiseWithResolvers(); + const childPermission = promiseWithResolvers(); + const idle = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [busy.promise, childPermission.promise, idle.promise]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "turn.completed"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "approval-required", + }); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "Delegate to a child", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + busy.resolve({ + id: "evt-child-ancestry-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + yield* Fiber.join(sendFiber); + + const requestOpened = promiseWithResolvers(); + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID === "ses_child") requestOpened.resolve(undefined); + }; + childPermission.resolve({ + id: "evt-child-ancestry-permission", + type: "permission.asked", + properties: permissionRequest("per_child_ancestry", "ses_child"), + }); + yield* Effect.promise(() => requestOpened.promise); + yield* Effect.yieldNow; + runtimeMock.state.sessionStatus = "idle"; + idle.resolve({ + id: "evt-child-ancestry-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.deepEqual( + events.map((event) => event.type), + ["request.opened", "turn.completed"], + ); + const completed = events[1]; + if (completed?.type === "turn.completed") { + NodeAssert.deepEqual(completed.payload.tokenUsage, { + usageStatus: "unavailable", + usageScope: "main_agent", + hasSubagents: true, + }); + } + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("sums owned OpenCode step usage and marks unresolved usage partial", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-step-usage"); + const busy = promiseWithResolvers(); + const firstStep = promiseWithResolvers(); + const assistantMessage = promiseWithResolvers(); + const duplicateStep = promiseWithResolvers(); + const secondStep = promiseWithResolvers(); + const unresolvedHeader = promiseWithResolvers(); + const unresolvedStep = promiseWithResolvers(); + const recoveredStep = promiseWithResolvers(); + const recoveredIncompleteHeader = promiseWithResolvers(); + const recoveredCompleteHeader = promiseWithResolvers(); + const childSession = promiseWithResolvers(); + const idle = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + busy.promise, + firstStep.promise, + assistantMessage.promise, + duplicateStep.promise, + secondStep.promise, + unresolvedHeader.promise, + unresolvedStep.promise, + recoveredStep.promise, + recoveredIncompleteHeader.promise, + recoveredCompleteHeader.promise, + childSession.promise, + idle.promise, + ]; + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "Use two model steps", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + busy.resolve({ + id: "evt-step-usage-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + yield* Fiber.join(sendFiber); + const promptMessageId = (runtimeMock.state.promptCalls[0] as { messageID: string }).messageID; + + const stepPart = { + id: "step-usage-1", + sessionID: "http://127.0.0.1:9999/session", + messageID: "assistant-step-usage-1", + type: "step-finish", + reason: "tool-calls", + cost: 0, + tokens: { + input: 100, + output: 20, + reasoning: 5, + cache: { read: 40, write: 10 }, + }, + } as const; + firstStep.resolve({ + id: "evt-step-usage-1", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: stepPart, + }, + }); + assistantMessage.resolve({ + id: "evt-step-usage-assistant", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "assistant-step-usage-1", + role: "assistant", + parentID: promptMessageId, + }, + }, + }); + duplicateStep.resolve({ + id: "evt-step-usage-1-duplicate", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: stepPart, + }, + }); + secondStep.resolve({ + id: "evt-step-usage-2", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + ...stepPart, + id: "step-usage-2", + tokens: { + input: 50, + output: 10, + reasoning: 2, + cache: { read: 10, write: 0 }, + }, + }, + }, + }); + unresolvedHeader.resolve({ + id: "evt-step-usage-unresolved-header", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "assistant-step-usage-without-parent", + role: "assistant", + }, + }, + }); + unresolvedStep.resolve({ + id: "evt-step-usage-unresolved", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + ...stepPart, + id: "step-usage-unresolved", + messageID: "assistant-step-usage-without-parent", + tokens: { + input: 1_000, + output: 1_000, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }, + }); + recoveredStep.resolve({ + id: "evt-step-usage-recovered", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + ...stepPart, + id: "step-usage-recovered", + messageID: "assistant-step-usage-recovered", + tokens: { + input: 30, + output: 10, + reasoning: 2, + cache: { read: 5, write: 1 }, + }, + }, + }, + }); + recoveredIncompleteHeader.resolve({ + id: "evt-step-usage-recovered-incomplete-header", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "assistant-step-usage-recovered", + role: "assistant", + parentID: "", + }, + }, + }); + recoveredCompleteHeader.resolve({ + id: "evt-step-usage-recovered-complete-header", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "assistant-step-usage-recovered", + role: "assistant", + parentID: promptMessageId, + }, + }, + }); + childSession.resolve({ + id: "evt-step-usage-child", + type: "session.created", + properties: { + info: { + id: "child-step-usage", + parentID: "http://127.0.0.1:9999/session", + }, + }, + }); + idle.resolve({ + id: "evt-step-usage-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + + const completed = yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")); + NodeAssert.equal(completed._tag, "Some"); + if (completed._tag === "Some" && completed.value.type === "turn.completed") { + NodeAssert.deepStrictEqual(completed.value.payload.tokenUsage, { + usageStatus: "partial", + usageScope: "main_agent", + inputTokens: 246, + cachedInputTokens: 55, + cacheCreationTokens: 11, + outputTokens: 49, + reasoningTokens: 9, + hasSubagents: true, + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("keeps the next turn usage while the prior completion is delayed", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-token-usage-terminal-handoff"); + const firstBusy = promiseWithResolvers(); + const firstAssistantMessage = promiseWithResolvers(); + const firstStep = promiseWithResolvers(); + const firstIdle = promiseWithResolvers(); + const secondBusy = promiseWithResolvers(); + const secondAssistantMessage = promiseWithResolvers(); + const secondStep = promiseWithResolvers(); + const secondIdle = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + firstBusy.promise, + firstAssistantMessage.promise, + firstStep.promise, + firstIdle.promise, + secondBusy.promise, + secondAssistantMessage.promise, + secondStep.promise, + secondIdle.promise, + ]; + + const firstStepWriteStarted = yield* Deferred.make(); + const firstStepWriteRelease = yield* Deferred.make(); + const terminalUuidStarted = yield* Deferred.make(); + const terminalUuidRelease = yield* Deferred.make(); + let blockFirstStepWrite = true; + let blockNextUuid = false; + const nodeCrypto = yield* Crypto.Crypto; + const gatedCrypto = { + ...nodeCrypto, + randomUUIDv4: Effect.suspend(() => { + if (!blockNextUuid) return nodeCrypto.randomUUIDv4; + blockNextUuid = false; + return Deferred.succeed(terminalUuidStarted, undefined).pipe( + Effect.andThen(Deferred.await(terminalUuidRelease)), + Effect.andThen(nodeCrypto.randomUUIDv4), + ); + }), + } satisfies Crypto.Crypto; + const adapter = yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { + nativeEventLogger: { + filePath: "memory://opencode-token-usage-terminal-handoff", + write: (record) => { + const eventType = (record as { event?: { type?: unknown } }).event?.type; + if (blockFirstStepWrite && eventType === "message.part.updated") { + blockFirstStepWrite = false; + return Deferred.succeed(firstStepWriteStarted, undefined).pipe( + Effect.andThen(Deferred.await(firstStepWriteRelease)), + ); + } + return Effect.void; + }, + close: () => Effect.void, + }, + }).pipe(Effect.provideService(Crypto.Crypto, gatedCrypto)); + + const completedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const firstSend = yield* adapter + .sendTurn({ + threadId, + input: "Run the first token handoff turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + firstBusy.resolve({ + id: "evt-token-handoff-first-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + const firstTurn = yield* Fiber.join(firstSend); + const firstPromptMessageId = (runtimeMock.state.promptCalls[0] as { messageID: string }) + .messageID; + firstAssistantMessage.resolve({ + id: "evt-token-handoff-first-assistant", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "assistant-token-handoff-first", + role: "assistant", + parentID: firstPromptMessageId, + }, + }, + }); + firstStep.resolve({ + id: "evt-token-handoff-first-step", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "step-token-handoff-first", + sessionID: "http://127.0.0.1:9999/session", + messageID: "assistant-token-handoff-first", + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { + input: 100, + output: 20, + reasoning: 5, + cache: { read: 40, write: 10 }, + }, + }, + }, + }); + yield* Deferred.await(firstStepWriteStarted); + blockNextUuid = true; + firstIdle.resolve({ + id: "evt-token-handoff-first-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + yield* Deferred.succeed(firstStepWriteRelease, undefined); + yield* Deferred.await(terminalUuidStarted); + + runtimeMock.state.sessionStatus = "busy"; + const secondTurn = yield* adapter.sendTurn({ + threadId, + input: "Run the second token handoff turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + NodeAssert.notEqual(secondTurn.turnId, firstTurn.turnId); + const secondPromptMessageId = (runtimeMock.state.promptCalls[1] as { messageID: string }) + .messageID; + yield* Deferred.succeed(terminalUuidRelease, undefined); + + secondBusy.resolve({ + id: "evt-token-handoff-second-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + secondAssistantMessage.resolve({ + id: "evt-token-handoff-second-assistant", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "assistant-token-handoff-second", + role: "assistant", + parentID: secondPromptMessageId, + }, + }, + }); + secondStep.resolve({ + id: "evt-token-handoff-second-step", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "step-token-handoff-second", + sessionID: "http://127.0.0.1:9999/session", + messageID: "assistant-token-handoff-second", + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { + input: 90, + output: 13, + reasoning: 3, + cache: { read: 20, write: 5 }, + }, + }, + }, + }); + secondIdle.resolve({ + id: "evt-token-handoff-second-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + + const completed = Array.from( + yield* Fiber.join(completedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.deepStrictEqual( + completed.map((event) => + event.type === "turn.completed" ? event.payload.tokenUsage : undefined, + ), + [ + { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 150, + cachedInputTokens: 40, + cacheCreationTokens: 10, + outputTokens: 25, + reasoningTokens: 5, + hasSubagents: false, + }, + { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 115, + cachedInputTokens: 20, + cacheCreationTokens: 5, + outputTokens: 16, + reasoningTokens: 3, + hasSubagents: false, + }, + ], + ); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("ignores a stale admission status response after the next turn starts", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -2232,6 +2784,188 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("ignores a late prior-turn step after the next prompt is admitted", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-token-usage-late-prior-step"); + const firstBusy = promiseWithResolvers(); + const firstAssistant = promiseWithResolvers(); + const secondBusy = promiseWithResolvers(); + const secondAssistant = promiseWithResolvers(); + const lateFirstStep = promiseWithResolvers(); + const secondStep = promiseWithResolvers(); + const secondIdle = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + firstBusy.promise, + firstAssistant.promise, + secondBusy.promise, + secondAssistant.promise, + lateFirstStep.promise, + secondStep.promise, + secondIdle.promise, + ]; + + const terminalsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "turn.aborted" || event.type === "turn.completed"), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const firstSend = yield* adapter + .sendTurn({ + threadId, + input: "Start the interrupted turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + firstBusy.resolve({ + id: "evt-token-late-first-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + yield* Fiber.join(firstSend); + const firstPromptMessageId = (runtimeMock.state.promptCalls[0] as { messageID: string }) + .messageID; + firstAssistant.resolve({ + id: "evt-token-late-first-assistant", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "assistant-token-late-first", + role: "assistant", + parentID: firstPromptMessageId, + }, + }, + }); + yield* Effect.yieldNow; + yield* adapter.interruptTurn(threadId); + + const secondSend = yield* adapter + .sendTurn({ + threadId, + input: "Start the next turn", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }) + .pipe(Effect.forkChild); + while (runtimeMock.state.promptCalls.length < 2) { + yield* Effect.yieldNow; + } + secondBusy.resolve({ + id: "evt-token-late-second-busy", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "busy" }, + }, + }); + yield* Fiber.join(secondSend); + const secondPromptMessageId = (runtimeMock.state.promptCalls[1] as { messageID: string }) + .messageID; + secondAssistant.resolve({ + id: "evt-token-late-second-assistant", + type: "message.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + info: { + id: "assistant-token-late-second", + role: "assistant", + parentID: secondPromptMessageId, + }, + }, + }); + lateFirstStep.resolve({ + id: "evt-token-late-first-step", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "step-token-late-first", + sessionID: "http://127.0.0.1:9999/session", + messageID: "assistant-token-late-first", + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { + input: 1_000, + output: 1_000, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + }, + }); + secondStep.resolve({ + id: "evt-token-late-second-step", + type: "message.part.updated", + properties: { + sessionID: "http://127.0.0.1:9999/session", + part: { + id: "step-token-late-second", + sessionID: "http://127.0.0.1:9999/session", + messageID: "assistant-token-late-second", + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { + input: 40, + output: 10, + reasoning: 2, + cache: { read: 5, write: 1 }, + }, + }, + }, + }); + secondIdle.resolve({ + id: "evt-token-late-second-idle", + type: "session.status", + properties: { + sessionID: "http://127.0.0.1:9999/session", + status: { type: "idle" }, + }, + }); + + const terminals = Array.from( + yield* Fiber.join(terminalsFiber).pipe(Effect.timeout("1 second")), + ); + const secondCompleted = terminals.find((event) => event.type === "turn.completed"); + NodeAssert.equal(secondCompleted?.type, "turn.completed"); + if (secondCompleted?.type === "turn.completed") { + NodeAssert.deepStrictEqual(secondCompleted.payload.tokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 46, + cachedInputTokens: 5, + cacheCreationTokens: 1, + outputTokens: 12, + reasoningTokens: 2, + hasSubagents: false, + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("reconciles a sole idle when the matching prompt echo arrives later", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; @@ -5464,6 +6198,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { return Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; + const startedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.started"), + Stream.runHead, + Effect.forkChild, + ); yield* adapter.startSession({ provider: ProviderDriverKind.make("opencode"), threadId: asThreadId("thread-custom-instance"), @@ -5502,6 +6241,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), parts: [{ type: "text", text: "Fix it" }], }); + const started = yield* Fiber.join(startedFiber); + NodeAssert.equal(started._tag, "Some"); + if (started._tag === "Some" && started.value.type === "turn.started") { + NodeAssert.equal(started.value.payload.effort, undefined); + } }).pipe(Effect.provide(adapterLayer)); }); @@ -6153,6 +6897,17 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { ["Current response", "zero"], ); NodeAssert.equal(events.filter((event) => event.type === "item.completed").length, 1); + const completed = events.find((event) => event.type === "turn.completed"); + NodeAssert.deepEqual(completed?.payload.tokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 46, + cachedInputTokens: 5, + cacheCreationTokens: 1, + outputTokens: 12, + reasoningTokens: 2, + hasSubagents: false, + }); yield* adapter.stopSession(threadId); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 6252cbeb0..66d07ba8f 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -10,6 +10,7 @@ import { RuntimeRequestId, ThreadId, type ToolLifecycleItemType, + type TurnTokenUsage, TurnId, type UserInputQuestion, } from "@t3tools/contracts"; @@ -333,6 +334,8 @@ type OpenCodeTextPartState = Pick, "id" | "tokens">; + interface OpenCodeSessionContext { session: ProviderSession; /** Immutable identity captured before the context can be removed or replaced. */ @@ -351,6 +354,7 @@ interface OpenCodeSessionContext { readonly messageRoleById: Map; // Completed text can receive later edits; native removal releases retained text. readonly textPartsByMessageId: Map>; + turnTokenUsage: OpenCodeTurnTokenUsageAccumulator | undefined; activeTurnId: TurnId | undefined; activeAgent: string | undefined; activeVariant: string | undefined; @@ -382,6 +386,78 @@ interface OpenCodeSessionContext { readonly sessionScope: Scope.Closeable; } +interface OpenCodeTurnTokenUsageAccumulator { + readonly partIds: Set; + readonly promptMessageIds: Set; + readonly assistantOwnershipByMessageId: Map; + // Native removal does not undo usage. Keep unresolved counts until this turn settles. + readonly unresolvedStepsByMessageId: Map>; + inputTokens: number; + cachedInputTokens: number; + cacheCreationTokens: number; + outputTokens: number; + reasoningTokens: number; + complete: boolean; + hasSubagents: boolean; +} + +function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumulator { + return { + partIds: new Set(), + promptMessageIds: new Set(), + assistantOwnershipByMessageId: new Map(), + unresolvedStepsByMessageId: new Map(), + inputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + complete: true, + hasSubagents: false, + }; +} + +function accumulateOpenCodeStepUsage( + accumulator: OpenCodeTurnTokenUsageAccumulator, + part: OpenCodeStepUsage, +): void { + if (accumulator.partIds.has(part.id)) return; + accumulator.partIds.add(part.id); + accumulator.inputTokens += part.tokens.input + part.tokens.cache.read + part.tokens.cache.write; + accumulator.cachedInputTokens += part.tokens.cache.read; + accumulator.cacheCreationTokens += part.tokens.cache.write; + accumulator.outputTokens += part.tokens.output + part.tokens.reasoning; + accumulator.reasoningTokens += part.tokens.reasoning; +} + +function takeOpenCodeTurnTokenUsage( + context: OpenCodeSessionContext, + complete: boolean, +): TurnTokenUsage { + const usage = context.turnTokenUsage; + context.turnTokenUsage = undefined; + if (!usage || usage.partIds.size === 0) { + return { + usageStatus: "unavailable", + usageScope: "main_agent", + hasSubagents: usage?.hasSubagents ?? false, + }; + } + return { + usageStatus: + complete && usage.complete && usage.unresolvedStepsByMessageId.size === 0 + ? "complete" + : "partial", + usageScope: "main_agent", + inputTokens: usage.inputTokens, + cachedInputTokens: usage.cachedInputTokens, + cacheCreationTokens: usage.cacheCreationTokens, + outputTokens: usage.outputTokens, + reasoningTokens: Math.min(usage.outputTokens, usage.reasoningTokens), + hasSubagents: usage.hasSubagents, + }; +} + export interface OpenCodeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; @@ -1069,6 +1145,7 @@ export function makeOpenCodeAdapter( ) { context.pendingIdleReconciliation = undefined; } + const tokenUsage = takeOpenCodeTurnTokenUsage(context, true); context.activeTurnId = undefined; context.activeAgent = undefined; context.activeVariant = undefined; @@ -1098,6 +1175,7 @@ export function makeOpenCodeAdapter( type: "turn.completed", payload: { state: "completed", + tokenUsage, }, }); }); @@ -1235,6 +1313,7 @@ export function makeOpenCodeAdapter( deleteContextIfCurrent(context); return; } + const tokenUsage = takeOpenCodeTurnTokenUsage(context, false); context.promptAdmission = undefined; context.activeTurnId = undefined; context.activeAgent = undefined; @@ -1256,6 +1335,7 @@ export function makeOpenCodeAdapter( payload: { state: "failed", errorMessage: detail, + tokenUsage, }, }); yield* emit(context.sessionIncarnationId, { @@ -1434,7 +1514,13 @@ export function makeOpenCodeAdapter( if (cancellation) { context.cancellation = undefined; } + let tokenUsage: TurnTokenUsage = { + usageStatus: "unavailable", + usageScope: "main_agent", + hasSubagents: false, + }; if (context.activeTurnId === turnId) { + tokenUsage = takeOpenCodeTurnTokenUsage(context, false); context.activeTurnId = undefined; context.activeAgent = undefined; context.activeVariant = undefined; @@ -1454,6 +1540,7 @@ export function makeOpenCodeAdapter( type: "turn.aborted", payload: { reason: "Interrupted by user.", + tokenUsage, }, }); if (cancellation) { @@ -1578,6 +1665,16 @@ export function makeOpenCodeAdapter( } }); + // Records a child session of this thread. A child seen during a live turn + // means that turn used subagents, whether the relation came from a + // `session.created` event or a later ancestry lookup after reconnect. + const addRelatedOpenCodeSession = (context: OpenCodeSessionContext, sessionId: string) => { + context.relatedSessionIds.add(sessionId); + if (context.activeTurnId && context.turnTokenUsage) { + context.turnTokenUsage.hasSubagents = true; + } + }; + const isRelatedOpenCodeSession = Effect.fn("isRelatedOpenCodeSession")(function* ( context: OpenCodeSessionContext, candidateSessionId: string, @@ -1609,7 +1706,7 @@ export function makeOpenCodeAdapter( let sessionId: string | undefined = candidateSessionId; for (let depth = 0; sessionId !== undefined && depth < 32; depth += 1) { if (context.relatedSessionIds.has(sessionId)) { - context.relatedSessionIds.add(candidateSessionId); + addRelatedOpenCodeSession(context, candidateSessionId); return true; } if (seen.has(sessionId)) { @@ -2109,6 +2206,9 @@ export function makeOpenCodeAdapter( } yield* schedulePendingRequestRecovery(context); if (!isFirstConnection) { + if (context.turnTokenUsage) { + context.turnTokenUsage.complete = false; + } yield* schedulePromptAdmissionRecovery(context, event); if (context.activeTurnId !== undefined && context.promptAdmission === undefined) { yield* scheduleIdleReconciliation(context, context.activeTurnId, event); @@ -2128,7 +2228,7 @@ export function makeOpenCodeAdapter( if (event.type === "session.created" || event.type === "session.updated") { const session = event.properties.info; if (session.parentID && context.relatedSessionIds.has(session.parentID)) { - context.relatedSessionIds.add(session.id); + addRelatedOpenCodeSession(context, session.id); } } else if (event.type === "session.deleted") { context.relatedSessionIds.delete(event.properties.info.id); @@ -2258,6 +2358,37 @@ export function makeOpenCodeAdapter( context.textPartsByMessageId.delete(event.properties.info.id); } if (event.properties.info.role === "assistant") { + const usage = context.turnTokenUsage; + const parentMessageId = + typeof event.properties.info.parentID === "string" && + event.properties.info.parentID.trim().length > 0 + ? event.properties.info.parentID + : undefined; + const observedOwnership = + parentMessageId === undefined + ? "unknown" + : usage?.promptMessageIds.has(parentMessageId) + ? "owned" + : "other"; + const priorOwnership = usage?.assistantOwnershipByMessageId.get( + event.properties.info.id, + ); + const ownership = + priorOwnership === undefined || priorOwnership === "unknown" + ? observedOwnership + : priorOwnership; + if (usage) { + usage.assistantOwnershipByMessageId.set(event.properties.info.id, ownership); + if (ownership !== "unknown") { + const steps = usage.unresolvedStepsByMessageId.get(event.properties.info.id); + if (ownership === "owned" && steps) { + for (const step of steps.values()) { + accumulateOpenCodeStepUsage(usage, step); + } + } + usage.unresolvedStepsByMessageId.delete(event.properties.info.id); + } + } for (const part of context.textPartsByMessageId .get(event.properties.info.id) ?.values() ?? []) { @@ -2325,6 +2456,24 @@ export function makeOpenCodeAdapter( const part = event.properties.part; const messageRole = messageRoleForPart(context, part); + if (turnId && part.type === "step-finish" && context.turnTokenUsage) { + const usage = context.turnTokenUsage; + const ownership = usage.assistantOwnershipByMessageId.get(part.messageID); + if (ownership === "owned") { + accumulateOpenCodeStepUsage(usage, part); + } else if ( + ownership === "unknown" || + (ownership === undefined && + context.messageRoleById.get(part.messageID) !== "assistant") + ) { + const steps = + usage.unresolvedStepsByMessageId.get(part.messageID) ?? + new Map(); + steps.set(part.id, { id: part.id, tokens: part.tokens }); + usage.unresolvedStepsByMessageId.set(part.messageID, steps); + } + } + if ((part.type === "text" || part.type === "reasoning") && messageRole !== "user") { const state = retainOpenCodeTextPart(context, part); if (messageRole === "assistant") { @@ -2529,6 +2678,7 @@ export function makeOpenCodeAdapter( terminalCancellation.turnSettled = true; terminalCancellation.acknowledged = true; } + const tokenUsage = activeTurnId ? takeOpenCodeTurnTokenUsage(context, false) : undefined; context.activeTurnId = undefined; context.activeAgent = undefined; context.activeVariant = undefined; @@ -2553,6 +2703,7 @@ export function makeOpenCodeAdapter( payload: { state: "failed", errorMessage: message, + tokenUsage, }, }); } @@ -2874,6 +3025,7 @@ export function makeOpenCodeAdapter( pendingQuestions: new Map(), textPartsByMessageId: new Map(), messageRoleById: new Map(), + turnTokenUsage: undefined, activeTurnId: undefined, activeAgent: undefined, activeVariant: undefined, @@ -3047,6 +3199,10 @@ export function makeOpenCodeAdapter( context.promptAdmission = promptAdmission; context.activeTurnId = turnId; + if (steeringTurnId === undefined) { + context.turnTokenUsage = makeOpenCodeTurnTokenUsageAccumulator(); + } + context.turnTokenUsage?.promptMessageIds.add(messageId); context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); context.activeVariant = variant; if (steeringTurnId === undefined) { @@ -3080,7 +3236,6 @@ export function makeOpenCodeAdapter( : {}), payload: { model: modelSelection?.model ?? context.session.model, - ...(variant ? { effort: variant } : {}), }, }); } @@ -3145,6 +3300,7 @@ export function makeOpenCodeAdapter( } return; } + const tokenUsage = takeOpenCodeTurnTokenUsage(context, false); context.promptAdmission = undefined; context.activeTurnId = undefined; context.activeAgent = undefined; @@ -3161,7 +3317,10 @@ export function makeOpenCodeAdapter( yield* emit(context.sessionIncarnationId, { ...(yield* buildEventBase({ threadId: input.threadId, turnId })), type: "turn.aborted", - payload: { reason: requestError.detail }, + payload: { + reason: requestError.detail, + tokenUsage, + }, }); return; } @@ -3189,6 +3348,7 @@ export function makeOpenCodeAdapter( }); return; } + const tokenUsage = takeOpenCodeTurnTokenUsage(context, false); context.promptAdmission = undefined; context.activeTurnId = undefined; context.activeAgent = undefined; @@ -3212,6 +3372,7 @@ export function makeOpenCodeAdapter( type: "turn.aborted", payload: { reason: requestError.detail, + tokenUsage, }, }); }), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 587d651cc..0067ebbde 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -581,6 +581,69 @@ function makeFakeCodexAdapter( }; } +interface RecordedAnalyticsEvent { + readonly event: string; + readonly properties?: Readonly>; +} + +function makeRecordingAnalytics() { + const events: Array = []; + const layer = Layer.succeed( + AnalyticsService.AnalyticsService, + AnalyticsService.AnalyticsService.of({ + record: (event, properties) => + Effect.sync(() => { + events.push({ event, ...(properties ? { properties } : {}) }); + }), + flush: Effect.void, + }), + ); + + return { + layer, + reset: () => { + events.length = 0; + }, + eventsByName: (event: string) => events.filter((entry) => entry.event === event), + }; +} + +function makeStaticInstanceRegistry( + entries: ReadonlyArray]>, +): ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] { + const adapters = new Map(entries); + const unsupported = (instanceId: ProviderInstanceId) => + new ProviderUnsupportedError({ + provider: ProviderDriverKind.make(instanceId), + }); + + return { + getByInstance: (instanceId) => { + const adapter = adapters.get(instanceId); + return adapter ? Effect.succeed(adapter) : Effect.fail(unsupported(instanceId)); + }, + getInstanceInfo: (instanceId) => { + const adapter = adapters.get(instanceId); + return adapter + ? Effect.succeed({ + instanceId, + driverKind: adapter.provider, + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind: adapter.provider, + continuationKey: `${adapter.provider}:instance:${instanceId}`, + }, + }) + : Effect.fail(unsupported(instanceId)); + }, + listInstances: () => Effect.succeed(Array.from(adapters.keys())), + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }; +} + const advanceTestClock = (ms: number) => TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); @@ -598,6 +661,7 @@ const hasMetricSnapshot = ( function makeProviderServiceLayer( input: { readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; + readonly analyticsLayer?: Layer.Layer; readonly registry?: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"]; } = {}, ) { @@ -639,7 +703,7 @@ function makeProviderServiceLayer( Layer.provide(rollbackRepositoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(serverConfigTestLayer), - Layer.provideMerge(AnalyticsService.layerTest), + Layer.provideMerge(input.analyticsLayer ?? AnalyticsService.layerTest), Layer.provide( Layer.succeed( ProviderEventLoggers.ProviderEventLoggers, @@ -845,6 +909,125 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => }), ); +it.effect("ProviderServiceLive flushes deferred completions during shutdown", () => + Effect.gen(function* () { + const recordedAnalytics = makeRecordingAnalytics(); + const codex = makeFakeCodexAdapter(); + const registry = makeStaticInstanceRegistry([[codexInstanceId, codex.adapter]]); + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); + const providerLayer = Layer.mergeAll( + makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(recordedAnalytics.layer), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + directoryLayer, + runtimeRepositoryLayer, + NodeServices.layer, + ); + const scope = yield* Scope.make(); + const runtimeServices = yield* Layer.build(providerLayer).pipe(Scope.provide(scope)); + const provider = yield* ProviderService.ProviderService.pipe(Effect.provide(runtimeServices)); + const threadId = asThreadId("thread-turn-analytics-stop-all-deferred"); + const firstStarted = yield* Deferred.make(); + const secondStarted = yield* Deferred.make(); + const sendRelease = yield* Deferred.make(); + const turnId = asTurnId("turn-analytics-stop-all-deferred"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + codex.sendTurn + .mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(firstStarted, undefined); + yield* Deferred.await(sendRelease); + return { threadId, turnId }; + }), + ) + .mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(secondStarted, undefined); + yield* Deferred.await(sendRelease); + return { threadId, turnId: asTurnId("turn-analytics-stop-all-other") }; + }), + ); + + const firstSend = yield* provider + .sendTurn({ threadId, input: "first", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstStarted); + const secondSend = yield* provider + .sendTurn({ threadId, input: "second", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(secondStarted); + + const runtimeEvents = yield* Stream.take(provider.streamEvents, 2).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + codex.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-analytics-stop-all-deferred-start"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { model: "native-stop-all" }, + }); + codex.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-stop-all-deferred-complete"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { + state: "completed", + tokenUsage: { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 1_200, + outputTokens: 300, + hasSubagents: false, + }, + }, + }); + yield* Fiber.join(runtimeEvents); + assert.equal(recordedAnalytics.eventsByName("provider.turn.completed").length, 0); + + const closeExit = yield* Scope.close(scope, Exit.void).pipe(Effect.exit); + const completed = recordedAnalytics.eventsByName("provider.turn.completed"); + assert.equal(Exit.isSuccess(closeExit), true); + assert.equal(completed.length, 1); + assert.equal(completed[0]?.properties?.model, "native-stop-all"); + assert.equal(completed[0]?.properties?.inputTokens, 1_200); + assert.equal(completed[0]?.properties?.outputTokens, 300); + yield* Fiber.interrupt(firstSend); + yield* Fiber.interrupt(secondSend); + assert.equal(recordedAnalytics.eventsByName("provider.turn.completed").length, 1); + }), +); + it.effect("ProviderServiceLive rejects new sessions for disabled providers", () => Effect.gen(function* () { const codex = makeFakeCodexAdapter(); @@ -3940,7 +4123,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { threadId: session.threadId, turnId: asTurnId("turn-1"), sessionIncarnationId: session.sessionIncarnationId, - status: "completed", + payload: { state: "completed" }, }; fanout.codex.emit(completedEvent); @@ -4226,7 +4409,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { threadId: session.threadId, turnId: asTurnId("turn-1"), sessionIncarnationId: session.sessionIncarnationId, - status: "completed", + payload: { state: "completed" }, }); yield* Fiber.join(consumer); @@ -4292,7 +4475,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { createdAt: "2026-01-01T00:00:00.000Z", threadId: session.threadId, turnId: asTurnId("turn-1"), - status: "completed", + payload: { state: "completed" }, }, ]; @@ -4536,6 +4719,1070 @@ citations.layer("ProviderServiceLive assistant citations", (it) => { ); }); +const recordedTurnAnalytics = makeRecordingAnalytics(); +const secondaryCodexInstanceId = ProviderInstanceId.make("codex_work"); +const primaryAnalyticsCodex = makeFakeCodexAdapter(); +const secondaryAnalyticsCodex = makeFakeCodexAdapter(); +const turnAnalytics = makeProviderServiceLayer({ + analyticsLayer: recordedTurnAnalytics.layer, + registry: makeStaticInstanceRegistry([ + [codexInstanceId, primaryAnalyticsCodex.adapter], + [secondaryCodexInstanceId, secondaryAnalyticsCodex.adapter], + ]), +}); + +turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { + it.effect("records one completed-turn event with the allowed properties", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-complete"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const turn = yield* provider.sendTurn({ + threadId, + input: "measure this turn", + attachments: [], + interactionMode: "plan", + modelSelection: createModelSelection(codexInstanceId, "gpt-5.6-sol", [ + { id: "reasoningEffort", value: "high" }, + ]), + }); + yield* advanceTestClock(40); + + const runtimeEvents = yield* Stream.take(provider.streamEvents, 2).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const completedEvent: LegacyProviderRuntimeEvent = { + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-complete"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: turn.turnId, + payload: { + state: "completed", + tokenUsage: { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 1_200, + cachedInputTokens: 800, + cacheCreationTokens: 100, + outputTokens: 300, + reasoningTokens: 120, + hasSubagents: false, + }, + }, + }; + primaryAnalyticsCodex.emit(completedEvent); + primaryAnalyticsCodex.emit({ + ...completedEvent, + eventId: asEventId("evt-turn-analytics-complete-duplicate"), + }); + yield* Fiber.join(runtimeEvents); + + const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 1); + assert.deepEqual(completed[0]?.properties, { + provider: CODEX_DRIVER, + model: "gpt-5.6-sol", + effort: "high", + interactionMode: "plan", + runtimeMode: "full-access", + mixedModels: false, + durationMs: 40, + terminalStatus: "completed", + usageStatus: "complete", + usageScope: "main_agent", + hasSubagents: false, + inputTokens: 1_200, + cachedInputTokens: 800, + cacheCreationTokens: 100, + outputTokens: 300, + reasoningTokens: 120, + }); + }), + ); + + it.effect("does not report a generic model variant as reasoning effort", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-generic-variant"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const turn = yield* provider.sendTurn({ + threadId, + input: "use the provider preset", + attachments: [], + modelSelection: createModelSelection(codexInstanceId, "provider/model", [ + { id: "variant", value: "high" }, + ]), + }); + + const runtimeEvent = yield* Stream.take(provider.streamEvents, 1).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + primaryAnalyticsCodex.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-generic-variant"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: turn.turnId, + payload: { state: "completed" }, + }); + yield* Fiber.join(runtimeEvent); + + const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 1); + assert.notProperty(completed[0]?.properties ?? {}, "effort"); + }), + ); + + it.effect("rejects model metadata bound to another provider instance", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-mismatched-model-instance"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + // Pylon rejects a selection for another instance before the send starts, + // so its model and effort can never reach this session's turn analytics. + const result = yield* provider + .sendTurn({ + threadId, + input: "ignore this mismatched selection", + attachments: [], + modelSelection: createModelSelection(secondaryCodexInstanceId, "wrong-model", [ + { id: "reasoningEffort", value: "high" }, + ]), + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 0); + assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.sent").length, 0); + }), + ); + + it.effect("keeps overlapping request metadata with out-of-order adapter responses", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-overlap"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + + const firstStarted = yield* Deferred.make(); + const secondStarted = yield* Deferred.make(); + const firstRelease = yield* Deferred.make(); + const secondRelease = yield* Deferred.make(); + const initialStartsObserved = yield* Deferred.make(); + let initialStartCount = 0; + const firstTurnId = asTurnId("turn-analytics-overlap-first"); + const secondTurnId = asTurnId("turn-analytics-overlap-second"); + primaryAnalyticsCodex.sendTurn + .mockImplementationOnce((input) => + Effect.gen(function* () { + yield* Deferred.succeed(firstStarted, undefined); + yield* Deferred.await(firstRelease); + return { threadId: input.threadId, turnId: firstTurnId }; + }), + ) + .mockImplementationOnce((input) => + Effect.gen(function* () { + yield* Deferred.succeed(secondStarted, undefined); + yield* Deferred.await(secondRelease); + return { threadId: input.threadId, turnId: secondTurnId }; + }), + ); + + const runtimeEvents = yield* Stream.take(provider.streamEvents, 5).pipe( + Stream.tap((event) => { + if (event.type !== "turn.started" || initialStartCount >= 2) return Effect.void; + initialStartCount += 1; + return initialStartCount === 2 + ? Deferred.succeed(initialStartsObserved, undefined).pipe(Effect.asVoid) + : Effect.void; + }), + Stream.runDrain, + Effect.forkChild, + ); + const firstSend = yield* provider + .sendTurn({ + threadId, + input: "first", + attachments: [], + interactionMode: "default", + modelSelection: createModelSelection(codexInstanceId, "requested-first"), + }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstStarted); + const secondSend = yield* provider + .sendTurn({ + threadId, + input: "second", + attachments: [], + interactionMode: "plan", + modelSelection: createModelSelection(codexInstanceId, "requested-second"), + }) + .pipe(Effect.forkChild); + yield* Deferred.await(secondStarted); + + for (const [turnId, suffix] of [ + [firstTurnId, "first"], + [secondTurnId, "second"], + ] as const) { + primaryAnalyticsCodex.emit({ + type: "turn.started", + eventId: asEventId(`evt-turn-analytics-overlap-start-${suffix}`), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { model: `native-${suffix}`, effort: `native-effort-${suffix}` }, + }); + } + yield* Deferred.await(initialStartsObserved); + yield* Deferred.succeed(secondRelease, undefined); + yield* Fiber.join(secondSend); + primaryAnalyticsCodex.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-analytics-overlap-start-second-duplicate"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: secondTurnId, + payload: { model: "native-second", effort: "native-effort-second" }, + }); + yield* Deferred.succeed(firstRelease, undefined); + yield* Fiber.join(firstSend); + for (const [turnId, suffix] of [ + [secondTurnId, "second"], + [firstTurnId, "first"], + ] as const) { + primaryAnalyticsCodex.emit({ + type: "turn.completed", + eventId: asEventId(`evt-turn-analytics-overlap-complete-${suffix}`), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { state: "completed" }, + }); + } + yield* Fiber.join(runtimeEvents); + + const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 2); + assert.deepInclude(completed[0]?.properties ?? {}, { + model: "native-second", + effort: "native-effort-second", + interactionMode: "plan", + }); + assert.deepInclude(completed[1]?.properties ?? {}, { + model: "native-first", + effort: "native-effort-first", + interactionMode: "default", + }); + }), + ); + + it.effect("waits for the adapter response when a turn completes before sendTurn returns", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-fast-completion"); + const turnId = asTurnId("turn-analytics-fast-completion"); + const returnRelease = yield* Deferred.make(); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* Effect.yieldNow; + primaryAnalyticsCodex.sendTurn.mockImplementationOnce((input) => + Effect.gen(function* () { + primaryAnalyticsCodex.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-analytics-fast-start"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { model: "native-fast", effort: "high" }, + }); + primaryAnalyticsCodex.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-fast-complete"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { state: "completed" }, + }); + yield* Deferred.await(returnRelease); + return { threadId: input.threadId, turnId }; + }), + ); + + const terminalReceipt = yield* provider.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const sendFiber = yield* provider + .sendTurn({ + threadId, + input: "finish immediately", + attachments: [], + interactionMode: "plan", + modelSelection: createModelSelection(codexInstanceId, "requested-fast"), + }) + .pipe(Effect.forkChild); + const terminal = yield* Fiber.join(terminalReceipt); + assert.equal(terminal._tag, "Some"); + assert.equal(sendFiber.pollUnsafe(), undefined); + assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 0); + + yield* Deferred.succeed(returnRelease, undefined); + yield* Fiber.join(sendFiber); + const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 1); + assert.deepInclude(completed[0]?.properties ?? {}, { + model: "native-fast", + effort: "high", + interactionMode: "plan", + }); + }), + ); + + it.effect("does not give a synthetic turn the metadata of an in-flight send", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-synthetic-start"); + const syntheticTurnId = asTurnId("turn-analytics-synthetic"); + const realTurnId = asTurnId("turn-analytics-real"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* Effect.yieldNow; + // Claude closes a leftover synthetic turn while it prepares the real + // turn, so both events arrive before sendTurn returns the real turn ID. + primaryAnalyticsCodex.sendTurn.mockImplementationOnce((input) => + Effect.gen(function* () { + primaryAnalyticsCodex.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-analytics-synthetic-start"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: syntheticTurnId, + payload: {}, + }); + primaryAnalyticsCodex.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-synthetic-complete"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: syntheticTurnId, + payload: { state: "completed" }, + }); + primaryAnalyticsCodex.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-analytics-real-start"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: realTurnId, + payload: { model: "native-real" }, + }); + yield* Effect.yieldNow; + return { threadId: input.threadId, turnId: realTurnId }; + }), + ); + + yield* provider.sendTurn({ + threadId, + input: "start the real turn", + attachments: [], + interactionMode: "plan", + modelSelection: createModelSelection(codexInstanceId, "requested-real"), + }); + const realCompletion = yield* provider.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed" && event.turnId === realTurnId), + Stream.runHead, + Effect.forkChild, + ); + yield* Effect.yieldNow; + primaryAnalyticsCodex.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-real-complete"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: realTurnId, + payload: { state: "completed" }, + }); + yield* Fiber.join(realCompletion); + + const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 2); + assert.equal(completed[0]?.properties?.interactionMode, undefined); + assert.equal(completed[0]?.properties?.model, undefined); + assert.deepInclude(completed[1]?.properties ?? {}, { + model: "native-real", + interactionMode: "plan", + }); + }), + ); + + it.effect("defers overlapping terminal analytics until exact request association", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-overlap-fast-completion"); + const firstStarted = yield* Deferred.make(); + const secondStarted = yield* Deferred.make(); + const firstRelease = yield* Deferred.make(); + const secondRelease = yield* Deferred.make(); + const firstTurnId = asTurnId("turn-analytics-overlap-fast-first"); + const secondTurnId = asTurnId("turn-analytics-overlap-fast-second"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + primaryAnalyticsCodex.sendTurn + .mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(firstStarted, undefined); + yield* Deferred.await(firstRelease); + return { threadId, turnId: firstTurnId }; + }), + ) + .mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(secondStarted, undefined); + yield* Deferred.await(secondRelease); + return { threadId, turnId: secondTurnId }; + }), + ); + + const runtimeEvents = yield* Stream.take(provider.streamEvents, 4).pipe( + Stream.runDrain, + Effect.forkChild, + ); + const firstSend = yield* provider + .sendTurn({ + threadId, + input: "first fast completion", + attachments: [], + interactionMode: "default", + modelSelection: createModelSelection(codexInstanceId, "requested-first"), + }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstStarted); + yield* advanceTestClock(10); + const secondSend = yield* provider + .sendTurn({ + threadId, + input: "second fast completion", + attachments: [], + interactionMode: "plan", + modelSelection: createModelSelection(codexInstanceId, "requested-second"), + }) + .pipe(Effect.forkChild); + yield* Deferred.await(secondStarted); + yield* advanceTestClock(20); + + for (const [turnId, suffix] of [ + [firstTurnId, "first"], + [secondTurnId, "second"], + ] as const) { + primaryAnalyticsCodex.emit({ + type: "turn.started", + eventId: asEventId(`evt-turn-analytics-overlap-fast-start-${suffix}`), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { model: `native-${suffix}`, effort: `native-effort-${suffix}` }, + }); + primaryAnalyticsCodex.emit({ + type: "turn.completed", + eventId: asEventId(`evt-turn-analytics-overlap-fast-complete-${suffix}`), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { state: "completed" }, + }); + } + yield* Fiber.join(runtimeEvents); + assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 0); + yield* advanceTestClock(40); + + yield* Deferred.succeed(secondRelease, undefined); + yield* Fiber.join(secondSend); + let completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 1); + assert.deepInclude(completed[0]?.properties ?? {}, { + model: "native-second", + effort: "native-effort-second", + interactionMode: "plan", + durationMs: 20, + }); + + yield* Deferred.succeed(firstRelease, undefined); + yield* Fiber.join(firstSend); + completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 2); + assert.deepInclude(completed[1]?.properties ?? {}, { + model: "native-first", + effort: "native-effort-first", + interactionMode: "default", + durationMs: 30, + }); + }), + ); + + it.effect("cleans pending metadata when a send is canceled", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-canceled-send"); + const canceledStarted = yield* Deferred.make(); + const canceledRelease = yield* Deferred.make(); + const nextReturnRelease = yield* Deferred.make(); + const nextTurnId = asTurnId("turn-analytics-after-canceled-send"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + primaryAnalyticsCodex.sendTurn + .mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(canceledStarted, undefined); + yield* Deferred.await(canceledRelease); + return { threadId, turnId: asTurnId("turn-analytics-canceled") }; + }), + ) + .mockImplementationOnce(() => + Effect.gen(function* () { + primaryAnalyticsCodex.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-analytics-after-canceled-start"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: nextTurnId, + payload: { model: "native-next", effort: "high" }, + }); + primaryAnalyticsCodex.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-after-canceled-complete"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: nextTurnId, + payload: { state: "completed" }, + }); + yield* Deferred.await(nextReturnRelease); + return { threadId, turnId: nextTurnId }; + }), + ); + + const canceledSend = yield* provider + .sendTurn({ + threadId, + input: "cancel this request", + attachments: [], + interactionMode: "default", + }) + .pipe(Effect.forkChild); + yield* Deferred.await(canceledStarted); + yield* Fiber.interrupt(canceledSend); + + const terminalReceipt = yield* provider.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const nextSend = yield* provider + .sendTurn({ + threadId, + input: "measure the next request", + attachments: [], + interactionMode: "plan", + }) + .pipe(Effect.forkChild); + const terminal = yield* Fiber.join(terminalReceipt); + assert.equal(terminal._tag, "Some"); + assert.equal(nextSend.pollUnsafe(), undefined); + // The canceled request must not hold the completion. The live request + // still does, until its adapter response links it to the turn. + assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 0); + yield* Deferred.succeed(nextReturnRelease, undefined); + const nextTurn = yield* Fiber.join(nextSend); + assert.equal(nextTurn.turnId, nextTurnId); + + const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 1); + assert.deepInclude(completed[0]?.properties ?? {}, { + model: "native-next", + effort: "high", + interactionMode: "plan", + }); + }), + ); + + it.effect("bounds deferred completions and drains them after sends are canceled", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-bounded-deferred"); + const allStarted = yield* Deferred.make(); + const sendRelease = yield* Deferred.make(); + const turnIds = Array.from({ length: 9 }, (_, index) => + asTurnId(`turn-analytics-bounded-deferred-${index + 1}`), + ); + let startedCount = 0; + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + for (const turnId of turnIds) { + primaryAnalyticsCodex.sendTurn.mockImplementationOnce((input) => + Effect.gen(function* () { + startedCount += 1; + if (startedCount === turnIds.length) { + yield* Deferred.succeed(allStarted, undefined); + } + yield* Deferred.await(sendRelease); + return { threadId: input.threadId, turnId }; + }), + ); + } + + const runtimeEvents = yield* Stream.take(provider.streamEvents, turnIds.length * 2).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const sends = []; + for (let index = 0; index < turnIds.length; index += 1) { + sends.push( + yield* provider + .sendTurn({ + threadId, + input: `bounded deferred ${index + 1}`, + attachments: [], + interactionMode: index % 2 === 0 ? "default" : "plan", + }) + .pipe(Effect.forkChild), + ); + } + yield* Deferred.await(allStarted); + + for (const [index, turnId] of turnIds.entries()) { + primaryAnalyticsCodex.emit({ + type: "turn.started", + eventId: asEventId(`evt-turn-analytics-bounded-deferred-start-${index + 1}`), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { model: `native-bounded-${index + 1}` }, + }); + primaryAnalyticsCodex.emit({ + type: "turn.completed", + eventId: asEventId(`evt-turn-analytics-bounded-deferred-complete-${index + 1}`), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { state: "completed" }, + }); + } + yield* Fiber.join(runtimeEvents); + assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 1); + + for (const send of sends) { + yield* Fiber.interrupt(send); + } + assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 9); + }), + ); + + it.effect("flushes a deferred completion when the session stops", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-stop-deferred"); + const firstStarted = yield* Deferred.make(); + const secondStarted = yield* Deferred.make(); + const sendRelease = yield* Deferred.make(); + const turnId = asTurnId("turn-analytics-stop-deferred"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + primaryAnalyticsCodex.sendTurn + .mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(firstStarted, undefined); + yield* Deferred.await(sendRelease); + return { threadId, turnId }; + }), + ) + .mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(secondStarted, undefined); + yield* Deferred.await(sendRelease); + return { threadId, turnId: asTurnId("turn-analytics-stop-other") }; + }), + ); + + const firstSend = yield* provider + .sendTurn({ threadId, input: "first", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstStarted); + const secondSend = yield* provider + .sendTurn({ threadId, input: "second", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(secondStarted); + + const runtimeEvents = yield* Stream.take(provider.streamEvents, 2).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + primaryAnalyticsCodex.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-analytics-stop-deferred-start"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { model: "native-stop" }, + }); + primaryAnalyticsCodex.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-stop-deferred-complete"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { state: "completed" }, + }); + yield* Fiber.join(runtimeEvents); + assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 0); + + yield* provider.stopSession({ threadId }); + const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 1); + assert.equal(completed[0]?.properties?.model, "native-stop"); + yield* Fiber.interrupt(firstSend); + yield* Fiber.interrupt(secondSend); + }), + ); + + it.effect("keeps the first metadata when steering reuses a rerouted turn", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-steering"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "auto", + }); + const firstTurn = yield* provider.sendTurn({ + threadId, + input: "start", + attachments: [], + interactionMode: "default", + modelSelection: createModelSelection(codexInstanceId, "gpt-5.6-sol", [ + { id: "reasoningEffort", value: "high" }, + ]), + }); + yield* advanceTestClock(10); + + const reroutedEvent = yield* Stream.take(provider.streamEvents, 1).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + primaryAnalyticsCodex.emit({ + type: "model.rerouted", + eventId: asEventId("evt-turn-analytics-rerouted"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: firstTurn.turnId, + payload: { + fromModel: "gpt-5.6-sol", + toModel: "gpt-5.6-terra", + reason: "capacity", + }, + }); + yield* Fiber.join(reroutedEvent); + yield* advanceTestClock(15); + + const steeredTurn = yield* provider.sendTurn({ + threadId, + input: "steer", + attachments: [], + interactionMode: "plan", + modelSelection: createModelSelection(codexInstanceId, "gpt-5.6-terra", [ + { id: "reasoningEffort", value: "low" }, + ]), + }); + assert.equal(steeredTurn.turnId, firstTurn.turnId); + yield* advanceTestClock(20); + + const completedEvent = yield* Stream.take(provider.streamEvents, 1).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + primaryAnalyticsCodex.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-steered-complete"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: firstTurn.turnId, + payload: { + state: "completed", + tokenUsage: { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 500, + outputTokens: 100, + hasSubagents: false, + }, + }, + }); + yield* Fiber.join(completedEvent); + + const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 1); + assert.equal(completed[0]?.properties?.model, "gpt-5.6-sol"); + assert.equal(completed[0]?.properties?.effort, "high"); + assert.equal(completed[0]?.properties?.interactionMode, "default"); + assert.equal(completed[0]?.properties?.mixedModels, true); + assert.equal(completed[0]?.properties?.durationMs, 45); + }), + ); + + it.effect("bounds active metadata while preserving recent delayed completions", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-bounded-active"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const runtimeEvents = yield* Stream.take(provider.streamEvents, 12).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + + for (let index = 1; index <= 10; index += 1) { + primaryAnalyticsCodex.emit({ + type: "turn.started", + eventId: asEventId(`evt-turn-analytics-bounded-start-${index}`), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: asTurnId(`turn-analytics-bounded-${index}`), + payload: { model: `model-${index}` }, + }); + } + for (const index of [3, 1]) { + primaryAnalyticsCodex.emit({ + type: "turn.completed", + eventId: asEventId(`evt-turn-analytics-bounded-complete-${index}`), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: asTurnId(`turn-analytics-bounded-${index}`), + payload: { state: "completed" }, + }); + } + yield* Fiber.join(runtimeEvents); + + const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 2); + assert.equal(completed[0]?.properties?.model, "model-3"); + assert.notProperty(completed[1]?.properties ?? {}, "model"); + }), + ); + + it.effect("separates provider instances and omits unavailable counts", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-instances"); + const turnId = asTurnId("turn-shared-between-instances"); + // A thread routes events from one current instance at a time, so the + // same turn id reaches analytics from each instance across a switch. + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const primaryRuntimeEvent = yield* Stream.take(provider.streamEvents, 1).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + const event: LegacyProviderRuntimeEvent = { + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-primary-instance"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { + state: "completed", + tokenUsage: { + usageStatus: "unavailable", + usageScope: "main_agent", + hasSubagents: false, + }, + }, + }; + primaryAnalyticsCodex.emit(event); + yield* Fiber.join(primaryRuntimeEvent); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: secondaryCodexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const secondaryRuntimeEvent = yield* Stream.take(provider.streamEvents, 1).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + secondaryAnalyticsCodex.emit({ + ...event, + eventId: asEventId("evt-turn-analytics-secondary-instance"), + }); + yield* Fiber.join(secondaryRuntimeEvent); + + const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 2); + for (const entry of completed) { + assert.deepEqual(entry.properties, { + provider: CODEX_DRIVER, + terminalStatus: "completed", + usageStatus: "unavailable", + usageScope: "main_agent", + hasSubagents: false, + }); + } + }), + ); + + it.effect("records known token counts for an interrupted turn", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-interrupted"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "approval-required", + }); + const turn = yield* provider.sendTurn({ + threadId, + input: "stop after some work", + attachments: [], + }); + + const runtimeEvent = yield* Stream.take(provider.streamEvents, 1).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + primaryAnalyticsCodex.emit({ + type: "turn.aborted", + eventId: asEventId("evt-turn-analytics-interrupted"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: turn.turnId, + payload: { + reason: "Interrupted by user", + tokenUsage: { + usageStatus: "partial", + usageScope: "main_agent", + inputTokens: 120, + outputTokens: 30, + hasSubagents: true, + }, + }, + }); + yield* Fiber.join(runtimeEvent); + + const completed = recordedTurnAnalytics.eventsByName("provider.turn.completed"); + assert.equal(completed.length, 1); + assert.equal(completed[0]?.properties?.terminalStatus, "interrupted"); + assert.equal(completed[0]?.properties?.usageStatus, "partial"); + assert.equal(completed[0]?.properties?.inputTokens, 120); + assert.equal(completed[0]?.properties?.outputTokens, 30); + assert.equal(completed[0]?.properties?.hasSubagents, true); + }), + ); +}); + const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { it.effect("rejects citation-expanded input over the provider character limit", () => diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 27ea9aaa7..aa879dc7e 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -56,6 +56,7 @@ import { } from "@t3tools/contracts"; import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { causeErrorTag } from "@t3tools/shared/observability"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -139,6 +140,75 @@ export interface ProviderServiceLiveOptions { readonly onStartReservationCountChange?: (count: number) => void; } +interface TurnAnalyticsMetadata { + readonly requestId: number; + readonly provider: ProviderDriverKind; + readonly startedAtMs: number; + readonly mixedModels: boolean; + readonly model?: string; + readonly effort?: string; + readonly interactionMode?: string; + readonly runtimeMode?: string; +} + +interface ActiveTurnAnalytics { + readonly metadata: TurnAnalyticsMetadata; + readonly requestAssociated: boolean; +} + +interface DeferredTurnAnalyticsCompletion { + readonly completionKey: string; + readonly completedAtMs: number; + readonly terminalProperties: Readonly>; +} + +interface TurnAnalyticsSessionState { + readonly pendingByRequestId: Map; + readonly activeByTurnId: Map; + readonly deferredCompletionsByTurnId: Map; +} + +interface TurnAnalyticsState { + readonly sessions: Map; + readonly completedKeys: Set; + readonly completedOrder: Array; +} + +const MAX_COMPLETED_TURN_ANALYTICS_KEYS = 512; +const MAX_ACTIVE_TURN_ANALYTICS_PER_SESSION = 8; + +function setActiveTurnAnalytics( + session: TurnAnalyticsSessionState, + turnId: string, + active: ActiveTurnAnalytics, +): void { + session.activeByTurnId.set(turnId, active); + while (session.activeByTurnId.size > MAX_ACTIVE_TURN_ANALYTICS_PER_SESSION) { + const oldestTurnId = session.activeByTurnId.keys().next().value; + if (oldestTurnId === undefined) return; + session.activeByTurnId.delete(oldestTurnId); + } +} + +function turnAnalyticsSessionKey(instanceId: ProviderInstanceId, threadId: ThreadId): string { + return `${String(instanceId)}\u0000${String(threadId)}`; +} + +function turnAnalyticsCompletionKey( + instanceId: ProviderInstanceId, + threadId: ThreadId, + turnId: string, +): string { + return `${turnAnalyticsSessionKey(instanceId, threadId)}\u0000${turnId}`; +} + +function turnEffort(modelSelection: ProviderSendTurnInput["modelSelection"]): string | undefined { + return ( + getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? + getModelSelectionStringOptionValue(modelSelection, "effort") + ); +} + type ProviderServiceMethod = ProviderService.ProviderService["Service"][Name]; @@ -488,6 +558,12 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* Deferred.succeed(pending.completion, terminal); return true; }); + const turnAnalytics = yield* Ref.make({ + sessions: new Map(), + completedKeys: new Set(), + completedOrder: [], + }); + let turnAnalyticsRequestId = 0; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const requireAdapterGenerationCurrent = Effect.fnUntraced(function* ( adapter: ProviderAdapterShape, @@ -508,6 +584,359 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( : Effect.flatMap(adapter.runtimeFence.isCurrent, (current) => current ? record : Effect.void, ); + + const finishTurnAnalytics = ( + state: TurnAnalyticsState, + input: { + readonly sessionKey: string; + readonly turnId: string; + readonly completion: DeferredTurnAnalyticsCompletion; + }, + ): Readonly> | undefined => { + if (state.completedKeys.has(input.completion.completionKey)) return undefined; + state.completedKeys.add(input.completion.completionKey); + state.completedOrder.push(input.completion.completionKey); + while (state.completedOrder.length > MAX_COMPLETED_TURN_ANALYTICS_KEYS) { + const expired = state.completedOrder.shift(); + if (expired) state.completedKeys.delete(expired); + } + + const session = state.sessions.get(input.sessionKey); + const metadata = session?.activeByTurnId.get(input.turnId)?.metadata; + session?.activeByTurnId.delete(input.turnId); + session?.deferredCompletionsByTurnId.delete(input.turnId); + if ( + session && + session.activeByTurnId.size === 0 && + session.pendingByRequestId.size === 0 && + session.deferredCompletionsByTurnId.size === 0 + ) { + state.sessions.delete(input.sessionKey); + } + + return { + ...input.completion.terminalProperties, + ...(metadata?.model ? { model: metadata.model } : {}), + ...(metadata?.effort ? { effort: metadata.effort } : {}), + ...(metadata?.interactionMode ? { interactionMode: metadata.interactionMode } : {}), + ...(metadata?.runtimeMode ? { runtimeMode: metadata.runtimeMode } : {}), + ...(metadata ? { mixedModels: metadata.mixedModels } : {}), + ...(metadata + ? { durationMs: Math.max(0, input.completion.completedAtMs - metadata.startedAtMs) } + : {}), + }; + }; + + const recordCompletedTurnProperties = ( + properties: ReadonlyArray>>, + ) => + Effect.forEach(properties, (entry) => analytics.record("provider.turn.completed", entry), { + discard: true, + }); + + const clearTurnAnalyticsSession = (providerInstanceId: ProviderInstanceId, threadId: ThreadId) => + Effect.gen(function* () { + const properties = yield* Ref.modify(turnAnalytics, (state) => { + const sessionKey = turnAnalyticsSessionKey(providerInstanceId, threadId); + const session = state.sessions.get(sessionKey); + const completed: Array>> = []; + if (session) { + for (const [turnId, completion] of session.deferredCompletionsByTurnId) { + const entry = finishTurnAnalytics(state, { sessionKey, turnId, completion }); + if (entry) completed.push(entry); + } + } + state.sessions.delete(sessionKey); + return [completed, state] as const; + }); + yield* recordCompletedTurnProperties(properties); + }); + + const beginTurnAnalytics = Effect.fn("beginTurnAnalytics")(function* (input: { + readonly providerInstanceId: ProviderInstanceId; + readonly provider: ProviderDriverKind; + readonly threadId: ThreadId; + readonly modelSelection: ProviderSendTurnInput["modelSelection"]; + readonly interactionMode: ProviderSendTurnInput["interactionMode"]; + readonly runtimeMode: string | undefined; + }) { + const startedAtMs = DateTime.toEpochMillis(yield* DateTime.now); + turnAnalyticsRequestId += 1; + const requestId = turnAnalyticsRequestId; + const effort = turnEffort(input.modelSelection); + return yield* Ref.modify(turnAnalytics, (state) => { + const key = turnAnalyticsSessionKey(input.providerInstanceId, input.threadId); + const session = state.sessions.get(key) ?? { + pendingByRequestId: new Map(), + activeByTurnId: new Map(), + deferredCompletionsByTurnId: new Map(), + }; + const metadata: TurnAnalyticsMetadata = { + provider: input.provider, + startedAtMs, + mixedModels: false, + requestId, + ...(input.modelSelection?.model ? { model: input.modelSelection.model } : {}), + ...(effort ? { effort } : {}), + ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), + ...(input.runtimeMode ? { runtimeMode: input.runtimeMode } : {}), + }; + session.pendingByRequestId.set(requestId, metadata); + state.sessions.set(key, session); + return [metadata, state] as const; + }); + }); + + const clearPendingTurnAnalytics = (input: { + readonly providerInstanceId: ProviderInstanceId; + readonly threadId: ThreadId; + readonly requestId: number; + }) => + Effect.gen(function* () { + const properties = yield* Ref.modify(turnAnalytics, (state) => { + const sessionKey = turnAnalyticsSessionKey(input.providerInstanceId, input.threadId); + const session = state.sessions.get(sessionKey); + if (!session) + return [[] as ReadonlyArray>>, state] as const; + session.pendingByRequestId.delete(input.requestId); + const completed: Array>> = []; + if (session.pendingByRequestId.size === 0) { + for (const [turnId, completion] of session.deferredCompletionsByTurnId) { + const entry = finishTurnAnalytics(state, { sessionKey, turnId, completion }); + if (entry) completed.push(entry); + } + } + if ( + session.activeByTurnId.size === 0 && + session.pendingByRequestId.size === 0 && + session.deferredCompletionsByTurnId.size === 0 + ) { + state.sessions.delete(sessionKey); + } + return [completed, state] as const; + }); + yield* recordCompletedTurnProperties(properties); + }); + + const associateTurnAnalytics = (input: { + readonly providerInstanceId: ProviderInstanceId; + readonly threadId: ThreadId; + readonly turnId: string; + readonly metadata: TurnAnalyticsMetadata; + }) => + Effect.gen(function* () { + const properties = yield* Ref.modify(turnAnalytics, (state) => { + const completionKey = turnAnalyticsCompletionKey( + input.providerInstanceId, + input.threadId, + input.turnId, + ); + const sessionKey = turnAnalyticsSessionKey(input.providerInstanceId, input.threadId); + const session = state.sessions.get(sessionKey); + if (!session || state.completedKeys.has(completionKey)) { + if (session) { + session.pendingByRequestId.delete(input.metadata.requestId); + if ( + session.activeByTurnId.size === 0 && + session.pendingByRequestId.size === 0 && + session.deferredCompletionsByTurnId.size === 0 + ) { + state.sessions.delete(sessionKey); + } + } + return [[] as ReadonlyArray>>, state] as const; + } + const existing = session.activeByTurnId.get(input.turnId); + const existingMetadata = existing?.metadata; + const base = existing?.requestAssociated ? existing.metadata : input.metadata; + setActiveTurnAnalytics(session, input.turnId, { + requestAssociated: true, + metadata: { + ...base, + ...(existingMetadata?.model + ? { model: existingMetadata.model } + : input.metadata.model + ? { model: input.metadata.model } + : {}), + ...(existingMetadata?.effort + ? { effort: existingMetadata.effort } + : input.metadata.effort + ? { effort: input.metadata.effort } + : {}), + ...(base?.interactionMode + ? {} + : input.metadata.interactionMode + ? { interactionMode: input.metadata.interactionMode } + : {}), + ...(base?.runtimeMode + ? {} + : input.metadata.runtimeMode + ? { runtimeMode: input.metadata.runtimeMode } + : {}), + mixedModels: existingMetadata?.mixedModels ?? input.metadata.mixedModels, + }, + }); + session.pendingByRequestId.delete(input.metadata.requestId); + const completion = session.deferredCompletionsByTurnId.get(input.turnId); + const completed = completion + ? finishTurnAnalytics(state, { + sessionKey, + turnId: input.turnId, + completion, + }) + : undefined; + return [completed ? [completed] : [], state] as const; + }); + yield* recordCompletedTurnProperties(properties); + }); + + const observeTurnStartedForAnalytics = Effect.fn("observeTurnStartedForAnalytics")(function* ( + source: { readonly instanceId: ProviderInstanceId; readonly provider: ProviderDriverKind }, + event: Extract, + ) { + if (!event.turnId) return; + const observedAtMs = DateTime.toEpochMillis(yield* DateTime.now); + yield* Ref.update(turnAnalytics, (state) => { + const completionKey = turnAnalyticsCompletionKey( + source.instanceId, + event.threadId, + String(event.turnId), + ); + if (state.completedKeys.has(completionKey)) return state; + const sessionKey = turnAnalyticsSessionKey(source.instanceId, event.threadId); + const session = state.sessions.get(sessionKey) ?? { + pendingByRequestId: new Map(), + activeByTurnId: new Map(), + deferredCompletionsByTurnId: new Map(), + }; + // A start never binds send metadata on its own. Claude can start a + // synthetic turn for leftover agent output while sendTurn is still + // preparing the real turn, so only the adapter's sendTurn response + // links a request to its turn. Completions that land before that + // response wait in deferredCompletionsByTurnId. + const current = session.activeByTurnId.get(String(event.turnId)); + const metadata: TurnAnalyticsMetadata = { + ...(current?.metadata ?? { + requestId: ++turnAnalyticsRequestId, + provider: source.provider, + startedAtMs: observedAtMs, + mixedModels: false, + }), + ...(event.payload.model ? { model: event.payload.model } : {}), + ...(event.payload.effort ? { effort: event.payload.effort } : {}), + }; + setActiveTurnAnalytics(session, String(event.turnId), { + metadata, + requestAssociated: current?.requestAssociated ?? false, + }); + state.sessions.set(sessionKey, session); + return state; + }); + }); + + const observeModelReroutedForAnalytics = ( + source: { readonly instanceId: ProviderInstanceId }, + event: Extract, + ) => + Ref.update(turnAnalytics, (state) => { + const session = state.sessions.get( + turnAnalyticsSessionKey(source.instanceId, event.threadId), + ); + if (!session) return state; + if (event.turnId) { + const current = session.activeByTurnId.get(String(event.turnId)); + if (current) { + session.activeByTurnId.set(String(event.turnId), { + ...current, + metadata: { ...current.metadata, mixedModels: true }, + }); + } + } else { + for (const [turnId, current] of session.activeByTurnId) { + session.activeByTurnId.set(turnId, { + ...current, + metadata: { ...current.metadata, mixedModels: true }, + }); + } + } + return state; + }); + + const recordTurnCompletedAnalytics = Effect.fn("recordTurnCompletedAnalytics")(function* ( + source: { readonly instanceId: ProviderInstanceId; readonly provider: ProviderDriverKind }, + event: Extract, + ) { + if (!event.turnId) return; + const completedAtMs = DateTime.toEpochMillis(yield* DateTime.now); + const tokenUsage = event.payload.tokenUsage; + const completion: DeferredTurnAnalyticsCompletion = { + completionKey: turnAnalyticsCompletionKey( + source.instanceId, + event.threadId, + String(event.turnId), + ), + completedAtMs, + terminalProperties: { + provider: source.provider, + terminalStatus: + event.type === "turn.completed" + ? event.payload.state + : event.payload.reason.toLowerCase().includes("interrupt") + ? "interrupted" + : "cancelled", + usageStatus: tokenUsage?.usageStatus ?? "unavailable", + usageScope: tokenUsage?.usageScope ?? "main_agent", + ...(tokenUsage ? { hasSubagents: tokenUsage.hasSubagents } : {}), + ...(tokenUsage?.inputTokens !== undefined ? { inputTokens: tokenUsage.inputTokens } : {}), + ...(tokenUsage?.cachedInputTokens !== undefined + ? { cachedInputTokens: tokenUsage.cachedInputTokens } + : {}), + ...(tokenUsage?.cacheCreationTokens !== undefined + ? { cacheCreationTokens: tokenUsage.cacheCreationTokens } + : {}), + ...(tokenUsage?.outputTokens !== undefined + ? { outputTokens: tokenUsage.outputTokens } + : {}), + ...(tokenUsage?.reasoningTokens !== undefined + ? { reasoningTokens: tokenUsage.reasoningTokens } + : {}), + }, + }; + const properties = yield* Ref.modify(turnAnalytics, (state) => { + if (state.completedKeys.has(completion.completionKey)) { + return [[] as ReadonlyArray>>, state] as const; + } + const turnId = String(event.turnId); + const sessionKey = turnAnalyticsSessionKey(source.instanceId, event.threadId); + const session = state.sessions.get(sessionKey); + if (session?.deferredCompletionsByTurnId.has(turnId)) { + return [[] as ReadonlyArray>>, state] as const; + } + const active = session?.activeByTurnId.get(turnId); + const needsAssociation = + (session?.pendingByRequestId.size ?? 0) > 0 && active?.requestAssociated !== true; + if (!session || !needsAssociation) { + const completed = finishTurnAnalytics(state, { sessionKey, turnId, completion }); + return [completed ? [completed] : [], state] as const; + } + + session.deferredCompletionsByTurnId.set(turnId, completion); + const completed: Array>> = []; + while (session.deferredCompletionsByTurnId.size > MAX_ACTIVE_TURN_ANALYTICS_PER_SESSION) { + const oldest = session.deferredCompletionsByTurnId.entries().next().value; + if (!oldest) break; + const [oldestTurnId, oldestCompletion] = oldest; + const entry = finishTurnAnalytics(state, { + sessionKey, + turnId: oldestTurnId, + completion: oldestCompletion, + }); + if (entry) completed.push(entry); + } + return [completed, state] as const; + }); + yield* recordCompletedTurnProperties(properties); + }); /** * Attach the `t3-code` MCP server to the session that is about to start. * @@ -929,6 +1358,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( provider: canonicalEvent.provider, eventType: canonicalEvent.type, }); + if (canonicalEvent.type === "turn.started") { + yield* observeTurnStartedForAnalytics(source, canonicalEvent); + } else if (canonicalEvent.type === "model.rerouted") { + yield* observeModelReroutedForAnalytics(source, canonicalEvent); + } else if ( + canonicalEvent.type === "turn.completed" || + canonicalEvent.type === "turn.aborted" + ) { + yield* recordTurnCompletedAnalytics(source, canonicalEvent); + } else if (canonicalEvent.type === "session.exited") { + yield* clearTurnAnalyticsSession(source.instanceId, canonicalEvent.threadId); + } yield* publishCompactionAwareRuntimeEvent(source.instanceId, canonicalEvent); if ( @@ -1397,6 +1838,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( instanceId: resolvedInstanceId, adapter, }); + yield* clearTurnAnalyticsSession(resolvedInstanceId, threadId); yield* prepareMcpSession(threadId, resolvedInstanceId, adapter); const session = yield* adapter .startSession({ @@ -1724,7 +2166,35 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); } } - const turn = yield* routed.adapter.sendTurn(input); + const analyticsModelSelection = + input.modelSelection?.instanceId === routed.instanceId ? input.modelSelection : undefined; + const turn = yield* Effect.acquireUseRelease( + beginTurnAnalytics({ + providerInstanceId: routed.instanceId, + provider: routed.adapter.provider, + threadId: input.threadId, + modelSelection: analyticsModelSelection, + interactionMode: input.interactionMode, + runtimeMode: routed.runtimeMode, + }), + (turnMetadata) => + Effect.gen(function* () { + const turn = yield* routed.adapter.sendTurn(input); + yield* associateTurnAnalytics({ + providerInstanceId: routed.instanceId, + threadId: input.threadId, + turnId: String(turn.turnId), + metadata: turnMetadata, + }); + return turn; + }), + (turnMetadata) => + clearPendingTurnAnalytics({ + providerInstanceId: routed.instanceId, + threadId: input.threadId, + requestId: turnMetadata.requestId, + }), + ); yield* requireAdapterGenerationCurrent(routed.adapter, "ProviderService.sendTurn"); yield* directory.upsert( { @@ -2946,6 +3416,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* settleCompaction(input.threadId, pendingCompaction, "turn.aborted"); } timedOutNativeCompactions.delete(input.threadId); + yield* clearTurnAnalyticsSession(routed.instanceId, input.threadId); yield* clearMcpSession(input.threadId, routed.adapter.runtimeFence); const latestBinding = Option.getOrUndefined(yield* directory.getBinding(input.threadId)); @@ -3453,6 +3924,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( Effect.map((settings) => settings.continueThreadsAfterServerUpdate), Effect.orElseSucceed(() => false), ); + const properties = yield* Ref.modify(turnAnalytics, (state) => { + const completed: Array>> = []; + for (const [sessionKey, session] of state.sessions) { + for (const [turnId, completion] of session.deferredCompletionsByTurnId) { + const entry = finishTurnAnalytics(state, { sessionKey, turnId, completion }); + if (entry) completed.push(entry); + } + } + state.sessions.clear(); + return [completed, state] as const; + }); + yield* recordCompletedTurnProperties(properties); const threadIds = yield* directory.listThreadIds(); const currentAdapters = yield* getAdapterEntries; const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => diff --git a/apps/server/src/telemetry/AnalyticsService.test.ts b/apps/server/src/telemetry/AnalyticsService.test.ts index 13da77113..c38ec4b86 100644 --- a/apps/server/src/telemetry/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/AnalyticsService.test.ts @@ -164,4 +164,41 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { ); }), ); + + it.effect("does not send batch requests when telemetry is disabled", () => + Effect.gen(function* () { + const capturedPaths: Array = []; + const serverConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-disabled-", + }); + const telemetryLayer = AnalyticsService.layer.pipe(Layer.provideMerge(serverConfigLayer)); + const configLayer = ConfigProvider.layer( + ConfigProvider.fromUnknown({ + T3CODE_TELEMETRY_ENABLED: false, + T3CODE_POSTHOG_KEY: "phc_test_key", + T3CODE_POSTHOG_HOST: "http://localhost", + }), + ); + const batchServerLayer = HttpServer.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + capturedPaths.push(request.url); + return HttpServerResponse.jsonUnsafe({}); + }), + ); + const runtimeLayer = telemetryLayer.pipe( + Layer.provide(configLayer), + Layer.provideMerge(NodeHttpServer.layerTest), + ); + + yield* Effect.gen(function* () { + yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); + const analytics = yield* AnalyticsService.AnalyticsService; + yield* analytics.record("test.disabled", { index: 1 }); + yield* analytics.flush; + }).pipe(Effect.provide(runtimeLayer)); + + assert.deepEqual(capturedPaths, []); + }), + ); }); diff --git a/docs/README.md b/docs/README.md index 5090cd9d6..33e645622 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ - [Browser snapshots for agents](./user/browser-snapshots.md) - [Organizing threads](./user/thread-sidebar.md) - [Review usage](./user/usage.md) +- [Product usage data](./user/telemetry.md) - [Customize a project icon](./user/project-settings.md) - [Mobile appearance](./user/mobile-appearance.md) - [Environment themes](./user/environment-theme.md) @@ -38,6 +39,7 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A - [Remote environments](./internals/remote.md) - [Server updates](./internals/server-updates.md) - [Resource telemetry](./internals/resource-telemetry.md) +- [Product analytics](./internals/product-analytics.md) - [Environment auth](./internals/environment-auth.md) - [Pylon Connect](./internals/t3-connect.md) - [CI gates](./internals/ci.md) diff --git a/docs/internals/product-analytics.md b/docs/internals/product-analytics.md new file mode 100644 index 000000000..dd3d14a6f --- /dev/null +++ b/docs/internals/product-analytics.md @@ -0,0 +1,63 @@ +# Product analytics + +> For maintainers. Using Pylon? See [docs/user](../user/). + +The server owns PostHog delivery, opt-out, and identity. Clients do not load the +PostHog browser SDK. [AnalyticsService](../../apps/server/src/telemetry/AnalyticsService.ts) +ships with no project key, so a stock Pylon server records and sends nothing. +Setting `T3CODE_POSTHOG_KEY` to a Pylon-owned project enables delivery; +`T3CODE_TELEMETRY_ENABLED=false` disables it again even when a key is present. +[Identity selection](../../apps/server/src/telemetry/Identify.ts) hashes an +available provider account ID and falls back to an installation-scoped ID. +PostHog person profiles stay disabled. + +## Provider turn events + +`provider.turn.sent` records an accepted send request. `provider.turn.completed` +records one event per provider instance, thread, and turn when the provider emits +a completed or aborted turn. [ProviderService](../../apps/server/src/provider/Layers/ProviderService.ts) +correlates each send with the turn ID the adapter returns before recording, and +holds a completion that arrives before that response. Session start, stop, +`session.exited`, and server shutdown flush held completions, and duplicate +terminal events are recorded once. Analytics observe only events that already +passed the runtime generation and session incarnation fences. + +Send and completion counts need not match. Providers can emit synthetic turns +without a send request. Collection is best effort, with no scan or backfill of +provider history. + +## Token usage + +Adapters attach a normalized `tokenUsage` record to `turn.completed` and +`turn.aborted`. It counts the main agent only. `inputTokens` includes uncached +input, cache reads, and cache writes; `outputTokens` includes reasoning. Cache +and reasoning counts are subsets of those totals. + +- `complete` means the provider supplied whole-turn input and output totals. +- `partial` means every included count is valid, but the turn was not fully + observed, for example after an interruption, failure, or reconnect. +- `unavailable` means the provider supplied no trustworthy counts. + +Unknown counts stay absent rather than zero. Keep these distinctions when +changing normalization or building reports. + +| Provider | Source | +| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Codex | Deltas of the thread's cumulative `thread/tokenUsage/updated` totals. The newest response's `last` usage is the delta when no prior total exists or the total shrank. | +| Claude | The final result's per-turn `usage`, including thinking tokens. Cumulative `modelUsage` is not used. | +| OpenCode | Unique `step-finish` totals from assistant messages answering this turn's prompts. Steps whose owning message is unresolved make the turn partial. | +| Cursor, Grok, Antigravity, Prime | Unavailable until their token fields and scope are verified. | + +Child agents and model rerouting keep a turn from representing one +provider/model combination's cost. `hasSubagents` and `mixedModels` mark those +turns. For provider comparisons, require complete usage, no subagents, and no +mixed models, and compare matching model, effort, interaction mode, and terminal +status. Divide summed output by summed input; averaging per-turn ratios lets +small-input turns dominate. + +## Collection boundary + +Keep analytics payloads to product metadata and normalized measurements. Do not +send prompts, responses, authentication material, raw provider payloads, +user-assigned device names, conversation identifiers, provider instance IDs, or +child-agent output. diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md new file mode 100644 index 000000000..7f555ad58 --- /dev/null +++ b/docs/user/telemetry.md @@ -0,0 +1,18 @@ +# Product usage data + +Pylon does not send product usage data by default. The server only sends events +when whoever runs it configures a PostHog project key with +`T3CODE_POSTHOG_KEY`. + +When a key is configured, events go to that PostHog project, associated with a +hashed account or installation identifier. They include the provider, model, +reasoning effort, permission mode, turn result, duration, and main-agent token +totals when the provider reports them. Token totals can be complete, partial, or +unavailable, and child-agent token use is excluded. + +Events do not include prompts, responses, file contents, authentication tokens, +conversation IDs, raw provider events, or child-agent output. + +To turn collection off on a server that has a key, set +`T3CODE_TELEMETRY_ENABLED=false` in the server's environment before starting +it. This stops product events from being recorded or sent. diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 9dc7a8837..369464b2b 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -15,6 +15,52 @@ describe("ProviderRuntimeEvent", () => { expectTypeOf().toEqualTypeOf(); }); + it("requires input and output totals for complete turn usage", () => { + const completeEvent = { + type: "turn.completed", + eventId: "event-complete-usage", + provider: "codex", + createdAt: "2026-02-28T00:00:00.000Z", + threadId: "thread-1", + turnId: "turn-1", + payload: { + state: "completed", + tokenUsage: { + usageStatus: "complete", + usageScope: "main_agent", + hasSubagents: false, + }, + }, + }; + + expect(() => decodeRuntimeEvent(completeEvent)).toThrow(); + expect( + decodeRuntimeEvent({ + ...completeEvent, + payload: { + ...completeEvent.payload, + tokenUsage: { + ...completeEvent.payload.tokenUsage, + inputTokens: 10, + outputTokens: 2, + }, + }, + }).type, + ).toBe("turn.completed"); + expect( + decodeRuntimeEvent({ + ...completeEvent, + payload: { + ...completeEvent.payload, + tokenUsage: { + ...completeEvent.payload.tokenUsage, + usageStatus: "partial", + }, + }, + }).type, + ).toBe("turn.completed"); + }); + it("accepts fork-provided driver kinds as branded slugs", () => { const parsed = decodeRuntimeEvent({ type: "session.started", diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index b5f6119e7..f86115c27 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -414,6 +414,35 @@ const TurnStartedPayload = Schema.Struct({ }); export type TurnStartedPayload = typeof TurnStartedPayload.Type; +/** + * Normalized main-agent usage for one turn. Input includes cache reads and + * writes. Output includes reasoning, and reasoningTokens is an optional subset. + * Complete means the provider supplied full input and output totals. Partial + * means every included count is valid, but the full turn total is not known. + */ +const TurnTokenUsageCommonFields = { + usageScope: Schema.Literal("main_agent"), + cachedInputTokens: Schema.optional(NonNegativeInt), + cacheCreationTokens: Schema.optional(NonNegativeInt), + reasoningTokens: Schema.optional(NonNegativeInt), + hasSubagents: Schema.Boolean, +}; +export const TurnTokenUsage = Schema.Union([ + Schema.Struct({ + ...TurnTokenUsageCommonFields, + usageStatus: Schema.Literal("complete"), + inputTokens: NonNegativeInt, + outputTokens: NonNegativeInt, + }), + Schema.Struct({ + ...TurnTokenUsageCommonFields, + usageStatus: Schema.Literals(["partial", "unavailable"]), + inputTokens: Schema.optional(NonNegativeInt), + outputTokens: Schema.optional(NonNegativeInt), + }), +]); +export type TurnTokenUsage = typeof TurnTokenUsage.Type; + const TurnCompletedPayload = Schema.Struct({ state: RuntimeTurnState, stopReason: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)), @@ -421,11 +450,13 @@ const TurnCompletedPayload = Schema.Struct({ modelUsage: Schema.optional(UnknownRecordSchema), totalCostUsd: Schema.optional(Schema.Number), errorMessage: Schema.optional(TrimmedNonEmptyStringSchema), + tokenUsage: Schema.optional(TurnTokenUsage), }); export type TurnCompletedPayload = typeof TurnCompletedPayload.Type; const TurnAbortedPayload = Schema.Struct({ reason: TrimmedNonEmptyStringSchema, + tokenUsage: Schema.optional(TurnTokenUsage), }); export type TurnAbortedPayload = typeof TurnAbortedPayload.Type; From 66bcecdc35ed7f4c00b8121c7cb78512304fee3c Mon Sep 17 00:00:00 2001 From: Vitaly Iegorov Date: Tue, 8 Sep 2026 01:04:11 +0200 Subject: [PATCH 02/12] fix(codex): name the usage limit and its reset instead of relaying "out of credits" When Codex stops a turn on a usage limit, the failed turn and its runtime error now name the exhausted window, its reset, and the next step, composed from the session's merged rate-limit snapshot. Pylon adaptations: - The composed runtime.error carries the session incarnation like every other event the adapter emits. - Pylon keeps relaying every account/rateLimits/updated notification for its pushed usage windows; the snapshot merge is side state only. The earlier snapshot test counts the sparse update Pylon relays. (cherry picked from commit d64335bb5913c21869633a4236d9409911e3da52) Adopted from d64335bb5913c21869633a4236d9409911e3da52 (#10473) --- .../src/provider/Layers/CodexAdapter.test.ts | 301 ++++++++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 63 +++- .../provider/Layers/codexUsageLimits.test.ts | 96 ++++++ .../src/provider/Layers/codexUsageLimits.ts | 75 +++++ docs/user/providers-codex.md | 7 + 5 files changed, 541 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 724f5b2a2..4d30ba28d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -2820,3 +2820,304 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () => } }), ); + +const usageLimitRuntimeFactory = makeRuntimeFactory(); +const usageLimitLayer = it.layer( + Layer.effect( + CodexAdapter, + Effect.gen(function* () { + const codexConfig = decodeCodexSettings({}); + return yield* makeCodexAdapter(codexConfig, { + makeRuntime: usageLimitRuntimeFactory.factory, + }); + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ), +); + +const USAGE_LIMIT_NOW = "2026-01-01T00:00:00.000Z"; +const USAGE_LIMIT_NOW_SECONDS = Date.parse(USAGE_LIMIT_NOW) / 1000; +const CODEX_OUT_OF_CREDITS = + "Your workspace is out of credits. Ask your workspace owner to refill in order to continue."; + +function startUsageLimitRuntime() { + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + runtimeMode: "full-access", + }); + const runtime = usageLimitRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + return { adapter, runtime }; + }); +} + +function codexErrorNotification(input: { + readonly id: string; + readonly message: string; + readonly codexErrorInfo?: string; +}): ProviderEvent { + return { + id: asEventId(input.id), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-limit"), + createdAt: USAGE_LIMIT_NOW, + method: "error", + payload: { + threadId: "thread-1", + turnId: "turn-limit", + willRetry: false, + error: { + message: input.message, + ...(input.codexErrorInfo ? { codexErrorInfo: input.codexErrorInfo } : {}), + }, + }, + }; +} + +function codexRateLimitsNotification(input: { + readonly id: string; + readonly rateLimitReachedType?: string; + readonly primary?: { readonly usedPercent: number; readonly resetsInSeconds: number }; + readonly secondary?: { readonly usedPercent: number; readonly resetsInSeconds: number }; +}): ProviderEvent { + return { + id: asEventId(input.id), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-limit"), + createdAt: USAGE_LIMIT_NOW, + method: "account/rateLimits/updated", + payload: { + rateLimits: { + limitId: "codex", + ...(input.rateLimitReachedType ? { rateLimitReachedType: input.rateLimitReachedType } : {}), + ...(input.primary + ? { + primary: { + usedPercent: input.primary.usedPercent, + resetsAt: USAGE_LIMIT_NOW_SECONDS + input.primary.resetsInSeconds, + windowDurationMins: 300, + }, + } + : {}), + ...(input.secondary + ? { + secondary: { + usedPercent: input.secondary.usedPercent, + resetsAt: USAGE_LIMIT_NOW_SECONDS + input.secondary.resetsInSeconds, + windowDurationMins: 10_080, + }, + } + : {}), + }, + }, + }; +} + +function codexUsageLimitTurnFailed(id: string, turnId = "turn-limit"): ProviderEvent { + return { + id: asEventId(id), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-1"), + turnId: asTurnId(turnId), + createdAt: USAGE_LIMIT_NOW, + method: "turn/completed", + payload: { + threadId: "thread-1", + turn: { + id: turnId, + items: [], + status: "failed", + error: { message: CODEX_OUT_OF_CREDITS, codexErrorInfo: "usageLimitExceeded" }, + }, + }, + }; +} + +usageLimitLayer("CodexAdapterLive usage limits", (it) => { + it.effect("names the exhausted window and the workspace's missing credits", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startUsageLimitRuntime(); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.take(5), + Stream.runCollect, + Effect.forkChild, + ); + + yield* runtime.emit( + codexErrorNotification({ + id: "evt-limit-error", + message: CODEX_OUT_OF_CREDITS, + codexErrorInfo: "usageLimitExceeded", + }), + ); + yield* runtime.emit( + codexRateLimitsNotification({ + id: "evt-limit-rate-limits", + rateLimitReachedType: "workspace_owner_credits_depleted", + primary: { usedPercent: 40, resetsInSeconds: 3_600 }, + secondary: { usedPercent: 100, resetsInSeconds: 5 * 86_400 + 5 * 3_600 }, + }), + ); + yield* runtime.emit(codexUsageLimitTurnFailed("evt-limit-turn")); + // A second turn stopping on the same limit says as much as the first. + yield* runtime.emit(codexUsageLimitTurnFailed("evt-limit-turn-2", "turn-limit-2")); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const expected = + "Codex usage limit reached. The weekly limit resets in 5d 5h. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets."; + NodeAssert.deepStrictEqual( + events.map((event) => event.type), + [ + "account.rate-limits.updated", + "runtime.error", + "turn.completed", + "runtime.error", + "turn.completed", + ], + ); + for (const event of events) { + if (event.type === "runtime.error") { + NodeAssert.equal(event.payload.message, expected); + NodeAssert.equal(event.payload.detail, CODEX_OUT_OF_CREDITS); + } + if (event.type === "turn.completed") { + NodeAssert.equal(event.payload.errorMessage, expected); + } + } + }), + ); + + it.effect("names the session window for a plan limit", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startUsageLimitRuntime(); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* runtime.emit( + codexErrorNotification({ + id: "evt-plan-error", + message: "You've hit your usage limit.", + codexErrorInfo: "usageLimitExceeded", + }), + ); + yield* runtime.emit( + codexRateLimitsNotification({ + id: "evt-plan-rate-limits", + rateLimitReachedType: "rate_limit_reached", + primary: { usedPercent: 100, resetsInSeconds: 3 * 3_600 + 20 * 60 }, + }), + ); + yield* runtime.emit(codexUsageLimitTurnFailed("evt-plan-turn")); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completed = events.find((event) => event.type === "turn.completed"); + NodeAssert.equal( + completed?.payload.errorMessage, + "Codex usage limit reached. The session limit resets in 3h 20m. Send the message again once the limit resets.", + ); + }), + ); + + it.effect("reads a rate-limit snapshot seen earlier in the session", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startUsageLimitRuntime(); + // Pylon relays every rate-limit notification, including the sparse one, + // so both updates precede the runtime error and the failed turn. + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + + // The window arrives long before the stop, and the update that reports the + // limit as reached carries no windows of its own. + yield* runtime.emit( + codexRateLimitsNotification({ + id: "evt-early-rate-limits", + primary: { usedPercent: 100, resetsInSeconds: 3 * 3_600 + 20 * 60 }, + }), + ); + yield* runtime.emit( + codexRateLimitsNotification({ + id: "evt-sparse-rate-limits", + rateLimitReachedType: "rate_limit_reached", + }), + ); + yield* runtime.emit(codexUsageLimitTurnFailed("evt-early-turn")); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completed = events.find((event) => event.type === "turn.completed"); + NodeAssert.equal( + completed?.payload.errorMessage, + "Codex usage limit reached. The session limit resets in 3h 20m. Send the message again once the limit resets.", + ); + }), + ); + + it.effect("falls back to the short message without a rate-limit snapshot", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startUsageLimitRuntime(); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + + yield* runtime.emit( + codexErrorNotification({ + id: "evt-bare-error", + message: CODEX_OUT_OF_CREDITS, + codexErrorInfo: "usageLimitExceeded", + }), + ); + yield* runtime.emit(codexUsageLimitTurnFailed("evt-bare-turn")); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const expected = "Codex usage limit reached. Send the message again once the limit resets."; + NodeAssert.deepStrictEqual( + events.map((event) => event.type), + ["runtime.error", "turn.completed"], + ); + const runtimeError = events.find((event) => event.type === "runtime.error"); + NodeAssert.equal(runtimeError?.payload.message, expected); + const completed = events.find((event) => event.type === "turn.completed"); + NodeAssert.equal(completed?.payload.errorMessage, expected); + }), + ); + + it.effect("still relays other provider errors as they arrive", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startUsageLimitRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit( + codexErrorNotification({ + id: "evt-other-error", + message: "Codex is temporarily unavailable.", + codexErrorInfo: "internalServerError", + }), + ); + + const first = yield* Fiber.join(firstEventFiber); + NodeAssert.equal(first._tag, "Some"); + if (first._tag !== "Some" || first.value.type !== "runtime.error") return; + NodeAssert.equal(first.value.payload.message, "Codex is temporarily unavailable."); + NodeAssert.equal(first.value.payload.class, "provider_error"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 2dc45614d..74cac74ac 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -74,6 +74,11 @@ import { } from "./CodexSessionRuntime.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; +import { + type CodexRateLimitSnapshot, + codexUsageLimitMessage, + mergeCodexRateLimits, +} from "./codexUsageLimits.ts"; const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError); const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError); const isCodexSessionRuntimeThreadIdMissingError = Schema.is( @@ -2298,6 +2303,12 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( : {}), }; const turnTokenUsage = makeCodexTurnTokenUsageState(); + // Codex reports a usage-limit stop as OpenAI's own sentence, which on a + // Business workspace blames credits for a window that ran out. The + // snapshot naming that window arrives in its own notification, before or + // after the stop and often sparse, so keep the session's merged view of + // it and read it when a turn fails on the limit. + let rateLimits: CodexRateLimitSnapshot | undefined; const sessionScope = yield* Scope.make("sequential"); let sessionScopeTransferred = false; yield* Effect.addFinalizer(() => @@ -2357,7 +2368,53 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } } - const runtimeEvents = mapToRuntimeEvents(event, event.threadId).map((runtimeEvent) => { + if (event.method === "account/rateLimits/updated") { + const limitsPayload = readPayload( + EffectCodexSchema.V2AccountRateLimitsUpdatedNotification, + event.payload, + ); + if (limitsPayload) { + rateLimits = mergeCodexRateLimits(rateLimits, limitsPayload.rateLimits); + } + } else if (event.method === "error") { + const errorPayload = readPayload( + EffectCodexSchema.V2ErrorNotification, + event.payload, + ); + // The failed `turn/completed` repeats this sentence and is answered + // below; relaying both would show the limit twice. + if (errorPayload?.error.codexErrorInfo === "usageLimitExceeded") return; + } + + let usageLimitError: ProviderRuntimeEvent | undefined; + let usageLimitMessage: string | undefined; + if (event.method === "turn/completed") { + const completedPayload = readPayload( + EffectCodexSchema.V2TurnCompletedNotification, + event.payload, + ); + const turnError = + completedPayload?.turn.status === "failed" + ? completedPayload.turn.error + : undefined; + if (turnError?.codexErrorInfo === "usageLimitExceeded") { + usageLimitMessage = codexUsageLimitMessage(rateLimits, event.createdAt); + usageLimitError = { + ...runtimeEventBase(event, event.threadId), + ...(input.sessionIncarnationId !== undefined + ? { sessionIncarnationId: input.sessionIncarnationId } + : {}), + type: "runtime.error", + payload: { + message: usageLimitMessage, + class: "provider_error", + ...(turnError.message ? { detail: turnError.message } : {}), + }, + }; + } + } + + const mappedEvents = mapToRuntimeEvents(event, event.threadId).map((runtimeEvent) => { const stampedRuntimeEvent = runtimeEvent.sessionIncarnationId !== undefined || input.sessionIncarnationId === undefined @@ -2368,6 +2425,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...stampedRuntimeEvent, payload: { ...stampedRuntimeEvent.payload, + ...(usageLimitMessage ? { errorMessage: usageLimitMessage } : {}), tokenUsage: completeCodexTurnTokenUsage( turnTokenUsage, String(stampedRuntimeEvent.turnId), @@ -2403,6 +2461,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const { turnId: _turnId, ...correlation } = admission; return { ...stampedRuntimeEvent, ...correlation }; }); + const runtimeEvents = usageLimitError + ? [usageLimitError, ...mappedEvents] + : mappedEvents; if (runtimeEvents.length === 0) { yield* Effect.logDebug("ignoring unhandled Codex provider event", { method: event.method, diff --git a/apps/server/src/provider/Layers/codexUsageLimits.test.ts b/apps/server/src/provider/Layers/codexUsageLimits.test.ts index 00b7aa028..c3584d71c 100644 --- a/apps/server/src/provider/Layers/codexUsageLimits.test.ts +++ b/apps/server/src/provider/Layers/codexUsageLimits.test.ts @@ -6,6 +6,8 @@ import { codexRateLimitsToLimits, codexRateLimitsToUpdate, codexResetCreditsToContract, + codexUsageLimitMessage, + mergeCodexRateLimits, } from "./codexUsageLimits.ts"; const checkedAt = "2026-07-18T10:00:00.000Z"; @@ -202,3 +204,97 @@ describe("codexResetCreditsToContract", () => { ).toEqual({ availableCount: 1, checkedAt }); }); }); + +describe("codexUsageLimitMessage", () => { + const at = "2026-01-01T00:00:00.000Z"; + const atSeconds = Date.parse(at) / 1000; + + it("names the exhausted window and the workspace's missing credits", () => { + expect( + codexUsageLimitMessage( + { + limitId: "codex", + rateLimitReachedType: "workspace_owner_credits_depleted", + primary: { usedPercent: 40, resetsAt: atSeconds + 3_600, windowDurationMins: 300 }, + secondary: { + usedPercent: 100, + resetsAt: atSeconds + 5 * 86_400 + 5 * 3_600, + windowDurationMins: 10_080, + }, + }, + at, + ), + ).toBe( + "Codex usage limit reached. The weekly limit resets in 5d 5h. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.", + ); + }); + + it("points a reached spend cap at the workspace owner", () => { + expect( + codexUsageLimitMessage( + { + limitId: "codex", + rateLimitReachedType: "workspace_member_usage_limit_reached", + primary: { + usedPercent: 100, + resetsAt: atSeconds + 3 * 3_600 + 20 * 60, + windowDurationMins: 300, + }, + }, + at, + ), + ).toBe( + "Codex usage limit reached. The session limit resets in 3h 20m. The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets.", + ); + }); + + it("names no window when credits run out without one", () => { + expect( + codexUsageLimitMessage( + { limitId: "codex", rateLimitReachedType: "workspace_member_credits_depleted" }, + at, + ), + ).toBe( + "Codex usage limit reached. The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets.", + ); + }); + + it("says only what it knows without a snapshot", () => { + expect(codexUsageLimitMessage(undefined, at)).toBe( + "Codex usage limit reached. Send the message again once the limit resets.", + ); + }); +}); + +describe("mergeCodexRateLimits", () => { + it("keeps windows an update does not carry", () => { + const merged = mergeCodexRateLimits( + { + limitId: "codex", + planType: "business", + primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, + }, + { rateLimitReachedType: "rate_limit_reached" }, + ); + + expect(merged).toEqual({ + limitId: "codex", + planType: "business", + rateLimitReachedType: "rate_limit_reached", + primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, + }); + }); + + it("ignores a model-specific snapshot so it cannot replace the main allowance", () => { + const main = { + limitId: "codex", + primary: { usedPercent: 100, resetsAt: 1_800_000_000, windowDurationMins: 300 }, + }; + expect( + mergeCodexRateLimits(main, { + limitId: "spark", + primary: { usedPercent: 3, resetsAt: 1_800_000_000, windowDurationMins: 300 }, + }), + ).toBe(main); + }); +}); diff --git a/apps/server/src/provider/Layers/codexUsageLimits.ts b/apps/server/src/provider/Layers/codexUsageLimits.ts index 41e532d10..19057ab58 100644 --- a/apps/server/src/provider/Layers/codexUsageLimits.ts +++ b/apps/server/src/provider/Layers/codexUsageLimits.ts @@ -28,6 +28,7 @@ interface CodexRateLimitWindow { export interface CodexRateLimitSnapshot { readonly limitId?: string | null | undefined; readonly planType?: string | null | undefined; + readonly rateLimitReachedType?: string | null | undefined; readonly primary?: CodexRateLimitWindow | null | undefined; readonly secondary?: CodexRateLimitWindow | null | undefined; } @@ -162,3 +163,77 @@ export function codexRateLimitsFailureMessage(error: CodexErrors.CodexAppServerE return "Codex did not answer the usage request."; } } + +/** + * Codex sends `account/rateLimits/updated` as a partial view of the snapshot: a + * field the update omits keeps the value observed earlier in the session, so a + * later notification that only names the limit it reached must not drop the + * windows an earlier one carried. + */ +export function mergeCodexRateLimits( + previous: CodexRateLimitSnapshot | undefined, + update: CodexRateLimitSnapshot, +): CodexRateLimitSnapshot | undefined { + // Model-specific snapshots (such as Spark) describe a different allowance + // and must not overwrite the main one, the same rule the usage rows apply. + if (update.limitId && update.limitId !== "codex") return previous; + if (!previous) return update; + return { + ...previous, + ...(update.limitId !== undefined ? { limitId: update.limitId } : {}), + ...(update.planType !== undefined ? { planType: update.planType } : {}), + ...(update.rateLimitReachedType !== undefined + ? { rateLimitReachedType: update.rateLimitReachedType } + : {}), + ...(update.primary !== undefined ? { primary: update.primary } : {}), + ...(update.secondary !== undefined ? { secondary: update.secondary } : {}), + }; +} + +/** Coarse remaining wait, matching how the usage rows read: `5d 5h`, `3h 20m`, `12m`. */ +function formatCodexUsageLimitWait(waitMs: number): string { + const totalMinutes = Math.ceil(waitMs / 60_000); + const days = Math.floor(totalMinutes / (24 * 60)); + const hours = Math.floor((totalMinutes % (24 * 60)) / 60); + const minutes = totalMinutes % 60; + if (days > 0) return hours === 0 ? `${days}d` : `${days}d ${hours}h`; + if (hours === 0) return `${totalMinutes}m`; + return minutes === 0 ? `${hours}h` : `${hours}h ${minutes}m`; +} + +function codexUsageLimitNextStep(rateLimitReachedType: string | null | undefined): string { + switch (rateLimitReachedType) { + case "workspace_owner_credits_depleted": + case "workspace_member_credits_depleted": + return " The workspace has no credits to continue sooner: ask your workspace owner to add credits, or send the message again once the limit resets."; + case "workspace_owner_usage_limit_reached": + case "workspace_member_usage_limit_reached": + return " The workspace spend limit is reached: ask your workspace owner to raise it, or send the message again once the limit resets."; + default: + return " Send the message again once the limit resets."; + } +} + +/** + * The message a usage-limit stop shows instead of the provider sentence, which + * on a Business workspace blames credits for a window that simply ran out. The + * window named is the exhausted one that has yet to reset, latest first; `atIso` + * is the stopping event's timestamp, not the wall clock. + */ +export function codexUsageLimitMessage( + snapshot: CodexRateLimitSnapshot | undefined, + atIso: string, +): string { + const atMs = Date.parse(atIso); + const windows = snapshot && Number.isFinite(atMs) ? codexRateLimitsToWindows(snapshot) : []; + let reset = ""; + let latestResetMs = Number.NEGATIVE_INFINITY; + for (const window of windows) { + if (window.usedPercent < 100 || !window.resetsAt) continue; + const resetMs = Date.parse(window.resetsAt); + if (!Number.isFinite(resetMs) || resetMs <= atMs || resetMs <= latestResetMs) continue; + latestResetMs = resetMs; + reset = ` The ${window.kind} limit resets in ${formatCodexUsageLimitWait(resetMs - atMs)}.`; + } + return `Codex usage limit reached.${reset}${codexUsageLimitNextStep(snapshot?.rateLimitReachedType)}`; +} diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 7d2908c52..6536bd037 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -34,6 +34,13 @@ The Codex usage gauge shows the main account allowance. Spark has a separate model-specific allowance, which does not replace the main session or weekly reading. Older Codex versions that report a single allowance remain supported. +## Codex says I hit a usage limit + +When Codex stops on a usage limit, the thread names the window that ran out and +when it resets, when Codex reports them. Send the message again after the reset. On a workspace plan the +message also says whether your workspace owner needs to add credits or raise the +spend limit to continue sooner. + ## Send feedback to OpenAI In an existing Codex thread, send `/feedback` or `/feedback` followed by a description of the From d1aeb0f76eccbb390564280fcb06aa4816861d1f Mon Sep 17 00:00:00 2001 From: maria Date: Mon, 7 Sep 2026 20:47:13 -0300 Subject: [PATCH 03/12] fix(web): copy selected pull request link from PR page The copy-reference shortcut and command palette action now copy the pull request open on the pull requests page, including a provider URL carried by the surface. Pylon adaptation: the command palette keeps Pylon's detected branch pull request fallback for thread references, and the panel URL hook keeps Pylon's environment-scoped cached detail lookup. (cherry picked from commit ea2983afbbcd5ad6ee2e7db80c2a9270ff4964a9) Adopted from ea2983afbbcd5ad6ee2e7db80c2a9270ff4964a9 (#10615) --- apps/web/src/components/CommandPalette.tsx | 24 ++++++--- .../src/hooks/useOpenPanelPullRequestUrl.ts | 1 + apps/web/src/rightPanelStore.test.ts | 6 +++ apps/web/src/rightPanelStore.ts | 39 ++++++++++++-- apps/web/src/routes/_chat.pull-requests.tsx | 51 +++++++++++-------- 5 files changed, 89 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index e3b89ccec..0c306571e 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -105,7 +105,11 @@ import { import { onOpenCommandPalette } from "../commandPaletteBus"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; +import { + PULL_REQUESTS_PANEL_REF, + selectActiveRightPanel, + useRightPanelStore, +} from "../rightPanelStore"; import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { cn, @@ -641,14 +645,22 @@ function OpenCommandPaletteDialog(props: { ), retainTerminalOnBranchMismatch: activeThread.worktreePath === null, })?.url ?? null); - const openPanelPullRequestUrl = useOpenPanelPullRequestUrl( - activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null, - ); + const referenceThreadRef = + pathname === "/pull-requests" + ? environments.some( + (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true, + ) + ? PULL_REQUESTS_PANEL_REF + : null + : activeThread + ? scopeThreadRef(activeThread.environmentId, activeThread.id) + : null; + const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef); const activeThreadReferenceCopyTarget = - activeThread == null + referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl) ? null : resolveThreadReferenceCopyTarget({ - threadId: activeThread.id, + threadId: referenceThreadRef.threadId, openPanelPullRequestUrl, linkedPullRequestUrl: activeThreadPullRequest?.url ?? null, detectedPullRequestUrl, diff --git a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts index 519d13d6a..1bba0b35e 100644 --- a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts +++ b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts @@ -55,6 +55,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { environmentId, reference, })?.url ?? + reference.url ?? gitHubPullRequestBrowserUrl( project?.repositoryIdentity, reference.repository, diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 4336aa2a3..46091d3fd 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -587,6 +587,8 @@ describe("rightPanelStore", () => { const second = { projectId: "project-a", repository: "pingdotgg/t3code", number: 4910 }; useRightPanelStore.getState().openPullRequest(refA, first); useRightPanelStore.getState().openPullRequest(refA, second); + const url = "https://gitlab.example.com/pingdotgg/t3code/-/merge_requests/4909"; + useRightPanelStore.getState().openPullRequest(refA, { ...first, url }); useRightPanelStore.getState().openPullRequest(refA, first); const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); @@ -595,6 +597,10 @@ describe("rightPanelStore", () => { pullRequestSurfaceId(second), ]); expect(state.activeSurfaceId).toBe(pullRequestSurfaceId(first)); + expect( + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA), + ).toMatchObject({ url }); + expect(state.surfaces[1]).not.toHaveProperty("url"); }); it("keeps one pull request read from two servers as two tabs", () => { diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index bc57e4feb..dc35cdde7 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -7,8 +7,13 @@ * terminal surfaces point at terminal session ids, file surfaces point at * workspace paths, and diff/files remain singleton surfaces. */ -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { ChatFileAttachment, ScopedThreadRef } from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ThreadId, + type ChatFileAttachment, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -65,6 +70,7 @@ export type RightPanelSurface = projectId: string; repository: string; number: number; + url?: string; } | { id: "agents"; kind: "agents" }; @@ -74,8 +80,14 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. const RIGHT_PANEL_STORAGE_VERSION = 11; +/** A fixed workspace-level ref: each PR surface carries its own real environment. */ +export const PULL_REQUESTS_PANEL_REF = scopeThreadRef( + EnvironmentId.make("pull-requests-panel"), + ThreadId.make("pull-requests-panel"), +); + /** - * The pull-request list's shared panel (see PULL_REQUESTS_PANEL_ID in the route) is session + * The pull-request list's shared panel is session * state: reopening the app should show the list, not last session's tabs and detail fetches. */ const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel"); @@ -109,7 +121,13 @@ interface RightPanelStoreState { openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; openPullRequest: ( ref: ScopedThreadRef, - target: { environmentId?: string; projectId: string; repository: string; number: number }, + target: { + environmentId?: string; + projectId: string; + repository: string; + number: number; + url?: string; + }, ) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( @@ -210,6 +228,7 @@ export function pullRequestSurface(target: { projectId: string; repository: string; number: number; + url?: string; }): PullRequestSurface { return { id: pullRequestSurfaceId(target), @@ -218,6 +237,7 @@ export function pullRequestSurface(target: { projectId: target.projectId, repository: target.repository, number: target.number, + ...(typeof target.url === "string" ? { url: target.url } : {}), }; } @@ -444,7 +464,16 @@ export const useRightPanelStore = create()( openPullRequest: (ref, target) => set((state) => userAction(state, scopedThreadKey(ref), (current) => { - return upsertSurface(current, pullRequestSurface(target)); + const surface = pullRequestSurface(target); + const next = upsertSurface(current, surface); + return target.url + ? { + ...next, + surfaces: next.surfaces.map((entry) => + entry.id === surface.id ? surface : entry, + ), + } + : next; }), ), openFile: (ref, relativePath, line) => diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index da5582820..cdb8f6517 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2,8 +2,7 @@ import { environmentMachineIcon } from "../components/EnvironmentMachineIcon"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { Spinner } from "~/components/ui/spinner"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts"; +import { pullRequestHostOf, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import type { EnvironmentId, ProjectId, @@ -121,7 +120,11 @@ import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ import { SidebarInset } from "../components/ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; +import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; +import { toastManager } from "../components/ui/toast"; import { + PULL_REQUESTS_PANEL_REF, pullRequestSurfaceId, selectActiveRightPanelSurface, selectSelectedRightPanelSurface, @@ -205,15 +208,6 @@ const PAGE_SIZE = 99; const MAX_PAGE_SIZE = 500; /** Stable empty map so the memos below do not see a new object on every render. */ const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {}; -/** The list owns one environment-scoped right panel rather than borrowing a real thread's. */ -const PULL_REQUESTS_PANEL_ID = ThreadId.make("pull-requests-panel"); -/** - * A fixed sentinel, not a real server: the panel is one workspace-level surface list (each - * surface already carries the server it was read from), so its store key must not move when a - * capable server disconnects or reconnects. Real environment ids are server-generated UUIDs, so - * this string can never collide with one. - */ -const PULL_REQUESTS_PANEL_ENVIRONMENT_ID = "pull-requests-panel" as EnvironmentId; /** Stable so a read that is not wanted right now does not re-key on every render. */ const NO_LIST_TARGETS: ReadonlyArray> = []; const EMPTY_PREVIEW_SESSIONS = {}; @@ -429,13 +423,8 @@ function PullRequestsRouteView() { // read from, so tabs from two of them sit side by side instead of replacing each other. Its ref // uses a fixed sentinel environment, not whichever server happens to sort first, so the tab // strip survives a capable server disconnecting or losing the pull-requests capability. - const rightPanelRef = useMemo( - () => - capableEnvironments.length === 0 - ? null - : scopeThreadRef(PULL_REQUESTS_PANEL_ENVIRONMENT_ID, PULL_REQUESTS_PANEL_ID), - [capableEnvironments.length], - ); + const rightPanelRef = capableEnvironments.length === 0 ? null : PULL_REQUESTS_PANEL_REF; + const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(rightPanelRef); const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, rightPanelRef), ); @@ -458,7 +447,7 @@ function PullRequestsRouteView() { rightPanelState.isOpen && selectedPullRequestSurface !== null, rightPanelPresenceValue, panelAnimationsActive, - rightPanelRef === null ? null : PULL_REQUESTS_PANEL_ID, + rightPanelRef?.threadId ?? null, panelAnimationDurationMs, ); const rightPanelPresent = rightPanelPresence.present; @@ -1858,8 +1847,27 @@ function PullRequestsRouteView() { selectSurfaceInUrl(null); }; - // This page has no ChatView, so the shared panel handles `rightPanel.close` - // itself. With nothing open the event falls through to its native meaning. + // This page has no ChatView, so it handles the shared panel shortcuts itself. + const copyPullRequestFromShortcut = useEffectEvent((event: KeyboardEvent) => { + if (!openPanelPullRequestUrl) return; + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + const url = openPanelPullRequestUrl; + void writeTextToClipboard(url, "pull request link").then( + (didCopy) => { + if (didCopy) + toastManager.add({ type: "success", title: "PR link copied", description: url }); + }, + (error) => { + toastManager.add({ + type: "error", + title: "Failed to copy PR link", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + ); + }); const closeActiveSurfaceFromShortcut = useEffectEvent((event: KeyboardEvent) => { if (activePullRequestSurface === null) return; event.preventDefault(); @@ -1873,6 +1881,7 @@ function PullRequestsRouteView() { context: { terminalFocus: isTerminalFocused() }, }); if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event); + if (command === "thread.copyReference") copyPullRequestFromShortcut(event); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); From 4883813f7b28f27a29aa979c366b6f1b0881860a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 7 Sep 2026 17:12:33 -0700 Subject: [PATCH 04/12] fix(web): open pull request markdown links in the panel Pull request links in PR descriptions, comments, and previews now open in the panel that shows them, including the standalone pull requests page, instead of leaving it. (cherry picked from commit d081ab7abc16a21570d3d96948acb6c1f8d847a4) Adopted from d081ab7abc16a21570d3d96948acb6c1f8d847a4 (#10623) --- apps/web/src/components/ChatMarkdown.tsx | 5 +++- .../pullRequest/PullRequestDetailPanel.tsx | 6 ++++- .../pullRequest/PullRequestMarkdown.tsx | 13 ++++++--- apps/web/src/lib/openPullRequestLink.ts | 27 ++++++++++++++++--- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 51b7c0b41..d13dabfab 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -186,6 +186,8 @@ interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; + /** Panel that receives pull request links, including the standalone PR view. */ + pullRequestPanelRef?: ScopedThreadRef | undefined; /** Environment that owns non-thread markdown, such as a pull request panel. */ environmentId?: EnvironmentId | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; @@ -2204,6 +2206,7 @@ function useChatMarkdownState({ text, cwd, threadRef, + pullRequestPanelRef, environmentId: explicitEnvironmentId, onTaskListChange, isStreaming = false, @@ -2370,7 +2373,7 @@ function useChatMarkdownState({ event.clipboardData.setData("text/plain", payload.text); event.clipboardData.setData("text/html", payload.html); }, []); - const openChangeRequestLink = useOpenChangeRequestLink(threadRef); + const openChangeRequestLink = useOpenChangeRequestLink(threadRef, pullRequestPanelRef); const openDeferredMarkdownLink = useOpenLink(threadRef); // Anchors decide synchronously whether to intercept, so subscribe to hydrated settings. const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 9bf018319..113bb4495 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -653,6 +653,10 @@ export function PullRequestDetailPanel({ if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); }, [detail?.autoMergeMethod, pullRequestKey]); const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); + const markdownContext = useMemo( + () => ({ repositoryUrl: detail?.provider === "github" ? repositoryUrl : null, threadRef }), + [detail?.provider, repositoryUrl, threadRef], + ); const authorProfileUrl = detail?.provider === "github" && detail.author !== null && @@ -2333,7 +2337,7 @@ export function PullRequestDetailPanel({ {...(unavailableGitHubUrl ? { gitHubUrl: unavailableGitHubUrl } : {})} /> ) : detail ? ( - + {mountedTabs.has("summary") ? (
(null); +export const PullRequestMarkdownContext = createContext<{ + repositoryUrl: string | null; + threadRef: ScopedThreadRef | null; +} | null>(null); /** Renders PR uploads inline, with retry and an original link when video playback fails. */ export function PullRequestMarkdown({ @@ -27,7 +31,9 @@ export function PullRequestMarkdown({ className?: string; }) { const segments = splitPullRequestBody(text); - const repositoryUrl = useContext(PullRequestMarkdownContext); + const context = useContext(PullRequestMarkdownContext); + const repositoryUrl = context?.repositoryUrl; + const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined; const extraRemarkPlugins = useMemo>( () => (repositoryUrl ? [[remarkPullRequestAutolinks, { repositoryUrl }]] : []), [repositoryUrl], @@ -41,7 +47,8 @@ export function PullRequestMarkdown({ key={segment.id} text={segment.text} cwd={cwd} - threadRef={threadRef ?? undefined} + threadRef={resolvedThreadRef} + pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF} environmentId={environmentId} extraRemarkPlugins={extraRemarkPlugins} /> diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 2d46e3984..ae487b67d 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -243,6 +243,7 @@ export function shouldOpenPullRequestExternally( export function useOpenChangeRequestLink( threadRef?: ScopedThreadRef, + panelRef?: ScopedThreadRef, ): ( event: Pick< MouseEvent, @@ -260,6 +261,7 @@ export function useOpenChangeRequestLink( (event, targetUrl, targetThreadRef, targetEnvironmentId) => { if (shouldOpenPullRequestExternally(event)) return false; const resolvedThreadRef = targetThreadRef ?? threadRef; + const resolvedPanelRef = panelRef ?? resolvedThreadRef; const parsed = parseChangeRequestUrl(targetUrl); if (parsed === null) return false; const reads = (environmentId: string) => @@ -286,14 +288,33 @@ export function useOpenChangeRequestLink( if (project === undefined || !reads(project.environmentId)) return false; event.preventDefault(); event.stopPropagation(); - if (resolvedThreadRef) { - useRightPanelStore.getState().openPullRequest(resolvedThreadRef, { + if (resolvedPanelRef) { + useRightPanelStore.getState().openPullRequest(resolvedPanelRef, { + // The standalone PR panel has a synthetic ref; each tab keeps its real environment. + ...(resolvedPanelRef.environmentId === project.environmentId + ? {} + : { environmentId: project.environmentId }), projectId: project.id, // The identity's own spelling, not the one read out of the URL: the panel asks the // provider for this repository, while matching a link only ever compares lower case. repository: project.repositoryIdentity?.displayName ?? parsed.repository, number: parsed.number, }); + if (!resolvedThreadRef) { + void navigate({ + to: "/pull-requests", + search: (previous) => ({ + ...previous, + involvement: previous.involvement ?? "all", + state: previous.state ?? "all", + repository: project.repositoryIdentity?.displayName ?? parsed.repository, + number: parsed.number, + selectedProjectId: project.id, + selectedEnvironmentId: project.environmentId, + }), + replace: true, + }); + } return true; } void navigate({ @@ -312,7 +333,7 @@ export function useOpenChangeRequestLink( }); return true; }, - [allProjects, navigate, primaryEnvironmentId, serverConfigs, threadRef], + [allProjects, navigate, panelRef, primaryEnvironmentId, serverConfigs, threadRef], ); } From cfc267403a684c1d49466b25f78135dd83224058 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 8 Sep 2026 00:52:07 -0300 Subject: [PATCH 05/12] fix(web): navigate markdown images as galleries Opening an image from chat or pull request markdown now lets arrow buttons move through every image in that message or PR section, wrapping at either end, with larger overlay navigation buttons. Pylon adaptation: Pylon's preview caption has no snapshot contents control, so only the caption color change applies. (cherry picked from commit 6df0add6e65b7c0040b5774635f794e4ff802ddd) Adopted from 6df0add6e65b7c0040b5774635f794e4ff802ddd (#10625) --- apps/web/src/components/ChatMarkdown.tsx | 64 +++++++++++-------- .../components/chat/ExpandedImageDialog.tsx | 29 +++++---- .../components/chat/markdownImageGallery.ts | 51 +++++++++++++++ .../pullRequest/PullRequestMarkdown.tsx | 2 +- apps/web/src/components/ui/button.tsx | 1 + 5 files changed, 107 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/chat/markdownImageGallery.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d13dabfab..e80147e95 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -89,6 +89,7 @@ import { type ExpandedImagePreview, } from "./chat/ExpandedImagePreview"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; +import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery"; import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; @@ -1305,10 +1306,7 @@ const MarkdownLinkContext = React.createContext(false); function expandableMarkdownImageProps( onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, - src: string, alt: string, - originalUrl?: string, - actionsSource?: MediaActionSource, ) { if (!onImageExpand) return {}; const previewName = alt.trim() || "image"; @@ -1316,17 +1314,8 @@ function expandableMarkdownImageProps( if (event.currentTarget.closest("a")) return; event.preventDefault(); event.stopPropagation(); - onImageExpand({ - images: [ - { - src, - name: previewName, - ...(originalUrl ? { originalUrl } : {}), - ...(actionsSource ? { actionsSource } : {}), - }, - ], - index: 0, - }); + const item = markdownImageItems.get(event.currentTarget); + if (item) onImageExpand(markdownImageGallery(event.currentTarget, item)); }; return { role: "button" as const, @@ -1420,9 +1409,19 @@ function ChatMarkdownImage(props: { // A failure forgets the decoded image so the next URL loads behind the slot. const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); // Cached images are complete before `onLoad` can fire. - const markLoadedIfComplete = useCallback((image: HTMLImageElement | null) => { - if (image?.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); - }, []); + const markLoadedIfComplete = useCallback( + (image: HTMLImageElement | null) => { + if (!image) return; + if (image.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); + markdownImageItems.set(image, { + src, + name: props.alt.trim() || "image", + actionsSource: props.actionsSource, + ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), + }); + }, + [props.actionsSource, props.alt, props.originalUrl, src], + ); const imageEvents = (loadingSrc: string) => ({ onLoad: () => { setLoadedSrc(loadingSrc); @@ -1451,13 +1450,7 @@ function ChatMarkdownImage(props: { props.onImageExpand && "cursor-zoom-in", )} style={props.style} - {...expandableMarkdownImageProps( - props.onImageExpand, - src, - props.alt, - props.originalUrl, - props.actionsSource, - )} + {...expandableMarkdownImageProps(props.onImageExpand, props.alt)} {...imageEvents(src)} /> @@ -2217,6 +2210,7 @@ function useChatMarkdownState({ }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); + const markdownRef = useRef(null); const expandMedia = onImageExpand ?? setLocalMediaPreview; const mediaRequestId = useRef(0); useEffect(() => { @@ -2247,7 +2241,7 @@ function useChatMarkdownState({ ); const preparedConnection = usePreparedConnection(environmentId); const openMarkdownMedia = useCallback( - (source: string, resolvedFilePath?: string) => { + (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => { const requestId = ++mediaRequestId.current; void resolveMarkdownMediaPreview({ source, @@ -2262,7 +2256,14 @@ function useChatMarkdownState({ : undefined, }).then( (preview) => { - if (preview && mediaRequestId.current === requestId) expandMedia(preview); + if (preview && mediaRequestId.current === requestId) { + const selected = preview.images[preview.index]; + expandMedia( + selected && selected.type !== "video" && markdownRef.current + ? markdownImageGallery(clickedImage ?? markdownRef.current, selected) + : preview, + ); + } }, (error: unknown) => { if (mediaRequestId.current !== requestId) return; @@ -2666,6 +2667,7 @@ function useChatMarkdownState({ return { componentState, handleCopy, + markdownRef, markdownUrlTransform, localMediaPreview, setLocalMediaPreview, @@ -2853,7 +2855,13 @@ const CHAT_MARKDOWN_COMPONENTS = { ) { event.preventDefault(); event.stopPropagation(); - openMarkdownMedia(href); + openMarkdownMedia( + href, + undefined, + event.target instanceof HTMLImageElement + ? event.target + : event.currentTarget.querySelector("img"), + ); return; } // A link to a change request in a workspace project opens beside the @@ -3152,6 +3160,7 @@ function ChatMarkdown({ const { componentState, handleCopy, + markdownRef, markdownUrlTransform, localMediaPreview, setLocalMediaPreview, @@ -3170,6 +3179,7 @@ function ChatMarkdown({ // complete source token instead of dropping it from the rendered message. return (
{ - setImageOffset((current) => current + direction); - }, []); + const navigateImage = useCallback( + (direction: -1 | 1) => { + setImageOffset( + (current) => (current + direction + preview.images.length) % preview.images.length, + ); + }, + [preview.images.length], + ); // The element that opened the preview gets focus back on close. Without // this a close button click leaves focus on the unmounted dialog, and the @@ -149,13 +154,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ {preview.images.length > 1 && ( )} @@ -190,7 +195,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ onError={() => setFailedImageSrc(item.src)} /> )} -

+

{item.name} {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}

@@ -199,13 +204,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ {preview.images.length > 1 && ( )}
, diff --git a/apps/web/src/components/chat/markdownImageGallery.ts b/apps/web/src/components/chat/markdownImageGallery.ts new file mode 100644 index 000000000..b462f23c8 --- /dev/null +++ b/apps/web/src/components/chat/markdownImageGallery.ts @@ -0,0 +1,51 @@ +import { mediaKindFromPath } from "@t3tools/shared/filePreview"; +import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; +import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview"; +import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; +import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent"; + +// Weak keys retain resolved media actions only while the rendered image is reachable. +export const markdownImageItems = new WeakMap(); + +/** Collect in document order only when opened, including PR sections separated by videos. */ +export function markdownImageGallery( + element: Element, + selected: ExpandedImageItem, +): ExpandedImagePreview { + const scope = element.closest("[data-image-gallery]") ?? element.closest(".chat-markdown"); + const images: ExpandedImageItem[] = []; + let index = -1; + for (const image of scope?.querySelectorAll("img") ?? []) { + const registered = markdownImageItems.get(image); + if (!registered) continue; + const link = image.closest("a"); + const href = link?.getAttribute("href") ?? ""; + if (link && mediaKindFromPath(href) !== "image") continue; + const linkedSource = + resolveExternalWebLinkHost(href) !== null ? resolveProtocolRelativeMediaUrl(href) : null; + const reference = mediaUrlReference(href); + const item = linkedSource + ? { + ...registered, + src: linkedSource, + originalUrl: href, + actionsSource: { + kind: "image" as const, + name: registered.name, + src: linkedSource, + ...(reference ? { reference } : {}), + }, + } + : registered; + if ( + image === element || + (!markdownImageItems.has(element) && index < 0 && item.src === selected.src) + ) { + index = images.length; + images.push(selected); + } else { + images.push(item); + } + } + return index < 0 ? { images: [selected], index: 0 } : { images, index }; +} diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index fb084c7ce..edfc1c19f 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -39,7 +39,7 @@ export function PullRequestMarkdown({ [repositoryUrl], ); return ( -
+
{segments.map((segment) => { if (segment.kind === "markdown") { return ( diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index bec3e7307..0e688db83 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -51,6 +51,7 @@ const buttonVariants = cva( link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline", outline: "[--control-icon-color:var(--contrast-muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64", + overlay: "border-transparent bg-black/70 text-white/65 [:hover,[data-pressed]]:bg-black/90", secondary: "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90", "warning-outline": From f3b3f4ffaacd975d36275ac8ef51650b50abef9e Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:55:02 +0300 Subject: [PATCH 06/12] fix: generate thread titles with the selected model across connections The text generation model is now a shared server setting: choosing it in General writes the model and its options to every connected environment whose matching provider instance is enabled, and "Apply to all" reconciles drift. Claude text generation runs in non-interactive dontAsk mode so titles work on servers running as root. Pylon adaptation: splice the source settings and target settings arguments into Pylon's provider-instance-aware settings writer. Mobile's thread settings writer never sends the text generation model, so it keeps its existing call. (cherry picked from commit bc4b00666272188027d086361fa5ae01cda53bd2) Adopted from bc4b00666272188027d086361fa5ae01cda53bd2 (#10526) --- .../features/settings/SettingsRouteScreen.tsx | 2 + .../ClaudeTextGeneration.test.ts | 4 + .../textGeneration/ClaudeTextGeneration.ts | 2 + .../components/settings/SettingsPanels.tsx | 2 +- apps/web/src/hooks/useSettings.ts | 13 +- .../src/state/sharedSettings.test.ts | 129 +++++++++++++++++- .../src/state/sharedSettings.ts | 43 +++++- 7 files changed, 188 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index ee1436a97..a7f68112d 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -693,6 +693,8 @@ function SharedThreadSettingsRows() { patch: filterSharedServerPatch( patch, target?.serverConfig?.environment.capabilities, + target?.serverConfig?.settings, + referenceSettings, ), }, }); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index fc9c9691d..8fe5152d3 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -50,6 +50,10 @@ function makeFakeClaudeBinary(dir: string) { " process.exit(code);", "}", "", + 'const permissionIndex = argv.indexOf("--permission-mode");', + 'if (permissionIndex === -1 || argv[permissionIndex + 1] !== "dontAsk") {', + ' fail("text generation must deny permission prompts", 12);', + "}", 'const toolsIndex = argv.indexOf("--tools");', 'if (toolsIndex === -1 || argv[toolsIndex + 1] !== "") {', ' fail("text generation must receive an explicit empty tool set", 6);', diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 2151495d9..76d5b256b 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -214,6 +214,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu "", "--disable-slash-commands", "--strict-mcp-config", + "--permission-mode", + "dontAsk", ], { env: claudeEnvironment }, ); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 683441eb2..a96fd2f09 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2769,7 +2769,7 @@ export function GeneralSettingsPanel() { 0) { + const sourceSettings = environments.find( + (target) => target.environmentId === environmentId, + )?.serverConfig?.settings; const targets = new Set( environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId), ); @@ -442,6 +445,9 @@ function useUpdateSettingsTarget( const targetPatch = filterSharedServerPatch( sharedPatch, target?.serverConfig?.environment.capabilities, + target?.serverConfig?.settings, + sourceSettings, + targetId === environmentId, ); if (Object.keys(targetPatch).length === 0) continue; wroteToTarget = true; @@ -527,7 +533,12 @@ export function useSharedSettingsSync() { void persistServerSettings({ environmentId: mismatch.environmentId, input: { - patch: filterSharedServerPatch(patch, target?.serverConfig?.environment.capabilities), + patch: filterSharedServerPatch( + patch, + target?.serverConfig?.environment.capabilities, + target?.serverConfig?.settings, + primarySettings, + ), }, }); } diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 1d504bd00..d713e8a63 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -1,4 +1,9 @@ -import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import { @@ -38,6 +43,47 @@ describe("supportsSharedSettingsSync", () => { }); describe("splitSharedServerPatch", () => { + it.each([ + { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.6-sol", + options: [{ id: "reasoningEffort", value: "low" }], + }, + { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + options: [{ id: "effort", value: "high" }], + }, + DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, + ])("shares the text generation model and options, including reset (%j)", (selection) => { + const patch = { textGenerationModelSelection: selection }; + expect(splitSharedServerPatch(patch)).toEqual({ sharedPatch: patch, localPatch: {} }); + expect(pickSharedServerSettings({ ...DEFAULT_SERVER_SETTINGS, ...patch })).toMatchObject(patch); + const environment = { + environmentId: boxId, + label: "Remote Box", + syncEligible: true, + settings: { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: { ...selection, model: "different-model" }, + }, + }; + const input = { + primaryEnvironmentId: primaryId, + primarySettings: { ...DEFAULT_SERVER_SETTINGS, ...patch }, + environments: [environment], + }; + expect(findSharedSettingsMismatches(input)).toEqual([ + { environmentId: boxId, label: "Remote Box" }, + ]); + expect( + findSharedSettingsMismatches({ + ...input, + environments: [{ ...environment, settings: input.primarySettings }], + }), + ).toEqual([]); + }); + it("routes preference keys to the shared patch and machine keys to the local patch", () => { const { sharedPatch, localPatch } = splitSharedServerPatch({ sidebarAutoSettleAfterDays: 7, @@ -65,11 +111,92 @@ describe("pickSharedServerSettings", () => { "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", "sourceControlWritingStyle", + "textGenerationModelSelection", ]); }); }); describe("filterSharedServerPatch", () => { + it.each([true, false])( + "resets a disabled default provider only on the originating environment (%s)", + (targetIsSource) => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + codex: { driver: ProviderDriverKind.make("codex"), enabled: false, config: {} }, + claudeAgent: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: {}, + }, + }, + textGenerationModelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-4-6", + }, + }; + const patch = { + textGenerationModelSelection: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection, + continueThreadsAfterServerUpdate: true, + sidebarAutoSettleAfterDays: 7, + }; + expect(filterSharedServerPatch(patch, undefined, settings, settings, targetIsSource)).toEqual( + { + ...(targetIsSource + ? { textGenerationModelSelection: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection } + : {}), + sidebarAutoSettleAfterDays: 7, + }, + ); + }, + ); + + it.each(["missing", "disabled", "different-driver", "enabled"] as const)( + "shares a custom model only when its target provider is enabled (%s)", + (availability) => { + const instanceId = ProviderInstanceId.make("codex_personal"); + const selection = { + instanceId, + model: "gpt-5.6-luna", + options: [{ id: "reasoningEffort", value: "low" }], + }; + const instance = { + driver: ProviderDriverKind.make( + availability === "different-driver" ? "claudeAgent" : "codex", + ), + enabled: availability !== "disabled", + config: {}, + }; + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: availability === "missing" ? {} : { [instanceId]: instance }, + }; + const patch = { sidebarAutoSettleAfterDays: 7, textGenerationModelSelection: selection }; + const sourceSettings = { + ...settings, + providerInstances: { + [instanceId]: { ...instance, driver: ProviderDriverKind.make("codex"), enabled: true }, + }, + }; + expect(filterSharedServerPatch(patch, restartCapabilities, settings, sourceSettings)).toEqual( + availability === "enabled" ? patch : { sidebarAutoSettleAfterDays: 7 }, + ); + const primarySettings = { + ...sourceSettings, + textGenerationModelSelection: selection, + }; + expect( + findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings, + environments: [ + { environmentId: boxId, label: "Remote Box", syncEligible: true, settings }, + ], + }), + ).toEqual(availability === "enabled" ? [{ environmentId: boxId, label: "Remote Box" }] : []); + }, + ); + it.each([true, false])("preserves supported restart preference %s", (enabled) => { const patch = { continueThreadsAfterServerUpdate: enabled, sidebarAutoSettleAfterDays: 7 }; expect(filterSharedServerPatch(patch, restartCapabilities)).toEqual(patch); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 679d409f5..6ce4323cd 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -15,6 +15,7 @@ import type { ServerSettingsPatch, } from "@t3tools/contracts"; import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; +import { isModelSelectionProviderEnabled } from "@t3tools/shared/serverSettings"; import * as Equal from "effect/Equal"; import * as Struct from "effect/Struct"; @@ -26,6 +27,7 @@ const SHARED_SERVER_SETTING_KEYS = [ "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", + "textGenerationModelSelection", ] as const satisfies ReadonlyArray; export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number]; @@ -52,11 +54,31 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): { }; } -/** Omit restart recovery on servers that cannot persist its preference. */ +/** Filter unsupported preferences; direct model writes retain the server's fallback behavior. */ export function filterSharedServerPatch( patch: ServerSettingsPatch, capabilities: Pick | undefined, + settings?: ServerSettings, + sourceSettings = settings, + targetIsSource = false, ): ServerSettingsPatch { + const instanceId = + patch.textGenerationModelSelection?.instanceId ?? + sourceSettings?.textGenerationModelSelection.instanceId; + if ( + !targetIsSource && + patch.textGenerationModelSelection && + (!settings || + (instanceId !== undefined && + (sourceSettings?.providerInstances[instanceId]?.driver ?? instanceId) !== + (settings.providerInstances[instanceId]?.driver ?? instanceId)) || + !isModelSelectionProviderEnabled(settings, { + ...settings.textGenerationModelSelection, + ...patch.textGenerationModelSelection, + })) + ) { + patch = Struct.omit(patch, ["textGenerationModelSelection"]); + } return capabilities?.threadRestartContinuation === true ? patch : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); @@ -67,7 +89,11 @@ export function pickSharedServerSettings( settings: ServerSettings, capabilities?: Pick, ): ServerSettingsPatch { - return filterSharedServerPatch(Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), capabilities); + return filterSharedServerPatch( + Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), + capabilities, + settings, + ); } /** @@ -130,11 +156,20 @@ export function findSharedSettingsMismatches(input: { ) { return []; } - const expected = filterSharedServerPatch(primarySettings, environment.capabilities); - const actual = filterSharedServerPatch( + const expected = filterSharedServerPatch( + primarySettings, + environment.capabilities, + environment.settings, + input.primarySettings ?? undefined, + ); + let actual = filterSharedServerPatch( pickSharedServerSettings(environment.settings, environment.capabilities), input.primaryCapabilities, + environment.settings, ); + if (!expected.textGenerationModelSelection) { + actual = Struct.omit(actual, ["textGenerationModelSelection"]); + } return Equal.equals(actual, expected) ? [] : [{ environmentId: environment.environmentId, label: environment.label }]; From 60e5d815e999a1e97a35c3494c77dd279cc083da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 8 Sep 2026 00:40:35 -0700 Subject: [PATCH 07/12] fix(usage): keep account columns aligned across limit rows Pooled limit rows now keep each account in one column across windows, ordered by the session reset (or the first available window), with a gap where an account does not report a window. Each row's reset list stays chronological. Web, desktop, and mobile share the order. Pylon adaptations: ordering reads the same normalized window names the pools use, because older servers omit window ids and kinds; a focused test covers that. The Pylon hub account keys and the condensed usage guide are kept, with the column order described. (cherry picked from commit 1f14d6d10afbcc99ec255b23544ee25c08dea321) Adopted from 1f14d6d10afbcc99ec255b23544ee25c08dea321 (#10690) --- .../src/features/usage/UsageLimitsPooled.tsx | 48 ++++---- .../components/usage/UsageLimitsPooled.tsx | 31 ++--- docs/user/usage.md | 2 + packages/shared/src/usageLimits.test.ts | 116 +++++++++++++++++- packages/shared/src/usageLimits.ts | 41 +++++-- 5 files changed, 188 insertions(+), 50 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 097d881e1..6baf163cf 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -124,30 +124,34 @@ function PoolWindowCard({ ) : null} - {pool.members.map(({ account, window }, index) => ( - openAccount(account)} - className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" - > - - - - {index + 1} - - - - ))} + {pool.columns.map(({ account, window }, index) => { + if (!window) return ; + return ( + openAccount(account)} + className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" + > + + + + {index + 1} + + + + ); + })} - {pool.members.map(({ account, window }, index) => { + {pool.columns.map(({ account, window }, index) => { + if (!window) return null; const credits = account.limits.resetCredits?.availableCount ?? 0; const resetsIn = formatResetsIn(window, now); return ( diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx index 0c17779da..37654140c 100644 --- a/apps/web/src/components/usage/UsageLimitsPooled.tsx +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -449,28 +449,29 @@ function PoolBar({
- {pool.members.map(({ account, window }, position) => ( - - ))} + {pool.columns.map((member, position) => + member.window ? ( + + ) : null, + )}
); } /** - * Big pooled number and the segment bar. The bar is sorted by reset, so who - * refills next is its left edge; the exact time and share restored live in - * each segment's popover rather than a list restating the bar. + * Big pooled number and the segment bar. Accounts keep the same column across + * windows; each segment's popover shows its own reset time and share restored. */ function PoolWindowCard({ pool, diff --git a/docs/user/usage.md b/docs/user/usage.md index 4e63265a0..6a667bcad 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -37,6 +37,8 @@ the dialog. If a recovered environment needs a complete price, discard the pendi Choose **Limits** to see remaining quota, reset times, and pace across your connected accounts. Accounts for the same provider are pooled across the selected environments; expand a pool to inspect its accounts. Limits are provider-reported subscription allowances, separate from the Usage page’s estimated token costs. Refresh to update the readings and reset countdowns. +Each window's bar has one segment per account, and an account keeps the same column across windows. Accounts are ordered by their 5-hour reset, soonest first, or by the first available window when no account reports a 5-hour limit. A gap means the account does not report that window. + In a thread, submit **/usage-limits** by itself to show the current provider’s quota above the composer without starting an agent turn. Dismiss the panel with its close control; a successful message send clears it. Provider-defined commands with the same name keep their own behavior. On mobile, use Usage → Limits before creating a thread. When Codex reports reset credits, **Use reset** asks you to confirm before redeeming one. A confirmed result remains visible even if refreshing the balance fails. If the request’s outcome is uncertain, retrying checks the same attempt. diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 19dfd14da..4880df384 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -9,6 +9,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { + type LimitAccount, isUsageLimitsCommand, shouldHandleUsageLimitsCommand, USAGE_LIMITS_COMMAND, @@ -933,7 +934,7 @@ describe("pools", () => { ["weekly", 1], ["monthly", 1], ]); - // Segments read left to right as "who refills next", matching the reset list. + // Session resets determine the account order for every row. expect(session?.members.map((member) => member.account.key)).toEqual([ JSON.stringify(["hub", "env-a", "hub", "a"]), JSON.stringify(["hub", "env-a", "hub", "b"]), @@ -945,6 +946,119 @@ describe("pools", () => { }); }); +describe("pooled account columns", () => { + const weekly = { + ...window, + id: "seven_day", + kind: "weekly", + label: "Weekly", + windowDurationMins: 7 * 24 * 60, + } as const; + const account = (key: string, windows: LimitAccount["limits"]["windows"]): LimitAccount => ({ + key, + driver: ProviderDriverKind.make("claudeAgent"), + displayName: key, + email: undefined, + plan: undefined, + accentColor: undefined, + environments: [], + sourceLabel: "Hub", + redeem: null, + limits: { checkedAt: "2026-09-03T11:00:00.000Z", windows }, + }); + const keys = (pool: ReturnType[number]) => + pool.windows.map((row) => + row.columns.map((member) => (member.window ? member.account.key : null)), + ); + + it("keeps session columns across rows with opposite reset and usage orders", () => { + const accounts = [ + account("a", [ + { ...weekly, usedPercent: 80, resetsAt: "2026-09-05T12:00:00.000Z" }, + { ...window, usedPercent: 10, resetsAt: "2026-09-03T15:00:00.000Z" }, + ]), + account("b", [ + { ...weekly, usedPercent: 20, resetsAt: "2026-09-06T12:00:00.000Z" }, + { ...window, usedPercent: 90, resetsAt: "2026-09-03T13:00:00.000Z" }, + ]), + ]; + const [pool] = collectLimitPools(accounts, now); + expect(pool!.accounts.map((account) => account.key)).toEqual(["b", "a"]); + expect(keys(pool!)).toEqual([ + ["b", "a"], + ["b", "a"], + ]); + expect(pool!.windows[1]!.resets.map((reset) => reset.member.account.key)).toEqual(["a", "b"]); + expect(pool!.windows[1]!.remainingPercent).toBe(50); + expect(keys(collectLimitPools(accounts.toReversed(), now)[0]!)).toEqual(keys(pool!)); + }); + + it("preserves gaps without counting missing windows toward pooled quota", () => { + const [pool] = collectLimitPools( + [ + account("a", [window]), + account("b", [ + { ...window, resetsAt: "2026-09-03T15:00:00.000Z" }, + { ...weekly, usedPercent: 80 }, + ]), + account("c", [weekly]), + ], + now, + ); + expect(keys(pool!)).toEqual([ + ["a", "b", null], + [null, "b", "c"], + ]); + expect(pool!.windows[1]!.members.map((member) => member.account.key)).toEqual(["b", "c"]); + expect(pool!.windows[1]!.remainingPercent).toBe(40); + expect(pool!.windows[1]!.resets.map((reset) => reset.restoresPercent)).toEqual([40, 20]); + }); + + it("falls back to weekly resets when no account reports a session", () => { + const [pool] = collectLimitPools( + [ + account("a", [{ ...weekly, resetsAt: "2026-09-06T12:00:00.000Z" }]), + account("b", [{ ...weekly, resetsAt: "2026-09-05T12:00:00.000Z" }]), + ], + now, + ); + expect(keys(pool!)).toEqual([["b", "a"]]); + }); + + it("sorts unknown resets last and breaks ties consistently", () => { + const accounts = [ + account("z", [{ ...window, resetsAt: undefined }]), + account("b", [window]), + account("a", [window]), + account("y", [{ ...window, resetsAt: "invalid" }]), + ]; + expect(keys(collectLimitPools(accounts, now)[0]!)).toEqual([["a", "b", "y", "z"]]); + expect(keys(collectLimitPools(accounts.toReversed(), now)[0]!)).toEqual([["a", "b", "y", "z"]]); + }); + + it("orders by the session reset when an older server omits window ids and kinds", () => { + const { id: _id, kind: _kind, ...legacySession } = window; + const { id: _weeklyId, kind: _weeklyKind, ...legacyWeekly } = weekly; + const [pool] = collectLimitPools( + [ + account("a", [ + { ...legacyWeekly, resetsAt: "2026-09-05T12:00:00.000Z" }, + { ...legacySession, resetsAt: "2026-09-03T15:00:00.000Z" }, + ]), + account("b", [ + { ...legacyWeekly, resetsAt: "2026-09-06T12:00:00.000Z" }, + { ...legacySession, resetsAt: "2026-09-03T13:00:00.000Z" }, + ]), + ], + now, + ); + expect(keys(pool!)).toEqual([ + ["b", "a"], + ["b", "a"], + ]); + }); +}); + describe("collectLimitNotices", () => { const checkedAt = "2026-09-03T11:00:00.000Z"; const claude = ProviderDriverKind.make("claudeAgent"); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 32cf1b67b..a02bf8970 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -445,6 +445,11 @@ export interface LimitPoolWindow { readonly kind: UsageWindowKind; readonly label: string; readonly members: readonly LimitPoolMember[]; + /** Fixed account positions across rows; a null window leaves a gap. */ + readonly columns: ReadonlyArray<{ + readonly account: LimitAccount; + readonly window: NamedUsageWindow | null; + }>; readonly remainingPercent: number; readonly usedPercent: number; readonly pace: LimitPace | null; @@ -477,10 +482,10 @@ const WINDOW_KIND_ORDER: Record = { * a month on Free/Go), and a monthly allowance must not average into a * five-hour pool. Pools order by kind, then first appearance. * - * `accounts` is the table order: instances the user can act on (native, - * named) before hub-only accounts, each group alphabetical. Each window's - * `members` sort by reset instead, soonest first, so a bar reads left to - * right as "who refills next" and matches the reset list under it. + * Accounts and columns share the session reset order, soonest first. When + * no account reports a session window, use the first window by kind instead. + * Missing reset times sort last, with account names and keys breaking ties. + * Each window's reset list still follows its own clock. */ export function collectLimitPools( accounts: readonly LimitAccount[], @@ -493,10 +498,23 @@ export function collectLimitPools( else byDriver.set(account.driver, [account]); } return [...byDriver].map(([driver, members]) => { + // Older servers omit window ids and kinds, so order by the same names the pools use. + const namedWindows = (account: LimitAccount) => + account.limits.windows.map((window) => normalizeUsageWindow(window, account.driver)); + const orderWindow = members + .flatMap(namedWindows) + .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0]; + const orderReset = (account: LimitAccount) => { + const window = namedWindows(account).find( + (window) => window.kind === orderWindow?.kind && window.id === orderWindow.id, + ); + return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY; + }; const sorted = [...members].sort( (left, right) => - Number(left.redeem === null) - Number(right.redeem === null) || - accountSortName(left).localeCompare(accountSortName(right)), + orderReset(left) - orderReset(right) || + accountSortName(left).localeCompare(accountSortName(right)) || + left.key.localeCompare(right.key), ); return { driver, accounts: sorted, windows: poolWindows(sorted, now) }; }); @@ -517,12 +535,8 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L else byKey.set(key, [{ account, window }]); } } - const pools = [...byKey.values()].map((unordered): LimitPoolWindow => { - const members = [...unordered].sort( - (left, right) => - (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) - - (resetMillis(right.window) ?? Number.POSITIVE_INFINITY), - ); + const pools = [...byKey.values()].map((members): LimitPoolWindow => { + const memberByAccount = new Map(members.map((member) => [member.account.key, member])); const first = members[0]!.window; const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; // Pace compares spend against the clock, so it is judged only over the @@ -554,6 +568,9 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L kind: first.kind, label: first.label, members, + columns: accounts.map( + (account) => memberByAccount.get(account.key) ?? { account, window: null }, + ), usedPercent: Math.round(usedPercent), remainingPercent: Math.round(100 - usedPercent), pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed), From 08985601436bc4da0c59950177c2e14ed688d818 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:31:22 +0530 Subject: [PATCH 08/12] feat(web): accept file drops into sidebar threads Dropping files from the computer onto a sidebar thread row or search result opens that thread and attaches the files in its composer through the same path as a workspace drop. Nothing is sent. The flat and project sidebars both accept drops. Pylon adaptation: splice the row prop and handler beside Pylon's change request snapshot props, and describe the drop in Pylon's sidebar guide. b5d89038ae72142038dfa8cf69d49b7a607fe98e is an empty duplicate of this source and changes nothing. (cherry picked from commit bde39d4d7977ce85d6ea396a983d6b6a25bf7e07) Adopted from bde39d4d7977ce85d6ea396a983d6b6a25bf7e07 (#7892) Already covered: b5d89038ae72142038dfa8cf69d49b7a607fe98e (#7892, empty duplicate) --- apps/web/src/components/ChatView.tsx | 51 ++++++ apps/web/src/components/LegacySidebar.tsx | 55 +++++- apps/web/src/components/Sidebar.tsx | 110 +++++++++++- .../routes/_chat.$environmentId.$threadId.tsx | 12 +- .../src/sidebarPendingFileDropStore.test.ts | 163 ++++++++++++++++++ apps/web/src/sidebarPendingFileDropStore.ts | 74 ++++++++ docs/user/thread-sidebar.md | 5 + 7 files changed, 462 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/sidebarPendingFileDropStore.test.ts create mode 100644 apps/web/src/sidebarPendingFileDropStore.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5d2290166..6d2c4b664 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -193,6 +193,10 @@ import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; +import { + isSameSidebarThreadRef, + useSidebarPendingFileDropStore, +} from "../sidebarPendingFileDropStore"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, @@ -8697,6 +8701,53 @@ export default function ChatView(props: ChatViewProps) { void onRevertToTurnCountRef.current(target); }, []); + // Files dropped on a sidebar row land here once the dropped-on thread is + // actually open, then take the exact same path as a workspace drop: + // validate, compress, focus the composer, never send. Kept above the + // no-active-thread early return so hook order never changes. + const pendingSidebarFileDrops = useSidebarPendingFileDropStore((state) => state.pending); + const consumePendingFileDrop = useSidebarPendingFileDropStore( + (state) => state.consumePendingFileDrop, + ); + useEffect(() => { + if (pendingSidebarFileDrops.length === 0) return; + // A promoting draft can mount this view with the server thread id while + // its composer is still draft-keyed; finalization would discard what we + // attach there. Only the canonical thread target may consume a drop. + if ( + typeof composerDraftTarget === "string" || + !pendingSidebarFileDrops.some((drop) => + isSameSidebarThreadRef(composerDraftTarget, drop.threadRef), + ) + ) { + return; + } + if (!activeThread) return; + if (!composerRef.current) { + const raf = window.requestAnimationFrame(() => { + if (!composerRef.current) return; + if (typeof composerDraftTarget === "string") return; + // Consume matches by target, so a newer drop that arrived meanwhile + // is collected too rather than orphaned. + const files = consumePendingFileDrop(composerDraftTarget); + if (files !== null) { + composerRef.current?.addDroppedFiles(files); + } + }); + return () => window.cancelAnimationFrame(raf); + } + const files = consumePendingFileDrop(composerDraftTarget); + if (files !== null) { + composerRef.current.addDroppedFiles(files); + } + }, [ + activeThread, + composerDraftTarget, + composerRef, + consumePendingFileDrop, + pendingSidebarFileDrops, + ]); + // Empty state: no active thread if (!activeThread) { return ; diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 80d3581d4..dd54353ad 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -79,6 +79,8 @@ import { useOpenPrLink } from "../lib/openPullRequestLink"; import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; +import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; +import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { readThreadShell, useProject, @@ -332,7 +334,7 @@ interface SidebarThreadRowProps { threadRef: ScopedThreadRef, orderedProjectThreadKeys: readonly string[], ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; + navigateToThread: (threadRef: ScopedThreadRef) => Promise; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( threadRef: ScopedThreadRef, @@ -351,6 +353,7 @@ interface SidebarThreadRowProps { prUrl: string, threadRef?: ScopedThreadRef, ) => boolean; + onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; } const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -378,10 +381,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP cancelRename, attemptArchiveThread, openPrLink, + onFileDropThreads, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); + const [isFileDragOver, setIsFileDragOver] = useState(false); + const fileDropHandlers = useMemo( + () => + makeWorkspaceFileDropHandlers({ + setDragActive: setIsFileDragOver, + addFiles: (files) => { + onFileDropThreads(threadRef, files); + }, + }), + [onFileDropThreads, threadRef], + ); + useEffect(() => { + if (!isFileDragOver) return; + const clearFileDrag = () => setIsFileDragOver(false); + window.addEventListener("dragend", clearFileDrag); + return () => window.removeEventListener("dragend", clearFileDrag); + }, [isFileDragOver]); const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); @@ -710,6 +731,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ref={rowRef} className="w-full" data-thread-item + {...fileDropHandlers} onMouseLeave={handleMouseLeave} onBlurCapture={handleBlurCapture} > @@ -721,7 +743,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={`${resolveThreadRowClassName({ isActive, isSelected, - })} relative isolate`} + })} relative isolate${isFileDragOver ? " ring-1 ring-inset ring-primary/70" : ""}`} onClick={handleRowClick} onDoubleClick={handleRowDoubleClick} onKeyDown={handleRowKeyDown} @@ -966,7 +988,8 @@ interface SidebarProjectThreadListProps { threadRef: ScopedThreadRef, orderedProjectThreadKeys: readonly string[], ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; + navigateToThread: (threadRef: ScopedThreadRef) => Promise; + onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( threadRef: ScopedThreadRef, @@ -1019,6 +1042,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( attachThreadListAutoAnimateRef, handleThreadClick, navigateToThread, + onFileDropThreads, handleMultiSelectContextMenu, handleThreadContextMenu, clearSelection, @@ -1071,6 +1095,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( confirmArchiveButtonRefs={confirmArchiveButtonRefs} handleThreadClick={handleThreadClick} navigateToThread={navigateToThread} + onFileDropThreads={onFileDropThreads} handleMultiSelectContextMenu={handleMultiSelectContextMenu} handleThreadContextMenu={handleThreadContextMenu} clearSelection={clearSelection} @@ -1188,6 +1213,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec (settings) => settings.sidebarThreadPreviewCount, ); const router = useRouter(); + const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); + const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); const { isMobile, setOpenMobile } = useSidebar(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); @@ -1792,13 +1819,32 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (isMobile) { setOpenMobile(false); } - void router.navigate({ + return router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }); }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); + const handleThreadFileDrop = useCallback( + async (threadRef: ScopedThreadRef, files: File[]) => { + const dropId = queuePendingFileDrop({ threadRef, files }); + const targetPathname = router.buildLocation({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }).pathname; + if (targetPathname === router.state.location.pathname) return; + try { + await navigateToThread(threadRef); + if (targetPathname !== router.state.location.pathname) { + clearPendingFileDrop(dropId); + } + } catch { + clearPendingFileDrop(dropId); + } + }, + [clearPendingFileDrop, navigateToThread, queuePendingFileDrop, router], + ); const handleThreadClick = useCallback( ( @@ -2455,6 +2501,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} handleThreadClick={handleThreadClick} navigateToThread={navigateToThread} + onFileDropThreads={handleThreadFileDrop} handleMultiSelectContextMenu={handleMultiSelectContextMenu} handleThreadContextMenu={handleThreadContextMenu} clearSelection={clearSelection} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fe29532f5..49a7e5be3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -95,6 +95,10 @@ import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { releaseComposerDraftUploads } from "../lib/composerDraftUploads"; import { readLocalApi } from "../localApi"; +import { + isSameSidebarThreadRef, + useSidebarPendingFileDropStore, +} from "../sidebarPendingFileDropStore"; import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots, @@ -203,6 +207,7 @@ import { type SnoozePreset, } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; +import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { @@ -1023,6 +1028,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { threadKey: string, snapshot: ThreadChangeRequestSnapshot | null, ) => void; + /** + * External files dropped onto this row. The row highlights while the drag + * is over it; the callback opens the thread and hands the files to its + * composer. Absent when the sidebar cannot open server threads. + */ + onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; }) { const { isRenaming, @@ -1032,6 +1043,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onCommitRename, onContextMenu, onAcknowledgeWoke, + onFileDropThreads, onRenameTitleChange, onSettle, onSnooze, @@ -1301,6 +1313,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [isRenaming, onStartRename, thread.title, threadRef], ); + const [isFileDragOver, setIsFileDragOver] = useState(false); + const fileDropHandlers = useMemo( + () => + onFileDropThreads + ? makeWorkspaceFileDropHandlers({ + setDragActive: setIsFileDragOver, + addFiles: (files) => { + onFileDropThreads(threadRef, files); + }, + }) + : null, + [onFileDropThreads, threadRef], + ); + // A drop lands on a child or outside the window entirely, so dragend is + // the reset of last resort for the row's highlight. + useEffect(() => { + if (!isFileDragOver) return; + const clearFileDrag = () => setIsFileDragOver(false); + window.addEventListener("dragend", clearFileDrag); + return () => window.removeEventListener("dragend", clearFileDrag); + }, [isFileDragOver]); const renameCommittedRef = useRef(false); useEffect(() => { if (isRenaming) renameCommittedRef.current = false; @@ -1415,6 +1448,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { !props.isActive && !isSelected && "opacity-70 transition-opacity hover:opacity-100", + isFileDragOver && "ring-1 ring-inset ring-primary/70", + // The hover tint must not clobber an active/selected row's own surface. + isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover", // The lifted row is an opaque card so the rows beneath it never show // through. The row tint is translucent in dark themes and the pointer // keeps the hover color applied, so both the tint and the solid sidebar @@ -1587,6 +1623,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
  • void; onSelect: () => void; + onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; }) { const { thread } = props; + const threadRef = useMemo( + () => scopeThreadRef(thread.environmentId, thread.id), + [thread.environmentId, thread.id], + ); const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease( props.isHighlighted || props.isRouteActive, ); @@ -2085,8 +2128,25 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { threadId: thread.id, }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); + const [isFileDragOver, setIsFileDragOver] = useState(false); + const fileDropHandlers = useMemo( + () => + makeWorkspaceFileDropHandlers({ + setDragActive: setIsFileDragOver, + addFiles: (files) => { + props.onFileDropThreads(threadRef, files); + }, + }), + [props.onFileDropThreads, threadRef], + ); + useEffect(() => { + if (!isFileDragOver) return; + const clearFileDrag = () => setIsFileDragOver(false); + window.addEventListener("dragend", clearFileDrag); + return () => window.removeEventListener("dragend", clearFileDrag); + }, [isFileDragOver]); return ( -
  • +
  • } @@ -2856,7 +2918,7 @@ export default function Sidebar() { if (isMobile) { setOpenMobile(false); } - void router.navigate({ + return router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }); @@ -2864,6 +2926,48 @@ export default function Sidebar() { [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); + // Dropping files on a row opens that thread and attaches the files there. + // The composer only accepts drops for its OWN thread, so when the row is + // not the open thread we stash the files and let ChatView hand them over + // once the navigation actually lands; if the route bounced (thread gone), + // nothing will consume them, so clear instead of surprising the user later. + const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); + const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); + const handleThreadFileDrop = useCallback( + async (threadRef: ScopedThreadRef, files: File[]) => { + // Queued, not replaced: a second drop before the thread opens keeps + // both files, and the id lets cleanup below touch only this drop. + const dropId = queuePendingFileDrop({ threadRef, files }); + // Key match alone is not "already there": during draft promotion the + // resolved route key is the server thread while the URL is still the + // draft route, and its composer would swallow the drop then discard it. + const landedBefore = + router.buildLocation({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }).pathname === router.state.location.pathname; + if (landedBefore) return; + try { + await navigateToThread(threadRef); + // A newer drop may have arrived while the navigation was in flight; + // clearing by id leaves those files untouched. + const landed = + router.buildLocation({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }).pathname === router.state.location.pathname; + if (!landed) { + clearPendingFileDrop(dropId); + } + } catch { + // Navigation failed outright; nothing will consume this drop, but a + // newer drop for the same thread may still be deliverable. + clearPendingFileDrop(dropId); + } + }, + [clearPendingFileDrop, navigateToThread, queuePendingFileDrop, router], + ); + const navigateToDraft = useCallback( (draftId: DraftId) => { // Unconditional: also drops a stale selection anchor left by @@ -4645,6 +4749,7 @@ export default function Sidebar() { resultId={`sidebar-thread-search-result-${index}`} onHighlight={() => setActiveSearchResultIndex(index)} onSelect={() => selectThreadSearchResult(thread)} + onFileDropThreads={handleThreadFileDrop} /> ); })} @@ -4808,6 +4913,7 @@ export default function Sidebar() { changeRequestSnapshotByKey.get(threadKey) ?? null } onChangeRequestSnapshot={setThreadChangeRequestSnapshot} + onFileDropThreads={handleThreadFileDrop} /> ); }; diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 5ac4665cf..16c0c4dfc 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -6,6 +6,7 @@ import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; import { resolveThreadSyncPhase } from "../threadSync"; +import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, @@ -62,8 +63,15 @@ function ChatThreadRouteView() { return; } - if (renderState === "missing" && environmentHasAnyThreads) { - void navigate({ to: "/", replace: true }); + // Navigation already resolved onto this path, so a drop aimed here + // passed its landing check; once the thread reads as missing it can + // never be attached, release it even when there is nowhere to redirect. + if (renderState === "missing") { + const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState(); + clearPendingFileDropsForThread(threadRef); + if (environmentHasAnyThreads) { + void navigate({ to: "/", replace: true }); + } } }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]); diff --git a/apps/web/src/sidebarPendingFileDropStore.test.ts b/apps/web/src/sidebarPendingFileDropStore.test.ts new file mode 100644 index 000000000..5965a5f1c --- /dev/null +++ b/apps/web/src/sidebarPendingFileDropStore.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { + isSameSidebarThreadRef, + useSidebarPendingFileDropStore, + type SidebarPendingFileDrop, +} from "./sidebarPendingFileDropStore"; + +function makeFiles(...names: string[]): File[] { + return names.map((name) => new File(["x"], name)); +} + +function makeEntry( + environmentId: string, + threadId: string, + files: File[], +): Omit { + return { + threadRef: scopeThreadRef(environmentId as EnvironmentId, ThreadId.make(threadId)), + files, + }; +} + +function fileNames(files: File[]): string[] { + return files.map((file) => file.name); +} + +function refOf(environmentId: string, threadId: string) { + return makeEntry(environmentId, threadId, []).threadRef; +} + +beforeEach(() => { + useSidebarPendingFileDropStore.setState({ pending: [] }); +}); + +describe("sidebarPendingFileDropStore", () => { + it("starts empty", () => { + expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]); + }); + + it("stashes and consumes a drop for the matching thread", () => { + const files = makeFiles("a.png", "b.png"); + const store = useSidebarPendingFileDropStore.getState(); + store.queuePendingFileDrop(makeEntry("env-1", "thread-1", files)); + + expect( + useSidebarPendingFileDropStore.getState().consumePendingFileDrop(refOf("env-1", "thread-1")), + ).toEqual(files); + expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]); + }); + + it("accumulates repeat drops onto the same thread instead of replacing", () => { + const store = useSidebarPendingFileDropStore.getState(); + store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png"))); + store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png"))); + + expect( + fileNames( + useSidebarPendingFileDropStore + .getState() + .consumePendingFileDrop(refOf("env-1", "thread-1")) ?? [], + ), + ).toEqual(["a.png", "b.png"]); + expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]); + }); + + it("keeps drops for other threads when consuming one thread", () => { + const store = useSidebarPendingFileDropStore.getState(); + store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png"))); + store.queuePendingFileDrop(makeEntry("env-1", "thread-2", makeFiles("b.png"))); + + expect( + fileNames( + useSidebarPendingFileDropStore + .getState() + .consumePendingFileDrop(refOf("env-1", "thread-2")) ?? [], + ), + ).toEqual(["b.png"]); + expect(useSidebarPendingFileDropStore.getState().pending).toHaveLength(1); + }); + + it("clears only the drop matching a stale navigation's id", () => { + const store = useSidebarPendingFileDropStore.getState(); + const firstId = store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png"))); + store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png"))); + + useSidebarPendingFileDropStore.getState().clearPendingFileDrop(firstId); + expect( + fileNames( + useSidebarPendingFileDropStore + .getState() + .consumePendingFileDrop(refOf("env-1", "thread-1")) ?? [], + ), + ).toEqual(["b.png"]); + }); + + it("keeps a newer drop deliverable after the first navigation fails", () => { + // Mirrors handleThreadFileDrop: two drops queued for the same unopened + // thread, then the first navigation fails (or lands elsewhere) and cleans + // up by its own drop id. The newer drop must survive with its files + // intact so the thread opening still attaches them. + const store = useSidebarPendingFileDropStore.getState(); + const firstId = store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png"))); + const secondId = store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png"))); + + // First navigation fails: handler clears only its own drop. + useSidebarPendingFileDropStore.getState().clearPendingFileDrop(firstId); + const remaining = useSidebarPendingFileDropStore.getState().pending; + expect(remaining).toHaveLength(1); + expect(remaining[0]?.id).toBe(secondId); + expect(fileNames(remaining[0]?.files ?? [])).toEqual(["b.png"]); + + // Thread opens: the surviving drop is still attached. + expect( + fileNames( + useSidebarPendingFileDropStore + .getState() + .consumePendingFileDrop(refOf("env-1", "thread-1")) ?? [], + ), + ).toEqual(["b.png"]); + expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]); + }); + + it("clears every drop for a missing thread", () => { + const store = useSidebarPendingFileDropStore.getState(); + store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png"))); + store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png"))); + store.queuePendingFileDrop(makeEntry("env-1", "thread-2", makeFiles("c.png"))); + + useSidebarPendingFileDropStore + .getState() + .clearPendingFileDropsForThread(refOf("env-1", "thread-1")); + expect( + fileNames( + useSidebarPendingFileDropStore + .getState() + .consumePendingFileDrop(refOf("env-1", "thread-2")) ?? [], + ), + ).toEqual(["c.png"]); + expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]); + }); + + it("does not confuse refs whose joined keys collide on colons", () => { + const store = useSidebarPendingFileDropStore.getState(); + store.queuePendingFileDrop(makeEntry("a", "b:c", makeFiles("a.png"))); + + expect( + useSidebarPendingFileDropStore.getState().consumePendingFileDrop(refOf("a:b", "c")), + ).toBeNull(); + expect(useSidebarPendingFileDropStore.getState().pending).toHaveLength(1); + }); +}); + +describe("isSameSidebarThreadRef", () => { + it("compares fields, not joined keys", () => { + expect(isSameSidebarThreadRef(refOf("a", "b:c"), refOf("a", "b:c"))).toBe(true); + expect(isSameSidebarThreadRef(refOf("a", "b:c"), refOf("a:b", "c"))).toBe(false); + expect(isSameSidebarThreadRef(refOf("a", "b"), refOf("a", "c"))).toBe(false); + }); +}); diff --git a/apps/web/src/sidebarPendingFileDropStore.ts b/apps/web/src/sidebarPendingFileDropStore.ts new file mode 100644 index 000000000..3c1fe328b --- /dev/null +++ b/apps/web/src/sidebarPendingFileDropStore.ts @@ -0,0 +1,74 @@ +import { create } from "zustand"; + +import type { ScopedThreadRef } from "@t3tools/contracts"; + +/** + * Field-wise ref equality. `scopedThreadKey` joins with `:`, so two distinct + * refs can collide when an id itself contains one; drops must never cross + * threads on that account. + */ +export function isSameSidebarThreadRef(a: ScopedThreadRef, b: ScopedThreadRef): boolean { + return a.environmentId === b.environmentId && a.threadId === b.threadId; +} + +/** + * One sidebar row drop. Drops queue up instead of replacing each other, so a + * second drop onto the same thread before it opens keeps both files; each + * entry carries its own id so a stale navigation can only ever clear the drop + * that started it. + */ +export interface SidebarPendingFileDrop { + id: string; + threadRef: ScopedThreadRef; + files: File[]; +} + +interface SidebarPendingFileDropStoreState { + pending: SidebarPendingFileDrop[]; + /** + * Appends a drop to the queue and returns its id, for later + * identity-checked cleanup. + */ + queuePendingFileDrop: (entry: Omit) => string; + /** Removes the single drop with this id, leaving newer drops untouched. */ + clearPendingFileDrop: (id: string) => void; + /** Removes every queued drop aimed at this thread (e.g. it went missing). */ + clearPendingFileDropsForThread: (threadRef: ScopedThreadRef) => void; + /** + * Returns every queued drop's files for `threadRef`, oldest first, and + * removes them; returns null (leaving state untouched) when none match. + */ + consumePendingFileDrop: (threadRef: ScopedThreadRef) => File[] | null; +} + +let nextPendingFileDropId = 0; + +export const useSidebarPendingFileDropStore = create()( + (set, get) => ({ + pending: [], + queuePendingFileDrop: (entry) => { + const id = `sidebar-file-drop-${(nextPendingFileDropId += 1)}`; + set((state) => ({ pending: [...state.pending, { ...entry, id }] })); + return id; + }, + clearPendingFileDrop: (id) => { + set((state) => ({ pending: state.pending.filter((drop) => drop.id !== id) })); + }, + clearPendingFileDropsForThread: (threadRef) => { + set((state) => ({ + pending: state.pending.filter((drop) => !isSameSidebarThreadRef(drop.threadRef, threadRef)), + })); + }, + consumePendingFileDrop: (threadRef) => { + const matches = get().pending.filter((drop) => + isSameSidebarThreadRef(drop.threadRef, threadRef), + ); + if (matches.length === 0) { + return null; + } + const matchedIds = new Set(matches.map((drop) => drop.id)); + set((state) => ({ pending: state.pending.filter((drop) => !matchedIds.has(drop.id)) })); + return matches.flatMap((drop) => drop.files); + }, + }), +); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e672260e2..c5b0c14d6 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -51,6 +51,11 @@ them together. Your unpin confirmation setting applies. Both sidebar layouts con deletion after a thread fails, keeping failed threads selected for retry. A worktree removal failure is reported separately when the thread itself was deleted successfully. +On web and desktop, you can also drag files from your computer onto any thread row, including +search results: the thread opens and the files are attached in its composer, ready for your next +message. Nothing is sent automatically. The same per-message file limits apply as when attaching +files directly; see [Composer](./composer.md). + Project settings are available from the project menu in either sidebar and from the breadcrumb context menu when composing a new thread. From 35e41737e847e2675445535c0c7ae1c0a95427c4 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:04:51 +0530 Subject: [PATCH 09/12] fix(web): honor terminal link browser overrides Terminal links now open with a primary click, dragging from a link starts a text selection, and Cmd/Ctrl-click sends the link to the system browser regardless of the Open links in setting. Pylon adaptation: Pylon's terminal link helper keeps falling back to the system browser when a settings read or in-app open fails, so upstream's reject-on-settings-failure test and the drawer's error toast are not ported. The link guide now describes the plain click and the override. (cherry picked from commit 772ea1473a4a8f8fb0e0a5c9ba6d7a2ef14eac6e) Adopted from 772ea1473a4a8f8fb0e0a5c9ba6d7a2ef14eac6e (#10060) --- .../src/components/ThreadTerminalDrawer.tsx | 4 +- .../preview/openTerminalLinkInPreview.test.ts | 22 ++++ .../preview/openTerminalLinkInPreview.ts | 11 +- .../settings/IntegrationsSettings.tsx | 2 +- apps/web/src/terminal/ghostty/surface.test.ts | 95 ++++++++++++--- apps/web/src/terminal/ghostty/surface.ts | 108 ++++++++++++------ docs/user/opening-links.md | 2 +- 7 files changed, 186 insertions(+), 58 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 2dc036969..cc1ed66a7 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -56,7 +56,7 @@ import { } from "~/terminal/ghostty/surface"; import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; import { useOpenInPreferredEditor } from "../editorPreferences"; -import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; +import { isTerminalUrl, resolvePathLinkTarget } from "../terminal-links"; import { isDiffToggleShortcut, isTerminalClearShortcut, @@ -756,7 +756,6 @@ function TerminalViewport({ } function handleLinkActivate(text: string, event: MouseEvent): void { - if (!isTerminalLinkActivation(event)) return; const latestTerminal = terminalRef.current; if (!latestTerminal) return; if (isTerminalUrl(text)) { @@ -777,6 +776,7 @@ function TerminalViewport({ threadRef, openPreview, fallbackToBrowser, + forceBrowser: event.metaKey || event.ctrlKey, }); return; } diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 567dba11e..330ca5912 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -78,6 +78,7 @@ describe("openTerminalLinkInPreview", () => { threadRef, openPreview, fallbackToBrowser, + forceBrowser: false, }); expect(fallbackToBrowser).toHaveBeenCalledOnce(); @@ -93,6 +94,7 @@ describe("openTerminalLinkInPreview", () => { threadRef, openPreview, fallbackToBrowser, + forceBrowser: false, }); expect(openPreview).toHaveBeenCalledOnce(); @@ -118,6 +120,7 @@ describe("openTerminalLinkInPreview", () => { threadRef, openPreview, fallbackToBrowser: vi.fn(), + forceBrowser: false, }); await started; @@ -148,6 +151,7 @@ describe("openTerminalLinkInPreview", () => { threadRef, openPreview: async () => AsyncResult.failure(cause), fallbackToBrowser, + forceBrowser: false, }); expect(fallbackToBrowser).toHaveBeenCalledOnce(); @@ -174,6 +178,7 @@ describe("openTerminalLinkInPreview", () => { throw cause; }, fallbackToBrowser, + forceBrowser: false, }); expect(fallbackToBrowser).toHaveBeenCalledOnce(); }); @@ -187,9 +192,26 @@ describe("openTerminalLinkInPreview", () => { threadRef, openPreview: async () => AsyncResult.failure(Cause.interrupt()), fallbackToBrowser, + forceBrowser: false, }); expect(reportError).not.toHaveBeenCalled(); expect(fallbackToBrowser).not.toHaveBeenCalled(); }); + + it("opens in the system browser when Ctrl or Command is held", async () => { + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await openTerminalLinkInPreview({ + url: "https://example.com/docs", + threadRef, + openPreview, + fallbackToBrowser, + forceBrowser: true, + }); + + expect(fallbackToBrowser).toHaveBeenCalledOnce(); + expect(openPreview).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index a8b871b52..57e05efa6 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -34,19 +34,20 @@ interface OpenTerminalLinkInPreviewInput { readonly threadRef: ScopedThreadRef; readonly openPreview: OpenPreviewMutation; readonly fallbackToBrowser: () => void; + /** Cmd/Ctrl-click bypasses the preference and opens in the system browser. */ + readonly forceBrowser: boolean; } /** - * Opens a terminal hyperlink where the "Open links in" setting says. Terminal - * links are activated with the platform modifier already held, so unlike chat - * links the modifier cannot double as the system-browser override; the setting - * alone decides, and the system browser is the fallback whenever the in-app - * one cannot take the URL. + * Opens a terminal hyperlink where the "Open links in" setting says, unless a + * Cmd/Ctrl-click explicitly requests the system browser. The system browser is + * also the fallback whenever the in-app one cannot take the URL. */ export async function openTerminalLinkInPreview( input: OpenTerminalLinkInPreviewInput, ): Promise { const supportsPreview = + !input.forceBrowser && isWebUrl(input.url) && isPreviewSupportedInRuntime() && input.threadRef.threadId.length > 0 && diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index 6d83c4daa..11c3bd615 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -516,7 +516,7 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) return ( { resize() { for (const callback of resizeCallbacks) callback(); }, - pointer(type: string, clientX: number, buttons: number) { + pointer(type: string, clientX: number, buttons: number, shiftKey = false) { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, @@ -176,6 +175,7 @@ describe("GhosttyTerminalSurface visibility", () => { pointerId: 1, button: 0, buttons, + shiftKey, }), ); }, @@ -280,6 +280,84 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false); }); + it("starts a selection when dragging from a link", async () => { + const harness = createHarness(); + const onLinkActivate = vi.fn(); + const surface = await harness.create({ onLinkActivate }); + surface.write("https://example.com"); + harness.flushFrame(); + + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 37, 1); + harness.pointer("pointerup", 37, 0); + + expect(onLinkActivate).not.toHaveBeenCalled(); + expect(surface.getSelection()).toBe("https"); + }); + + it("keeps a link click active through slight pointer movement", async () => { + const harness = createHarness(); + const onLinkActivate = vi.fn(); + const surface = await harness.create({ onLinkActivate }); + surface.write("https://example.com"); + harness.flushFrame(); + + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 6, 1); + harness.pointer("pointerup", 6, 0); + + expect(onLinkActivate).toHaveBeenCalledOnce(); + }); + + it("uses repeated link clicks for word and line selection", async () => { + const harness = createHarness(); + const onLinkActivate = vi.fn(); + const surface = await harness.create({ onLinkActivate }); + surface.write("https://example.com tail"); + harness.flushFrame(); + + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointerup", 5, 0); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointerup", 5, 0); + expect(onLinkActivate).toHaveBeenCalledOnce(); + expect(surface.getSelection()).not.toBe(""); + + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointerup", 5, 0); + expect(onLinkActivate).toHaveBeenCalledOnce(); + expect(surface.getSelection()).toBe("https://example.com tail"); + }); + + it("uses Shift drags over links for selection", async () => { + const harness = createHarness(); + const onLinkActivate = vi.fn(); + const surface = await harness.create({ onLinkActivate }); + surface.write("https://example.com"); + harness.flushFrame(); + + harness.pointer("pointerdown", 5, 1, true); + harness.pointer("pointermove", 37, 1, true); + harness.pointer("pointerup", 37, 0, true); + expect(onLinkActivate).not.toHaveBeenCalled(); + expect(surface.getSelection()).toBe("https"); + }); + + it("does not activate a link replaced before pointer release", async () => { + const harness = createHarness(); + const onLinkActivate = vi.fn(); + const surface = await harness.create({ onLinkActivate }); + surface.write("https://first.example"); + harness.flushFrame(); + + harness.pointer("pointerdown", 5, 1); + surface.write("\x1b[2J\x1b[Hhttps://second.example"); + harness.flushFrame(); + harness.pointer("pointerup", 5, 0); + + expect(onLinkActivate).not.toHaveBeenCalled(); + }); + it("stops zero-size mounts and repaints when the same size returns", async () => { const harness = createHarness(); const surface = await harness.create(); @@ -890,19 +968,6 @@ describe("terminalWheelArrowData", () => { }); }); -describe("isTerminalLinkPointerGesture", () => { - it("uses Command on macOS and Control elsewhere", () => { - expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "MacIntel")).toBe(true); - expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "MacIntel")).toBe(false); - expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "Linux x86_64")).toBe( - true, - ); - expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "Linux x86_64")).toBe( - false, - ); - }); -}); - describe("advanceTerminalSelectionClickSequence", () => { it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => { const first = advanceTerminalSelectionClickSequence(null, { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 2017d646a..bf43b207d 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -249,6 +249,19 @@ export interface TerminalLinkWithRange { readonly range: GhosttyCellRange; } +function isSameTerminalLink( + left: TerminalLinkWithRange, + right: TerminalLinkWithRange | null, +): boolean { + return ( + right?.text === left.text && + right.range.start.x === left.range.start.x && + right.range.start.y === left.range.start.y && + right.range.end.x === left.range.end.x && + right.range.end.y === left.range.end.y + ); +} + function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number { for (let column = 0; column < row.cells.length; column += 1) { const nextOffset = terminalColumnOffset(row, column + 1); @@ -467,15 +480,6 @@ export function terminalWheelArrowData(rows: number, applicationCursorKeys: bool return sequence.repeat(Math.abs(rows)); } -export function isTerminalLinkPointerGesture( - event: Pick, - platform = navigator.platform, -): boolean { - return isMacPlatform(platform) - ? event.metaKey && !event.ctrlKey - : event.ctrlKey && !event.metaKey; -} - export function ghosttyMouseButton(button: number): number | null { switch (button) { case 0: @@ -500,6 +504,8 @@ export interface TerminalSelectionClickSequence { readonly y: number; } +const TERMINAL_LINK_DRAG_THRESHOLD_PX = 4; + export function advanceTerminalSelectionClickSequence( previous: TerminalSelectionClickSequence | null, event: Pick, @@ -507,7 +513,8 @@ export function advanceTerminalSelectionClickSequence( const repeats = previous !== null && event.timeStamp - previous.time <= SELECTION_MULTI_CLICK_INTERVAL_MS && - Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= 4; + Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= + TERMINAL_LINK_DRAG_THRESHOLD_PX; return { count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1, time: event.timeStamp, @@ -592,9 +599,14 @@ export class GhosttyTerminalSurface { private mouseReportingPointerId: number | null = null; private mouseReportingButton: number | null = null; private linkActivationPointerId: number | null = null; + private linkActivationLink: TerminalLinkWithRange | null = null; + private linkActivationOrigin: { + x: number; + y: number; + clickCount: number; + } | null = null; private hoveredLink: TerminalLinkWithRange | null = null; private hoverPointer: { x: number; y: number } | null = null; - private linkModifierActive = false; private selectionClickSequence: TerminalSelectionClickSequence | null = null; private selectionMoved = false; private composing = false; @@ -1018,7 +1030,6 @@ export class GhosttyTerminalSurface { } private readonly onKeyDown = (event: KeyboardEvent) => { - this.updateLinkModifier(event); // Presses handled outside the terminal must also swallow their release: // beforeKey runs side effects (keybindings, navigation sends), so it cannot // be consulted again on keyup, and Kitty report-event-types sessions would @@ -1121,7 +1132,6 @@ export class GhosttyTerminalSurface { }; private readonly onKeyUp = (event: KeyboardEvent) => { - this.updateLinkModifier(event); if (this.suppressedKeyCodes.delete(event.code)) return; if (isTerminalCompositionKey(event, this.composing)) { return; @@ -1143,7 +1153,6 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; - this.linkModifierActive = false; this.refreshHoveredLink(); // Suppressions survive blur deliberately: a shortcut that moves focus (for // example terminal-toggle) must still swallow its own keyup if focus comes @@ -1264,21 +1273,39 @@ export class GhosttyTerminalSurface { return; } if (event.button !== 0) return; - if (isTerminalLinkPointerGesture(event)) { + const clickCount = this.recordSelectionClick(event); + const link = this.linkAt(event.clientX, event.clientY); + if (link && !event.shiftKey && clickCount === 1) { event.preventDefault(); event.stopPropagation(); this.linkActivationPointerId = event.pointerId; + this.linkActivationLink = link; + this.linkActivationOrigin = { + x: event.clientX, + y: event.clientY, + clickCount, + }; this.canvas.setPointerCapture(event.pointerId); return; } this.clearHoveredLink(); - const cell = this.cellAt(event.clientX, event.clientY); - this.selectionMoved = false; + this.beginSelection(event, clickCount); + this.canvas.setPointerCapture(event.pointerId); + }; + + private recordSelectionClick( + event: Pick, + ): number { this.selectionClickSequence = advanceTerminalSelectionClickSequence( this.selectionClickSequence, event, ); - const clickCount = this.selectionClickSequence.count; + return this.selectionClickSequence.count; + } + + private beginSelection(event: { clientX: number; clientY: number }, clickCount: number): void { + const cell = this.cellAt(event.clientX, event.clientY); + this.selectionMoved = false; this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell"; const range = this.selectionMode === "line" @@ -1306,12 +1333,31 @@ export class GhosttyTerminalSurface { } } this.forceFullRender = true; - this.canvas.setPointerCapture(event.pointerId); this.requestRender(); - }; + } private readonly onPointerMove = (event: PointerEvent) => { - if (this.linkActivationPointerId === event.pointerId) return; + if (this.linkActivationPointerId === event.pointerId) { + const origin = this.linkActivationOrigin; + if ( + origin === null || + Math.hypot(event.clientX - origin.x, event.clientY - origin.y) <= + TERMINAL_LINK_DRAG_THRESHOLD_PX + ) { + return; + } + this.linkActivationPointerId = null; + this.linkActivationLink = null; + this.linkActivationOrigin = null; + this.clearHoveredLink(); + this.beginSelection( + { + clientX: origin.x, + clientY: origin.y, + }, + origin.clickCount, + ); + } // Hover motion is only reportable in any-event tracking (DEC 1003); normal and // button-event tracking never report motion without a captured pressed button. const anyEventTracking = this.synchronizeMouseTrackingState(); @@ -1321,7 +1367,6 @@ export class GhosttyTerminalSurface { ) { event.preventDefault(); this.hoverPointer = { x: event.clientX, y: event.clientY }; - this.linkModifierActive = isTerminalLinkPointerGesture(event); // A drag whose press was already sent to the terminal application cannot // turn into link activation midway through, so link feedback would lie. this.setHoveredLink(null); @@ -1396,14 +1441,6 @@ export class GhosttyTerminalSurface { private updateHoverCursor(event: PointerEvent): void { this.hoverPointer = { x: event.clientX, y: event.clientY }; - this.linkModifierActive = isTerminalLinkPointerGesture(event); - this.refreshHoveredLink(); - } - - private updateLinkModifier(event: Pick): void { - const active = isTerminalLinkPointerGesture(event); - if (active === this.linkModifierActive) return; - this.linkModifierActive = active; this.refreshHoveredLink(); } @@ -1420,7 +1457,7 @@ export class GhosttyTerminalSurface { private refreshHoveredLink(): void { const pointer = this.hoverPointer; - const link = pointer && this.linkModifierActive ? this.linkAt(pointer.x, pointer.y) : null; + const link = pointer ? this.linkAt(pointer.x, pointer.y) : null; this.setHoveredLink(link); } @@ -1444,13 +1481,17 @@ export class GhosttyTerminalSurface { if (this.linkActivationPointerId === event.pointerId) { event.preventDefault(); event.stopPropagation(); + const link = this.linkActivationLink; this.linkActivationPointerId = null; + this.linkActivationLink = null; + this.linkActivationOrigin = null; if (this.canvas.hasPointerCapture(event.pointerId)) { this.canvas.releasePointerCapture(event.pointerId); } if (event.type !== "pointercancel") { - const link = this.linkAt(event.clientX, event.clientY); - if (link) this.options.onLinkActivate(link.text, event); + if (link && isSameTerminalLink(link, this.linkAt(event.clientX, event.clientY))) { + this.options.onLinkActivate(link.text, event); + } } return; } @@ -1467,7 +1508,6 @@ export class GhosttyTerminalSurface { this.clearHoveredLink(); } else { this.hoverPointer = { x: event.clientX, y: event.clientY }; - this.linkModifierActive = isTerminalLinkPointerGesture(event); this.refreshHoveredLink(); } return; diff --git a/docs/user/opening-links.md b/docs/user/opening-links.md index b084d1148..9173f8e9f 100644 --- a/docs/user/opening-links.md +++ b/docs/user/opening-links.md @@ -4,6 +4,6 @@ In the desktop app, go to **Settings → Integrations → Browser → Open links Choosing Pylon opens web links beside the current thread. The setting applies to chat and terminal links, pull-request descriptions and comments, their editor previews, and check details. Pull requests that belong to a connected project still open in the pull-request panel. -Hold Command on macOS or Ctrl on Windows and Linux while clicking a chat link to use your default browser. Terminal links already require that modifier to activate, so they follow the setting without this override. The chat link context menu also offers an explicit browser choice. +Terminal links open with a plain click; drag from a link to select its text instead. Hold Command on macOS or Ctrl on Windows and Linux while clicking a chat or terminal link to use your default browser. The chat link context menu also offers an explicit browser choice. If an in-app open fails, Pylon tries your default browser. Links outside a thread, non-web links, and links opened from the web or mobile clients use their normal external browser or app. The desktop setting is unavailable in the web client. From ec3cafa943d399391aa62b06b4b1e5a727e93b08 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 18:29:41 -0600 Subject: [PATCH 10/12] docs(upstream): record web panels batch --- .agents/upstream-review.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 5377f5d5c..a6affae3c 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -54,6 +54,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Live activity motion parity / `6c583620ff7ad3235b135af7107c0543467eecfa` | Corrects Pylon's rendering of already-classified `c7c1dfe4df` (#9709), `ce4712d5b0` (#9799) and the #444 port of `6cf0c6ea55` (#9093) / `c3b8825bf4` (#9606) | Pylon had diverged: `cdde7f3150` gave every active row the sweeping overlay and deleted `live-tool-shine`, while upstream reserves the overlay for label-only rows (`active && shimmer`, passed only by the Thinking row) and keeps the shine for tool labels. Restores upstream's `shimmer` prop, `animated = active && !failed` guard, row-level observer ref, overlay on worktree setup and compaction, and both CSS utilities byte-identical to upstream. That same commit also deleted `@utility visible-animate-spin` as dead while `spinner.tsx` and `refresh-icon.tsx` still applied it, so every spinner and refresh glyph was static; restored. Preserved: Pylon's `workingStepLabel`, and the `document.hasFocus()` gate AGENTS.md requires and upstream lacks. Cursor unchanged. | [Live activity parity #452](https://github.com/pylon-code/pylon/pull/452); 161 focused tests, web typecheck, scoped lint/format, region diffed against `t3code-upstream/main`. | | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. | +| Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #PRNUM, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #PRNUM](https://github.com/pylon-code/pylon/pull/PRNUM); 1,684 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. | ## Deferred register From 04e85b167f55f126ab6c5a56193447dfd241c718 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 18:31:00 -0600 Subject: [PATCH 11/12] docs(upstream): link web panels batch to #458 --- .agents/upstream-review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index a6affae3c..1e6bb36ad 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -54,7 +54,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Live activity motion parity / `6c583620ff7ad3235b135af7107c0543467eecfa` | Corrects Pylon's rendering of already-classified `c7c1dfe4df` (#9709), `ce4712d5b0` (#9799) and the #444 port of `6cf0c6ea55` (#9093) / `c3b8825bf4` (#9606) | Pylon had diverged: `cdde7f3150` gave every active row the sweeping overlay and deleted `live-tool-shine`, while upstream reserves the overlay for label-only rows (`active && shimmer`, passed only by the Thinking row) and keeps the shine for tool labels. Restores upstream's `shimmer` prop, `animated = active && !failed` guard, row-level observer ref, overlay on worktree setup and compaction, and both CSS utilities byte-identical to upstream. That same commit also deleted `@utility visible-animate-spin` as dead while `spinner.tsx` and `refresh-icon.tsx` still applied it, so every spinner and refresh glyph was static; restored. Preserved: Pylon's `workingStepLabel`, and the `document.hasFocus()` gate AGENTS.md requires and upstream lacks. Cursor unchanged. | [Live activity parity #452](https://github.com/pylon-code/pylon/pull/452); 161 focused tests, web typecheck, scoped lint/format, region diffed against `t3code-upstream/main`. | | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. | -| Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #PRNUM, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #PRNUM](https://github.com/pylon-code/pylon/pull/PRNUM); 1,684 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. | +| Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,684 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. | ## Deferred register From 74868a2771c731de90609ff8e07ad409ccf2e287 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Thu, 10 Sep 2026 19:13:38 -0600 Subject: [PATCH 12/12] fix(server): flush turn analytics on adapter shutdown and fenced sessions Pylon's finalizer runs runShutdown, which reaches runStopAll only when no adapter owns shutdown, so held turn completions were never recorded when the Prime daemon adapter was registered. Flush them at the start of runShutdown as well. When a thread starts on another provider instance, or a fenced adapter is rebuilt, Pylon drops the old session's later turn events before analytics sees them. Clear that instance's analytics for the thread in both paths so its held completions are recorded instead of lingering until reuse. Focused tests cover the adapter-owned shutdown path, an instance switch, and a fenced adapter rebuild. --- .agents/upstream-review.md | 2 +- .../provider/Layers/ProviderService.test.ts | 419 +++++++++++++----- .../src/provider/Layers/ProviderService.ts | 35 +- docs/internals/product-analytics.md | 7 +- 4 files changed, 331 insertions(+), 132 deletions(-) diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 1e6bb36ad..0c4194b88 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -54,7 +54,7 @@ Historical groups are indexed in the linked archive. This file migration changes | Live activity motion parity / `6c583620ff7ad3235b135af7107c0543467eecfa` | Corrects Pylon's rendering of already-classified `c7c1dfe4df` (#9709), `ce4712d5b0` (#9799) and the #444 port of `6cf0c6ea55` (#9093) / `c3b8825bf4` (#9606) | Pylon had diverged: `cdde7f3150` gave every active row the sweeping overlay and deleted `live-tool-shine`, while upstream reserves the overlay for label-only rows (`active && shimmer`, passed only by the Thinking row) and keeps the shine for tool labels. Restores upstream's `shimmer` prop, `animated = active && !failed` guard, row-level observer ref, overlay on worktree setup and compaction, and both CSS utilities byte-identical to upstream. That same commit also deleted `@utility visible-animate-spin` as dead while `spinner.tsx` and `refresh-icon.tsx` still applied it, so every spinner and refresh glyph was static; restored. Preserved: Pylon's `workingStepLabel`, and the `document.hasFocus()` gate AGENTS.md requires and upstream lacks. Cursor unchanged. | [Live activity parity #452](https://github.com/pylon-code/pylon/pull/452); 161 focused tests, web typecheck, scoped lint/format, region diffed against `t3code-upstream/main`. | | Skeleton loading pulse / `6c583620ff7ad3235b135af7107c0543467eecfa` | `21b9dda5afb00a33e228a68d2ccc885bba7285dc` (#9448) | Adopted. The shared `Skeleton` drops its swept gradient band for upstream's single stepped opacity pulse, whose keyframes were already byte-identical to the `ghost-pulse` Pylon ran on the pull-request ghosts; `ghost-pulse` is retired and every loading state now uses `animate-skeleton`. Preserve Pylon's `!seed` gate on the detail ghost, so a panel already showing real content does not breathe, and Pylon's own usage fill-in timing. The maintainer chose upstream theming and styling with only the logo staying Pylon's, which settles this kind of visual divergence in upstream's favour by default. Cursor unchanged. | [Skeleton pulse #451](https://github.com/pylon-code/pylon/pull/451); 339 focused tests, web typecheck, scoped lint/format. | -| Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,684 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. | +| Panel links, markdown galleries, shared title model, usage columns, sidebar file drops, terminal link overrides, Codex limit naming and turn token telemetry / `6c583620ff7ad3235b135af7107c0543467eecfa` | Full ten-source list in PR #458, from #9132 through #10060 | Nine adopted; `b5d89038ae72142038dfa8cf69d49b7a607fe98e` is already covered as an empty duplicate of #7892. Turn telemetry stays inert without a Pylon PostHog key, is recorded only after runtime generation and session incarnation fences, and treats Prime, Cursor, Grok and Antigravity usage as unavailable; OpenCode uses the final #10116 step-retention design. Codex limit errors carry the session incarnation and Pylon keeps relaying every rate-limit notification. Preserve Pylon PR detection, environment-scoped PR caches, provider-instance settings writes, hub account keys, older-server window names, change-request row props and terminal external-browser fallback. New product analytics and usage data docs. Cursor unchanged. | [Web panels #458](https://github.com/pylon-code/pylon/pull/458); 1,687 focused tests, six package typechecks (web, server, shared, client-runtime, contracts, mobile), scoped lint/format; upstream UI evidence linked, no local client pass. | ## Deferred register diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 0067ebbde..46d8778e0 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -909,124 +909,141 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => }), ); -it.effect("ProviderServiceLive flushes deferred completions during shutdown", () => - Effect.gen(function* () { - const recordedAnalytics = makeRecordingAnalytics(); - const codex = makeFakeCodexAdapter(); - const registry = makeStaticInstanceRegistry([[codexInstanceId, codex.adapter]]); - const providerAdapterLayer = Layer.succeed( - ProviderAdapterRegistry.ProviderAdapterRegistry, - registry, - ); - const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( - Layer.provide(SqlitePersistenceMemory), - ); - const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); - const providerLayer = Layer.mergeAll( - makeProviderServiceLive().pipe( - Layer.provide(NodeServices.layer), - Layer.provide(providerAdapterLayer), - Layer.provide(directoryLayer), - Layer.provide(defaultServerSettingsLayer), - Layer.provide(serverConfigTestLayer), - Layer.provide(recordedAnalytics.layer), - Layer.provide( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, +// Pylon's finalizer reaches runStopAll only when no adapter owns shutdown; the +// Prime daemon adapter does, so both shutdown paths must flush held completions. +for (const adapterOwnsShutdown of [false, true]) { + it.effect( + `ProviderServiceLive flushes deferred completions during shutdown${ + adapterOwnsShutdown ? " when an adapter owns shutdown" : "" + }`, + () => + Effect.gen(function* () { + const recordedAnalytics = makeRecordingAnalytics(); + const codex = makeFakeCodexAdapter(); + const registry = makeStaticInstanceRegistry([ + [ + codexInstanceId, + adapterOwnsShutdown ? { ...codex.adapter, shutdown: () => Effect.void } : codex.adapter, + ], + ]); + const providerAdapterLayer = Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + registry, + ); + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = Layer.mergeAll( + makeProviderServiceLive().pipe( + Layer.provide(NodeServices.layer), + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(recordedAnalytics.layer), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), ), - ), - ), - directoryLayer, - runtimeRepositoryLayer, - NodeServices.layer, - ); - const scope = yield* Scope.make(); - const runtimeServices = yield* Layer.build(providerLayer).pipe(Scope.provide(scope)); - const provider = yield* ProviderService.ProviderService.pipe(Effect.provide(runtimeServices)); - const threadId = asThreadId("thread-turn-analytics-stop-all-deferred"); - const firstStarted = yield* Deferred.make(); - const secondStarted = yield* Deferred.make(); - const sendRelease = yield* Deferred.make(); - const turnId = asTurnId("turn-analytics-stop-all-deferred"); - yield* provider.startSession(threadId, { - provider: CODEX_DRIVER, - providerInstanceId: codexInstanceId, - threadId, - runtimeMode: "full-access", - }); - codex.sendTurn - .mockImplementationOnce(() => - Effect.gen(function* () { - yield* Deferred.succeed(firstStarted, undefined); - yield* Deferred.await(sendRelease); - return { threadId, turnId }; - }), - ) - .mockImplementationOnce(() => - Effect.gen(function* () { - yield* Deferred.succeed(secondStarted, undefined); - yield* Deferred.await(sendRelease); - return { threadId, turnId: asTurnId("turn-analytics-stop-all-other") }; - }), - ); - - const firstSend = yield* provider - .sendTurn({ threadId, input: "first", attachments: [] }) - .pipe(Effect.forkChild); - yield* Deferred.await(firstStarted); - const secondSend = yield* provider - .sendTurn({ threadId, input: "second", attachments: [] }) - .pipe(Effect.forkChild); - yield* Deferred.await(secondStarted); - - const runtimeEvents = yield* Stream.take(provider.streamEvents, 2).pipe( - Stream.runDrain, - Effect.forkChild, - ); - yield* Effect.yieldNow; - codex.emit({ - type: "turn.started", - eventId: asEventId("evt-turn-analytics-stop-all-deferred-start"), - provider: CODEX_DRIVER, - createdAt: "2026-01-01T00:00:00.000Z", - threadId, - turnId, - payload: { model: "native-stop-all" }, - }); - codex.emit({ - type: "turn.completed", - eventId: asEventId("evt-turn-analytics-stop-all-deferred-complete"), - provider: CODEX_DRIVER, - createdAt: "2026-01-01T00:00:00.000Z", - threadId, - turnId, - payload: { - state: "completed", - tokenUsage: { - usageStatus: "complete", - usageScope: "main_agent", - inputTokens: 1_200, - outputTokens: 300, - hasSubagents: false, - }, - }, - }); - yield* Fiber.join(runtimeEvents); - assert.equal(recordedAnalytics.eventsByName("provider.turn.completed").length, 0); + directoryLayer, + runtimeRepositoryLayer, + NodeServices.layer, + ); + const scope = yield* Scope.make(); + const runtimeServices = yield* Layer.build(providerLayer).pipe(Scope.provide(scope)); + const provider = yield* ProviderService.ProviderService.pipe( + Effect.provide(runtimeServices), + ); + const threadId = asThreadId("thread-turn-analytics-stop-all-deferred"); + const firstStarted = yield* Deferred.make(); + const secondStarted = yield* Deferred.make(); + const sendRelease = yield* Deferred.make(); + const turnId = asTurnId("turn-analytics-stop-all-deferred"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + codex.sendTurn + .mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(firstStarted, undefined); + yield* Deferred.await(sendRelease); + return { threadId, turnId }; + }), + ) + .mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(secondStarted, undefined); + yield* Deferred.await(sendRelease); + return { threadId, turnId: asTurnId("turn-analytics-stop-all-other") }; + }), + ); - const closeExit = yield* Scope.close(scope, Exit.void).pipe(Effect.exit); - const completed = recordedAnalytics.eventsByName("provider.turn.completed"); - assert.equal(Exit.isSuccess(closeExit), true); - assert.equal(completed.length, 1); - assert.equal(completed[0]?.properties?.model, "native-stop-all"); - assert.equal(completed[0]?.properties?.inputTokens, 1_200); - assert.equal(completed[0]?.properties?.outputTokens, 300); - yield* Fiber.interrupt(firstSend); - yield* Fiber.interrupt(secondSend); - assert.equal(recordedAnalytics.eventsByName("provider.turn.completed").length, 1); - }), -); + const firstSend = yield* provider + .sendTurn({ threadId, input: "first", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(firstStarted); + const secondSend = yield* provider + .sendTurn({ threadId, input: "second", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(secondStarted); + + const runtimeEvents = yield* Stream.take(provider.streamEvents, 2).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + codex.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-analytics-stop-all-deferred-start"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { model: "native-stop-all" }, + }); + codex.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-stop-all-deferred-complete"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + payload: { + state: "completed", + tokenUsage: { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 1_200, + outputTokens: 300, + hasSubagents: false, + }, + }, + }); + yield* Fiber.join(runtimeEvents); + assert.equal(recordedAnalytics.eventsByName("provider.turn.completed").length, 0); + + const closeExit = yield* Scope.close(scope, Exit.void).pipe(Effect.exit); + const completed = recordedAnalytics.eventsByName("provider.turn.completed"); + assert.equal(Exit.isSuccess(closeExit), true); + assert.equal(completed.length, 1); + assert.equal(completed[0]?.properties?.model, "native-stop-all"); + assert.equal(completed[0]?.properties?.inputTokens, 1_200); + assert.equal(completed[0]?.properties?.outputTokens, 300); + yield* Fiber.interrupt(firstSend); + yield* Fiber.interrupt(secondSend); + assert.equal(recordedAnalytics.eventsByName("provider.turn.completed").length, 1); + }), + ); +} it.effect("ProviderServiceLive rejects new sessions for disabled providers", () => Effect.gen(function* () { @@ -4362,6 +4379,120 @@ fanout.layer("ProviderServiceLive fanout", (it) => { }), ); + it.effect("flushes held turn analytics when a fenced adapter is rebuilt", () => + Effect.gen(function* () { + const recordedAnalytics = makeRecordingAnalytics(); + const oldCodex = makeFakeCodexAdapter(); + const rebuiltCodex = makeFakeCodexAdapter(); + const changes = yield* PubSub.unbounded(); + const rebuiltSubscribed = yield* Deferred.make(); + const oldAdapter: ProviderAdapterShape = { + ...oldCodex.adapter, + runtimeFence: { + generation: {}, + configRevision: "private-analytics-revision", + isCurrent: Effect.succeed(true), + }, + }; + const rebuiltAdapter: ProviderAdapterShape = { + ...rebuiltCodex.adapter, + streamEvents: rebuiltCodex.adapter.streamEvents.pipe( + Stream.onStart(Deferred.succeed(rebuiltSubscribed, undefined)), + ), + }; + let currentAdapter = oldAdapter; + const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { + getByInstance: () => Effect.succeed(currentAdapter), + getInstanceInfo: (instanceId) => + Effect.succeed({ + instanceId, + driverKind: ProviderDriverKind.make("codex"), + displayName: undefined, + enabled: true, + continuationIdentity: { + driverKind: ProviderDriverKind.make("codex"), + continuationKey: "codex:instance:codex", + }, + }), + listInstances: () => Effect.succeed([codexInstanceId]), + subscribeChanges: PubSub.subscribe(changes), + }; + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = Layer.mergeAll( + makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(recordedAnalytics.layer), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + directoryLayer, + runtimeRepositoryLayer, + ).pipe(Layer.provideMerge(NodeServices.layer)); + const scope = yield* Scope.make(); + const services = yield* Layer.build(providerLayer).pipe(Scope.provide(scope)); + const provider = yield* ProviderService.ProviderService.pipe(Effect.provide(services)); + const threadId = asThreadId("thread-turn-analytics-fenced-rebuild"); + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + const sendStarted = yield* Deferred.make(); + const sendRelease = yield* Deferred.make(); + oldCodex.sendTurn.mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(sendStarted, undefined); + yield* Deferred.await(sendRelease); + return { threadId, turnId: asTurnId("turn-analytics-fenced-rebuild-send") }; + }), + ); + const send = yield* provider + .sendTurn({ threadId, input: "held", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(sendStarted); + const runtimeEvent = yield* Stream.take(provider.streamEvents, 1).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + oldCodex.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-fenced-rebuild-complete"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: asTurnId("turn-analytics-fenced-rebuild"), + payload: { state: "completed" }, + }); + yield* Fiber.join(runtimeEvent); + assert.equal(recordedAnalytics.eventsByName("provider.turn.completed").length, 0); + + // The rebuild fences out the old session; its held completion is recorded + // with it rather than kept until the thread is reused. + currentAdapter = rebuiltAdapter; + yield* PubSub.publish(changes, undefined); + yield* Deferred.await(rebuiltSubscribed); + assert.equal(recordedAnalytics.eventsByName("provider.turn.completed").length, 1); + + yield* Fiber.interrupt(send); + yield* Scope.close(scope, Exit.void); + assert.equal(recordedAnalytics.eventsByName("provider.turn.completed").length, 1); + }), + ); + it.effect("fans out canonical runtime events in emission order", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -5524,6 +5655,62 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { }), ); + it.effect("flushes a held completion when the thread moves to another instance", () => + Effect.gen(function* () { + recordedTurnAnalytics.reset(); + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-turn-analytics-instance-move"); + const sendStarted = yield* Deferred.make(); + const sendRelease = yield* Deferred.make(); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + primaryAnalyticsCodex.sendTurn.mockImplementationOnce(() => + Effect.gen(function* () { + yield* Deferred.succeed(sendStarted, undefined); + yield* Deferred.await(sendRelease); + return { threadId, turnId: asTurnId("turn-analytics-instance-move-send") }; + }), + ); + const send = yield* provider + .sendTurn({ threadId, input: "held", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(sendStarted); + + const runtimeEvent = yield* Stream.take(provider.streamEvents, 1).pipe( + Stream.runDrain, + Effect.forkChild, + ); + yield* Effect.yieldNow; + primaryAnalyticsCodex.emit({ + type: "turn.completed", + eventId: asEventId("evt-turn-analytics-instance-move-complete"), + provider: CODEX_DRIVER, + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId: asTurnId("turn-analytics-instance-move"), + payload: { state: "completed" }, + }); + yield* Fiber.join(runtimeEvent); + assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 0); + + // The new incarnation fences out the primary session, so its held + // completion is recorded now instead of lingering under the old key. + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: secondaryCodexInstanceId, + threadId, + runtimeMode: "full-access", + }); + assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 1); + yield* Fiber.interrupt(send); + assert.equal(recordedTurnAnalytics.eventsByName("provider.turn.completed").length, 1); + }), + ); + it.effect("keeps the first metadata when steering reuses a rerouted turn", () => Effect.gen(function* () { recordedTurnAnalytics.reset(); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index aa879dc7e..a2e969704 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -652,6 +652,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* recordCompletedTurnProperties(properties); }); + /** Records every held completion and forgets all turn analytics state, before providers stop. */ + const flushAllTurnAnalytics = Effect.gen(function* () { + const properties = yield* Ref.modify(turnAnalytics, (state) => { + const completed: Array>> = []; + for (const [sessionKey, session] of state.sessions) { + for (const [turnId, completion] of session.deferredCompletionsByTurnId) { + const entry = finishTurnAnalytics(state, { sessionKey, turnId, completion }); + if (entry) completed.push(entry); + } + } + state.sessions.clear(); + return [completed, state] as const; + }); + yield* recordCompletedTurnProperties(properties); + }); + const beginTurnAnalytics = Effect.fn("beginTurnAnalytics")(function* (input: { readonly providerInstanceId: ProviderInstanceId; readonly provider: ProviderDriverKind; @@ -1429,6 +1445,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( for (const [threadId, incarnation] of currentSessionIncarnations) { if (incarnation.instanceId !== instanceId || incarnation.adapter !== oldAdapter) continue; yield* clearMcpSession(threadId, oldAdapter.runtimeFence); + yield* clearTurnAnalyticsSession(instanceId, threadId); currentSessionIncarnations.delete(threadId); activeTurnAdmissions.delete(threadId); } @@ -1697,6 +1714,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( instanceId === input.currentInstanceId ? Effect.void : Effect.gen(function* () { + // The new incarnation fences out this instance's later turn events, + // so its analytics for the thread can never complete on their own. + yield* clearTurnAnalyticsSession(instanceId, input.threadId); const hasSession = yield* adapter.hasSession(input.threadId); if (!hasSession) { return; @@ -3924,18 +3944,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( Effect.map((settings) => settings.continueThreadsAfterServerUpdate), Effect.orElseSucceed(() => false), ); - const properties = yield* Ref.modify(turnAnalytics, (state) => { - const completed: Array>> = []; - for (const [sessionKey, session] of state.sessions) { - for (const [turnId, completion] of session.deferredCompletionsByTurnId) { - const entry = finishTurnAnalytics(state, { sessionKey, turnId, completion }); - if (entry) completed.push(entry); - } - } - state.sessions.clear(); - return [completed, state] as const; - }); - yield* recordCompletedTurnProperties(properties); + yield* flushAllTurnAnalytics; const threadIds = yield* directory.listThreadIds(); const currentAdapters = yield* getAdapterEntries; const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => @@ -3990,6 +3999,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); const runShutdown = Effect.fn("runShutdown")(function* () { + // Adapters with their own shutdown skip runStopAll, so flush here as well. + yield* flushAllTurnAnalytics; const currentAdapters = yield* getAdapterEntries; if (currentAdapters.every(([, adapter]) => adapter.shutdown === undefined)) { return yield* runStopAll(); diff --git a/docs/internals/product-analytics.md b/docs/internals/product-analytics.md index dd3d14a6f..8eda57b69 100644 --- a/docs/internals/product-analytics.md +++ b/docs/internals/product-analytics.md @@ -17,9 +17,10 @@ PostHog person profiles stay disabled. records one event per provider instance, thread, and turn when the provider emits a completed or aborted turn. [ProviderService](../../apps/server/src/provider/Layers/ProviderService.ts) correlates each send with the turn ID the adapter returns before recording, and -holds a completion that arrives before that response. Session start, stop, -`session.exited`, and server shutdown flush held completions, and duplicate -terminal events are recorded once. Analytics observe only events that already +holds a completion that arrives before that response. Session start and stop, +`session.exited`, a thread moving to another provider instance or adapter +generation, and server shutdown flush held completions, including when an adapter +owns its own shutdown. Duplicate terminal events are recorded once. Analytics observe only events that already passed the runtime generation and session incarnation fences. Send and completion counts need not match. Providers can emit synthetic turns