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,
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({
</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 =
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...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,
@@ -859,6 +859,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat
model: currentAgent.model,
features: currentAgent.features,
thinkingOptionId: currentAgent.thinkingOptionId,
lastUsage: currentAgent.lastUsage,
}
: 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;
/** 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<MessageInputRef, MessageInputProps>(funct
disabled = false,
isInputActive = true,
leftContent,
beforeVoiceContent,
rightContent,
voiceServerId,
voiceAgentId,
@@ -1015,6 +1018,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}>
{beforeVoiceContent}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleVoicePress}
+28 -10
View File
@@ -44,6 +44,7 @@ type TooltipContextValue = {
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
enabled: boolean;
openOnPress: boolean;
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({
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<TooltipContextValue>(
() => ({
@@ -227,9 +236,10 @@ export function Tooltip({
setOpen: setIsOpen,
triggerRef,
enabled,
openOnPress: isMobile,
delayDuration,
}),
[isOpen, setIsOpen, enabled, delayDuration],
[isOpen, setIsOpen, enabled, isMobile, delayDuration],
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
@@ -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)}
>
<View pointerEvents="box-none" style={styles.overlay}>
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
@@ -513,7 +531,7 @@ export function TooltipContent({
>
{children}
</Animated.View>
</View>
</Pressable>
</Modal>
);
}
@@ -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 });
@@ -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;
}
@@ -132,6 +132,8 @@ export type AgentUsage = {
cachedInputTokens?: number;
outputTokens?: number;
totalCostUsd?: number;
contextWindowMaxTokens?: number;
contextWindowUsedTokens?: number;
};
export const TOOL_CALL_ICON_NAMES = [
@@ -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<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 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<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 {
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 (
@@ -40,6 +40,82 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
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(
[
@@ -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,
};
@@ -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);
});
});
@@ -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<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 {
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<string, number>();
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<AgentModelDefinition[]> {
@@ -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<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 = {
@@ -695,6 +933,8 @@ export type OpenCodeEventTranslationState = {
emittedStructuredMessageIds: Set<string>;
/** Tracks the type of each part by ID, learned from message.part.updated events. */
partTypes: Map<string, string>;
modelContextWindowsByModelKey?: ReadonlyMap<string, number>;
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<string, number>;
private currentMode: string = "default";
private pendingPermissions = new Map<string, AgentPermissionRequest>();
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<string, ToolCallTimelineItem>();
private selectedModelContextWindowMaxTokens: number | undefined;
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
sessionId: string,
logger: Logger,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = 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<void> {
@@ -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);
}
}
@@ -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();
+2
View File
@@ -146,6 +146,8 @@ const AgentUsageSchema: z.ZodType<AgentUsage> = 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({