Skip to content
Merged
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
95 changes: 36 additions & 59 deletions src/common/utils/compaction/contextBudget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,26 @@
return measureBudgetContent(result);
}

type BudgetContentKind =
| "json"
| "messages"
| "message"
| "parts"
| "part"
| "assistant-parts"
| "assistant-part"
| "output"
| "output-parts"
| "output-part"
| "request-options";

const BUDGET_ITEM_KIND: Partial<Record<BudgetContentKind, BudgetContentKind>> = {
messages: "message",
parts: "part",
"assistant-parts": "assistant-part",
"output-parts": "output-part",
};

function measureBudgetContent(
result: unknown,
textParts?: string[],
Expand All @@ -164,33 +184,19 @@
toolResultChars: number;
imageParts: number;
} {
let toolResultChars = 0;

Check warning on line 187 in src/common/utils/compaction/contextBudget.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: 0 -> 1

No unit test failed when measureBudgetContent had 0 -> 1. Add a test that tells them apart, or say why they are equivalent.
let imageParts = 0;
const ancestors = new Set<object>();
const stack: Array<{
value: unknown;
leave?: boolean;
kind?:
| "json"
| "messages"
| "message"
| "parts"
| "part"
| "assistant-parts"
| "assistant-part"
| "reasoning-options"
| "openai-reasoning-options"
| "tool-result-options"
| "mux-tool-result-options"
| "output";
}> = [{ value: result, kind }];
const stack: Array<{ value: unknown; leave?: boolean; kind?: BudgetContentKind }> = [
{ value: result, kind },
];
while (stack.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- legacy site from before the ban; remove when touching this line
const entry = stack.pop()!;
const value = entry.value;
if (value == null) {
toolResultChars += 4;
textParts?.push("null");

Check warning on line 199 in src/common/utils/compaction/contextBudget.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: ?. -> .

No unit test failed when measureBudgetContent had ?. -> .. Add a test that tells them apart, or say why they are equivalent.
continue;
}
if (typeof value === "string") {
Expand All @@ -200,7 +206,7 @@
continue;
}
if (typeof value !== "object") {
if (typeof value === "number" || typeof value === "boolean") {

Check warning on line 209 in src/common/utils/compaction/contextBudget.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: || -> &&

No unit test failed when measureBudgetContent had || -> &&. Add a test that tells them apart, or say why they are equivalent.

Check warning on line 209 in src/common/utils/compaction/contextBudget.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: === -> !==

No unit test failed when measureBudgetContent had === -> !==. Add a test that tells them apart, or say why they are equivalent.

Check warning on line 209 in src/common/utils/compaction/contextBudget.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: === -> !==

No unit test failed when measureBudgetContent had === -> !==. Add a test that tells them apart, or say why they are equivalent.
toolResultChars += String(value).length;
textParts?.push(String(value));
}
Expand All @@ -220,18 +226,8 @@
stack.push({ value, leave: true });
toolResultChars += 2;
if (Array.isArray(value)) {
for (const child of value)
stack.push({
value: child,
kind:
entry.kind === "messages"
? "message"
: entry.kind === "assistant-parts"
? "assistant-part"
: entry.kind === "parts"
? "part"
: "json",
});
const itemKind = (entry.kind && BUDGET_ITEM_KIND[entry.kind]) ?? "json";

Check warning on line 229 in src/common/utils/compaction/contextBudget.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: ?? -> ||

No unit test failed when measureBudgetContent had ?? -> ||. Add a test that tells them apart, or say why they are equivalent.
for (const child of value) stack.push({ value: child, kind: itemKind });
toolResultChars += value.length;
continue;
}
Expand All @@ -241,7 +237,8 @@
// Tool JSON can impersonate SDK part shapes. Only direct model-message/fresh
// attachment parts get SDK media semantics; canonical tool wrappers are also
// safe because the shared attachment sanitizer removes their data recursively.
const isPart = entry.kind === "part" || entry.kind === "assistant-part";
const messagePart = entry.kind === "part" || entry.kind === "assistant-part";
const isPart = messagePart || entry.kind === "output-part";
const image = isPart && record.type === "image" && "image" in record;
const inlineText =
typeof record.data === "object" &&
Expand All @@ -252,7 +249,7 @@
isPart && record.type === "file" && !inlineText && ("data" in record || "url" in record);
const dataMedia = isPart && (record.type === "image-data" || record.type === "file-data");
const urlMedia = isPart && (record.type === "image-url" || record.type === "file-url");
if (toolMedia || image || file || dataMedia || urlMedia) imageParts += 1;

Check warning on line 252 in src/common/utils/compaction/contextBudget.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: || -> &&

No unit test failed when measureBudgetContent had || -> &&. Add a test that tells them apart, or say why they are equivalent.
for (const [key, child] of Object.entries(record)) {
if (
((toolMedia || displayOnly) && key === "data") ||
Expand All @@ -262,23 +259,13 @@
(urlMedia && key === "url")
)
continue;
// Ciphertext length does not measure reasoning tokens. Provider usage tracks the underlying reasoning.
// Exclude only the SDK replay field; identical keys in user/tool JSON must still count.
if (
entry.kind === "openai-reasoning-options" &&
key === "reasoningEncryptedContent" &&
typeof child === "string"
)
continue;
// The dedupe projection's restoration stash (toolCatalog.ts) never reaches
// the wire: adapters serialize only their own providerOptions namespace.
if (
entry.kind === "mux-tool-result-options" &&
key === "rawToolSearchOutput" &&
typeof child === "string"
)
continue;
// On model output, providerOptions replays provider metadata (reasoning signatures and
// ciphertext, item IDs, cache hints), which providers do not bill as input text.
if (key === "providerOptions" && entry.kind === "assistant-part") continue;
Comment thread
ethanndickson marked this conversation as resolved.
// Options on user and tool content can reach the model as text (an Anthropic
// document's context and title), but adapters never send Xum's own namespace.
if (entry.kind === "request-options" && key === "mux") continue;
toolResultChars += JSON.stringify(key).length + 2;

Check warning on line 268 in src/common/utils/compaction/contextBudget.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: + -> -

No unit test failed when measureBudgetContent had + -> -. Add a test that tells them apart, or say why they are equivalent.
textParts?.push(key);
let childKind: typeof entry.kind = "json";
if (entry.kind === "message" && key === "content") {
Expand All @@ -286,20 +273,10 @@
else if (record.role === "user" || record.role === "tool") childKind = "parts";
} else if (isPart && record.type === "tool-result" && key === "output") {
childKind = "output";
} else if (isPart && record.type === "tool-result" && key === "providerOptions") {
childKind = "tool-result-options";
} else if (entry.kind === "tool-result-options" && key === "mux") {
childKind = "mux-tool-result-options";
} else if (entry.kind === "output" && record.type === "content" && key === "value") {
childKind = "parts";
} else if (
entry.kind === "assistant-part" &&
record.type === "reasoning" &&
key === "providerOptions"
) {
childKind = "reasoning-options";
} else if (entry.kind === "reasoning-options" && key === "openai") {
childKind = "openai-reasoning-options";
childKind = "output-parts";
} else if (key === "providerOptions" && (messagePart || entry.kind === "message")) {

Check warning on line 278 in src/common/utils/compaction/contextBudget.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: === -> !==

No unit test failed when measureBudgetContent had === -> !==. Add a test that tells them apart, or say why they are equivalent.

Check warning on line 278 in src/common/utils/compaction/contextBudget.ts

View workflow job for this annotation

GitHub Actions / Quality / Mutation

Mutation survived: && -> ||

No unit test failed when measureBudgetContent had && -> ||. Add a test that tells them apart, or say why they are equivalent.
childKind = "request-options";
}
stack.push({ value: child, kind: childKind });
}
Expand Down
33 changes: 33 additions & 0 deletions src/node/services/aiService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2638,6 +2638,8 @@ describe("AIService.streamMessage compaction boundary slicing", () => {
duringPlaceholderAppend?: () => void;
abortController?: AbortController;
activeTurnThinkingOverride?: ActiveTurnThinkingOverride;
/** History rows before the turn's user row. */
earlier?: MuxMessage[];
} = {}
) {
const metadata = createLocalWorkspaceMetadata("turn-start-stage", xumHomePath);
Expand All @@ -2658,6 +2660,9 @@ describe("AIService.streamMessage compaction boundary slicing", () => {
return appended;
});
}
for (const row of options.earlier ?? []) {
expect((await harness.historyService.appendToHistory(metadata.id, row)).success).toBe(true);
}
const user = createMuxMessage("user-1", "user", USER_TEXT);
expect((await harness.historyService.appendToHistory(metadata.id, user)).success).toBe(true);
const read = await harness.historyService.getHistoryFromLatestBoundary(metadata.id);
Expand Down Expand Up @@ -2851,6 +2856,34 @@ describe("AIService.streamMessage compaction boundary slicing", () => {
expect(turn.measured).toEqual([await countRequest(turn, sent.messages)]);
expect(turn.countOptions.length).toBe(3);
});

// #7662: the turn anchor holds the provider's count at the reply's thinking level. The fold
// rebuilds the request at the pending level, so step zero gets the anchor of that rebuild.
it.each([
{ replyLevel: "off", anchored: false },
{ replyLevel: "high", anchored: true },
] as const)(
"a pre-stream thinking fold dispatches the turn anchor of its rebuild (reply at $replyLevel)",
async ({ replyLevel, anchored }) => {
using xumHome = new DisposableTempDir("ai-turn-start-thinking-fold-anchor");
const reply = createMuxMessage("reply-0", "assistant", "done", {
model: KNOWN_MODELS.SONNET.id,
metadataModel: KNOWN_MODELS.SONNET.id,
agentId: "exec",
thinkingLevel: replyLevel,
systemMessageTokens: 0,
contextUsage: { inputTokens: 500, outputTokens: 10, totalTokens: 510 },
});
const override: ActiveTurnThinkingOverride = { pending: "high" };
const turn = await runStageTurn(xumHome.path, {
stageCallback: false,
activeTurnThinkingOverride: override,
earlier: [createMuxMessage("user-0", "user", "first request"), reply],
});
expect(turn.sent!.thinkingLevel).toBe("high");
expect(turn.sent!.contextBudgetTurnAnchor != null).toBe(anchored);
}
);
});

it("emits startup breadcrumbs as runtime-status events before stream start", async () => {
Expand Down
Loading
Loading