fix(sessions): move provider catalog network refreshes off request paths

The shared ModelRuntime was constructed with network refreshes enabled, so
reloadConfig()/login()/logout() — called on the model picker, session model
changes, and auth dialogs — performed unbounded provider-catalog fetches.
A single stalled fetch blocked those requests for minutes and, through pi's
coalesced per-provider refresh, dragged session creation along with it.

Construct the runtime with PI_OFFLINE forced so every runtime-driven refresh
stays local, and add ModelCatalogRefresher as the single deliberate network
path: bounded by an abort timeout, serialized through one in-flight run,
scheduled in the background, and triggered after provider auth changes.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-24 23:44:35 +02:00
parent 83e9014a8a
commit ed9c2f65bb
6 changed files with 358 additions and 9 deletions
+35 -2
View File
@@ -362,7 +362,7 @@ describe("AuthService", () => {
it("stores credentials in the configured agent directory", async () => {
const agentDir = await tempAgentDir();
const runtime = await createModelRuntimeForAgentDir(agentDir, false);
const runtime = await createModelRuntimeForAgentDir(agentDir);
const auth = await AuthService.create({ runtime });
await auth.saveApiKey("anthropic", "sk-test");
@@ -470,6 +470,39 @@ describe("AuthService", () => {
});
});
describe("createModelRuntimeForAgentDir", () => {
it("disables runtime-owned network refreshes so request paths stay local", async () => {
// The stall fix relies on this construction-time flag: reloadConfig(),
// login(), and logout() refresh with allowNetwork = modelNetworkEnabled.
// Asserting the private field is proportionate here because the flag is
// exactly the contract this change depends on.
const agentDir = await tempAgentDir();
const runtime = await createModelRuntimeForAgentDir(agentDir);
expect(Reflect.get(runtime, "modelNetworkEnabled")).toBe(false);
});
it("restores a previously set PI_OFFLINE after runtime creation", async () => {
// The file-level beforeEach stubs PI_OFFLINE=1.
const agentDir = await tempAgentDir();
await createModelRuntimeForAgentDir(agentDir);
expect(process.env["PI_OFFLINE"]).toBe("1");
});
it("restores a previously unset PI_OFFLINE after runtime creation", async () => {
vi.unstubAllEnvs();
const previous = process.env["PI_OFFLINE"];
delete process.env["PI_OFFLINE"];
try {
const agentDir = await tempAgentDir();
const runtime = await createModelRuntimeForAgentDir(agentDir);
expect(Reflect.get(runtime, "modelNetworkEnabled")).toBe(false);
expect(process.env["PI_OFFLINE"]).toBeUndefined();
} finally {
if (previous !== undefined) process.env["PI_OFFLINE"] = previous;
}
});
});
async function createAuthService(seed: Record<string, Credential> = {}, logger?: AuthServiceLogger) {
const credentials = new InMemoryCredentialStore();
for (const [providerId, credential] of Object.entries(seed)) {
@@ -486,7 +519,7 @@ async function createFileBackedAuthService(seed: Record<string, Credential>) {
const agentDir = await tempAgentDir();
const authPath = join(agentDir, "auth.json");
await writeFile(authPath, JSON.stringify(seed, null, 2));
const runtime = await createModelRuntimeForAgentDir(agentDir, false);
const runtime = await createModelRuntimeForAgentDir(agentDir);
const auth = await AuthService.create({ runtime });
const changes: AuthChange[] = [];
auth.subscribe((change) => { changes.push(change); });