Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,48 @@ describe("ProviderRuntimeIngestion", () => {
expect(resolvedPayload?.requestType).toBe("command_execution_approval");
});

it("normalizes Codex token_count last_token_usage payload into context usage activities", async () => {
const harness = await createHarness();
const now = new Date().toISOString();

harness.emit({
type: "thread.token-usage.updated",
eventId: asEventId("evt-thread-token-usage-codex-shape"),
provider: "codex",
createdAt: now,
threadId: asThreadId("thread-1"),
payload: {
usage: {
last_token_usage: {
total_tokens: 51800,
},
model_context_window: 258400,
},
},
});

const thread = await waitForThread(harness.engine, (entry) =>
entry.activities.some(
(activity: ProviderRuntimeTestActivity) =>
activity.id === "evt-thread-token-usage-codex-shape" &&
activity.kind === "thread.context.usage.updated",
),
);

const usageActivity = thread.activities.find(
(activity: ProviderRuntimeTestActivity) =>
activity.id === "evt-thread-token-usage-codex-shape",
);
const usagePayload =
usageActivity?.payload && typeof usageActivity.payload === "object"
? (usageActivity.payload as Record<string, unknown>)
: undefined;

expect(usagePayload?.usedTokens).toBe(51800);
expect(usagePayload?.maxTokens).toBe(258400);
expect(usagePayload?.percentUsed).toBeCloseTo((51800 / 258400) * 100, 6);
});

it("maps runtime.error into errored session state", async () => {
const harness = await createHarness();
const now = new Date().toISOString();
Expand Down
88 changes: 88 additions & 0 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,57 @@ function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

function asNonNegativeNumber(value: unknown): number | undefined {
const parsed =
typeof value === "number" && Number.isFinite(value)
? value
: typeof value === "string"
? Number.parseFloat(value.trim())
: Number.NaN;
if (!Number.isFinite(parsed) || parsed < 0) {
return undefined;
}
return parsed;
}

function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
return value as Record<string, unknown>;
}

function normalizeThreadTokenUsagePayload(usage: unknown): {
usedTokens: number | null;
maxTokens: number | null;
percentUsed: number | null;
sourceUsage: unknown;
} {
const usageRecord = asRecord(usage);
const lastTokenUsage = usageRecord
? (asRecord(usageRecord.last_token_usage) ?? asRecord(usageRecord.lastTokenUsage))
: undefined;
// Codex token_count info shape:
// usage: { last_token_usage: { total_tokens }, model_context_window }
const usedTokens = asNonNegativeNumber(
lastTokenUsage?.total_tokens ?? lastTokenUsage?.totalTokens,
);
const maxTokens = asNonNegativeNumber(
usageRecord?.model_context_window ?? usageRecord?.modelContextWindow,
);
const percentUsedFromUsage =
usedTokens !== undefined && maxTokens !== undefined && maxTokens > 0
? Math.min((usedTokens / maxTokens) * 100, 100)
: undefined;

return {
usedTokens: usedTokens ?? null,
maxTokens: maxTokens ?? null,
percentUsed: percentUsedFromUsage ?? null,
sourceUsage: usage,
};
}

function runtimePayloadRecord(event: ProviderRuntimeEvent): Record<string, unknown> | undefined {
const payload = (event as { payload?: unknown }).payload;
if (!payload || typeof payload !== "object") {
Expand Down Expand Up @@ -184,6 +235,43 @@ function runtimeEventToActivities(
: {};
})();
switch (event.type) {
case "thread.token-usage.updated": {
const normalizedUsage = normalizeThreadTokenUsagePayload(event.payload.usage);
return [
{
id: event.eventId,
createdAt: event.createdAt,
tone: "info",
kind: "thread.context.usage.updated",
summary: "Context usage updated",
payload: normalizedUsage,
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
},
];
}

case "thread.state.changed": {
if (event.payload.state !== "compacted") {
return [];
}
return [
{
id: event.eventId,
createdAt: event.createdAt,
tone: "info",
kind: "thread.context.compacted",
summary: "Context compacted",
payload: {
state: event.payload.state,
...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}),
},
turnId: toTurnId(event.turnId) ?? null,
...maybeSequence,
},
];
}

case "request.opened": {
if (event.payload.requestType === "tool_user_input") {
return [];
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,53 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
}),
);

it.effect(
"maps codex/event/token_count into thread.token-usage.updated using last_token_usage info",
() =>
Effect.gen(function* () {
const adapter = yield* CodexAdapter;
const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild);

lifecycleManager.emit("event", {
id: asEventId("evt-token-count"),
kind: "notification",
provider: "codex",
threadId: asThreadId("thread-1"),
createdAt: new Date().toISOString(),
method: "codex/event/token_count",
payload: {
id: "evt-token-count",
msg: {
type: "token_count",
info: {
last_token_usage: {
total_tokens: 87429,
},
model_context_window: 258400,
},
},
},
} satisfies ProviderEvent);

const firstEvent = yield* Fiber.join(firstEventFiber);

assert.equal(firstEvent._tag, "Some");
if (firstEvent._tag !== "Some") {
return;
}
assert.equal(firstEvent.value.type, "thread.token-usage.updated");
if (firstEvent.value.type !== "thread.token-usage.updated") {
return;
}
assert.deepEqual(firstEvent.value.payload.usage, {
last_token_usage: {
total_tokens: 87429,
},
model_context_window: 258400,
});
}),
);

