Skip to content
Merged
23 changes: 23 additions & 0 deletions src/common/utils/ai/cacheStrategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,29 @@ describe("cacheStrategy", () => {
}); // Last part has cache control
});

it("merges cache options into existing part providerOptions", () => {
// The tool-search dedupe projection stashes restoration data on the last
// part's providerOptions; adding cache control must not delete it.
const raw: unknown = {
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "tool_catalog_search",
output: { type: "text", value: "repeat status" },
providerOptions: { mux: { rawToolSearchOutput: "{}" } },
},
],
};
const result = applyCacheControl([raw as ModelMessage], "anthropic:claude-3-5-sonnet");
const content = result[0].content as Array<{ providerOptions?: unknown }>;
expect(content[0].providerOptions).toEqual({
mux: { rawToolSearchOutput: "{}" },
anthropic: { cacheControl: { type: "ephemeral" } },
});
});

it("should include cache TTL when provided", () => {
const messages: ModelMessage[] = [{ role: "user", content: "Hello" }];
const result = applyCacheControl(messages, "anthropic:claude-3-5-sonnet", "1h");
Expand Down
13 changes: 9 additions & 4 deletions src/common/utils/ai/cacheStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,22 @@ function addCacheControlToLastContentPart(
if (typeof content === "string") {
return {
...msg,
providerOptions: cacheOpts,
providerOptions: { ...msg.providerOptions, ...cacheOpts },
};
}

// Array content: add providerOptions to the last part
// Use type assertion since we're adding providerOptions which is valid but not in base types
if (Array.isArray(content) && content.length > 0) {
const lastIndex = content.length - 1;
const newContent = content.map((part, i) =>
i === lastIndex ? { ...part, providerOptions: cacheOpts } : part
);
// Merge, never replace: the part can already carry providerOptions (for
// example the tool-search dedupe projection's restoration stash). Approval
// parts have no providerOptions field, hence the `in` narrowing.
const newContent = content.map((part, i) => {
if (i !== lastIndex) return part;
const existing = "providerOptions" in part ? part.providerOptions : undefined;
return { ...part, providerOptions: { ...existing, ...cacheOpts } };
});
// Type assertion needed: ModelMessage types are strict unions but providerOptions
// on content parts is valid per SDK docs
const result = { ...msg, content: newContent };
Expand Down
21 changes: 21 additions & 0 deletions src/common/utils/compaction/contextBudget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,4 +424,25 @@ describe("request estimates", () => {
expect(estimate).toBeGreaterThanOrEqual(base + 4000);
}
});

test("the dedupe projection's restoration stash does not count toward the estimate", () => {
const searchResult = (providerOptions?: unknown) => ({
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "tool_catalog_search",
output: { type: "text", value: "repeat status" },
...(providerOptions == null ? {} : { providerOptions }),
},
],
});
const bare = estimateAssembledRequestTokens({ messages: [searchResult()] });
const stashed = estimateAssembledRequestTokens({
messages: [searchResult({ mux: { rawToolSearchOutput: "x".repeat(7000) } })],
});
// Only the wrapper keys may count: the stash never reaches the wire.
expect(stashed).toBeLessThan(bare + 50);
});
});
14 changes: 14 additions & 0 deletions src/common/utils/compaction/contextBudget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ function measureBudgetContent(
| "assistant-part"
| "reasoning-options"
| "openai-reasoning-options"
| "tool-result-options"
| "mux-tool-result-options"
| "output";
}> = [{ value: result, kind }];
while (stack.length > 0) {
Expand Down Expand Up @@ -265,6 +267,14 @@ function measureBudgetContent(
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;
toolResultChars += JSON.stringify(key).length + 2;
textParts?.push(key);
let childKind: typeof entry.kind = "json";
Expand All @@ -273,6 +283,10 @@ function measureBudgetContent(
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 (
Expand Down
236 changes: 236 additions & 0 deletions src/common/utils/tools/toolCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
buildToolCatalogOverview,
buildToolSearchModelOutput,
collectDeferLoadingToolNames,
dedupeNativeToolReferences,
unprojectNativeToolReferences,
computeActiveToolNames,
computeLoadedToolNames,
extractPreActivatedToolNames,
Expand Down Expand Up @@ -1011,6 +1013,240 @@ describe("native tool search model output", () => {
});
});

describe("dedupeNativeToolReferences", () => {
const reference = (toolName: string) => ({
type: "custom" as const,
providerOptions: { anthropic: { type: "tool-reference", toolName } },
});
const referencesOutput = (...names: string[]) => ({
type: "content" as const,
value: names.map(reference),
});

function searchResultMessage(
toolCallId: string,
output: unknown,
toolName = TOOL_SEARCH_TOOL_NAME
): ModelMessage {
const raw: unknown = {
role: "tool",
content: [{ type: "tool-result", toolCallId, toolName, output }],
};
return raw as ModelMessage;
}

/** Expected shape of a projected result: new output plus the raw stash. */
function projectedSearchResult(toolCallId: string, output: unknown, raw: unknown): ModelMessage {
const value: unknown = {
role: "tool",
content: [
{
type: "tool-result",
toolCallId,
toolName: TOOL_SEARCH_TOOL_NAME,
output,
providerOptions: { mux: { rawToolSearchOutput: JSON.stringify(raw) } },
},
],
};
return value as ModelMessage;
}

test("a fully repeated search projects to a deterministic text result", () => {
const messages = [
searchResultMessage("call-1", referencesOutput("alpha", "beta")),
searchResultMessage("call-2", referencesOutput("beta", "alpha")),
];
const deduped = dedupeNativeToolReferences(messages);
expect(deduped[0]).toBe(messages[0]);
// Structural contract, not generated copy: a text status naming both
// repeats, no remaining references, and the raw stash for the swap path.
const message = deduped[1];
if (message.role !== "tool") throw new Error("Expected a tool message");
const part = message.content[0];
if (part.type !== "tool-result") throw new Error("Expected a tool result");
const output = part.output;
if (output.type !== "text") throw new Error("Expected a text projection");
expect(output.value).toContain("beta");
expect(output.value).toContain("alpha");
expect(part.providerOptions?.mux?.rawToolSearchOutput).toBe(
JSON.stringify(referencesOutput("beta", "alpha"))
);
// Deterministic: a second application projects the same bytes.
expect(JSON.stringify(dedupeNativeToolReferences(messages))).toBe(JSON.stringify(deduped));
});

test("unprojectNativeToolReferences inverts a full projection byte-exactly", () => {
const messages = [
searchResultMessage("call-1", referencesOutput("alpha", "beta")),
searchResultMessage("call-2", referencesOutput("beta", "alpha")),
searchResultMessage("call-3", referencesOutput("beta")),
];
const deduped = dedupeNativeToolReferences(messages);
expect(JSON.stringify(unprojectNativeToolReferences(deduped))).toBe(JSON.stringify(messages));
// Stash-free arrays keep their identity.
expect(unprojectNativeToolReferences(messages)).toBe(messages);
});

test("unprojectNativeToolReferences inverts a mixed projection byte-exactly", () => {
// The mixed result keeps its new reference on the wire, yet the swap path
// must still recover the dropped repeat from the stash.
const messages = [
searchResultMessage("call-1", referencesOutput("alpha")),
searchResultMessage("call-2", referencesOutput("alpha", "beta")),
];
const deduped = dedupeNativeToolReferences(messages);
expect(JSON.stringify(unprojectNativeToolReferences(deduped))).toBe(JSON.stringify(messages));
});

test("each projected occurrence restores its own references, even under reused toolCallIds", () => {
// Restoration is a pure function of the part itself, so two results that
// share a reused toolCallId can never restore each other's content.
const first = searchResultMessage("dup", referencesOutput("alpha", "beta"));
const second = searchResultMessage("dup", referencesOutput("beta", "alpha"));
const deduped = dedupeNativeToolReferences([first, second]);
expect(JSON.stringify(unprojectNativeToolReferences([deduped[1]])[0])).toBe(
JSON.stringify(second)
);
expect(JSON.stringify(unprojectNativeToolReferences([deduped[0]])[0])).toBe(
JSON.stringify(first)
);
});

test("a mixed result drops repeats: text beside retained references is rejected", () => {
const messages = [
searchResultMessage("call-1", referencesOutput("alpha")),
searchResultMessage("call-2", referencesOutput("alpha", "beta")),
];
const deduped = dedupeNativeToolReferences(messages);
expect(deduped[0]).toBe(messages[0]);
expect(JSON.stringify(deduped[1])).toBe(
JSON.stringify(
projectedSearchResult("call-2", referencesOutput("beta"), referencesOutput("alpha", "beta"))
)
);
// No deduped result may mix text items with tool references (Anthropic 400s).
for (const message of dedupeNativeToolReferences(messages)) {
if (message.role !== "tool") continue;
for (const part of message.content) {
if (part.type !== "tool-result" || part.output.type !== "content") continue;
const kinds = new Set(part.output.value.map((item) => item.type));
expect(kinds.has("text") && kinds.has("custom")).toBe(false);
}
}
});

test("dedupes across parallel searches in one step", () => {
// Two searches of one assistant step land as parts of a single tool message.
const raw: unknown = {
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: TOOL_SEARCH_TOOL_NAME,
output: referencesOutput("alpha"),
},
{
type: "tool-result",
toolCallId: "call-2",
toolName: TOOL_SEARCH_TOOL_NAME,
output: referencesOutput("alpha", "beta"),
},
],
};
const [deduped] = dedupeNativeToolReferences([raw as ModelMessage]);
expect(JSON.stringify(deduped)).toContain('"toolCallId":"call-1"');
const parts = (deduped as { content: Array<{ output: unknown }> }).content;
expect(parts[0].output).toEqual(referencesOutput("alpha"));
expect(parts[1].output).toEqual(referencesOutput("beta"));
});

test("returns the same array when nothing repeats", () => {
const messages = [
searchResultMessage("call-1", referencesOutput("alpha")),
searchResultMessage("call-2", referencesOutput("beta")),
searchResultMessage("call-3", { type: "json", value: { hits: [] } }),
];
expect(dedupeNativeToolReferences(messages)).toBe(messages);
});

test("appending messages never rewrites the deduped prefix", () => {
const prefix = [
searchResultMessage("call-1", referencesOutput("alpha")),
searchResultMessage("call-2", referencesOutput("alpha", "beta")),
];
const dedupedPrefix = dedupeNativeToolReferences(prefix);
const extended = dedupeNativeToolReferences([
...dedupedPrefix,
searchResultMessage("call-3", referencesOutput("beta", "gamma")),
]);
expect(extended[0]).toBe(dedupedPrefix[0]);
expect(extended[1]).toBe(dedupedPrefix[1]);
expect(JSON.stringify(extended[2])).toBe(
JSON.stringify(
projectedSearchResult(
"call-3",
referencesOutput("gamma"),
referencesOutput("beta", "gamma")
)
)
);
});

test("a compacted prefix that dropped the first reference re-references in the later result", () => {
const first = searchResultMessage("call-1", referencesOutput("alpha"));
const later = searchResultMessage("call-2", referencesOutput("alpha"));
expect(JSON.stringify(dedupeNativeToolReferences([first, later])[1].content)).toContain(
'"type":"text"'
);
// Compaction removed the first referencing result: the later one stands.
expect(dedupeNativeToolReferences([later])[0]).toBe(later);
});

test("leaves scoped-mode outputs and other tools untouched", () => {
const messages = [
searchResultMessage("call-1", { type: "json", value: { matches: [] } }),
searchResultMessage("call-2", referencesOutput("alpha"), "bash"),
searchResultMessage("call-3", referencesOutput("alpha"), "bash"),
];
expect(dedupeNativeToolReferences(messages)).toBe(messages);
});

test("replayed history dedupes to the same bytes as the live steps", () => {
const deferred = new Set(["alpha", "beta"]);
const firstResult = {
query: "alpha",
matches: [{ name: "alpha", description: "A" }],
totalDeferred: 2,
};
const secondResult = {
query: "alpha beta",
matches: [
{ name: "alpha", description: "A" },
{ name: "beta", description: "B" },
],
totalDeferred: 2,
};
// Live: toModelOutput builds each result, the per-step transform dedupes.
const live = dedupeNativeToolReferences([
searchResultMessage("call-1", buildToolSearchModelOutput(firstResult, deferred)),
searchResultMessage("call-2", buildToolSearchModelOutput(secondResult, deferred)),
]);
// Replay: persisted raw results are rewritten, then deduped the same way.
const replayed = dedupeNativeToolReferences(
applyNativeToolSearchReplay(
[
searchResultMessage("call-1", { type: "json", value: firstResult }),
searchResultMessage("call-2", { type: "json", value: secondResult }),
],
deferred
)
);
expect(JSON.stringify(replayed)).toBe(JSON.stringify(live));
});
});

describe("seedToolSearchActivationsFromMessages", () => {
test("intersects history matches with the current deferred set", () => {
const state = prepareToolSearch({ tools: baseTools(), mcpToolNames: MCP_NAMES }).state!;
Expand Down
Loading
Loading