Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
147 changes: 147 additions & 0 deletions apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,153 @@ describe("latestTokenStats", () => {
const stats = latestTokenStats(events);
expect(stats.cacheReadTokens).toBe(350);
});

it.each(["claude", "codex", "opencode", "cursor", "droid"])(
"clears stale %s usage at a completed compaction boundary",
(provider) => {
const events = [
envelope(1, {
type: "done",
turnId: "turn-1",
status: "completed",
usage: { inputTokens: 100_000, outputTokens: 1_000, contextWindow: 100_000 },
}),
envelope(2, {
type: "context_compact",
trigger: "auto",
state: "completed",
provider,
turnId: "turn-1",
} as AgentChatEventEnvelope["event"]),
envelope(3, {
type: "done",
turnId: "turn-1",
status: "completed",
usage: { inputTokens: 100_000, outputTokens: 1_000, contextWindow: 100_000 },
}),
];
const before = latestTokenStats(events.slice(0, 1), 100_000);
expect(before.inputTokens).toBe(100_000);
expect(before.percent).toBe(100);
expect(latestTokenStats(events, 100_000).percent).toBeNull();
},
);

it("uses postTokens from a completed compaction and ignores stale same-turn totals", () => {
const events = [
envelope(1, {
type: "done",
turnId: "turn-1",
status: "completed",
usage: { inputTokens: 100_000, outputTokens: 1_000, contextWindow: 100_000 },
}),
envelope(2, {
type: "context_compact",
trigger: "auto",
state: "completed",
provider: "claude",
turnId: "turn-1",
postTokens: 18_000,
} as AgentChatEventEnvelope["event"]),
envelope(3, {
type: "done",
turnId: "turn-1",
status: "completed",
usage: { inputTokens: 100_000, outputTokens: 1_000, contextWindow: 100_000 },
}),
];
const stats = latestTokenStats(events, 100_000);
expect(stats.inputTokens).toBe(18_000);
expect(stats.contextWindow).toBe(100_000);
expect(stats.percent).toBe(18);
});

it("accepts an exact Codex usage update from the compaction turn", () => {
const events = [
envelope(1, {
type: "context_compact",
trigger: "auto",
state: "completed",
provider: "codex",
turnId: "turn-1",
} as AgentChatEventEnvelope["event"]),
envelope(2, {
type: "codex_token_usage",
usage: { last: { inputTokens: 21_000 }, modelContextWindow: 100_000 },
turnId: "turn-1",
} as AgentChatEventEnvelope["event"]),
];
const stats = latestTokenStats(events, 100_000);
expect(stats.inputTokens).toBe(21_000);
expect(stats.contextWindow).toBe(100_000);
expect(stats.percent).toBe(21);
});

it("ignores metadata-only Codex usage after compaction but accepts an explicit zero", () => {
const metadataOnlyEvents = [
envelope(1, {
type: "context_compact",
trigger: "auto",
state: "completed",
provider: "codex",
turnId: "turn-1",
} as AgentChatEventEnvelope["event"]),
envelope(2, {
type: "codex_token_usage",
usage: { modelContextWindow: 100_000 },
turnId: "turn-1",
} as AgentChatEventEnvelope["event"]),
envelope(3, {
type: "done",
turnId: "turn-1",
status: "completed",
usage: { inputTokens: 100_000, outputTokens: 1_000, contextWindow: 100_000 },
}),
];
const metadataOnlyStats = latestTokenStats(metadataOnlyEvents, 200_000);
expect(metadataOnlyStats.inputTokens).toBeNull();
expect(metadataOnlyStats.contextWindow).toBe(100_000);
expect(metadataOnlyStats.percent).toBeNull();

const explicitZeroStats = latestTokenStats([
envelope(1, {
type: "context_compact",
trigger: "auto",
state: "completed",
provider: "codex",
turnId: "turn-1",
} as AgentChatEventEnvelope["event"]),
envelope(2, {
type: "codex_token_usage",
usage: { last: { inputTokens: 0 }, modelContextWindow: 100_000 },
turnId: "turn-1",
} as AgentChatEventEnvelope["event"]),
], 200_000);
expect(explicitZeroStats.inputTokens).toBe(0);
expect(explicitZeroStats.contextWindow).toBe(100_000);
expect(explicitZeroStats.percent).toBe(0);
});

