426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
517 lines
20 KiB
TypeScript
517 lines
20 KiB
TypeScript
/**
|
|
* Shape tests for Anthropic prompt-cache injection in the OpenRouter text handler.
|
|
* Covers the runtime-fallback cacheControl (Fix 2), internal-field stripping from wire
|
|
* options (Fix 3a), segmented-user-content breakpoints with validated shapes and capping
|
|
* (Fix 3b/3c), the cacheSystem:false opt-out, verbatim survival of caller-supplied
|
|
* providerOptions (openrouter + arbitrary keys) alongside injected cacheControl and
|
|
* multi-breakpoint stamping (#15825), and the explicit boundary failure when a
|
|
* caller-supplied cacheControl is malformed (#15825, no silent drop). AI SDK and provider
|
|
* are mocked — no network calls, deterministic string fixtures.
|
|
*/
|
|
import type { IAgentRuntime } from "@elizaos/core";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
function createRuntime(settings: Record<string, string> = {}) {
|
|
return {
|
|
character: { system: "system prompt" },
|
|
emitEvent: vi.fn(async () => undefined),
|
|
getSetting: vi.fn((key: string) => {
|
|
return (
|
|
(
|
|
{
|
|
OPENROUTER_API_KEY: "test-key",
|
|
OPENROUTER_LARGE_MODEL: "anthropic/claude-opus-4-8",
|
|
...settings,
|
|
} as Record<string, string>
|
|
)[key] ?? null
|
|
);
|
|
}),
|
|
} as IAgentRuntime;
|
|
}
|
|
|
|
function mockModules() {
|
|
const generateText = vi.fn(async () => ({
|
|
text: "ok",
|
|
finishReason: "stop",
|
|
usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 },
|
|
}));
|
|
vi.doMock("ai", () => ({ generateText, streamText: vi.fn() }));
|
|
vi.doMock("../providers", () => ({
|
|
createOpenRouterProvider: () => ({
|
|
chat: (m: string) => ({ modelName: m }),
|
|
}),
|
|
}));
|
|
return { generateText };
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.doUnmock("ai");
|
|
vi.doUnmock("../providers");
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
});
|
|
|
|
describe("Anthropic cache injection — runtime cacheControl fallback", () => {
|
|
it("injects ephemeral cacheControl on system message for Anthropic models even without explicit providerOptions", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await handleTextLarge(createRuntime(), { prompt: "hello" } as never);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
const messages = call.messages as Array<Record<string, unknown>>;
|
|
const systemMsg = messages?.[0];
|
|
expect(systemMsg?.role).toBe("system");
|
|
const provOpts = systemMsg?.providerOptions as Record<string, unknown> | undefined;
|
|
const anthropicOpts = provOpts?.anthropic as Record<string, unknown> | undefined;
|
|
expect(anthropicOpts?.cacheControl).toEqual(expect.objectContaining({ type: "ephemeral" }));
|
|
});
|
|
|
|
it("respects ANTHROPIC_PROMPT_CACHE_TTL=1h when producing the fallback cacheControl", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await handleTextLarge(createRuntime({ ANTHROPIC_PROMPT_CACHE_TTL: "1h" }), {
|
|
prompt: "hello",
|
|
} as never);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
const messages = call.messages as Array<Record<string, unknown>>;
|
|
const anthropicOpts = (messages?.[0]?.providerOptions as Record<string, unknown>)?.anthropic as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
expect(anthropicOpts?.cacheControl).toEqual({
|
|
type: "ephemeral",
|
|
ttl: "1h",
|
|
});
|
|
});
|
|
|
|
it("does NOT inject cacheControl for non-Anthropic models even when ANTHROPIC_PROMPT_CACHE_TTL is set", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await handleTextLarge(
|
|
createRuntime({
|
|
OPENROUTER_LARGE_MODEL: "google/gemini-2.5-flash",
|
|
ANTHROPIC_PROMPT_CACHE_TTL: "1h",
|
|
}),
|
|
{ prompt: "hello" } as never
|
|
);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
// Non-Anthropic path routes through the plain prompt/system path — no message array with
|
|
// cacheControl injected.
|
|
if (Array.isArray(call.messages)) {
|
|
for (const msg of call.messages as Array<Record<string, unknown>>) {
|
|
expect(msg?.providerOptions).toBeUndefined();
|
|
}
|
|
}
|
|
const wireAnthropicOpts = (call.providerOptions as Record<string, unknown> | undefined)
|
|
?.anthropic as Record<string, unknown> | undefined;
|
|
expect(wireAnthropicOpts?.cacheControl).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("Anthropic cache injection — internal field stripping", () => {
|
|
it("strips cacheBreakpoints and maxBreakpoints from wire providerOptions", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await handleTextLarge(createRuntime(), {
|
|
prompt: "hello",
|
|
providerOptions: {
|
|
anthropic: {
|
|
cacheControl: { type: "ephemeral" },
|
|
cacheBreakpoints: [{ segmentIndex: 0, cacheControl: { type: "ephemeral" } }],
|
|
maxBreakpoints: 2,
|
|
},
|
|
gateway: { caching: "auto" },
|
|
},
|
|
} as never);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
const providerOpts = call.providerOptions as Record<string, unknown> | undefined;
|
|
const wireAnthropic = providerOpts?.anthropic as Record<string, unknown> | undefined;
|
|
expect(wireAnthropic?.cacheBreakpoints).toBeUndefined();
|
|
expect(wireAnthropic?.maxBreakpoints).toBeUndefined();
|
|
// Non-Anthropic provider options pass through untouched
|
|
expect(providerOpts?.gateway).toEqual({ caching: "auto" });
|
|
});
|
|
|
|
it("strips cacheSystem from wire options while keeping remaining anthropic fields", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await handleTextLarge(createRuntime({ OPENROUTER_LARGE_MODEL: "anthropic/claude-opus-4-8" }), {
|
|
messages: [
|
|
{ role: "system", content: "system prompt" },
|
|
{ role: "user", content: "hello" },
|
|
],
|
|
providerOptions: {
|
|
anthropic: { cacheSystem: true, thinking: { type: "enabled" } },
|
|
},
|
|
} as never);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
const wireAnthropic = (call.providerOptions as Record<string, unknown> | undefined)
|
|
?.anthropic as Record<string, unknown> | undefined;
|
|
expect(wireAnthropic?.cacheSystem).toBeUndefined();
|
|
expect(wireAnthropic?.thinking).toEqual({ type: "enabled" });
|
|
});
|
|
});
|
|
|
|
describe("Anthropic cache injection — segmented user content", () => {
|
|
it("builds N content blocks for N promptSegments, applying cacheControl only at breakpoint indices", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await handleTextLarge(createRuntime(), {
|
|
prompt: "seg1seg2",
|
|
promptSegments: [
|
|
{ content: "seg1", stable: true },
|
|
{ content: "seg2", stable: false },
|
|
],
|
|
providerOptions: {
|
|
anthropic: {
|
|
cacheBreakpoints: [{ segmentIndex: 0, cacheControl: { type: "ephemeral" } }],
|
|
},
|
|
},
|
|
} as never);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
const messages = call.messages as Array<Record<string, unknown>>;
|
|
const userMsg = messages?.[1];
|
|
const content = userMsg?.content as Array<Record<string, unknown>>;
|
|
expect(content).toHaveLength(2);
|
|
expect(content[0]?.text).toBe("seg1");
|
|
const seg0Opts = (content[0]?.providerOptions as Record<string, unknown>)?.anthropic as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
expect(seg0Opts?.cacheControl).toEqual({ type: "ephemeral" });
|
|
expect(content[1]?.text).toBe("seg2");
|
|
expect(content[1]?.providerOptions).toBeUndefined();
|
|
});
|
|
|
|
it("filters out cacheBreakpoints with invalid shapes without crashing", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await handleTextLarge(createRuntime(), {
|
|
prompt: "text",
|
|
promptSegments: [{ content: "text", stable: true }],
|
|
providerOptions: {
|
|
anthropic: {
|
|
cacheBreakpoints: [
|
|
{
|
|
segmentIndex: "not-a-number",
|
|
cacheControl: { type: "ephemeral" },
|
|
},
|
|
{ segmentIndex: 0, cacheControl: { type: "invalid" } },
|
|
null,
|
|
42,
|
|
],
|
|
},
|
|
},
|
|
} as never);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
const messages = call.messages as Array<Record<string, unknown>>;
|
|
// No valid breakpoints after filtering → user content blocks carry no cacheControl
|
|
const userMsg = messages?.[1];
|
|
if (userMsg) {
|
|
const content = userMsg.content as Array<Record<string, unknown>>;
|
|
for (const block of content) {
|
|
expect(block.providerOptions).toBeUndefined();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("caps applied breakpoints at maxBreakpoints", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await handleTextLarge(createRuntime(), {
|
|
prompt: "s0s1s2s3",
|
|
promptSegments: [
|
|
{ content: "s0", stable: true },
|
|
{ content: "s1", stable: true },
|
|
{ content: "s2", stable: true },
|
|
{ content: "s3", stable: false },
|
|
],
|
|
providerOptions: {
|
|
anthropic: {
|
|
maxBreakpoints: 1,
|
|
cacheBreakpoints: [
|
|
{ segmentIndex: 0, cacheControl: { type: "ephemeral" } },
|
|
{ segmentIndex: 1, cacheControl: { type: "ephemeral" } },
|
|
{ segmentIndex: 2, cacheControl: { type: "ephemeral" } },
|
|
],
|
|
},
|
|
},
|
|
} as never);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
const messages = call.messages as Array<Record<string, unknown>>;
|
|
const userMsg = messages?.[1];
|
|
const content = userMsg?.content as Array<Record<string, unknown>>;
|
|
const cachedBlocks = content?.filter(
|
|
(b) => (b.providerOptions as Record<string, unknown> | undefined)?.anthropic !== undefined
|
|
);
|
|
// Only segmentIndex 0 survives the cap of 1
|
|
expect(cachedBlocks).toHaveLength(1);
|
|
expect(cachedBlocks?.[0]?.text).toBe("s0");
|
|
});
|
|
});
|
|
|
|
describe("Anthropic cache injection — caller providerOptions survive verbatim", () => {
|
|
it("preserves openrouter.promptCacheKey and arbitrary provider keys alongside injected cacheControl", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await handleTextLarge(createRuntime(), {
|
|
prompt: "hello",
|
|
providerOptions: {
|
|
openrouter: { promptCacheKey: "caller-key-123" },
|
|
gateway: { caching: "auto" },
|
|
customProvider: { nested: { flag: true }, count: 7 },
|
|
},
|
|
} as never);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
const providerOpts = call.providerOptions as Record<string, unknown>;
|
|
// Caller keys survive unchanged into the serialized request.
|
|
expect(providerOpts.openrouter).toEqual({
|
|
promptCacheKey: "caller-key-123",
|
|
});
|
|
expect(providerOpts.gateway).toEqual({ caching: "auto" });
|
|
expect(providerOpts.customProvider).toEqual({
|
|
nested: { flag: true },
|
|
count: 7,
|
|
});
|
|
// And the injected message-level cacheControl is still applied on the system message.
|
|
const messages = call.messages as Array<Record<string, unknown>>;
|
|
const anthropicOpts = (messages?.[0]?.providerOptions as Record<string, unknown>)?.anthropic as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
expect(anthropicOpts?.cacheControl).toEqual(expect.objectContaining({ type: "ephemeral" }));
|
|
});
|
|
|
|
it("stamps cacheControl on multiple segment breakpoints while caller providerOptions survive", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await handleTextLarge(createRuntime(), {
|
|
prompt: "s0s1s2",
|
|
promptSegments: [
|
|
{ content: "s0", stable: true },
|
|
{ content: "s1", stable: true },
|
|
{ content: "s2", stable: false },
|
|
],
|
|
providerOptions: {
|
|
openrouter: { promptCacheKey: "multi-bp" },
|
|
anthropic: {
|
|
cacheBreakpoints: [
|
|
{ segmentIndex: 0, cacheControl: { type: "ephemeral" } },
|
|
{ segmentIndex: 1, cacheControl: { type: "ephemeral", ttl: "1h" } },
|
|
],
|
|
},
|
|
},
|
|
} as never);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
const messages = call.messages as Array<Record<string, unknown>>;
|
|
const content = messages?.[1]?.content as Array<Record<string, unknown>>;
|
|
expect(content).toHaveLength(3);
|
|
const cc0 = (content[0]?.providerOptions as Record<string, unknown>)?.anthropic as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
const cc1 = (content[1]?.providerOptions as Record<string, unknown>)?.anthropic as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
expect(cc0?.cacheControl).toEqual({ type: "ephemeral" });
|
|
expect(cc1?.cacheControl).toEqual({ type: "ephemeral", ttl: "1h" });
|
|
expect(content[2]?.providerOptions).toBeUndefined();
|
|
// Caller-supplied openrouter option survives.
|
|
expect((call.providerOptions as Record<string, unknown>).openrouter).toEqual({
|
|
promptCacheKey: "multi-bp",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Anthropic cache injection — malformed cacheControl fails loudly", () => {
|
|
it("throws when caller-supplied cacheControl has an unsupported type", async () => {
|
|
mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await expect(
|
|
handleTextLarge(createRuntime(), {
|
|
prompt: "hello",
|
|
providerOptions: {
|
|
anthropic: { cacheControl: { type: "persistent" } },
|
|
},
|
|
} as never)
|
|
).rejects.toThrow(/cacheControl/);
|
|
});
|
|
|
|
it("throws when cacheControl is present but not an object", async () => {
|
|
mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await expect(
|
|
handleTextLarge(createRuntime(), {
|
|
prompt: "hello",
|
|
providerOptions: { anthropic: { cacheControl: "ephemeral" } },
|
|
} as never)
|
|
).rejects.toThrow(/cacheControl/);
|
|
});
|
|
|
|
it("throws when cacheControl.ttl is an unsupported value", async () => {
|
|
mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await expect(
|
|
handleTextLarge(createRuntime(), {
|
|
prompt: "hello",
|
|
providerOptions: {
|
|
anthropic: { cacheControl: { type: "ephemeral", ttl: "2h" } },
|
|
},
|
|
} as never)
|
|
).rejects.toThrow(/ttl/);
|
|
});
|
|
});
|
|
|
|
describe("Anthropic cache injection — cacheSystem:false opt-out", () => {
|
|
it("passes system as plain string and does not inject cacheControl on system message", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
|
|
await handleTextLarge(createRuntime(), {
|
|
messages: [
|
|
{ role: "system", content: "system prompt" },
|
|
{ role: "user", content: "hello" },
|
|
],
|
|
providerOptions: {
|
|
anthropic: { cacheSystem: false },
|
|
},
|
|
} as never);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
// With cacheSystem:false the system is forwarded as call.system, not as a message
|
|
// with providerOptions, so there is no Anthropic cacheControl on any message.
|
|
expect(call.system).toBe("system prompt");
|
|
expect(call.messages).toEqual([{ role: "user", content: "hello" }]);
|
|
if (Array.isArray(call.messages)) {
|
|
for (const msg of call.messages as Array<Record<string, unknown>>) {
|
|
expect(msg?.providerOptions).toBeUndefined();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Anthropic cache injection — tools and trajectory breakpoints", () => {
|
|
const trajectory = [
|
|
{ role: "user", content: [{ type: "text", text: "question" }] },
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "tool-call", toolCallId: "1", toolName: "READ", input: {} }],
|
|
},
|
|
{
|
|
role: "tool",
|
|
content: [
|
|
{
|
|
type: "tool-result",
|
|
toolCallId: "1",
|
|
toolName: "READ",
|
|
output: { type: "text", value: "result" },
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
it("stamps only the last tool and the kept-trajectory tail", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
await handleTextLarge(createRuntime(), {
|
|
prompt: "ignored",
|
|
messages: trajectory,
|
|
tools: {
|
|
READ: { description: "Read", inputSchema: { type: "object" } },
|
|
WRITE: { description: "Write", inputSchema: { type: "object" } },
|
|
},
|
|
} as never);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
const tools = call.tools as Record<string, Record<string, unknown>>;
|
|
expect(tools.READ.providerOptions).toBeUndefined();
|
|
expect(tools.WRITE.providerOptions).toEqual({
|
|
anthropic: { cacheControl: { type: "ephemeral" } },
|
|
});
|
|
const messages = call.messages as Array<Record<string, unknown>>;
|
|
const tail = (messages.at(-1)?.content as Array<Record<string, unknown>>).at(-1);
|
|
expect(tail?.providerOptions).toEqual({
|
|
anthropic: { cacheControl: { type: "ephemeral" } },
|
|
});
|
|
const leadingUserPart = (messages[1]?.content as Array<Record<string, unknown>>)[0];
|
|
expect(leadingUserPart.providerOptions).toBeUndefined();
|
|
});
|
|
|
|
it("honors opt-outs, strips local flags, and preserves the runtime TTL", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
await handleTextLarge(createRuntime({ ANTHROPIC_PROMPT_CACHE_TTL: "1h" }), {
|
|
prompt: "ignored",
|
|
messages: trajectory,
|
|
tools: { READ: { description: "Read", inputSchema: { type: "object" } } },
|
|
providerOptions: {
|
|
anthropic: { cacheTools: false, cacheTrajectory: false },
|
|
},
|
|
} as never);
|
|
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
const tools = call.tools as Record<string, Record<string, unknown>>;
|
|
expect(tools.READ.providerOptions).toBeUndefined();
|
|
const messages = call.messages as Array<Record<string, unknown>>;
|
|
const tail = (messages.at(-1)?.content as Array<Record<string, unknown>>).at(-1);
|
|
expect(tail?.providerOptions).toBeUndefined();
|
|
expect(JSON.stringify(call.providerOptions ?? {})).not.toContain("cacheTools");
|
|
expect(JSON.stringify(call.providerOptions ?? {})).not.toContain("cacheTrajectory");
|
|
const systemAnthropic = (messages[0]?.providerOptions as Record<string, unknown>)?.anthropic;
|
|
expect(systemAnthropic).toEqual({
|
|
cacheControl: { type: "ephemeral", ttl: "1h" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Anthropic cache injection — breakpoint budget", () => {
|
|
it("reserves one of the four cache slots for the tools breakpoint", async () => {
|
|
const { generateText } = mockModules();
|
|
const { handleTextLarge } = await import("../models/text");
|
|
await handleTextLarge(createRuntime(), {
|
|
prompt: "s0s1s2",
|
|
promptSegments: [
|
|
{ content: "s0", stable: true },
|
|
{ content: "s1", stable: true },
|
|
{ content: "s2", stable: true },
|
|
],
|
|
tools: { READ: { description: "Read", inputSchema: { type: "object" } } },
|
|
providerOptions: {
|
|
anthropic: {
|
|
maxBreakpoints: 3,
|
|
cacheBreakpoints: [
|
|
{ segmentIndex: 0, cacheControl: { type: "ephemeral" } },
|
|
{ segmentIndex: 1, cacheControl: { type: "ephemeral" } },
|
|
{ segmentIndex: 2, cacheControl: { type: "ephemeral" } },
|
|
],
|
|
},
|
|
},
|
|
} as never);
|
|
const call = generateText.mock.calls[0][0] as Record<string, unknown>;
|
|
const messages = call.messages as Array<Record<string, unknown>>;
|
|
const segmentParts = messages[1]?.content as Array<Record<string, unknown>>;
|
|
expect(segmentParts.filter((part) => part.providerOptions)).toHaveLength(2);
|
|
expect(
|
|
(call.tools as Record<string, Record<string, unknown>>).READ.providerOptions
|
|
).toBeDefined();
|
|
});
|
|
});
|