feat: show remaining context window for Claude Code, Codex, and OpenCode

Track and display context window usage across all three agent providers,
letting users see how much context remains before hitting limits.
This commit is contained in:
Mohamed Boudra
2026-04-07 15:03:29 +07:00
parent 022eb33234
commit e06b691d5d
16 changed files with 1358 additions and 72 deletions
@@ -12,6 +12,7 @@ import {
DraftAgentStatusBar, DraftAgentStatusBar,
type DraftAgentStatusBarProps, type DraftAgentStatusBarProps,
} from "./agent-status-bar"; } from "./agent-status-bar";
import { ContextWindowMeter } from "./context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { import {
@@ -135,6 +136,8 @@ export function AgentInputArea({
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null; const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
return { return {
status: agent?.status ?? null, status: agent?.status ?? null,
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
}; };
}), }),
); );
@@ -636,6 +639,25 @@ export function AgentInputArea({
</View> </View>
); );
const hasContextWindowMeter =
typeof agentState.contextWindowMaxTokens === "number" &&
typeof agentState.contextWindowUsedTokens === "number";
const contextWindowMaxTokens = hasContextWindowMeter ? agentState.contextWindowMaxTokens : null;
const contextWindowUsedTokens = hasContextWindowMeter
? agentState.contextWindowUsedTokens
: null;
const beforeVoiceContent = (
<View style={styles.contextWindowMeterSlot}>
{contextWindowMaxTokens !== null && contextWindowUsedTokens !== null ? (
<ContextWindowMeter
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
/>
) : null}
</View>
);
const leftContent = const leftContent =
resolveStatusControlMode(statusControls) === "draft" && statusControls ? ( resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} /> <DraftAgentStatusBar {...statusControls} />
@@ -715,6 +737,7 @@ export function AgentInputArea({
disabled={isSubmitLoading} disabled={isSubmitLoading}
isInputActive={isInputActive} isInputActive={isInputActive}
leftContent={leftContent} leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent} rightContent={rightContent}
voiceServerId={serverId} voiceServerId={serverId}
voiceAgentId={agentId} voiceAgentId={agentId}
@@ -790,6 +813,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
contextWindowMeterSlot: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButton: { realtimeVoiceButton: {
width: 28, width: 28,
height: 28, height: 28,
@@ -859,6 +859,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
model: currentAgent.model, model: currentAgent.model,
features: currentAgent.features, features: currentAgent.features,
thinkingOptionId: currentAgent.thinkingOptionId, thinkingOptionId: currentAgent.thinkingOptionId,
lastUsage: currentAgent.lastUsage,
} }
: null; : null;
}), }),
@@ -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<typeof useUnistyles>["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 (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={styles.container}
accessibilityRole="image"
accessibilityLabel={`Context window ${roundedPercentage}% used`}
>
<Svg
width={SVG_SIZE}
height={SVG_SIZE}
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke={colors.track}
strokeWidth={STROKE_WIDTH}
/>
<Circle
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
stroke={colors.progress}
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashOffset}
/>
</Svg>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipContent}>
<Text style={styles.tooltipTitle}>Context window</Text>
<Text style={styles.tooltipText}>{`${roundedPercentage}% used`}</Text>
<Text
style={styles.tooltipDetail}
>{`${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens`}</Text>
</View>
</TooltipContent>
</Tooltip>
);
}
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,
},
}));
@@ -79,6 +79,8 @@ export interface MessageInputProps {
isInputActive?: boolean; isInputActive?: boolean;
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */ /** Content to render on the left side of the button row (e.g., AgentStatusBar) */
leftContent?: React.ReactNode; 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) */ /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
rightContent?: React.ReactNode; rightContent?: React.ReactNode;
voiceServerId?: string; voiceServerId?: string;
@@ -201,6 +203,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
disabled = false, disabled = false,
isInputActive = true, isInputActive = true,
leftContent, leftContent,
beforeVoiceContent,
rightContent, rightContent,
voiceServerId, voiceServerId,
voiceAgentId, voiceAgentId,
@@ -1015,6 +1018,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{/* Right: voice button, contextual button (realtime/send/cancel) */} {/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}> <View style={styles.rightButtonGroup}>
{beforeVoiceContent}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger <TooltipTrigger
onPress={handleVoicePress} onPress={handleVoicePress}
+28 -10
View File
@@ -44,6 +44,7 @@ type TooltipContextValue = {
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>; triggerRef: React.RefObject<View | null>;
enabled: boolean; enabled: boolean;
openOnPress: boolean;
delayDuration: number; delayDuration: number;
}; };
@@ -107,6 +108,18 @@ function measureElement(element: View): Promise<Rect> {
}); });
} }
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({ function computePosition({
triggerRect, triggerRect,
contentSize, contentSize,
@@ -214,12 +227,8 @@ export function Tooltip({
onOpenChange, onOpenChange,
}); });
const isWeb = Platform.OS === "web"; const isMobile = isMobileTooltipEnvironment();
const isMobileWeb = const enabled = isMobile ? enabledOnMobile : enabledOnDesktop;
isWeb &&
typeof navigator !== "undefined" &&
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
const value = useMemo<TooltipContextValue>( const value = useMemo<TooltipContextValue>(
() => ({ () => ({
@@ -227,9 +236,10 @@ export function Tooltip({
setOpen: setIsOpen, setOpen: setIsOpen,
triggerRef, triggerRef,
enabled, enabled,
openOnPress: isMobile,
delayDuration, delayDuration,
}), }),
[isOpen, setIsOpen, enabled, delayDuration], [isOpen, setIsOpen, enabled, isMobile, delayDuration],
); );
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>; return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -323,9 +333,17 @@ export function TooltipTrigger({
const handlePress = useCallback( const handlePress = useCallback(
(e: any) => { (e: any) => {
onPress?.(e); onPress?.(e);
if (!ctx.enabled || disabled) {
return;
}
if (ctx.openOnPress) {
clearOpenTimer();
ctx.setOpen(true);
return;
}
close(); close();
}, },
[close, onPress], [clearOpenTimer, close, ctx, disabled, onPress],
); );
const triggerProps = { const triggerProps = {
@@ -492,7 +510,7 @@ export function TooltipContent({
statusBarTranslucent={Platform.OS === "android"} statusBarTranslucent={Platform.OS === "android"}
onRequestClose={() => ctx.setOpen(false)} onRequestClose={() => ctx.setOpen(false)}
> >
<View pointerEvents="box-none" style={styles.overlay}> <Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
<Animated.View <Animated.View
pointerEvents="none" pointerEvents="none"
entering={FadeIn.duration(80)} entering={FadeIn.duration(80)}
@@ -513,7 +531,7 @@ export function TooltipContent({
> >
{children} {children}
</Animated.View> </Animated.View>
</View> </Pressable>
</Modal> </Modal>
); );
} }
@@ -260,6 +260,34 @@ describe("toAgentPayload", () => {
expect(permissionA.title).toBe("Run command"); 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", () => { it("produces null title and current mode even without overrides", () => {
const agent = createManagedAgent({ currentModeId: null, lastUserMessageAt: null }); const agent = createManagedAgent({ currentModeId: null, lastUserMessageAt: null });
const payload = toAgentPayload(agent); const payload = toAgentPayload(agent);
@@ -303,6 +331,56 @@ describe("toAgentPayload", () => {
expect(payload).not.toHaveProperty("lastUsage"); 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", () => { it("includes features in the snapshot payload", () => {
const features = [createFeature()]; const features = [createFeature()];
const agent = createManagedAgent({ features }); const agent = createManagedAgent({ features });
@@ -259,29 +259,41 @@ function sanitizeUsage(value: unknown): AgentUsage | undefined {
} }
const result: AgentUsage = {}; const result: AgentUsage = {};
const inputTokens = sanitized.inputTokens; const inputTokens = sanitized.inputTokens;
if (typeof inputTokens === "number") { if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) {
result.inputTokens = inputTokens; result.inputTokens = inputTokens;
} else if (inputTokens !== undefined && inputTokens !== null) { } else if (inputTokens !== undefined && inputTokens !== null) {
return undefined; return undefined;
} }
const cachedInputTokens = sanitized.cachedInputTokens; const cachedInputTokens = sanitized.cachedInputTokens;
if (typeof cachedInputTokens === "number") { if (typeof cachedInputTokens === "number" && Number.isFinite(cachedInputTokens)) {
result.cachedInputTokens = cachedInputTokens; result.cachedInputTokens = cachedInputTokens;
} else if (cachedInputTokens !== undefined && cachedInputTokens !== null) { } else if (cachedInputTokens !== undefined && cachedInputTokens !== null) {
return undefined; return undefined;
} }
const outputTokens = sanitized.outputTokens; const outputTokens = sanitized.outputTokens;
if (typeof outputTokens === "number") { if (typeof outputTokens === "number" && Number.isFinite(outputTokens)) {
result.outputTokens = outputTokens; result.outputTokens = outputTokens;
} else if (outputTokens !== undefined && outputTokens !== null) { } else if (outputTokens !== undefined && outputTokens !== null) {
return undefined; return undefined;
} }
const totalCostUsd = sanitized.totalCostUsd; const totalCostUsd = sanitized.totalCostUsd;
if (typeof totalCostUsd === "number") { if (typeof totalCostUsd === "number" && Number.isFinite(totalCostUsd)) {
result.totalCostUsd = totalCostUsd; result.totalCostUsd = totalCostUsd;
} else if (totalCostUsd !== undefined && totalCostUsd !== null) { } else if (totalCostUsd !== undefined && totalCostUsd !== null) {
return undefined; 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; return Object.keys(result).length ? result : undefined;
} }
@@ -132,6 +132,8 @@ export type AgentUsage = {
cachedInputTokens?: number; cachedInputTokens?: number;
outputTokens?: number; outputTokens?: number;
totalCostUsd?: number; totalCostUsd?: number;
contextWindowMaxTokens?: number;
contextWindowUsedTokens?: number;
}; };
export const TOOL_CALL_ICON_NAMES = [ export const TOOL_CALL_ICON_NAMES = [
@@ -112,6 +112,28 @@ describe("convertClaudeHistoryEntry", () => {
expect(mapBlocks).not.toHaveBeenCalled(); 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", () => { test("skips interrupt placeholder transcript noise", () => {
const interruptEntry = { const interruptEntry = {
type: "user", type: "user",
@@ -261,3 +283,356 @@ describe("ClaudeAgentClient.listModels", () => {
expect(defaultModel?.id).toBe("claude-opus-4-6"); expect(defaultModel?.id).toBe("claude-opus-4-6");
}); });
}); });
describe("ClaudeAgentSession context window usage", () => {
const logger = createTestLogger();
async function createSessionForTest(): Promise<any> {
const client = new ClaudeAgentClient({ logger });
return client.createSession({
provider: "claude",
cwd: process.cwd(),
});
}
function createQueryFactoryForTurns(turns: Array<Array<Record<string, unknown>>>) {
return vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const queuedMessages: Array<Record<string, unknown>> = [];
const waiters: Array<() => void> = [];
let turnIndex = 0;
let closed = false;
function wakeNextWaiter() {
const waiter = waiters.shift();
waiter?.();
}
function enqueue(message: Record<string, unknown>) {
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<void>((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,
});
});
});
@@ -18,6 +18,7 @@ import {
type SpawnOptions, type SpawnOptions,
type SDKMessage, type SDKMessage,
type SDKPartialAssistantMessage, type SDKPartialAssistantMessage,
type SDKTaskProgressMessage,
type SDKResultMessage, type SDKResultMessage,
type SDKSystemMessage, type SDKSystemMessage,
type SDKUserMessage, type SDKUserMessage,
@@ -967,7 +968,8 @@ function isSyntheticUserEntry(entry: unknown): boolean {
if (!entry || typeof entry !== "object") { if (!entry || typeof entry !== "object") {
return false; 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 { 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<string, unknown>)) {
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 { class ClaudeAgentSession implements AgentSession {
readonly provider: "claude" = "claude"; readonly provider: "claude" = "claude";
readonly capabilities = CLAUDE_CAPABILITIES; readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1202,6 +1238,7 @@ class ClaudeAgentSession implements AgentSession {
private pendingInterruptAbort = false; private pendingInterruptAbort = false;
private lastForegroundPromptText: string | null = null; private lastForegroundPromptText: string | null = null;
private foregroundHasVisibleActivity = false; private foregroundHasVisibleActivity = false;
private lastContextWindowUsedTokens: number | undefined;
private userMessageIds: string[] = []; private userMessageIds: string[] = [];
private recentStderr = ""; private recentStderr = "";
private closed = false; private closed = false;
@@ -1381,6 +1418,7 @@ class ClaudeAgentSession implements AgentSession {
const sdkMessage = this.toSdkUserMessage(prompt); const sdkMessage = this.toSdkUserMessage(prompt);
this.lastForegroundPromptText = this.extractPromptText(prompt); this.lastForegroundPromptText = this.extractPromptText(prompt);
this.lastContextWindowUsedTokens = undefined;
const turnId = this.createTurnId("foreground"); const turnId = this.createTurnId("foreground");
this.activeForegroundTurnId = turnId; this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false; this.foregroundHasVisibleActivity = false;
@@ -2207,6 +2245,7 @@ class ClaudeAgentSession implements AgentSession {
this.notifySubscribers(event); this.notifySubscribers(event);
this.activeForegroundTurnId = null; this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null; this.lastForegroundPromptText = null;
this.lastContextWindowUsedTokens = undefined;
this.cancelCurrentTurn = null; this.cancelCurrentTurn = null;
this.syncTurnState("foreground turn terminal"); this.syncTurnState("foreground turn terminal");
} }
@@ -2222,10 +2261,12 @@ class ClaudeAgentSession implements AgentSession {
if (this.activeForegroundTurnId) { if (this.activeForegroundTurnId) {
this.activeForegroundTurnId = null; this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null; this.lastForegroundPromptText = null;
this.lastContextWindowUsedTokens = undefined;
this.cancelCurrentTurn = null; this.cancelCurrentTurn = null;
this.syncTurnState("foreground turn terminal"); this.syncTurnState("foreground turn terminal");
} else if (this.autonomousTurn) { } else if (this.autonomousTurn) {
this.autonomousTurn = null; this.autonomousTurn = null;
this.lastContextWindowUsedTokens = undefined;
this.syncTurnState("autonomous turn terminal"); this.syncTurnState("autonomous turn terminal");
} }
} }
@@ -2238,6 +2279,7 @@ class ClaudeAgentSession implements AgentSession {
this.autonomousTurn = { this.autonomousTurn = {
id: this.createTurnId("autonomous"), id: this.createTurnId("autonomous"),
}; };
this.lastContextWindowUsedTokens = undefined;
this.notifySubscribers({ type: "turn_started", provider: "claude" }); this.notifySubscribers({ type: "turn_started", provider: "claude" });
this.syncTurnState("autonomous turn started"); this.syncTurnState("autonomous turn started");
} }
@@ -2248,6 +2290,7 @@ class ClaudeAgentSession implements AgentSession {
} }
this.notifySubscribers({ type: "turn_completed", provider: "claude" }); this.notifySubscribers({ type: "turn_completed", provider: "claude" });
this.autonomousTurn = null; this.autonomousTurn = null;
this.lastContextWindowUsedTokens = undefined;
this.syncTurnState("autonomous turn completed"); this.syncTurnState("autonomous turn completed");
} }
@@ -2576,6 +2619,10 @@ class ClaudeAgentSession implements AgentSession {
provider: "claude", provider: "claude",
}); });
} }
} else if (message.subtype === "task_progress") {
this.lastContextWindowUsedTokens =
readContextWindowUsedTokensFromTaskProgress(message) ??
this.lastContextWindowUsedTokens;
} }
break; break;
case "user": { case "user": {
@@ -2653,7 +2700,7 @@ class ClaudeAgentSession implements AgentSession {
break; break;
} }
case "result": { case "result": {
const usage = this.convertUsage(message); const usage = this.convertUsage(message, message.modelUsage);
if (message.subtype === "success") { if (message.subtype === "success") {
events.push({ type: "turn_completed", provider: "claude", usage }); events.push({ type: "turn_completed", provider: "claude", usage });
} else { } else {
@@ -2794,16 +2841,38 @@ class ClaudeAgentSession implements AgentSession {
return null; return null;
} }
private convertUsage(message: SDKResultMessage): AgentUsage | undefined { private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
if (!message.usage) { if (!message.usage) {
return undefined; return undefined;
} }
return { const usage: AgentUsage = {
inputTokens: message.usage.input_tokens, inputTokens: message.usage.input_tokens,
cachedInputTokens: message.usage.cache_read_input_tokens, cachedInputTokens: message.usage.cache_read_input_tokens,
outputTokens: message.usage.output_tokens, outputTokens: message.usage.output_tokens,
totalCostUsd: message.total_cost_usd, 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 ( private handlePermissionRequest: CanUseTool = async (
@@ -40,6 +40,82 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
describe("Codex app-server provider", () => { describe("Codex app-server provider", () => {
const logger = createTestLogger(); 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 () => { test("maps image prompt blocks to Codex localImage input", async () => {
const input = await codexAppServerTurnInputFromPrompt( const input = await codexAppServerTurnInputFromPrompt(
[ [
@@ -677,12 +677,42 @@ function terminateChildProcessTree(child: ChildProcessWithoutNullStreams): void
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined { function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
if (!tokenUsage || typeof tokenUsage !== "object") return undefined; if (!tokenUsage || typeof tokenUsage !== "object") return undefined;
const usage = tokenUsage as { 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 { return {
inputTokens: usage.last?.inputTokens, inputTokens: usage.last?.inputTokens,
cachedInputTokens: usage.last?.cachedInputTokens, cachedInputTokens: usage.last?.cachedInputTokens,
outputTokens: usage.last?.outputTokens, outputTokens: usage.last?.outputTokens,
...(contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}),
...(contextWindowUsedTokens !== undefined ? { contextWindowUsedTokens } : {}),
}; };
} }
@@ -4009,5 +4039,6 @@ export const __codexAppServerInternals = {
planStepsToMarkdown, planStepsToMarkdown,
mapCodexPlanToToolCall, mapCodexPlanToToolCall,
normalizeCodexQuestionPrompts, normalizeCodexQuestionPrompts,
toAgentUsage,
threadItemToTimeline, threadItemToTimeline,
}; };
@@ -12,7 +12,11 @@ import path from "node:path";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { createTestLogger } from "../../../test-utils/test-logger.js"; 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 { streamSession } from "./test-utils/session-stream-adapter.js";
import type { import type {
AgentSessionConfig, AgentSessionConfig,
@@ -202,6 +206,11 @@ const hasOpenCode = isBinaryInstalled("opencode");
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId) // HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
expect(model.id).toContain("/"); expect(model.id).toContain("/");
expect(model.metadata).toMatchObject({
providerId: expect.any(String),
modelId: expect.any(String),
contextWindowMaxTokens: expect.any(Number),
});
} }
}, 60_000); }, 60_000);
@@ -303,3 +312,149 @@ const hasOpenCode = isBinaryInstalled("opencode");
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}, 180_000); }, 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);
});
});
@@ -1,6 +1,9 @@
import { spawn, type ChildProcess } from "node:child_process"; import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs"; 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 net from "node:net";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { z } from "zod"; 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<string, unknown>;
},
): 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<string, unknown>;
}>;
} | 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<string, unknown>;
}>;
} | null | undefined): Map<string, number> {
const lookup = new Map<string, number>();
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 { export class OpenCodeServerManager {
private static instance: OpenCodeServerManager | null = null; private static instance: OpenCodeServerManager | null = null;
private static exitHandlerRegistered = false; private static exitHandlerRegistered = false;
@@ -423,6 +644,7 @@ export class OpenCodeAgentClient implements AgentClient {
private readonly serverManager: OpenCodeServerManager; private readonly serverManager: OpenCodeServerManager;
private readonly logger: Logger; private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>();
constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) { constructor(logger: Logger, runtimeSettings?: ProviderRuntimeSettings) {
this.logger = logger.child({ module: "agent", provider: "opencode" }); 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"); 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( async resumeSession(
@@ -485,7 +715,15 @@ export class OpenCodeAgentClient implements AgentClient {
directory: openCodeConfig.cwd, 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<AgentModelDefinition[]> { async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
@@ -533,6 +771,7 @@ export class OpenCodeAgentClient implements AgentClient {
} }
const models: AgentModelDefinition[] = []; const models: AgentModelDefinition[] = [];
this.modelContextWindows.clear();
for (const provider of providers.all) { for (const provider of providers.all) {
// Skip providers that aren't connected/configured // Skip providers that aren't connected/configured
if (!connectedProviderIds.has(provider.id)) { if (!connectedProviderIds.has(provider.id)) {
@@ -540,32 +779,15 @@ export class OpenCodeAgentClient implements AgentClient {
} }
for (const [modelId, model] of Object.entries(provider.models)) { for (const [modelId, model] of Object.entries(provider.models)) {
const rawVariants = model.variants ? Object.keys(model.variants) : []; const definition = buildOpenCodeModelDefinition(provider, modelId, model);
const thinkingOptions = rawVariants.map((id, index) => ({ const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
id, if (contextWindowMaxTokens !== undefined) {
label: id, this.modelContextWindows.set(
isDefault: index === 0, buildOpenCodeModelLookupKey(provider.id, modelId),
})); contextWindowMaxTokens,
);
models.push({ }
provider: "opencode", models.push(definition);
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,
},
});
} }
} }
@@ -685,6 +907,22 @@ export class OpenCodeAgentClient implements AgentClient {
} }
return { ...config, provider: "opencode" }; return { ...config, provider: "opencode" };
} }
private async populateModelContextWindowCache(
client: OpencodeClient,
cwd: string,
): Promise<void> {
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 = { export type OpenCodeEventTranslationState = {
@@ -695,6 +933,8 @@ export type OpenCodeEventTranslationState = {
emittedStructuredMessageIds: Set<string>; emittedStructuredMessageIds: Set<string>;
/** Tracks the type of each part by ID, learned from message.part.updated events. */ /** Tracks the type of each part by ID, learned from message.part.updated events. */
partTypes: Map<string, string>; partTypes: Map<string, string>;
modelContextWindowsByModelKey?: ReadonlyMap<string, number>;
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
}; };
function stringifyStructuredAssistantMessage(value: unknown): string | null { function stringifyStructuredAssistantMessage(value: unknown): string | null {
@@ -787,6 +1027,15 @@ export function translateOpenCodeEvent(
if (messageId && messageSessionId === state.sessionId && role) { if (messageId && messageSessionId === state.sessionId && role) {
state.messageRoles.set(messageId, 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 ( if (
role === "assistant" && role === "assistant" &&
!state.emittedStructuredMessageIds.has(messageId) && !state.emittedStructuredMessageIds.has(messageId) &&
@@ -894,20 +1143,7 @@ export function translateOpenCodeEvent(
}); });
} }
} else if (partType === "step-finish") { } else if (partType === "step-finish") {
const tokens = part.tokens as mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
| { 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;
}
} }
break; break;
} }
@@ -1083,6 +1319,7 @@ class OpenCodeAgentSession implements AgentSession {
private readonly client: OpencodeClient; private readonly client: OpencodeClient;
private readonly sessionId: string; private readonly sessionId: string;
private readonly logger: Logger; private readonly logger: Logger;
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
private currentMode: string = "default"; private currentMode: string = "default";
private pendingPermissions = new Map<string, AgentPermissionRequest>(); private pendingPermissions = new Map<string, AgentPermissionRequest>();
private abortController: AbortController | null = null; private abortController: AbortController | null = null;
@@ -1102,18 +1339,23 @@ class OpenCodeAgentSession implements AgentSession {
private nextTurnOrdinal = 0; private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null; private activeForegroundTurnId: string | null = null;
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>(); private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
private selectedModelContextWindowMaxTokens: number | undefined;
constructor( constructor(
config: OpenCodeAgentConfig, config: OpenCodeAgentConfig,
client: OpencodeClient, client: OpencodeClient,
sessionId: string, sessionId: string,
logger: Logger, logger: Logger,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
) { ) {
this.config = config; this.config = config;
this.client = client; this.client = client;
this.sessionId = sessionId; this.sessionId = sessionId;
this.logger = logger; this.logger = logger;
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId); this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.selectedModelContextWindowMaxTokens =
this.resolveConfiguredModelContextWindowMaxTokens(config.model);
} }
get id(): string | null { get id(): string | null {
@@ -1133,6 +1375,8 @@ class OpenCodeAgentSession implements AgentSession {
const normalizedModelId = const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
this.config.model = normalizedModelId ?? undefined; this.config.model = normalizedModelId ?? undefined;
this.selectedModelContextWindowMaxTokens =
this.resolveConfiguredModelContextWindowMaxTokens(this.config.model);
} }
async setThinkingOption(thinkingOptionId: string | null): Promise<void> { async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -1246,6 +1490,9 @@ class OpenCodeAgentSession implements AgentSession {
const turnAbortController = new AbortController(); const turnAbortController = new AbortController();
this.abortController = turnAbortController; this.abortController = turnAbortController;
await this.ensureMcpServersConfigured(); await this.ensureMcpServersConfigured();
const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens();
this.accumulatedUsage =
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
const parts = this.buildPromptParts(prompt); const parts = this.buildPromptParts(prompt);
const model = this.parseModel(this.config.model); const model = this.parseModel(this.config.model);
@@ -1781,28 +2028,35 @@ class OpenCodeAgentSession implements AgentSession {
streamedPartKeys: this.streamedPartKeys, streamedPartKeys: this.streamedPartKeys,
emittedStructuredMessageIds: this.emittedStructuredMessageIds, emittedStructuredMessageIds: this.emittedStructuredMessageIds,
partTypes: this.partTypes, partTypes: this.partTypes,
modelContextWindowsByModelKey: this.modelContextWindowsByModelKey,
onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => {
this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens;
if (!this.config.model) {
this.selectedModelContextWindowMaxTokens = contextWindowMaxTokens;
}
},
}); });
for (const translatedEvent of translated) { for (const translatedEvent of translated) {
if (translatedEvent.type === "permission_requested") { if (translatedEvent.type === "permission_requested") {
this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request); this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request);
} }
if (translatedEvent.type === "turn_completed") {
translatedEvent.usage = this.extractAndResetUsage();
}
} }
return translated; return translated;
} }
private extractAndResetUsage(): AgentUsage | undefined { private resolveSelectedModelContextWindowMaxTokens(): number | undefined {
const usage = this.accumulatedUsage; return this.selectedModelContextWindowMaxTokens;
this.accumulatedUsage = {}; }
if (!usage.inputTokens && !usage.outputTokens && !usage.totalCostUsd) { private resolveConfiguredModelContextWindowMaxTokens(
modelId: string | undefined,
): number | undefined {
const modelLookupKey = parseOpenCodeModelLookupKey(modelId);
if (!modelLookupKey) {
return undefined; return undefined;
} }
return this.modelContextWindowsByModelKey.get(modelLookupKey);
return usage;
} }
} }
@@ -14,6 +14,33 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
} }
describe("translateOpenCodeEvent", () => { 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", () => { it("does not duplicate assistant output when completed part echoes streamed delta", () => {
const state = createState(); const state = createState();
+2
View File
@@ -146,6 +146,8 @@ const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
cachedInputTokens: z.number().optional(), cachedInputTokens: z.number().optional(),
outputTokens: z.number().optional(), outputTokens: z.number().optional(),
totalCostUsd: z.number().optional(), totalCostUsd: z.number().optional(),
contextWindowMaxTokens: z.number().optional(),
contextWindowUsedTokens: z.number().optional(),
}); });
const McpStdioServerConfigSchema = z.object({ const McpStdioServerConfigSchema = z.object({