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({