Skip to content

Commit 4ab98d8

Browse files
committed
fix(provider): resume anthropic cache stamps from the protocol
Runners persist the catalog id, which is not the messages adapter name. Requiring that id to match the protocol dropped an expired stamp, so the next request sent the untrimmed history.
1 parent 5840a36 commit 4ab98d8

5 files changed

Lines changed: 180 additions & 25 deletions

File tree

‎src/exec/runner.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
891891
at: activeRunHandle.lastCacheWriteAt,
892892
storedModel: activeRunHandle.cacheWriteModel,
893893
liveProvider: config.providerName,
894+
liveProtocol: liveSource.provider,
894895
}),
895896
onBuilt: (agent, storage) => {
896897
currentAgent = agent;

‎src/provider/cache-ttl.test.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import { describe, expect, test } from "bun:test";
2+
import {
3+
buildAnthropicSource,
4+
buildGoSource,
5+
buildZenSource,
6+
} from "../config/index.js";
27
import {
38
anthropicCacheWriteAt,
49
cacheTtlMsFor,
@@ -79,28 +84,88 @@ describe("anthropic cache-write stamp", () => {
7984
at: 10,
8085
storedModel: "anthropic:claude-opus-4-6",
8186
liveProvider: "anthropic",
87+
liveProtocol: "anthropic",
8288
}),
8389
).toEqual({ at: 10, model: "anthropic:claude-opus-4-6" });
8490
expect(
8591
resumeCacheWriteSeed({
8692
at: 10,
8793
storedModel: "openai:gpt-5.6",
8894
liveProvider: "openai",
95+
liveProtocol: "openai",
8996
}),
9097
).toBeUndefined();
9198
expect(
9299
resumeCacheWriteSeed({
93100
at: 10,
94101
storedModel: "anthropic:claude-opus-4-6",
95102
liveProvider: "openai",
103+
liveProtocol: "openai",
96104
}),
97105
).toBeUndefined();
98106
expect(
99107
resumeCacheWriteSeed({
100108
at: undefined,
101109
storedModel: "anthropic:claude-opus-4-6",
102110
liveProvider: "anthropic",
111+
liveProtocol: "anthropic",
103112
}),
104113
).toBeUndefined();
105114
});
115+
116+
test("catalog ids that speak the Anthropic protocol still return the stamp", () => {
117+
const at = 10;
118+
const sources = [
119+
buildZenSource({
120+
id: "zen",
121+
model: "claude-opus-4-6",
122+
sessionId: "sess-zen",
123+
}),
124+
buildGoSource({
125+
id: "opencode-go",
126+
model: "minimax-m3",
127+
sessionId: "sess-go",
128+
}),
129+
buildAnthropicSource({
130+
id: "acme",
131+
baseURL: "https://example.invalid",
132+
model: "claude-opus-4-6",
133+
}),
134+
];
135+
for (const source of sources) {
136+
expect(source.id).not.toBe(source.provider);
137+
const storedModel = `${source.id}:${source.model}`;
138+
expect(
139+
resumeCacheWriteSeed({
140+
at,
141+
storedModel,
142+
liveProvider: source.id,
143+
liveProtocol: source.provider,
144+
}),
145+
).toEqual({ at, model: `${source.provider}:${source.model}` });
146+
}
147+
148+
const gemini = buildZenSource({
149+
id: "zen",
150+
model: "gemini-3-flash",
151+
sessionId: "sess-zen",
152+
});
153+
expect(
154+
resumeCacheWriteSeed({
155+
at,
156+
storedModel: `${gemini.id}:${gemini.model}`,
157+
liveProvider: gemini.id,
158+
liveProtocol: gemini.provider,
159+
}),
160+
).toBeUndefined();
161+
162+
expect(
163+
resumeCacheWriteSeed({
164+
at,
165+
storedModel: "zen-messages:claude-opus-4-6",
166+
liveProvider: "zen",
167+
liveProtocol: "zen-messages",
168+
}),
169+
).toEqual({ at, model: "zen-messages:claude-opus-4-6" });
170+
});
106171
});

‎src/provider/cache-ttl.ts‎

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,19 +98,48 @@ export function anthropicCacheWriteAt(
9898
}
9999