it("accepts an exact Claude context snapshot from the compaction turn", () => {
const events = [
envelope(1, {
type: "context_compact",
trigger: "auto",
state: "completed",
provider: "claude",
turnId: "turn-1",
} as AgentChatEventEnvelope["event"]),
envelope(2, {
type: "context_usage",
usage: { totalTokens: 24_000, maxTokens: 100_000 },
turnId: "turn-1",
} as AgentChatEventEnvelope["event"]),
];
const stats = latestTokenStats(events, 200_000);
expect(stats.inputTokens).toBe(24_000);
expect(stats.contextWindow).toBe(100_000);
expect(stats.percent).toBe(24);
});
});

describe("latestGoal", () => {
Expand Down
26 changes: 25 additions & 1 deletion apps/ade-cli/src/tuiClient/adeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,7 @@ export function latestTokenStats(
let cacheCreationTokens: number | null = null;
let costUsd: number | null = null;
let eventLimit: number | null = null;
let compactedTurnId: string | null = null;
let rateLimit: TokenStats["rateLimit"] = null;
const readCacheReadTokens = (bucket: Record<string, unknown> | null): number | null => {
if (!bucket) return null;
Expand All @@ -881,16 +882,21 @@ export function latestTokenStats(
if (event.type === "status" && event.turnStatus === "started") streaming = true;
if (event.type === "done" || (event.type === "status" && event.turnStatus === "completed")) streaming = false;
if (event.type === "tokens") {
if (compactedTurnId && event.turnId === compactedTurnId) continue;
inputTokens = typeof event.inputTokens === "number" ? event.inputTokens : inputTokens;
outputTokens = typeof event.outputTokens === "number" ? event.outputTokens : outputTokens;
cacheReadTokens = readCacheReadTokens(event) ?? cacheReadTokens;
cacheCreationTokens = typeof event.cacheWriteTokens === "number" ? event.cacheWriteTokens : cacheCreationTokens;
if (typeof event.contextWindow === "number") eventLimit = event.contextWindow;
compactedTurnId = null;
}
if (event.type === "codex_token_usage") {
const usage = event.usage && typeof event.usage === "object" ? event.usage as Record<string, unknown> : null;
const total = usage?.total && typeof usage.total === "object" ? usage.total as Record<string, unknown> : null;
const last = usage?.last && typeof usage.last === "object" ? usage.last as Record<string, unknown> : null;
const hasContextOccupancy = typeof last?.inputTokens === "number" || typeof total?.inputTokens === "number";
if (typeof usage?.modelContextWindow === "number") eventLimit = usage.modelContextWindow;
if (!hasContextOccupancy) continue;
inputTokens = typeof last?.inputTokens === "number"
? last.inputTokens
: typeof total?.inputTokens === "number" ? total.inputTokens : inputTokens;
Expand All @@ -901,9 +907,19 @@ export function latestTokenStats(
// cachedInputTokens (snake-cased upstream variant aliased through). Prefer
// last-turn reading over total.
cacheReadTokens = readCacheReadTokens(last) ?? readCacheReadTokens(total) ?? cacheReadTokens;
if (typeof usage?.modelContextWindow === "number") eventLimit = usage.modelContextWindow;
compactedTurnId = null;
}
if (event.type === "context_usage") {
const usage = event.usage && typeof event.usage === "object" ? event.usage as Record<string, unknown> : null;
inputTokens = typeof usage?.totalTokens === "number" ? usage.totalTokens : inputTokens;
outputTokens = null;
cacheReadTokens = null;
cacheCreationTokens = null;
if (typeof usage?.maxTokens === "number") eventLimit = usage.maxTokens;
compactedTurnId = null;
}
if (event.type === "done") {
if (compactedTurnId && event.turnId === compactedTurnId) continue;
const usage = event.usage && typeof event.usage === "object" ? event.usage as Record<string, unknown> : null;
inputTokens = typeof usage?.inputTokens === "number" ? usage.inputTokens : inputTokens;
outputTokens = typeof usage?.outputTokens === "number" ? usage.outputTokens : outputTokens;
Expand All @@ -914,6 +930,14 @@ export function latestTokenStats(
// effective context window to the terminal `done` event, so honor it for the
// dial when present (runtime-reported window beats the registry fallback).
if (typeof usage?.contextWindow === "number") eventLimit = usage.contextWindow;
compactedTurnId = null;
}
if (event.type === "context_compact" && event.state === "completed") {
compactedTurnId = typeof event.turnId === "string" ? event.turnId : null;
Comment thread
arul28 marked this conversation as resolved.
Outdated
inputTokens = typeof event.postTokens === "number" ? event.postTokens : null;
outputTokens = null;
cacheReadTokens = null;
cacheCreationTokens = null;
}
if (event.type === "system_notice" && event.noticeKind === "rate_limit") {
const detail = typeof event.detail === "string" ? event.detail : "";
Expand Down
16 changes: 14 additions & 2 deletions apps/desktop/src/main/services/chat/agentChatService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6425,7 +6425,12 @@ describe("createAgentChatService", () => {
type: "system",
subtype: "compact_boundary",
session_id: "sdk-session-compacting",
compact_metadata: { trigger: "manual", pre_tokens: 120_000 },
compact_metadata: {
trigger: "manual",
pre_tokens: 120_000,
post_tokens: 18_000,
duration_ms: 1_250,
},
};
yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } };
})());
Expand All @@ -6450,7 +6455,14 @@ describe("createAgentChatService", () => {
// A live begin, then a completed end — no longer a plain gray "Compacting..." notice.
expect(compactEvents).toEqual([
expect.objectContaining({ type: "context_compact", state: "started" }),
expect.objectContaining({ type: "context_compact", state: "completed", trigger: "manual", preTokens: 120_000 }),
expect.objectContaining({
type: "context_compact",
state: "completed",
trigger: "manual",
preTokens: 120_000,
postTokens: 18_000,
durationMs: 1_250,
}),
]);
const compactingNotices = onEvent.mock.calls
.map((call) => call[0])
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14946,6 +14946,8 @@ export function createAgentChatService(args: {
type: "context_compact",
trigger: compactMsg.compact_metadata?.trigger === "manual" ? "manual" : "auto",
preTokens: typeof compactMsg.compact_metadata?.pre_tokens === "number" ? compactMsg.compact_metadata.pre_tokens : undefined,
postTokens: typeof compactMsg.compact_metadata?.post_tokens === "number" ? compactMsg.compact_metadata.post_tokens : undefined,
durationMs: typeof compactMsg.compact_metadata?.duration_ms === "number" ? compactMsg.compact_metadata.duration_ms : undefined,
state: "completed",
turnId,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function buildContextCompactEvent(
preTokens?: number;
postTokens?: number;
tokensRemoved?: number;
durationMs?: number;
completedAtMs?: number;
},
): ContextCompactEvent {
Expand All @@ -68,7 +69,7 @@ export function buildContextCompactEvent(

const startedAt = state.startedAtByKey.get(mergeKey);
state.startedAtByKey.delete(mergeKey);
const durationMs = startedAt != null && now > startedAt ? now - startedAt : undefined;
const durationMs = input.durationMs ?? (startedAt != null && now > startedAt ? now - startedAt : undefined);
state.sessionCompactionCount += 1;

return {
Expand Down Expand Up @@ -101,6 +102,7 @@ export function mapLegacyCompactionEvent(
preTokens: event.preTokens,
postTokens: event.postTokens,
tokensRemoved: event.tokensRemoved,
durationMs: event.durationMs,
});
}
if (event.type === "codex_context_compaction") {
Expand Down
66 changes: 9 additions & 57 deletions apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import {
type ChatSurfacePresentation,
type AgentChatSessionSummary,
type CodexThreadGoal,
type CodexThreadTokenUsage,
type BuiltInBrowserContextItem,
type ComputerUseOwnerSnapshot,
type AppControlContextItem,
Expand Down Expand Up @@ -90,7 +89,7 @@ import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes";
import { cn } from "../ui/cn";
import { AgentChatComposer, type ParallelComposerControlSlot } from "./AgentChatComposer";
import { resolveModelDescriptorWithRuntimeCatalog, descriptorsFromAgentChatModelCatalog } from "../shared/ModelPicker/modelCatalog";
import { toUsageViewModel, type ContextUsageViewModel } from "./usage/contextUsageModel";
import { latestContextUsageInput, toUsageViewModel, type ContextUsageViewModel } from "./usage/contextUsageModel";
import { getSharedRuntimeCatalog } from "../shared/ModelPicker/runtimeCatalogCache";
import { familiesFromStatus } from "../shared/ModelPicker/useProviderAuthStatus";
import {
Expand Down Expand Up @@ -3671,18 +3670,6 @@ export function AgentChatPane({
}
return sawGoalEvent ? goalFromEvents : (selectedSession?.codexGoal ?? null);
}, [selectedEventsForDisplay, selectedSession?.codexGoal]);
const selectedCodexTokenUsage = useMemo<CodexThreadTokenUsage | null>(() => {
let usageFromEvents: CodexThreadTokenUsage | null = null;
let sawUsageEvent = false;
for (const envelope of selectedEventsForDisplay) {
const event = envelope.event;
if (event.type === "codex_token_usage") {
usageFromEvents = event.usage;
sawUsageEvent = true;
}
}
return sawUsageEvent ? usageFromEvents : (selectedSession?.codexTokenUsage ?? null);
}, [selectedEventsForDisplay, selectedSession?.codexTokenUsage]);
const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]);
const selectedScheduledWorkSnapshots = useMemo(() => deriveScheduledWorkSnapshots(selectedEvents), [selectedEvents]);
// Partition scheduled work into schedule kinds (wakeup/cron/loop/remote_trigger)
Expand Down Expand Up @@ -4443,53 +4430,18 @@ export function AgentChatPane({
turnActive,
]);
// Provider-agnostic context-usage for the composer dial. Codex pushes a live
// CodexThreadTokenUsage (with modelContextWindow); the other runtimes report a
// 4-field breakdown on the terminal `done`/`tokens` events. We take the
// freshest signal, fall back to the active model's registry context window
// when the runtime doesn't report one, and flatten everything into one VM so a
// single dial renders for every provider.
// Reduce provider telemetry across compaction boundaries before flattening it
// into the shared dial view-model. Exact post-compaction snapshots win;
// stale same-turn cumulative counters are ignored.
const selectedUsageViewModel = useMemo<ContextUsageViewModel | null>(() => {
let genericUsage:
| { inputTokens?: number | null; outputTokens?: number | null; cacheReadTokens?: number | null; cacheWriteTokens?: number | null; reasoningTokens?: number | null; contextWindow?: number | null }
| null = null;
for (const envelope of selectedEventsForDisplay) {
const event = envelope.event;
if (event.type === "done" && event.usage) {
const u = event.usage;
genericUsage = {
inputTokens: u.inputTokens ?? null,
outputTokens: u.outputTokens ?? null,
cacheReadTokens: u.cacheReadTokens ?? null,
cacheWriteTokens: u.cacheCreationTokens ?? null,
reasoningTokens: u.reasoningTokens ?? null,
contextWindow: u.contextWindow ?? null,
};
} else if (event.type === "tokens") {
genericUsage = {
inputTokens: event.inputTokens ?? null,
outputTokens: event.outputTokens ?? null,
cacheReadTokens: event.cacheReadTokens ?? null,
cacheWriteTokens: event.cacheWriteTokens ?? null,
contextWindow: event.contextWindow ?? null,
};
}
}
const provider = sessionProvider ?? selectedSession?.provider ?? "";
const descriptor = modelId ? (resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId)) : null;
const fallbackWindow = descriptor?.contextWindow ?? null;

if (selectedCodexTokenUsage && (provider === "codex" || !genericUsage)) {
return toUsageViewModel(
{ kind: "codex", provider: provider || "codex", usage: selectedCodexTokenUsage },
fallbackWindow,
);
}
if (genericUsage) {
const { contextWindow, ...rest } = genericUsage;
return toUsageViewModel({ kind: "generic", provider, usage: rest, contextWindow }, fallbackWindow);
}
return null;
}, [selectedEventsForDisplay, selectedCodexTokenUsage, selectedSession?.provider, sessionProvider, modelId]);
return toUsageViewModel(
latestContextUsageInput(selectedEventsForDisplay, provider, selectedSession?.codexTokenUsage),
fallbackWindow,
);
}, [selectedEventsForDisplay, selectedSession?.codexTokenUsage, selectedSession?.provider, sessionProvider, modelId]);

const [contextCompactionPulse, setContextCompactionPulse] = useState(false);
const compactionPulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down
Loading