diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 1e41fd620..847f23488 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -12,6 +12,7 @@ import { DraftAgentStatusBar, type DraftAgentStatusBarProps, } from "./agent-status-bar"; +import { ContextWindowMeter } from "./context-window-meter"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { useSessionStore } from "@/stores/session-store"; import { @@ -135,6 +136,8 @@ export function AgentInputArea({ const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null; return { status: agent?.status ?? null, + contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null, + contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null, }; }), ); @@ -636,6 +639,25 @@ export function AgentInputArea({ ); + const hasContextWindowMeter = + typeof agentState.contextWindowMaxTokens === "number" && + typeof agentState.contextWindowUsedTokens === "number"; + const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null; + const contextWindowUsedTokens = hasContextWindowMeter + ? agentState.contextWindowUsedTokens + : null; + + const beforeVoiceContent = ( + + {contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? ( + + ) : null} + + ); + const leftContent = resolveStatusControlMode(statusControls) === "draft" && statusControls ? ( @@ -715,6 +737,7 @@ export function AgentInputArea({ disabled={isSubmitLoading} isInputActive={isInputActive} leftContent={leftContent} + beforeVoiceContent={beforeVoiceContent} rightContent={rightContent} voiceServerId={serverId} voiceAgentId={agentId} @@ -790,6 +813,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({ alignItems: "center", gap: theme.spacing[2], }, + contextWindowMeterSlot: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + }, realtimeVoiceButton: { width: 28, height: 28, diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index b1cf99aa4..5954ad2af 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -859,6 +859,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat model: currentAgent.model, features: currentAgent.features, thinkingOptionId: currentAgent.thinkingOptionId, + lastUsage: currentAgent.lastUsage, } : null; }), diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx new file mode 100644 index 000000000..7b8f12c30 --- /dev/null +++ b/packages/app/src/components/context-window-meter.tsx @@ -0,0 +1,153 @@ +import { Pressable, Text, View } from "react-native"; +import Svg, { Circle } from "react-native-svg"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +type ContextWindowMeterProps = { + maxTokens: number; + usedTokens: number; +}; + +const SVG_SIZE = 20; +const CENTER = SVG_SIZE / 2; +const RADIUS = 7; +const STROKE_WIDTH = 2.25; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +function isValidMaxTokens(value: number): boolean { + return Number.isFinite(value) && value > 0; +} + +function isValidUsedTokens(value: number): boolean { + return Number.isFinite(value) && value >= 0; +} + +function getUsagePercentage(maxTokens: number, usedTokens: number): number | null { + if (!isValidMaxTokens(maxTokens) || !isValidUsedTokens(usedTokens)) { + return null; + } + return (usedTokens / maxTokens) * 100; +} + +function clampPercentage(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +function formatTokenCount(value: number): string { + if (value >= 1_000_000) { + return `${Math.round(value / 1_000_000)}m`; + } + if (value >= 1_000) { + return `${Math.round(value / 1_000)}k`; + } + return Math.round(value).toString(); +} + +function getMeterColors( + percentage: number, + theme: ReturnType["theme"], +): { progress: string; track: string } { + const track = theme.colors.surface3; + if (percentage > 90) { + return { progress: theme.colors.destructive, track }; + } + if (percentage >= 70) { + return { progress: theme.colors.palette.amber[500], track }; + } + return { progress: theme.colors.foregroundMuted, track }; +} + +export function ContextWindowMeter({ maxTokens, usedTokens }: ContextWindowMeterProps) { + const { theme } = useUnistyles(); + const percentage = getUsagePercentage(maxTokens, usedTokens); + + if (percentage === null) { + return null; + } + + const clampedPercentage = clampPercentage(percentage); + const roundedPercentage = Math.round(percentage); + const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE; + const colors = getMeterColors(clampedPercentage, theme); + + return ( + + + + + + + + + + + + Context window + {`${roundedPercentage}% used`} + {`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + width: 28, + height: 28, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + svg: { + transform: [{ rotate: "-90deg" }], + }, + tooltipContent: { + gap: theme.spacing[1], + }, + tooltipTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + tooltipText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.4, + }, + tooltipDetail: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + lineHeight: theme.fontSize.xs * 1.4, + }, +})); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index c94bd8fbd..8e18b3706 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -79,6 +79,8 @@ export interface MessageInputProps { isInputActive?: boolean; /** Content to render on the left side of the button row (e.g., AgentStatusBar) */ leftContent?: React.ReactNode; + /** Content to render on the right side before the voice button (e.g., context window meter) */ + beforeVoiceContent?: React.ReactNode; /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */ rightContent?: React.ReactNode; voiceServerId?: string; @@ -201,6 +203,7 @@ export const MessageInput = forwardRef(funct disabled = false, isInputActive = true, leftContent, + beforeVoiceContent, rightContent, voiceServerId, voiceAgentId, @@ -1015,6 +1018,7 @@ export const MessageInput = forwardRef(funct {/* Right: voice button, contextual button (realtime/send/cancel) */} + {beforeVoiceContent} void; triggerRef: React.RefObject; enabled: boolean; + openOnPress: boolean; delayDuration: number; }; @@ -107,6 +108,18 @@ function measureElement(element: View): Promise { }); } +function isMobileTooltipEnvironment(): boolean { + if (Platform.OS !== "web") { + return true; + } + + if (typeof navigator === "undefined") { + return false; + } + + return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? ""); +} + function computePosition({ triggerRect, contentSize, @@ -214,12 +227,8 @@ export function Tooltip({ onOpenChange, }); - const isWeb = Platform.OS === "web"; - const isMobileWeb = - isWeb && - typeof navigator !== "undefined" && - /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? ""); - const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile; + const isMobile = isMobileTooltipEnvironment(); + const enabled = isMobile ? enabledOnMobile : enabledOnDesktop; const value = useMemo( () => ({ @@ -227,9 +236,10 @@ export function Tooltip({ setOpen: setIsOpen, triggerRef, enabled, + openOnPress: isMobile, delayDuration, }), - [isOpen, setIsOpen, enabled, delayDuration], + [isOpen, setIsOpen, enabled, isMobile, delayDuration], ); return {children}; @@ -323,9 +333,17 @@ export function TooltipTrigger({ const handlePress = useCallback( (e: any) => { onPress?.(e); + if (!ctx.enabled || disabled) { + return; + } + if (ctx.openOnPress) { + clearOpenTimer(); + ctx.setOpen(true); + return; + } close(); }, - [close, onPress], + [clearOpenTimer, close, ctx, disabled, onPress], ); const triggerProps = { @@ -492,7 +510,7 @@ export function TooltipContent({ statusBarTranslucent={Platform.OS === "android"} onRequestClose={() => ctx.setOpen(false)} > - + ctx.setOpen(false)}> {children} - + ); } diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index 1eefc4784..fef36e1fa 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -260,6 +260,34 @@ describe("toAgentPayload", () => { expect(permissionA.title).toBe("Run command"); }); + it("omits usage when any numeric usage field is NaN", () => { + const fields = [ + "inputTokens", + "cachedInputTokens", + "outputTokens", + "totalCostUsd", + "contextWindowMaxTokens", + "contextWindowUsedTokens", + ] as const; + + for (const field of fields) { + const agent = createManagedAgent({ + lastUsage: { + inputTokens: 10, + cachedInputTokens: 5, + outputTokens: 20, + totalCostUsd: 0.5, + contextWindowMaxTokens: 200_000, + contextWindowUsedTokens: 100_000, + [field]: Number.NaN, + }, + }); + + const payload = toAgentPayload(agent); + expect(payload.lastUsage).toBeUndefined(); + } + }); + it("produces null title and current mode even without overrides", () => { const agent = createManagedAgent({ currentModeId: null, lastUserMessageAt: null }); const payload = toAgentPayload(agent); @@ -303,6 +331,56 @@ describe("toAgentPayload", () => { expect(payload).not.toHaveProperty("lastUsage"); }); + it("preserves context window usage fields when they are valid numbers", () => { + const agent = createManagedAgent({ + lastUsage: { + inputTokens: 10, + contextWindowMaxTokens: 200_000, + contextWindowUsedTokens: 42_000, + }, + }); + + const payload = toAgentPayload(agent); + + expect(payload.lastUsage).toEqual({ + inputTokens: 10, + contextWindowMaxTokens: 200_000, + contextWindowUsedTokens: 42_000, + }); + }); + + it("omits lastUsage when context window usage fields are invalid", () => { + const agent = createManagedAgent({ + lastUsage: { + inputTokens: 10, + contextWindowMaxTokens: "200000" as unknown as number, + contextWindowUsedTokens: NaN, + }, + }); + + const payload = toAgentPayload(agent); + + expect(payload).not.toHaveProperty("lastUsage"); + }); + + it("keeps existing lastUsage behavior when context window fields are absent", () => { + const agent = createManagedAgent({ + lastUsage: { + inputTokens: 10, + outputTokens: 20, + totalCostUsd: 1.25, + }, + }); + + const payload = toAgentPayload(agent); + + expect(payload.lastUsage).toEqual({ + inputTokens: 10, + outputTokens: 20, + totalCostUsd: 1.25, + }); + }); + it("includes features in the snapshot payload", () => { const features = [createFeature()]; const agent = createManagedAgent({ features }); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index a3f040d04..9e204aaad 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -259,29 +259,41 @@ function sanitizeUsage(value: unknown): AgentUsage | undefined { } const result: AgentUsage = {}; const inputTokens = sanitized.inputTokens; - if (typeof inputTokens === "number") { + if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) { result.inputTokens = inputTokens; } else if (inputTokens !== undefined && inputTokens !== null) { return undefined; } const cachedInputTokens = sanitized.cachedInputTokens; - if (typeof cachedInputTokens === "number") { + if (typeof cachedInputTokens === "number" && Number.isFinite(cachedInputTokens)) { result.cachedInputTokens = cachedInputTokens; } else if (cachedInputTokens !== undefined && cachedInputTokens !== null) { return undefined; } const outputTokens = sanitized.outputTokens; - if (typeof outputTokens === "number") { + if (typeof outputTokens === "number" && Number.isFinite(outputTokens)) { result.outputTokens = outputTokens; } else if (outputTokens !== undefined && outputTokens !== null) { return undefined; } const totalCostUsd = sanitized.totalCostUsd; - if (typeof totalCostUsd === "number") { + if (typeof totalCostUsd === "number" && Number.isFinite(totalCostUsd)) { result.totalCostUsd = totalCostUsd; } else if (totalCostUsd !== undefined && totalCostUsd !== null) { return undefined; } + const contextWindowMaxTokens = sanitized.contextWindowMaxTokens; + if (typeof contextWindowMaxTokens === "number" && Number.isFinite(contextWindowMaxTokens)) { + result.contextWindowMaxTokens = contextWindowMaxTokens; + } else if (contextWindowMaxTokens !== undefined && contextWindowMaxTokens !== null) { + return undefined; + } + const contextWindowUsedTokens = sanitized.contextWindowUsedTokens; + if (typeof contextWindowUsedTokens === "number" && Number.isFinite(contextWindowUsedTokens)) { + result.contextWindowUsedTokens = contextWindowUsedTokens; + } else if (contextWindowUsedTokens !== undefined && contextWindowUsedTokens !== null) { + return undefined; + } return Object.keys(result).length ? result : undefined; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 5b91fa1e2..0ab82be09 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -132,6 +132,8 @@ export type AgentUsage = { cachedInputTokens?: number; outputTokens?: number; totalCostUsd?: number; + contextWindowMaxTokens?: number; + contextWindowUsedTokens?: number; }; export const TOOL_CALL_ICON_NAMES = [ diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 9cde504e9..68732b9e9 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -112,6 +112,28 @@ describe("convertClaudeHistoryEntry", () => { expect(mapBlocks).not.toHaveBeenCalled(); }); + test("skips meta user entries from Claude skill loading", () => { + const entry = { + type: "user", + isMeta: true, + message: { + role: "user", + content: [ + { + type: "text", + text: "Base directory for this skill: /tmp/skill\n\n# Orchestrate\n\nYou are an end-to-end implementation orchestrator.", + }, + ], + }, + }; + + const mapBlocks = vi.fn().mockReturnValue([]); + const result = convertClaudeHistoryEntry(entry, mapBlocks); + + expect(result).toEqual([]); + expect(mapBlocks).not.toHaveBeenCalled(); + }); + test("skips interrupt placeholder transcript noise", () => { const interruptEntry = { type: "user", @@ -261,3 +283,356 @@ describe("ClaudeAgentClient.listModels", () => { expect(defaultModel?.id).toBe("claude-opus-4-6"); }); }); + +describe("ClaudeAgentSession context window usage", () => { + const logger = createTestLogger(); + + async function createSessionForTest(): Promise { + const client = new ClaudeAgentClient({ logger }); + return client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + } + + function createQueryFactoryForTurns(turns: Array>>) { + return vi.fn(({ prompt }: { prompt: AsyncIterable }) => { + const queuedMessages: Array> = []; + const waiters: Array<() => void> = []; + let turnIndex = 0; + let closed = false; + + function wakeNextWaiter() { + const waiter = waiters.shift(); + waiter?.(); + } + + function enqueue(message: Record) { + queuedMessages.push(message); + wakeNextWaiter(); + } + + void (async () => { + for await (const _prompt of prompt) { + const turnMessages = turns[turnIndex] ?? []; + turnIndex += 1; + for (const message of turnMessages) { + enqueue(message); + } + } + closed = true; + wakeNextWaiter(); + })(); + + return { + next: vi.fn(async () => { + while (queuedMessages.length === 0 && !closed) { + await new Promise((resolve) => { + waiters.push(resolve); + }); + } + if (queuedMessages.length === 0) { + return { done: true, value: undefined }; + } + return { done: false, value: queuedMessages.shift() }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => { + closed = true; + wakeNextWaiter(); + return undefined; + }), + close: vi.fn(() => { + closed = true; + wakeNextWaiter(); + }), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => []), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + [Symbol.asyncIterator]() { + return this; + }, + }; + }); + } + + test("convertUsage includes contextWindowMaxTokens and derives used tokens from result usage", async () => { + const session = await createSessionForTest(); + + const usage = session.convertUsage( + { + type: "result", + subtype: "success", + usage: { + input_tokens: 10, + cache_read_input_tokens: 5, + output_tokens: 7, + }, + total_cost_usd: 0.12, + }, + { + "claude-sonnet-4-6": { contextWindow: 200_000 }, + "claude-opus-4-6": { contextWindow: 1_000_000 }, + }, + ); + + expect(usage).toEqual({ + inputTokens: 10, + cachedInputTokens: 5, + outputTokens: 7, + totalCostUsd: 0.12, + contextWindowMaxTokens: 1_000_000, + contextWindowUsedTokens: 22, + }); + }); + + test("contextWindowUsedTokens is derived from result usage when task_progress is missing", async () => { + const session = await createSessionForTest(); + + const usage = session.convertUsage({ + type: "result", + subtype: "success", + usage: { + input_tokens: 10, + cache_creation_input_tokens: 3, + cache_read_input_tokens: 5, + output_tokens: 7, + }, + total_cost_usd: 0.12, + }); + + expect(usage).toEqual({ + inputTokens: 10, + cachedInputTokens: 5, + outputTokens: 7, + totalCostUsd: 0.12, + contextWindowUsedTokens: 25, + }); + }); + + test("contextWindowUsedTokens is populated from task_progress usage data", async () => { + const session = await createSessionForTest(); + + session.translateMessageToEvents({ + type: "system", + subtype: "task_progress", + task_id: "task-1", + description: "Processing", + usage: { + total_tokens: 999, + tool_uses: 1, + duration_ms: 50, + input_tokens: 345, + cache_read_input_tokens: 55, + }, + uuid: "task-progress-1", + session_id: "session-1", + }); + + const events = session.translateMessageToEvents({ + type: "result", + subtype: "success", + duration_ms: 100, + duration_api_ms: 75, + is_error: false, + num_turns: 1, + result: "done", + stop_reason: null, + total_cost_usd: 0.25, + usage: { + input_tokens: 10, + cache_read_input_tokens: 5, + output_tokens: 7, + }, + modelUsage: { + "claude-sonnet-4-6": { contextWindow: 200_000 }, + }, + permission_denials: [], + uuid: "result-1", + session_id: "session-1", + }); + + expect(events).toContainEqual({ + type: "turn_completed", + provider: "claude", + usage: { + inputTokens: 10, + cachedInputTokens: 5, + outputTokens: 7, + totalCostUsd: 0.25, + contextWindowMaxTokens: 200_000, + contextWindowUsedTokens: 999, + }, + }); + }); + + test("task_progress usage takes priority over derived result usage", async () => { + const session = await createSessionForTest(); + + session.translateMessageToEvents({ + type: "system", + subtype: "task_progress", + task_id: "task-1", + description: "Processing", + usage: { + total_tokens: 999, + tool_uses: 1, + duration_ms: 50, + input_tokens: 345, + cache_read_input_tokens: 55, + }, + uuid: "task-progress-1", + session_id: "session-1", + }); + + const usage = session.convertUsage({ + type: "result", + subtype: "success", + usage: { + input_tokens: 10, + cache_creation_input_tokens: 3, + cache_read_input_tokens: 5, + output_tokens: 7, + }, + total_cost_usd: 0.12, + }); + + expect(usage).toEqual({ + inputTokens: 10, + cachedInputTokens: 5, + outputTokens: 7, + totalCostUsd: 0.12, + contextWindowUsedTokens: 999, + }); + }); + + test("contextWindowUsedTokens resets between turns and falls back to result usage", async () => { + const queryFactory = createQueryFactoryForTurns([ + [ + { + type: "system", + subtype: "init", + session_id: "session-1", + permissionMode: "default", + model: "claude-sonnet-4-6", + }, + { + type: "system", + subtype: "task_progress", + task_id: "task-1", + description: "Processing", + usage: { + total_tokens: 999, + tool_uses: 1, + duration_ms: 50, + input_tokens: 345, + cache_read_input_tokens: 55, + }, + uuid: "task-progress-1", + session_id: "session-1", + }, + { + type: "result", + subtype: "success", + duration_ms: 100, + duration_api_ms: 75, + is_error: false, + num_turns: 1, + result: "done", + stop_reason: null, + total_cost_usd: 0.25, + usage: { + input_tokens: 10, + cache_read_input_tokens: 5, + output_tokens: 7, + }, + modelUsage: { + "claude-sonnet-4-6": { contextWindow: 200_000 }, + }, + permission_denials: [], + uuid: "result-1", + session_id: "session-1", + }, + ], + [ + { + type: "result", + subtype: "success", + duration_ms: 110, + duration_api_ms: 80, + is_error: false, + num_turns: 1, + result: "still done", + stop_reason: null, + total_cost_usd: 0.1, + usage: { + input_tokens: 11, + cache_creation_input_tokens: 3, + cache_read_input_tokens: 6, + output_tokens: 8, + }, + modelUsage: { + "claude-sonnet-4-6": { contextWindow: 200_000 }, + }, + permission_denials: [], + uuid: "result-2", + session_id: "session-1", + }, + ], + ]); + const client = new ClaudeAgentClient({ logger, queryFactory }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + try { + const firstTurn = await session.run("turn 1"); + const secondTurn = await session.run("turn 2"); + + expect(firstTurn.usage).toEqual({ + inputTokens: 10, + cachedInputTokens: 5, + outputTokens: 7, + totalCostUsd: 0.25, + contextWindowMaxTokens: 200_000, + contextWindowUsedTokens: 999, + }); + expect(secondTurn.usage).toEqual({ + inputTokens: 11, + cachedInputTokens: 6, + outputTokens: 8, + totalCostUsd: 0.1, + contextWindowMaxTokens: 200_000, + contextWindowUsedTokens: 28, + }); + } finally { + await session.close(); + } + }); + + test("convertUsage derives used tokens even when modelUsage is missing", async () => { + const session = await createSessionForTest(); + + const usage = session.convertUsage({ + type: "result", + subtype: "success", + usage: { + input_tokens: 10, + cache_read_input_tokens: 5, + output_tokens: 7, + }, + total_cost_usd: 0.12, + }); + + expect(usage).toEqual({ + inputTokens: 10, + cachedInputTokens: 5, + outputTokens: 7, + totalCostUsd: 0.12, + contextWindowUsedTokens: 22, + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index d330d66d0..f9940e8a7 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -18,6 +18,7 @@ import { type SpawnOptions, type SDKMessage, type SDKPartialAssistantMessage, + type SDKTaskProgressMessage, type SDKResultMessage, type SDKSystemMessage, type SDKUserMessage, @@ -967,7 +968,8 @@ function isSyntheticUserEntry(entry: unknown): boolean { if (!entry || typeof entry !== "object") { return false; } - return (entry as { isSynthetic?: unknown }).isSynthetic === true; + const candidate = entry as { isSynthetic?: unknown; isMeta?: unknown }; + return candidate.isSynthetic === true || candidate.isMeta === true; } export function readEventIdentifiers(message: SDKMessage): EventIdentifiers { @@ -1161,6 +1163,40 @@ function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): string } } +function extractContextWindowSize(modelUsage: unknown): number | undefined { + if (!modelUsage || typeof modelUsage !== "object") { + return undefined; + } + + let maxContextWindow: number | undefined; + for (const value of Object.values(modelUsage as Record)) { + if (!value || typeof value !== "object") { + continue; + } + const contextWindow = (value as { contextWindow?: unknown }).contextWindow; + if ( + typeof contextWindow !== "number" || + !Number.isFinite(contextWindow) || + contextWindow <= 0 + ) { + continue; + } + maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow); + } + + return maxContextWindow; +} + +function readContextWindowUsedTokensFromTaskProgress( + message: SDKTaskProgressMessage, +): number | undefined { + const totalTokens = message.usage?.total_tokens; + if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) { + return undefined; + } + return totalTokens; +} + class ClaudeAgentSession implements AgentSession { readonly provider: "claude" = "claude"; readonly capabilities = CLAUDE_CAPABILITIES; @@ -1202,6 +1238,7 @@ class ClaudeAgentSession implements AgentSession { private pendingInterruptAbort = false; private lastForegroundPromptText: string | null = null; private foregroundHasVisibleActivity = false; + private lastContextWindowUsedTokens: number | undefined; private userMessageIds: string[] = []; private recentStderr = ""; private closed = false; @@ -1381,6 +1418,7 @@ class ClaudeAgentSession implements AgentSession { const sdkMessage = this.toSdkUserMessage(prompt); this.lastForegroundPromptText = this.extractPromptText(prompt); + this.lastContextWindowUsedTokens = undefined; const turnId = this.createTurnId("foreground"); this.activeForegroundTurnId = turnId; this.foregroundHasVisibleActivity = false; @@ -2207,6 +2245,7 @@ class ClaudeAgentSession implements AgentSession { this.notifySubscribers(event); this.activeForegroundTurnId = null; this.lastForegroundPromptText = null; + this.lastContextWindowUsedTokens = undefined; this.cancelCurrentTurn = null; this.syncTurnState("foreground turn terminal"); } @@ -2222,10 +2261,12 @@ class ClaudeAgentSession implements AgentSession { if (this.activeForegroundTurnId) { this.activeForegroundTurnId = null; this.lastForegroundPromptText = null; + this.lastContextWindowUsedTokens = undefined; this.cancelCurrentTurn = null; this.syncTurnState("foreground turn terminal"); } else if (this.autonomousTurn) { this.autonomousTurn = null; + this.lastContextWindowUsedTokens = undefined; this.syncTurnState("autonomous turn terminal"); } } @@ -2238,6 +2279,7 @@ class ClaudeAgentSession implements AgentSession { this.autonomousTurn = { id: this.createTurnId("autonomous"), }; + this.lastContextWindowUsedTokens = undefined; this.notifySubscribers({ type: "turn_started", provider: "claude" }); this.syncTurnState("autonomous turn started"); } @@ -2248,6 +2290,7 @@ class ClaudeAgentSession implements AgentSession { } this.notifySubscribers({ type: "turn_completed", provider: "claude" }); this.autonomousTurn = null; + this.lastContextWindowUsedTokens = undefined; this.syncTurnState("autonomous turn completed"); } @@ -2576,6 +2619,10 @@ class ClaudeAgentSession implements AgentSession { provider: "claude", }); } + } else if (message.subtype === "task_progress") { + this.lastContextWindowUsedTokens = + readContextWindowUsedTokensFromTaskProgress(message) ?? + this.lastContextWindowUsedTokens; } break; case "user": { @@ -2653,7 +2700,7 @@ class ClaudeAgentSession implements AgentSession { break; } case "result": { - const usage = this.convertUsage(message); + const usage = this.convertUsage(message, message.modelUsage); if (message.subtype === "success") { events.push({ type: "turn_completed", provider: "claude", usage }); } else { @@ -2794,16 +2841,38 @@ class ClaudeAgentSession implements AgentSession { return null; } - private convertUsage(message: SDKResultMessage): AgentUsage | undefined { + private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined { if (!message.usage) { return undefined; } - return { + const usage: AgentUsage = { inputTokens: message.usage.input_tokens, cachedInputTokens: message.usage.cache_read_input_tokens, outputTokens: message.usage.output_tokens, totalCostUsd: message.total_cost_usd, }; + const contextWindowMaxTokens = extractContextWindowSize( + modelUsage ?? message.modelUsage, + ); + if (contextWindowMaxTokens !== undefined) { + usage.contextWindowMaxTokens = contextWindowMaxTokens; + } + if (typeof this.lastContextWindowUsedTokens === "number") { + usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens; + } else if (message.usage) { + const usageWithCacheCreation = message.usage as typeof message.usage & { + cache_creation_input_tokens?: number; + }; + const derived = + (message.usage.input_tokens ?? 0) + + (usageWithCacheCreation.cache_creation_input_tokens ?? 0) + + (message.usage.cache_read_input_tokens ?? 0) + + (message.usage.output_tokens ?? 0); + if (Number.isFinite(derived) && derived > 0) { + usage.contextWindowUsedTokens = derived; + } + } + return usage; } private handlePermissionRequest: CanUseTool = async ( diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 1faf6e213..b40bbc6c4 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -40,6 +40,82 @@ function createSession(configOverrides: Partial = {}) { describe("Codex app-server provider", () => { const logger = createTestLogger(); + test("extracts context window usage from snake_case token payloads", () => { + expect( + __codexAppServerInternals.toAgentUsage({ + model_context_window: 200000, + last: { + total_tokens: 50000, + inputTokens: 30000, + cachedInputTokens: 5000, + outputTokens: 15000, + }, + }), + ).toEqual({ + inputTokens: 30000, + cachedInputTokens: 5000, + outputTokens: 15000, + contextWindowMaxTokens: 200000, + contextWindowUsedTokens: 50000, + }); + }); + + test("extracts context window usage from camelCase token payloads", () => { + expect( + __codexAppServerInternals.toAgentUsage({ + modelContextWindow: 200000, + last: { + totalTokens: 50000, + inputTokens: 30000, + cachedInputTokens: 5000, + outputTokens: 15000, + }, + }), + ).toEqual({ + inputTokens: 30000, + cachedInputTokens: 5000, + outputTokens: 15000, + contextWindowMaxTokens: 200000, + contextWindowUsedTokens: 50000, + }); + }); + + test("keeps existing usage behavior when context window fields are missing", () => { + expect( + __codexAppServerInternals.toAgentUsage({ + last: { + inputTokens: 30000, + cachedInputTokens: 5000, + outputTokens: 15000, + }, + }), + ).toEqual({ + inputTokens: 30000, + cachedInputTokens: 5000, + outputTokens: 15000, + }); + }); + + test("excludes invalid context window values", () => { + expect( + __codexAppServerInternals.toAgentUsage({ + model_context_window: Number.NaN, + modelContextWindow: "200000", + last: { + total_tokens: Number.NaN, + totalTokens: "50000", + inputTokens: 30000, + cachedInputTokens: 5000, + outputTokens: 15000, + }, + }), + ).toEqual({ + inputTokens: 30000, + cachedInputTokens: 5000, + outputTokens: 15000, + }); + }); + test("maps image prompt blocks to Codex localImage input", async () => { const input = await codexAppServerTurnInputFromPrompt( [ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 1b49b62df..4f09c9b1d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -677,12 +677,42 @@ function terminateChildProcessTree(child: ChildProcessWithoutNullStreams): void function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined { if (!tokenUsage || typeof tokenUsage !== "object") return undefined; const usage = tokenUsage as { - last?: { inputTokens?: number; cachedInputTokens?: number; outputTokens?: number }; + model_context_window?: number; + modelContextWindow?: number; + last?: { + inputTokens?: number; + cachedInputTokens?: number; + outputTokens?: number; + total_tokens?: number; + totalTokens?: number; + }; }; + const contextWindowMaxTokens = + typeof usage.model_context_window === "number" && + Number.isFinite(usage.model_context_window) && + usage.model_context_window > 0 + ? usage.model_context_window + : typeof usage.modelContextWindow === "number" && + Number.isFinite(usage.modelContextWindow) && + usage.modelContextWindow > 0 + ? usage.modelContextWindow + : undefined; + const contextWindowUsedTokens = + typeof usage.last?.total_tokens === "number" && + Number.isFinite(usage.last.total_tokens) && + usage.last.total_tokens > 0 + ? usage.last.total_tokens + : typeof usage.last?.totalTokens === "number" && + Number.isFinite(usage.last.totalTokens) && + usage.last.totalTokens > 0 + ? usage.last.totalTokens + : undefined; return { inputTokens: usage.last?.inputTokens, cachedInputTokens: usage.last?.cachedInputTokens, outputTokens: usage.last?.outputTokens, + ...(contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}), + ...(contextWindowUsedTokens !== undefined ? { contextWindowUsedTokens } : {}), }; } @@ -4009,5 +4039,6 @@ export const __codexAppServerInternals = { planStepsToMarkdown, mapCodexPlanToToolCall, normalizeCodexQuestionPrompts, + toAgentUsage, threadItemToTimeline, }; diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index b894292e5..2e220b9ba 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -12,7 +12,11 @@ import path from "node:path"; import { execFileSync } from "node:child_process"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { + __openCodeInternals, + OpenCodeAgentClient, + translateOpenCodeEvent, +} from "./opencode-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; import type { AgentSessionConfig, @@ -202,6 +206,11 @@ const hasOpenCode = isBinaryInstalled("opencode"); // HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId) expect(model.id).toContain("/"); + expect(model.metadata).toMatchObject({ + providerId: expect.any(String), + modelId: expect.any(String), + contextWindowMaxTokens: expect.any(Number), + }); } }, 60_000); @@ -303,3 +312,149 @@ const hasOpenCode = isBinaryInstalled("opencode"); rmSync(cwd, { recursive: true, force: true }); }, 180_000); }); + +describe("OpenCode adapter context-window normalization", () => { + test("preserves provider catalog context limit in model metadata", () => { + const definition = __openCodeInternals.buildOpenCodeModelDefinition( + { id: "openai", name: "OpenAI" }, + "gpt-5", + { + name: "GPT-5", + family: "gpt", + limit: { + context: 400_000, + input: 200_000, + output: 16_384, + }, + }, + ); + + expect(definition.metadata).toMatchObject({ + providerId: "openai", + modelId: "gpt-5", + contextWindowMaxTokens: 400_000, + limit: { + context: 400_000, + input: 200_000, + output: 16_384, + }, + }); + }); + + test("resolves selected model context window from connected provider catalog data", () => { + expect( + __openCodeInternals.resolveOpenCodeSelectedModelContextWindow( + { + connected: ["openai"], + all: [ + { + id: "openai", + models: { + "gpt-5": { + limit: { + context: 400_000, + output: 16_384, + }, + }, + }, + }, + { + id: "anthropic", + models: { + "claude-opus": { + limit: { + context: 1_000_000, + output: 8_192, + }, + }, + }, + }, + ], + }, + "openai/gpt-5", + ), + ).toBe(400_000); + + expect( + __openCodeInternals.resolveOpenCodeSelectedModelContextWindow( + { + connected: ["openai"], + all: [ + { + id: "anthropic", + models: { + "claude-opus": { + limit: { + context: 1_000_000, + output: 8_192, + }, + }, + }, + }, + ], + }, + "anthropic/claude-opus", + ), + ).toBeUndefined(); + }); + + test("normalizes step-finish usage into AgentUsage context window fields", () => { + const usage = { contextWindowMaxTokens: 400_000 }; + + __openCodeInternals.mergeOpenCodeStepFinishUsage(usage, { + cost: 0.25, + tokens: { + total: 999_999, + input: 30_000, + output: 12_000, + reasoning: 10_000, + cache: { + read: 2_000, + write: 1_000, + }, + }, + }); + + expect(usage).toEqual({ + contextWindowMaxTokens: 400_000, + contextWindowUsedTokens: 55_000, + cachedInputTokens: 2_000, + inputTokens: 30_000, + outputTokens: 12_000, + totalCostUsd: 0.25, + }); + expect(__openCodeInternals.hasNormalizedOpenCodeUsage(usage)).toBe(true); + }); + + test("resolves context window max tokens from assistant message metadata", () => { + const usage = {}; + const onAssistantModelContextWindowResolved = vi.fn(); + + translateOpenCodeEvent( + { + type: "message.updated", + properties: { + info: { + id: "message-1", + sessionID: "session-1", + role: "assistant", + providerID: "openai", + modelID: "gpt-5", + }, + }, + }, + { + sessionId: "session-1", + messageRoles: new Map(), + accumulatedUsage: usage, + streamedPartKeys: new Set(), + emittedStructuredMessageIds: new Set(), + partTypes: new Map(), + modelContextWindowsByModelKey: new Map([["openai/gpt-5", 400_000]]), + onAssistantModelContextWindowResolved, + }, + ); + + expect(onAssistantModelContextWindowResolved).toHaveBeenCalledWith(400_000); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index c022caa57..7965c96f1 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,6 +1,9 @@ import { spawn, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; -import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client"; +import { + createOpencodeClient, + type OpencodeClient, +} from "@opencode-ai/sdk/v2/client"; import net from "node:net"; import type { Logger } from "pino"; import { z } from "zod"; @@ -268,6 +271,224 @@ function sortOpenCodeModes(modes: AgentMode[]): AgentMode[] { }); } +function readPositiveFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function buildOpenCodeModelLookupKey(providerId: string, modelId: string): string { + return `${providerId}/${modelId}`; +} + +function parseOpenCodeModelLookupKey(modelId: string | null | undefined): string | undefined { + if (typeof modelId !== "string" || modelId.trim().length === 0) { + return undefined; + } + + const slashIndex = modelId.indexOf("/"); + if (slashIndex <= 0 || slashIndex === modelId.length - 1) { + return undefined; + } + + const providerId = modelId.slice(0, slashIndex).trim(); + const providerModelId = modelId.slice(slashIndex + 1).trim(); + if (!providerId || !providerModelId) { + return undefined; + } + + return buildOpenCodeModelLookupKey(providerId, providerModelId); +} + +function extractOpenCodeModelContextWindow(model: unknown): number | undefined { + if (!model || typeof model !== "object") { + return undefined; + } + const limit = (model as { limit?: { context?: unknown } }).limit; + return readPositiveFiniteNumber(limit?.context); +} + +function buildOpenCodeModelDefinition( + provider: { + id: string; + name: string; + }, + modelId: string, + model: { + name: string; + family?: string; + release_date?: string; + attachment?: boolean; + reasoning?: boolean; + tool_call?: boolean; + cost?: unknown; + limit?: { context?: number; input?: number; output?: number }; + variants?: Record; + }, +): AgentModelDefinition { + const rawVariants = model.variants ? Object.keys(model.variants) : []; + const thinkingOptions = rawVariants.map((id, index) => ({ + id, + label: id, + isDefault: index === 0, + })); + + return { + provider: "opencode", + id: `${provider.id}/${modelId}`, + label: model.name, + description: `${provider.name} - ${model.family ?? ""}`.trim(), + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, + defaultThinkingOptionId: thinkingOptions[0]?.id, + metadata: { + providerId: provider.id, + providerName: provider.name, + modelId, + family: model.family, + releaseDate: model.release_date, + supportsAttachments: model.attachment, + supportsReasoning: model.reasoning, + supportsToolCall: model.tool_call, + cost: model.cost, + contextWindowMaxTokens: extractOpenCodeModelContextWindow(model), + ...(model.limit ? { limit: model.limit } : {}), + }, + }; +} + +function resolveOpenCodeSelectedModelContextWindow( + providers: { + connected?: string[]; + all?: Array<{ + id: string; + models?: Record; + }>; + } | null | undefined, + modelId: string | null | undefined, +): number | undefined { + if (!providers) { + return undefined; + } + const modelLookupKey = parseOpenCodeModelLookupKey(modelId); + if (!modelLookupKey) { + return undefined; + } + const lookup = buildOpenCodeModelContextWindowLookup(providers); + return lookup.get(modelLookupKey); +} + +function buildOpenCodeModelContextWindowLookup(providers: { + connected?: string[]; + all?: Array<{ + id: string; + models?: Record; + }>; +} | null | undefined): Map { + const lookup = new Map(); + if (!providers) { + return lookup; + } + + const connectedProviderIds = new Set(providers.connected ?? []); + for (const provider of providers.all ?? []) { + if (!connectedProviderIds.has(provider.id)) { + continue; + } + for (const [modelId, modelDefinition] of Object.entries(provider.models ?? {})) { + const contextWindow = extractOpenCodeModelContextWindow(modelDefinition); + if (contextWindow === undefined) { + continue; + } + lookup.set(buildOpenCodeModelLookupKey(provider.id, modelId), contextWindow); + } + } + + return lookup; +} + +function resolveOpenCodeModelLookupKeyFromAssistantMessageInfo( + info: AgentMetadata | undefined, +): string | undefined { + if (!info || info.role !== "assistant") { + return undefined; + } + + const providerId = readNonEmptyString(info.providerID); + const modelId = readNonEmptyString(info.modelID); + if (!providerId || !modelId) { + return undefined; + } + + return buildOpenCodeModelLookupKey(providerId, modelId); +} + +function mergeOpenCodeStepFinishUsage( + usage: AgentUsage, + part: { + cost?: unknown; + tokens?: { + input?: unknown; + output?: unknown; + reasoning?: unknown; + total?: unknown; + cache?: { + read?: unknown; + write?: unknown; + }; + }; + }, +): void { + const inputTokens = readPositiveFiniteNumber(part.tokens?.input); + const outputTokens = readPositiveFiniteNumber(part.tokens?.output); + const reasoningTokens = readPositiveFiniteNumber(part.tokens?.reasoning); + const cacheReadTokens = readPositiveFiniteNumber(part.tokens?.cache?.read); + const cacheWriteTokens = readPositiveFiniteNumber(part.tokens?.cache?.write); + const totalTokens = + (inputTokens ?? 0) + + (outputTokens ?? 0) + + (reasoningTokens ?? 0) + + (cacheReadTokens ?? 0) + + (cacheWriteTokens ?? 0); + const cost = readPositiveFiniteNumber(part.cost); + + if (inputTokens !== undefined) { + usage.inputTokens = (usage.inputTokens ?? 0) + inputTokens; + } + if (cacheReadTokens !== undefined) { + usage.cachedInputTokens = (usage.cachedInputTokens ?? 0) + cacheReadTokens; + } + if (outputTokens !== undefined) { + usage.outputTokens = (usage.outputTokens ?? 0) + outputTokens; + } + if (totalTokens > 0) { + usage.contextWindowUsedTokens = (usage.contextWindowUsedTokens ?? 0) + totalTokens; + } + if (cost !== undefined) { + usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost; + } +} + +function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean { + return [ + usage.inputTokens, + usage.cachedInputTokens, + usage.outputTokens, + usage.totalCostUsd, + usage.contextWindowMaxTokens, + usage.contextWindowUsedTokens, + ].some((value) => typeof value === "number" && Number.isFinite(value)); +} + +export const __openCodeInternals = { + buildOpenCodeModelContextWindowLookup, + buildOpenCodeModelDefinition, + buildOpenCodeModelLookupKey, + extractOpenCodeModelContextWindow, + hasNormalizedOpenCodeUsage, + mergeOpenCodeStepFinishUsage, + parseOpenCodeModelLookupKey, + resolveOpenCodeModelLookupKeyFromAssistantMessageInfo, + resolveOpenCodeSelectedModelContextWindow, +}; + export class OpenCodeServerManager { private static instance: OpenCodeServerManager | null = null; private static exitHandlerRegistered = false; @@ -423,6 +644,7 @@ export class OpenCodeAgentClient implements AgentClient { private readonly serverManager: OpenCodeServerManager; private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; + private readonly modelContextWindows = new Map(); constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) { this.logger = logger.child({ module: "agent", provider: "opencode" }); @@ -460,7 +682,15 @@ export class OpenCodeAgentClient implements AgentClient { throw new Error("OpenCode session creation returned no data"); } - return new OpenCodeAgentSession(openCodeConfig, client, session.id, this.logger); + await this.populateModelContextWindowCache(client, openCodeConfig.cwd); + + return new OpenCodeAgentSession( + openCodeConfig, + client, + session.id, + this.logger, + new Map(this.modelContextWindows), + ); } async resumeSession( @@ -485,7 +715,15 @@ export class OpenCodeAgentClient implements AgentClient { directory: openCodeConfig.cwd, }); - return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId, this.logger); + await this.populateModelContextWindowCache(client, openCodeConfig.cwd); + + return new OpenCodeAgentSession( + openCodeConfig, + client, + handle.sessionId, + this.logger, + new Map(this.modelContextWindows), + ); } async listModels(options?: ListModelsOptions): Promise { @@ -533,6 +771,7 @@ export class OpenCodeAgentClient implements AgentClient { } const models: AgentModelDefinition[] = []; + this.modelContextWindows.clear(); for (const provider of providers.all) { // Skip providers that aren't connected/configured if (!connectedProviderIds.has(provider.id)) { @@ -540,32 +779,15 @@ export class OpenCodeAgentClient implements AgentClient { } for (const [modelId, model] of Object.entries(provider.models)) { - const rawVariants = model.variants ? Object.keys(model.variants) : []; - const thinkingOptions = rawVariants.map((id, index) => ({ - id, - label: id, - isDefault: index === 0, - })); - - models.push({ - provider: "opencode", - id: `${provider.id}/${modelId}`, - label: model.name, - description: `${provider.name} - ${model.family ?? ""}`.trim(), - thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, - defaultThinkingOptionId: thinkingOptions[0]?.id, - metadata: { - providerId: provider.id, - providerName: provider.name, - modelId, - family: model.family, - releaseDate: model.release_date, - supportsAttachments: model.attachment, - supportsReasoning: model.reasoning, - supportsToolCall: model.tool_call, - cost: model.cost, - }, - }); + const definition = buildOpenCodeModelDefinition(provider, modelId, model); + const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model); + if (contextWindowMaxTokens !== undefined) { + this.modelContextWindows.set( + buildOpenCodeModelLookupKey(provider.id, modelId), + contextWindowMaxTokens, + ); + } + models.push(definition); } } @@ -685,6 +907,22 @@ export class OpenCodeAgentClient implements AgentClient { } return { ...config, provider: "opencode" }; } + + private async populateModelContextWindowCache( + client: OpencodeClient, + cwd: string, + ): Promise { + const response = await client.provider.list({ directory: cwd }); + if (response.error || !response.data) { + return; + } + + const lookup = buildOpenCodeModelContextWindowLookup(response.data); + this.modelContextWindows.clear(); + for (const [modelLookupKey, contextWindowMaxTokens] of lookup.entries()) { + this.modelContextWindows.set(modelLookupKey, contextWindowMaxTokens); + } + } } export type OpenCodeEventTranslationState = { @@ -695,6 +933,8 @@ export type OpenCodeEventTranslationState = { emittedStructuredMessageIds: Set; /** Tracks the type of each part by ID, learned from message.part.updated events. */ partTypes: Map; + modelContextWindowsByModelKey?: ReadonlyMap; + onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void; }; function stringifyStructuredAssistantMessage(value: unknown): string | null { @@ -787,6 +1027,15 @@ export function translateOpenCodeEvent( if (messageId && messageSessionId === state.sessionId && role) { state.messageRoles.set(messageId, role); + if (role === "assistant") { + const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(info); + if (modelLookupKey) { + const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey); + if (contextWindowMaxTokens !== undefined) { + state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens); + } + } + } if ( role === "assistant" && !state.emittedStructuredMessageIds.has(messageId) && @@ -894,20 +1143,7 @@ export function translateOpenCodeEvent( }); } } else if (partType === "step-finish") { - const tokens = part.tokens as - | { input?: number; output?: number; reasoning?: number } - | undefined; - const cost = part.cost as number | undefined; - - if (tokens) { - state.accumulatedUsage.inputTokens = - (state.accumulatedUsage.inputTokens ?? 0) + (tokens.input ?? 0); - state.accumulatedUsage.outputTokens = - (state.accumulatedUsage.outputTokens ?? 0) + (tokens.output ?? 0); - } - if (cost !== undefined) { - state.accumulatedUsage.totalCostUsd = (state.accumulatedUsage.totalCostUsd ?? 0) + cost; - } + mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part); } break; } @@ -1083,6 +1319,7 @@ class OpenCodeAgentSession implements AgentSession { private readonly client: OpencodeClient; private readonly sessionId: string; private readonly logger: Logger; + private readonly modelContextWindowsByModelKey: ReadonlyMap; private currentMode: string = "default"; private pendingPermissions = new Map(); private abortController: AbortController | null = null; @@ -1102,18 +1339,23 @@ class OpenCodeAgentSession implements AgentSession { private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; private readonly runningToolCalls = new Map(); + private selectedModelContextWindowMaxTokens: number | undefined; constructor( config: OpenCodeAgentConfig, client: OpencodeClient, sessionId: string, logger: Logger, + modelContextWindowsByModelKey: ReadonlyMap = new Map(), ) { this.config = config; this.client = client; this.sessionId = sessionId; this.logger = logger; + this.modelContextWindowsByModelKey = modelContextWindowsByModelKey; this.currentMode = normalizeOpenCodeModeId(config.modeId); + this.selectedModelContextWindowMaxTokens = + this.resolveConfiguredModelContextWindowMaxTokens(config.model); } get id(): string | null { @@ -1133,6 +1375,8 @@ class OpenCodeAgentSession implements AgentSession { const normalizedModelId = typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; this.config.model = normalizedModelId ?? undefined; + this.selectedModelContextWindowMaxTokens = + this.resolveConfiguredModelContextWindowMaxTokens(this.config.model); } async setThinkingOption(thinkingOptionId: string | null): Promise { @@ -1246,6 +1490,9 @@ class OpenCodeAgentSession implements AgentSession { const turnAbortController = new AbortController(); this.abortController = turnAbortController; await this.ensureMcpServersConfigured(); + const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens(); + this.accumulatedUsage = + contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}; const parts = this.buildPromptParts(prompt); const model = this.parseModel(this.config.model); @@ -1781,28 +2028,35 @@ class OpenCodeAgentSession implements AgentSession { streamedPartKeys: this.streamedPartKeys, emittedStructuredMessageIds: this.emittedStructuredMessageIds, partTypes: this.partTypes, + modelContextWindowsByModelKey: this.modelContextWindowsByModelKey, + onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => { + this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens; + if (!this.config.model) { + this.selectedModelContextWindowMaxTokens = contextWindowMaxTokens; + } + }, }); for (const translatedEvent of translated) { if (translatedEvent.type === "permission_requested") { this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request); } - if (translatedEvent.type === "turn_completed") { - translatedEvent.usage = this.extractAndResetUsage(); - } } return translated; } - private extractAndResetUsage(): AgentUsage | undefined { - const usage = this.accumulatedUsage; - this.accumulatedUsage = {}; + private resolveSelectedModelContextWindowMaxTokens(): number | undefined { + return this.selectedModelContextWindowMaxTokens; + } - if (!usage.inputTokens && !usage.outputTokens && !usage.totalCostUsd) { + private resolveConfiguredModelContextWindowMaxTokens( + modelId: string | undefined, + ): number | undefined { + const modelLookupKey = parseOpenCodeModelLookupKey(modelId); + if (!modelLookupKey) { return undefined; } - - return usage; + return this.modelContextWindowsByModelKey.get(modelLookupKey); } } diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index b89673385..b68a4f009 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -14,6 +14,33 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState { } describe("translateOpenCodeEvent", () => { + it("resolves context window max tokens from assistant message.updated model metadata", () => { + const resolvedContextWindowMaxTokens: number[] = []; + const state = createState(); + state.modelContextWindowsByModelKey = new Map([["anthropic/claude-sonnet-4", 200_000]]); + state.onAssistantModelContextWindowResolved = (contextWindowMaxTokens) => { + resolvedContextWindowMaxTokens.push(contextWindowMaxTokens); + }; + + translateOpenCodeEvent( + { + type: "message.updated", + properties: { + info: { + id: "message-model-1", + sessionID: "session-1", + role: "assistant", + providerID: "anthropic", + modelID: "claude-sonnet-4", + }, + }, + }, + state, + ); + + expect(resolvedContextWindowMaxTokens).toEqual([200_000]); + }); + it("does not duplicate assistant output when completed part echoes streamed delta", () => { const state = createState(); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index e32c682f9..ba55577c2 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -146,6 +146,8 @@ const AgentUsageSchema: z.ZodType = z.object({ cachedInputTokens: z.number().optional(), outputTokens: z.number().optional(), totalCostUsd: z.number().optional(), + contextWindowMaxTokens: z.number().optional(), + contextWindowUsedTokens: z.number().optional(), }); const McpStdioServerConfigSchema = z.object({