it.effect("maps retryable Codex error notifications to runtime.warning", () =>
Effect.gen(function* () {
const adapter = yield* CodexAdapter;
Expand Down
27 changes: 18 additions & 9 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,15 +700,7 @@ function mapToRuntimeEvents(
}

if (event.method === "thread/tokenUsage/updated") {
return [
{
type: "thread.token-usage.updated",
...runtimeEventBase(event, canonicalThreadId),
payload: {
usage: event.payload ?? {},
},
},
];
return [];
}

if (event.method === "turn/started") {
Expand Down Expand Up @@ -945,6 +937,23 @@ function mapToRuntimeEvents(
];
}

if (event.method === "codex/event/token_count") {
const msg = codexEventMessage(payload);
const info = asObject(msg?.info);
if (!info) {
return [];
}
return [
{
...codexEventBase(event, canonicalThreadId),
type: "thread.token-usage.updated",
payload: {
usage: info,
},
},
];
}

if (event.method === "codex/event/task_started") {
const msg = codexEventMessage(payload);
const taskId = asString(payload?.id) ?? asString(msg?.turn_id);
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
deriveTimelineEntries,
deriveActiveWorkStartedAt,
deriveActivePlanState,
deriveThreadContextUsageSnapshot,
findLatestProposedPlan,
deriveWorkLogEntries,
hasToolActivityForTurn,
Expand Down Expand Up @@ -139,6 +140,7 @@ import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommand
import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";
import { CodexTraitsPicker } from "./chat/CodexTraitsPicker";
import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";
import { ComposerContextUsageIndicator } from "./chat/ComposerContextUsageIndicator";
import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel";
import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel";
import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner";
Expand Down Expand Up @@ -581,6 +583,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
() => derivePendingUserInputs(threadActivities),
[threadActivities],
);
const threadContextUsageSnapshot = useMemo(
() => deriveThreadContextUsageSnapshot(threadActivities, nowIso),
[nowIso, threadActivities],
);
useEffect(() => {
setNowTick(Date.now());
}, [threadActivities]);
const activePendingUserInput = pendingUserInputs[0] ?? null;
const activePendingDraftAnswers = useMemo(
() =>
Expand Down Expand Up @@ -1889,14 +1898,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
: "local";

useEffect(() => {
if (phase !== "running") return;
if (phase !== "running" && !threadContextUsageSnapshot.recentlyCompacted) return;
const timer = window.setInterval(() => {
setNowTick(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, [phase]);
}, [phase, threadContextUsageSnapshot.recentlyCompacted]);

const beginSendPhase = useCallback((nextPhase: Exclude<SendPhase, "idle">) => {
setSendStartedAt((current) => current ?? new Date().toISOString());
Expand Down Expand Up @@ -3510,6 +3519,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
selectedProvider={selectedProvider}
selectedCodexFastModeEnabled={selectedCodexFastModeEnabled}
reasoningOptions={reasoningOptions}
contextUsageSnapshot={threadContextUsageSnapshot}
onEffortSelect={onEffortSelect}
onCodexFastModeChange={onCodexFastModeChange}
onToggleInteractionMode={toggleInteractionMode}
Expand Down Expand Up @@ -3608,6 +3618,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
</Button>
</>
) : null}

<Separator
orientation="vertical"
className="mx-0.5 hidden h-4 sm:block"
/>
<ComposerContextUsageIndicator snapshot={threadContextUsageSnapshot} />
</>
)}
</div>
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/components/chat/CompactComposerControlsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { getDefaultReasoningEffort } from "@t3tools/shared/model";
import { memo } from "react";
import { EllipsisIcon, ListTodoIcon } from "lucide-react";
import type { ThreadContextUsageSnapshot } from "../../session-logic";
import { Button } from "../ui/button";
import {
Menu,
Expand All @@ -18,6 +19,7 @@ import {
MenuSeparator as MenuDivider,
MenuTrigger,
} from "../ui/menu";
import { buildComposerContextUsageIndicatorViewModel } from "./ComposerContextUsageIndicator.logic";

export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
activePlan: boolean;
Expand All @@ -28,13 +30,15 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
selectedProvider: ProviderKind;
selectedCodexFastModeEnabled: boolean;
reasoningOptions: ReadonlyArray<CodexReasoningEffort>;
contextUsageSnapshot: ThreadContextUsageSnapshot | null;
onEffortSelect: (effort: CodexReasoningEffort) => void;
onCodexFastModeChange: (enabled: boolean) => void;
onToggleInteractionMode: () => void;
onTogglePlanSidebar: () => void;
onToggleRuntimeMode: () => void;
}) {
const defaultReasoningEffort = getDefaultReasoningEffort("codex");
const contextViewModel = buildComposerContextUsageIndicatorViewModel(props.contextUsageSnapshot);
const reasoningLabelByOption: Record<CodexReasoningEffort, string> = {
low: "Low",
medium: "Medium",
Expand Down Expand Up @@ -121,6 +125,21 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
<MenuRadioItem value="full-access">Full access</MenuRadioItem>
</MenuRadioGroup>
</MenuGroup>
<MenuDivider />
<MenuGroup>
<div className="px-2 pt-1.5 pb-0.5 text-muted-foreground text-xs">Context window:</div>
<div className="px-2 pb-0.5 font-medium text-foreground text-sm leading-tight">
{contextViewModel.summaryLine}
</div>
<div className="px-2 pb-1.5 text-foreground text-sm leading-tight">
{contextViewModel.tokensLine}
</div>
{contextViewModel.showCompactionNotice ? (
<div className="px-2 pb-1.5 text-amber-600 text-xs leading-tight">
Context was compacted recently.
</div>
) : null}
</MenuGroup>
{props.activePlan ? (
<>
<MenuDivider />
Expand Down
Loading
Loading