diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 5377f5d5c..0c4194b88 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 #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/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/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/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..4d30ba28d 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(); @@ -2374,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 4dceb085f..74cac74ac 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, @@ -73,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( @@ -107,9 +113,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 +477,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 +2302,13 @@ 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(() => @@ -2145,12 +2339,114 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const eventFiber = yield* Stream.runForEach(runtime.events, (event) => Effect.gen(function* () { yield* writeNativeEvent(event); - const runtimeEvents = mapToRuntimeEvents(event, event.threadId).map((runtimeEvent) => { + 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; + } + } + + 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 ? runtimeEvent : { ...runtimeEvent, sessionIncarnationId: input.sessionIncarnationId }; + if (stampedRuntimeEvent.type === "turn.completed" && stampedRuntimeEvent.turnId) { + return { + ...stampedRuntimeEvent, + payload: { + ...stampedRuntimeEvent.payload, + ...(usageLimitMessage ? { errorMessage: usageLimitMessage } : {}), + 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, @@ -2165,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, @@ -2204,6 +2503,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( eventFiber, admissionSemaphore: yield* Semaphore.make(1), pendingAdmissions, + turnTokenUsage, stopped: false, }); sessionScopeTransferred = true; @@ -2362,7 +2662,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..46d8778e0 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,142 @@ it.effect("ProviderServiceLive catches stopAll failures during shutdown", () => }), ); +// 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); + + 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 +4140,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); @@ -4179,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; @@ -4226,7 +4540,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 +4606,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 +4850,1126 @@ 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("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(); + 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..a2e969704 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,375 @@ 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); + }); + + /** 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; + 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 +1374,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 ( @@ -988,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); } @@ -1256,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; @@ -1397,6 +1858,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 +2186,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 +3436,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 +3944,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( Effect.map((settings) => settings.continueThreadsAfterServerUpdate), Effect.orElseSucceed(() => false), ); + yield* flushAllTurnAnalytics; const threadIds = yield* directory.listThreadIds(); const currentAdapters = yield* getAdapterEntries; const activeSessions = yield* Effect.forEach(currentAdapters, ([instanceId, adapter]) => @@ -3507,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/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/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/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/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 51b7c0b41..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"; @@ -186,6 +187,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; @@ -1303,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"; @@ -1314,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, @@ -1418,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); @@ -1449,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)} /> @@ -2204,6 +2199,7 @@ function useChatMarkdownState({ text, cwd, threadRef, + pullRequestPanelRef, environmentId: explicitEnvironmentId, onTaskListChange, isStreaming = false, @@ -2214,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(() => { @@ -2244,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, @@ -2259,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; @@ -2370,7 +2374,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); @@ -2663,6 +2667,7 @@ function useChatMarkdownState({ return { componentState, handleCopy, + markdownRef, markdownUrlTransform, localMediaPreview, setLocalMediaPreview, @@ -2850,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 @@ -3149,6 +3160,7 @@ function ChatMarkdown({ const { componentState, handleCopy, + markdownRef, markdownUrlTransform, localMediaPreview, setLocalMediaPreview, @@ -3167,6 +3179,7 @@ function ChatMarkdown({ // complete source token instead of dropping it from the rendered message. return (
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/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/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/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/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 5a5147a3c..997369cfb 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -80,9 +80,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ } : source; - const navigateImage = useCallback((direction: -1 | 1) => { - 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/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/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,13 +31,15 @@ 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], ); return ( -
    +
    {segments.map((segment) => { if (segment.kind === "markdown") { return ( @@ -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/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 (
    - {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/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/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index d6c359d72..0e72eaafc 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -432,6 +432,9 @@ function useUpdateSettingsTarget( } } if (Object.keys(sharedPatch).length > 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/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], ); } 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.$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/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); 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/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 2ef7fe26a..7174261e6 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -12,7 +12,6 @@ import { isTerminalCompositionCommitInput, isTerminalCompositionKey, isTerminalCopyShortcut, - isTerminalLinkPointerGesture, isTerminalPasteShortcut, loadTerminalFontFamily, primeTerminalCopyInput, @@ -168,7 +167,7 @@ describe("GhosttyTerminalSurface visibility", () => { 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/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..8eda57b69 --- /dev/null +++ b/docs/internals/product-analytics.md @@ -0,0 +1,64 @@ +# 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 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 +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/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. 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 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/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. 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/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 }]; 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; 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),