summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/api/src/agent-manager.ts18
-rw-r--r--packages/api/src/app.ts7
-rw-r--r--packages/core/src/agent/agent.tsbin65763 -> 66313 bytes
-rw-r--r--packages/core/tests/agent/agent.test.ts43
-rw-r--r--packages/frontend/src/lib/cache-warming.svelte.ts7
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts7
-rw-r--r--packages/frontend/tests/cache-warming.test.ts17
7 files changed, 92 insertions, 7 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 109dd33..0a6f3c6 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -1040,7 +1040,12 @@ export class AgentManager {
*/
async warmCacheForTab(
tabId: string,
- opts: { keyId?: string; modelId?: string; agentModels?: AgentModelEntry[] } = {},
+ opts: {
+ keyId?: string;
+ modelId?: string;
+ agentModels?: AgentModelEntry[];
+ reasoningEffort?: ReasoningEffort;
+ } = {},
): Promise<{ ok: true; usage: UsageData } | { ok: false; error: string }> {
if (this.getTabStatus(tabId) === "running") {
return { ok: false, error: "tab is generating" };
@@ -1060,6 +1065,13 @@ export class AgentManager {
primary?.model_id || opts.modelId,
);
+ // Resolve the SAME reasoning effort the next real turn would use:
+ // per-model (agent definition) → per-tab selector → Agent default.
+ // This drives the thinking providerOptions, which is an Anthropic
+ // message-cache key — warming MUST match it or it warms a different
+ // cache bucket than the real turn reads (the 0%-on-switch bug).
+ const effort = primary?.effort ?? opts.reasoningEffort;
+
// Rebuild the genuine history exactly as `getOrCreateAgentForTab`'s
// pre-population does, but keep the FULL history (no trailing-user
// trim): warming replays the complete cached prefix as-is.
@@ -1071,7 +1083,9 @@ export class AgentManager {
history = [...agent.messages];
}
- const usage = await agent.warmCache(history);
+ const usage = await agent.warmCache(history, {
+ ...(effort ? { reasoningEffort: effort } : {}),
+ });
return { ok: true, usage };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index a957da7..72188ff 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -239,6 +239,7 @@ app.post("/chat/warm", async (c) => {
keyId?: unknown;
modelId?: unknown;
agentModels?: unknown;
+ reasoningEffort?: unknown;
}>();
const { tabId } = body;
if (typeof tabId !== "string" || tabId.trim() === "") {
@@ -247,11 +248,17 @@ app.post("/chat/warm", async (c) => {
const keyId = typeof body.keyId === "string" ? body.keyId : undefined;
const modelId = typeof body.modelId === "string" ? body.modelId : undefined;
const agentModels = sanitizeAgentModels(body.agentModels);
+ // Same effort the real turn would use — a message-cache key, so warming must
+ // match it to refresh the SAME bucket the next real message reads.
+ const reasoningEffort = isReasoningEffort(body.reasoningEffort)
+ ? body.reasoningEffort
+ : undefined;
const result = await agentManager.warmCacheForTab(tabId, {
...(keyId ? { keyId } : {}),
...(modelId ? { modelId } : {}),
...(agentModels ? { agentModels } : {}),
+ ...(reasoningEffort ? { reasoningEffort } : {}),
});
if (!result.ok) {
// "tab is generating" is an expected race (not a server fault) → 409.
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index d0a3bb9..2e2dbb2 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
Binary files differ
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
index 86a7a5b..797aea2 100644
--- a/packages/core/tests/agent/agent.test.ts
+++ b/packages/core/tests/agent/agent.test.ts
@@ -1709,7 +1709,12 @@ describe("anthropicThinkingProviderOptions — adaptive-thinking model detection
expect(userMsgs).toHaveLength(2);
});
- it("sends Anthropic cache_control breakpoints + toolChoice none", async () => {
+ it("sends Anthropic cache_control breakpoints with the SAME toolChoice/thinking as a real turn", async () => {
+ // Anthropic keys the MESSAGE cache on `tool_choice` AND the extended-
+ // thinking parameters. If warming sent a different value than a real
+ // turn, it would warm a DIFFERENT message-cache bucket and the user's
+ // next real message would still miss. So warming MUST mirror run():
+ // toolChoice "auto" + the thinking providerOptions for the effort.
vi.mocked(streamText).mockReturnValue(
makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
);
@@ -1717,7 +1722,9 @@ describe("anthropicThinkingProviderOptions — adaptive-thinking model detection
await agent.warmCache(history);
const callArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
- expect(callArgs?.toolChoice).toBe("none");
+ expect(callArgs?.toolChoice).toBe("auto");
+ // Thinking providerOptions present (effort defaults to "max").
+ expect(callArgs?.providerOptions?.anthropic).toBeDefined();
const messages = callArgs?.messages as Array<{
role: string;
providerOptions?: { anthropic?: { cacheControl?: unknown } };
@@ -1728,6 +1735,38 @@ describe("anthropicThinkingProviderOptions — adaptive-thinking model detection
expect(hasBreakpoint).toBe(true);
});
+ it("warming and a real turn send IDENTICAL cache-affecting params (same bucket)", async () => {
+ // The core invariant of the whole feature: warmCache() and run() must
+ // produce the same toolChoice + thinking providerOptions + maxOutputTokens
+ // so the warming replay refreshes the EXACT cache the next real message
+ // reads. Drive both and compare the cache-key inputs streamText receives.
+ const cfg = makeConfig({ provider: "anthropic" });
+
+ // 1) Real turn for the same history + the probe text as the user msg.
+ const realAgent = new Agent(cfg);
+ realAgent.messages.push(...history.map((m) => ({ ...m })));
+ vi.mocked(streamText).mockReturnValue(
+ makeMockStreamResult([{ type: "text-delta", id: "t0", text: "." }, finishStop]),
+ );
+ for await (const _ of realAgent.run("reply with just a .")) {
+ // consume
+ }
+ const realArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+
+ // 2) Warming replay for the same history.
+ const warmAgent = new Agent(cfg);
+ vi.mocked(streamText).mockReturnValue(
+ makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
+ );
+ await warmAgent.warmCache(history);
+ const warmArgs = vi.mocked(streamText).mock.calls.at(-1)?.[0];
+
+ // The cache-affecting parameters must be byte-identical.
+ expect(warmArgs?.toolChoice).toEqual(realArgs?.toolChoice);
+ expect(warmArgs?.maxOutputTokens).toEqual(realArgs?.maxOutputTokens);
+ expect(warmArgs?.providerOptions).toEqual(realArgs?.providerOptions);
+ });
+
it("does NOT mutate the agent's own message history", async () => {
vi.mocked(streamText).mockReturnValue(
makeWarmStream({ inputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 0 }),
diff --git a/packages/frontend/src/lib/cache-warming.svelte.ts b/packages/frontend/src/lib/cache-warming.svelte.ts
index cda3fd1..0253c08 100644
--- a/packages/frontend/src/lib/cache-warming.svelte.ts
+++ b/packages/frontend/src/lib/cache-warming.svelte.ts
@@ -41,6 +41,12 @@ export interface WarmRequestParams {
keyId: string | null;
modelId: string | null;
agentModels: AgentModelEntry[] | null;
+ /**
+ * The SAME reasoning effort the next real turn would use. It drives the
+ * Anthropic thinking providerOptions, which is a message-cache key — warming
+ * must match it so it refreshes the bucket the real message reads.
+ */
+ reasoningEffort: string | null;
}
/** Reactive, per-tab warming UI state (read by the Chat Settings debug strip). */
@@ -177,6 +183,7 @@ export function createCacheWarmingStore() {
...(params?.keyId ? { keyId: params.keyId } : {}),
...(params?.modelId ? { modelId: params.modelId } : {}),
...(params?.agentModels ? { agentModels: params.agentModels } : {}),
+ ...(params?.reasoningEffort ? { reasoningEffort: params.reasoningEffort } : {}),
}),
});
// A newer cancel/fire superseded this request — drop its result so it
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index a0125ef..ca04e62 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -251,7 +251,12 @@ export function createTabStore() {
cacheWarming.setRequestResolver((tabId) => {
const t = getTabById(tabId);
if (!t) return null;
- return { keyId: t.keyId, modelId: t.modelId, agentModels: t.agentModels };
+ return {
+ keyId: t.keyId,
+ modelId: t.modelId,
+ agentModels: t.agentModels,
+ reasoningEffort: t.reasoningEffort,
+ };
});
$effect.root(() => {
diff --git a/packages/frontend/tests/cache-warming.test.ts b/packages/frontend/tests/cache-warming.test.ts
index 012efb1..583d563 100644
--- a/packages/frontend/tests/cache-warming.test.ts
+++ b/packages/frontend/tests/cache-warming.test.ts
@@ -98,7 +98,12 @@ describe("firing cadence", () => {
const fetchMock = makeFetchOk({ inputTokens: 1000, cacheReadTokens: 900 });
vi.stubGlobal("fetch", fetchMock);
- store.setRequestResolver(() => ({ keyId: "k", modelId: "m", agentModels: null }));
+ store.setRequestResolver(() => ({
+ keyId: "k",
+ modelId: "m",
+ agentModels: null,
+ reasoningEffort: "high",
+ }));
store.setEnabled("tab-1", true);
await vi.advanceTimersByTimeAsync(WARM_INTERVAL_MS);
@@ -108,7 +113,15 @@ describe("firing cadence", () => {
const [url, opts] = (fetchMock as unknown as { mock: { calls: unknown[][] } }).mock
.calls[0] as [string, { body: string }];
expect(url).toContain("/chat/warm");
- expect(JSON.parse(opts.body)).toMatchObject({ tabId: "tab-1", keyId: "k", modelId: "m" });
+ // The request forwards the SAME effort the real turn uses — it's an
+ // Anthropic message-cache key, so warming must match it to refresh the
+ // bucket the next real message reads.
+ expect(JSON.parse(opts.body)).toMatchObject({
+ tabId: "tab-1",
+ keyId: "k",
+ modelId: "m",
+ reasoningEffort: "high",
+ });
const s = store.stateFor("tab-1");
expect(s.lastPct).toBe(90); // 900 / 1000