100100
/**
101-
* Seed for a resumed session's pre-infer fold. Absent unless both the
102-
* identity that wrote the cache and the provider about to be called are
103-
* Anthropic-protocol. A stamp alone is not enough: a non-Anthropic model
104-
* on the run record must not fold.
101+
* Seed for a resumed session's pre-infer fold. Absent unless the provider
102+
* about to be called speaks the Anthropic messages protocol. A stamp alone
103+
* is not enough: a non-Anthropic live adapter must not fold.
104+
*
105+
* `liveProvider` is the catalog id (`source.id`). Runners persist
106+
* `${id}:${model}`, and that id is `zen`, `opencode-go`, or a custom account
107+
* name — not `zen-messages`, `opencode-go-messages`, or `anthropic`.
108+
* `liveProtocol` is `source.provider`, the adapter that actually wrote the
109+
* cache. A catalog-form stamp is accepted only for this same catalog id, and
110+
* the returned model uses the protocol name so the governor's TTL check
111+
* follows the adapter. A protocol-form stamp (the in-memory `provider:model`
112+
* recorded at inference.done) is kept as-is.
105113
*/
106114
export function resumeCacheWriteSeed(args: {
107115
at: number | undefined;
108116
storedModel: string | undefined;
109117
liveProvider: string;
118+
liveProtocol: string;
110119
}): { at: number; model: string } | undefined {
111120
if (args.at === undefined) return undefined;
112-
if (args.storedModel === undefined) return undefined;
113-
if (cacheTtlMsFor(args.storedModel) === undefined) return undefined;
114-
if (cacheTtlMsFor(args.liveProvider) === undefined) return undefined;
115-
return { at: args.at, model: args.storedModel };
121+
if (args.storedModel === undefined || args.storedModel.length === 0) {
122+
return undefined;
123+
}
124+
if (cacheTtlMsFor(args.liveProtocol) === undefined) return undefined;
125+
if (cacheTtlMsFor(args.storedModel) !== undefined) {
126+
return { at: args.at, model: args.storedModel };
127+
}
128+
129+
const stored = splitRunModel(args.storedModel);
130+
if (stored.provider !== args.liveProvider) return undefined;
131+
if (stored.model.length === 0) return undefined;
132+
return { at: args.at, model: `${args.liveProtocol}:${stored.model}` };
133+
}
134+
135+
function splitRunModel(value: string): { provider: string; model: string } {
136+
const colon = value.indexOf(":");
137+
if (colon > 0) {
138+
return { provider: value.slice(0, colon), model: value.slice(colon + 1) };
139+
}
140+
const slash = value.indexOf("/");
141+
if (slash > 0) {
142+
return { provider: value.slice(0, slash), model: value.slice(slash + 1) };
143+
}
144+
return { provider: value, model: "" };
116145
}

‎src/session/cache-ttl-resume.test.ts‎

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,21 @@ import { createDefaultDependencies } from "@intx/inference/providers";
88
import type {
99
ConversationTurn,
1010
InferenceEvent,
11+
InferenceSource,
1112
ReactorAction,
1213
} from "@intx/types/runtime";
1314
import { createChatDirector } from "../agent/director.js";
1415
import {
1516
COMPACTION_CONTINUATION_EVENT,
1617
lastCycleSourceFromRunModel,
1718
} from "../agent/compaction.js";
19+
import {
20+
buildAnthropicSource,
21+
buildGoSource,
22+
buildOpenAISource,
23+
buildZenSource,
24+
} from "../config/index.js";
25+
import { resumeCacheWriteSeed } from "../provider/cache-ttl.js";
1826
import { HANDOFF_LATEST_KEY } from "./compaction-handoff.js";
1927
import { COMPACTED_PREFIX, createPruningCompactor } from "./compactor.js";
2028
import { createOptimizedContextStore } from "./optimized-context-store.js";
@@ -62,7 +70,10 @@ function actionsOf(result: ReactorAction | ReactorAction[]): ReactorAction[] {
6270
return Array.isArray(result) ? result : [result];
6371
}
6472

