Skip to content

Commit f2fe456

Browse files
committed
fix(subagent): apply the latest wrap's sources on continuation OAuth refresh
1 parent 743fbd8 commit f2fe456

2 files changed

Lines changed: 84 additions & 9 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type {
3+
InferenceSource,
4+
ReactorAction,
5+
ReactorDirector,
6+
} from "@intx/types/runtime";
7+
8+
import { withContinuationOAuthRefresh } from "./refresh-inference-source.js";
9+
10+
const baseSource = (id: string): InferenceSource => ({
11+
id,
12+
provider: "openai",
13+
baseURL: "https://api.openai.com/v1",
14+
credentialId: id,
15+
model: "gpt-4o",
16+
});
17+
18+
describe("withContinuationOAuthRefresh cache", () => {
19+
test("a second wrap of the same inner director applies the second wrap's sources", async () => {
20+
const seen: { sources: InferenceSource[]; defaultSource: string }[] = [];
21+
const agent = {
22+
setSources(sources: InferenceSource[], defaultSource: string) {
23+
seen.push({ sources, defaultSource });
24+
},
25+
};
26+
27+
const inner: ReactorDirector = {
28+
async decide(): Promise<ReactorAction[]> {
29+
return [{ type: "infer" }];
30+
},
31+
};
32+
33+
const sourcesA = [baseSource("openai")];
34+
const sourcesB = [baseSource("other")];
35+
36+
// First wrap with option set A.
37+
withContinuationOAuthRefresh(inner, {
38+
getAgent: () => agent,
39+
sources: sourcesA,
40+
defaultSource: "openai",
41+
catalog: [],
42+
});
43+
44+
// Second wrap of the SAME inner director with a different option set B.
45+
const wrapped = withContinuationOAuthRefresh(inner, {
46+
getAgent: () => agent,
47+
sources: sourcesB,
48+
defaultSource: "other",
49+
catalog: [],
50+
});
51+
52+
await wrapped.decide({} as never, {} as never, {} as never);
53+
54+
expect(seen).toHaveLength(1);
55+
const call = seen[0];
56+
expect(call?.defaultSource).toBe("other");
57+
expect(call?.sources).toEqual(sourcesB);
58+
});
59+
});

‎src/subagent/refresh-inference-source.ts‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,15 @@ export interface ContinuationRefreshOptions {
3131
const inFlightRefreshes = new Map<string, Promise<InferenceSource>>();
3232

3333
// Wrapped directors, mapped back to their proxy, so a second application is a
34-
// no-op instead of stacking duplicate refreshes.
35-
const wrappedDirectors = new WeakMap<object, ReactorDirector>();
34+
// no-op instead of stacking duplicate refreshes. The entry also holds the
35+
// latest options: a re-wrap with a new source set must update what decide()
36+
// reads, not be silently dropped by the cache.
37+
interface WrappedDirectorEntry {
38+
proxy: ReactorDirector;
39+
options: ContinuationRefreshOptions;
40+
}
41+
42+
const wrappedDirectors = new WeakMap<object, WrappedDirectorEntry>();
3643

3744
async function stagedOAuthTokensFresh(
3845
provenance: OAuthCredentialProvenance,
@@ -125,20 +132,28 @@ export function withContinuationOAuthRefresh<TDirector extends ReactorDirector>(
125132
options: ContinuationRefreshOptions,
126133
): TDirector {
127134
const cached = wrappedDirectors.get(inner);
128-
if (cached !== undefined) return cached as TDirector;
135+
if (cached !== undefined) {
136+
// Second application is a no-op for the proxy, but not for the options:
137+
// the caller may re-wrap the same director with a different source set,
138+
// and decide() below reads entry.options at call time, so the latest
139+
// wrap's sources win instead of the first wrap's.
140+
cached.options = options;
141+
return cached.proxy as TDirector;
142+
}
143+
const entry: WrappedDirectorEntry = { proxy: inner, options };
129144
const decide = async (
130145
event: Parameters<TDirector["decide"]>[0],
131146
state: Parameters<TDirector["decide"]>[1],
132147
capabilities: Parameters<TDirector["decide"]>[2],
133148
) => {
134149
const result = await inner.decide(event, state, capabilities);
135150
if (!returnsInferAction(result)) return result;
136-
const agent = options.getAgent();
151+
const agent = entry.options.getAgent();
137152
if (agent === null) return result;
138153
const fresh = await refreshInferenceSourceBundle(
139-
options.sources,
140-
options.defaultSource,
141-
options.catalog,
154+
entry.options.sources,
155+
entry.options.defaultSource,
156+
entry.options.catalog,
142157
);
143158
agent.setSources(fresh.sources, fresh.defaultSource);
144159
return result;
@@ -149,7 +164,8 @@ export function withContinuationOAuthRefresh<TDirector extends ReactorDirector>(
149164
return Reflect.get(target, property);
150165
},
151166
});
152-
wrappedDirectors.set(inner, proxy);
153-
wrappedDirectors.set(proxy, proxy);
167+
entry.proxy = proxy;
168+
wrappedDirectors.set(inner, entry);
169+
wrappedDirectors.set(proxy, entry);
154170
return proxy;
155171
}

0 commit comments

Comments
 (0)