65-
async function resumeAndInfer(args: { at: number; model: string }): Promise<{
73+
async function resumeAndInfer(args: {
74+
at: number;
75+
source: InferenceSource;
76+
}): Promise<{
6677
first: ReactorAction[];
6778
stored: ConversationTurn[];
6879
prompt: ConversationTurn[];
@@ -80,10 +91,21 @@ async function resumeAndInfer(args: { at: number; model: string }): Promise<{
8091
});
8192
await store.commit({ message: "seed" });
8293

83-
const source = lastCycleSourceFromRunModel(args.model);
84-
if (source === undefined) throw new Error("missing source");
94+
const storedModel = `${args.source.id}:${args.source.model}`;
95+
const cacheSeed = resumeCacheWriteSeed({
96+
at: args.at,
97+
storedModel,
98+
liveProvider: args.source.id,
99+
liveProtocol: args.source.provider,
100+
});
101+
const source =
102+
cacheSeed === undefined
103+
? undefined
104+
: lastCycleSourceFromRunModel(cacheSeed.model);
85105
const director = createChatDirector("test", [], {});
86-
director.restoreCacheWrite({ at: args.at, source, turns: seed });
106+
if (cacheSeed !== undefined && source !== undefined) {
107+
director.restoreCacheWrite({ at: cacheSeed.at, source, turns: seed });
108+
}
87109
const original = director.decide.bind(director);
88110
let first: ReactorAction[] | undefined;
89111
director.decide = async (event, state, capabilities) => {
@@ -104,13 +126,7 @@ async function resumeAndInfer(args: { at: number; model: string }): Promise<{
104126
const reactor = createReactor({
105127
sessionId: "cache-ttl-resume",
106128
director,
107-
source: {
108-
id: "anthropic:claude-opus-4-6",
109-
provider: "anthropic",
110-
model: "claude-opus-4-6",
111-
baseURL: "https://example.invalid",
112-
credentialId: "test",
113-
},
129+
source: args.source,
114130
toolRunner: {
115131
async run(call) {
116132
return { callId: call.id, content: "ok" };
@@ -150,9 +166,9 @@ async function resumeAndInfer(args: { at: number; model: string }): Promise<{
150166
},
151167
usage: USAGE,
152168
source: {
153-
sourceId: source.sourceId,
154-
provider: source.provider,
155-
model: source.model,
169+
sourceId: args.source.id,
170+
provider: args.source.provider,
171+
model: args.source.model,
156172
},
157173
},
158174
};
@@ -216,10 +232,16 @@ async function resumeAndInfer(args: { at: number; model: string }): Promise<{
216232
}
217233

218234
describe("resumed Anthropic cache write compacts before infer", () => {
235+
const native = buildAnthropicSource({
236+
id: "anthropic",
237+
baseURL: "https://example.invalid",
238+
model: "claude-opus-4-6",
239+
});
240+
219241
test("an expired Anthropic stamp folds into the context store first", async () => {
220242
const result = await resumeAndInfer({
221243
at: Date.now() - 6 * MINUTE_MS,
222-
model: "anthropic:claude-opus-4-6",
244+
source: native,
223245
});
224246

225247
expect(result.first[0]).toMatchObject({
@@ -242,7 +264,7 @@ describe("resumed Anthropic cache write compacts before infer", () => {
242264
test("a write inside 5 minutes leaves the stored turns in place", async () => {
243265
const result = await resumeAndInfer({
244266
at: Date.now() - 2 * MINUTE_MS,
245-
model: "anthropic:claude-opus-4-6",
267+
source: native,
246268
});
247269

248270
expect(result.first.some((action) => action.type === "compact")).toBe(
@@ -255,7 +277,11 @@ describe("resumed Anthropic cache write compacts before infer", () => {
255277
test("a non-Anthropic stored stamp does not compact", async () => {
256278
const result = await resumeAndInfer({
257279
at: Date.now() - 6 * MINUTE_MS,
258-
model: "openai:gpt-5.6",
280+
source: buildOpenAISource({
281+
id: "openai",
282+
baseURL: "https://example.invalid/v1",
283+
model: "gpt-5.6",
284+
}),
259285
});
260286

261287
expect(result.first.some((action) => action.type === "compact")).toBe(
@@ -264,4 +290,37 @@ describe("resumed Anthropic cache write compacts before infer", () => {
264290
expect(texts(result.stored)).toEqual(texts(result.seed));
265291
expect(result.handoff).toBeUndefined();
266292
});
293+
294+
test("expired Zen, OpenCode Go, and custom Anthropic catalog ids fold before infer", async () => {
295+
const sources = [
296+
buildZenSource({
297+
id: "zen",
298+
model: "claude-opus-4-6",
299+
sessionId: "sess-zen",
300+
}),
301+
buildGoSource({
302+
id: "opencode-go",
303+
model: "minimax-m3",
304+
sessionId: "sess-go",
305+
}),
306+
buildAnthropicSource({
307+
id: "acme",
308+
baseURL: "https://example.invalid",
309+
model: "claude-opus-4-6",
310+
}),
311+
];
312+
for (const source of sources) {
313+
expect(source.id).not.toBe(source.provider);
314+
const result = await resumeAndInfer({
315+
at: Date.now() - 6 * MINUTE_MS,
316+
source,
317+
});
318+
expect(result.first[0]).toMatchObject({
319+
type: "compact",
320+
compactor: "pruning-compactor",
321+
reason: "cache-ttl-recompress",
322+
});
323+
expect(result.stored.length).toBeLessThan(result.seed.length);
324+
}
325+
});
267326
});

‎src/tui/runner/session.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,7 @@ export async function assembleTUISession(
738738
at: start.activeRunHandle.lastCacheWriteAt,
739739
storedModel: start.activeRunHandle.cacheWriteModel,
740740
liveProvider: state.config.providerName,
741+
liveProtocol: state.liveSource.provider,
741742
}),
742743
onBuilt: (agent, storage) => {
743744
state.currentAgent = agent;

0 commit comments

Comments
 (0)