From 0b5be4823dc24485a4c7c1be5e1234469fbf2b9e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 10:49:33 -0700 Subject: [PATCH 1/2] Derive every sub-agent spawn fact from the live session config Provider, catalog, and settings were three snapshots seeded once at session start, each relying on every switch path to remember to refresh it. A mid-session model switch refreshed none of them, so workers spawned afterward kept running against the provider the operator had already switched away from, and tier settings written mid-session stayed invisible. All three now derive from the live config binding on each spawn, so no cache is left to go stale. --- src/session/runtime-assembly.test.ts | 71 ++++++++++++++++++++++++++++ src/session/runtime-assembly.ts | 29 ++++++++++++ src/tui/runner.ts | 31 ++++-------- 3 files changed, 109 insertions(+), 22 deletions(-) diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index e1eb712b8..60fe56e96 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -7,10 +7,16 @@ import * as permissionStore from "../permission/store.js"; import { buildSubAgentProvider, createApprovalPersist, + createLiveSubAgentSources, createSessionPruningCompactor, loadSeededApprovals, skillDirsFromEnabledPlugins, } from "./runtime-assembly.js"; +import type { + SubAgentProviderConfig, + SubAgentSourcesConfig, +} from "./runtime-assembly.js"; +import type { Settings } from "../config/settings.js"; import { generateSessionId, initSessionDir, sessionDir } from "./index.js"; import type { PluginModule } from "../plugins/loader.js"; @@ -51,6 +57,71 @@ describe("buildSubAgentProvider", () => { }); }); +describe("createLiveSubAgentSources", () => { + // One live-config owner for every fact a spawn reads. These were three + // separately-seeded snapshots that each switch path had to remember to + // refresh; a mid-session model switch refreshed none of them, so workers + // kept running against the provider the operator had switched away from. + const entry = (name: string): SubAgentSourcesConfig["providers"][number] => ({ + name, + baseURL: "https://api.openai.com/v1", + models: ["gpt-5"], + }); + const providerSettings = (name: string): Settings => ({ + providers: { [name]: { baseURL: "https://api.openai.com/v1", models: ["gpt-5"] } }, + }); + const initial = (): SubAgentSourcesConfig => ({ + providerName: "openai", + baseURL: "https://api.openai.com/v1", + model: "gpt-5", + providers: [entry("openai"), entry("anthropic")], + settings: providerSettings("openai"), + }); + + test("a spawn after a mid-session model switch sees the new provider", () => { + let config = initial(); + const live = createLiveSubAgentSources(() => config); + + expect(live.provider().providerName).toBe("openai"); + + // Mirrors the switch handler's `config = { ...config, providerName, model }`. + config = { ...config, providerName: "anthropic", model: "claude-opus" }; + + expect(live.provider().providerName).toBe("anthropic"); + expect(live.provider().model).toBe("claude-opus"); + }); + + test("a spawn after a mid-session connect sees the new catalog and settings", () => { + let config = initial(); + const live = createLiveSubAgentSources(() => config); + + expect(live.catalog().map((p) => p.name)).toEqual(["openai", "anthropic"]); + + config = { + ...config, + providers: [entry("openai"), entry("codex/work")], + settings: providerSettings("codex/work"), + }; + + expect(live.catalog().map((p) => p.name)).toEqual(["openai", "codex/work"]); + expect(Object.keys(live.settings()?.providers ?? {})).toEqual(["codex/work"]); + }); + + test("settings written mid-session are visible when the session started without any", () => { + // The old wiring attached a settings getter only when settings existed at + // startup, so settings written later in the session stayed invisible. + const { settings: _seeded, ...withoutSettings } = initial(); + let config: SubAgentSourcesConfig = withoutSettings; + const live = createLiveSubAgentSources(() => config); + + expect(live.settings()).toBeUndefined(); + + config = { ...config, settings: providerSettings("openai") }; + + expect(live.settings()).toBeDefined(); + }); +}); + describe("loadSeededApprovals merge order", () => { let cwd = ""; let home = ""; diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index e0d65bf18..cc97c1a53 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -69,6 +69,35 @@ export function buildSubAgentProvider(config: SubAgentProviderConfig): SubAgentP }; } +export type SubAgentSourcesConfig = SubAgentProviderConfig & { + providers: readonly ProviderCatalogEntry[]; + settings?: Settings; +}; + +export type LiveSubAgentSources = { + provider: () => SubAgentProvider; + catalog: () => readonly ProviderCatalogEntry[]; + settings: () => Settings | undefined; +}; + +/** + * The single owner of every session fact a sub-agent spawn reads. A runner's + * session config is reassigned by each switch path (model picker, /agent, + * post-connect refresh, favorite toggle), so all three derive from a config + * getter per spawn. Snapshot copies kept in sync by hand went stale whenever + * a new switch path forgot to update them, which is what stranded workers on + * a provider the operator had already switched away from. + */ +export function createLiveSubAgentSources( + getConfig: () => SubAgentSourcesConfig, +): LiveSubAgentSources { + return { + provider: () => buildSubAgentProvider(getConfig()), + catalog: () => getConfig().providers, + settings: () => getConfig().settings, + }; +} + // --------------------------------------------------------------------------- // 2. Permission approvals + persist callback // --------------------------------------------------------------------------- diff --git a/src/tui/runner.ts b/src/tui/runner.ts index def767213..5145af14b 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -140,7 +140,6 @@ import { FLEET_STALL_POLL_MS, observeFleet, taskToolDefinition, - type SubAgentProvider, } from "../subagent/index.js"; import type { InferenceSource, ToolDefinition, InboundMessage } from "@intx/types/runtime"; import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js"; @@ -205,8 +204,8 @@ import { RESUME_TRANSCRIPT_BLOCK_LIMIT, turnsToContentBlocks } from "./turns-to- import { WorkflowController } from "./workflow-controller.js"; import { buildSessionSourcesFromConfig, - buildSubAgentProvider, createApprovalPersist, + createLiveSubAgentSources, createSessionPruningCompactor, discoverSessionPlugins, loadSeededApprovals, @@ -804,19 +803,11 @@ export async function runTUI(initialConfig: Config): Promise { const permissionsAdmin = createPermissionsAdmin(permissionGate, config.cwd); // Track the active subagent provider so a live /agent switch (provider, model, - // or reasoning effort) reaches subagents spawned afterward. Seeded from config - // and updated by the App through onSubAgentProviderChange. - const liveSubAgentProvider: { current: SubAgentProvider } = { - current: buildSubAgentProvider(config), - }; - // Live catalog + runtime settings so task(tier=…) sees mid-session OAuth - // login and tier edits (config.providers / config.settings are load-time only). - const liveSubAgentCatalog: { current: typeof config.providers } = { - current: config.providers, - }; - const liveSubAgentSettings: { current: typeof config.settings } = { - current: config.settings, - }; + // or reasoning effort) reaches subagents spawned afterward. Derives from the + // live `config` binding on every spawn, so every switch path that reassigns + // `config` (model picker, /agent, post-connect refresh) is picked up without + // a separate cache to keep in sync. + const liveSubAgent = createLiveSubAgentSources(() => config); // Dedicated child-session records for enter-session inspection. Child events // land here only — never in the parent chat transcript. @@ -1238,7 +1229,7 @@ export async function runTUI(initialConfig: Config): Promise { return result.kind === "option" && result.index === 0; }, subAgent: { - provider: () => liveSubAgentProvider.current, + provider: liveSubAgent.provider, sessions: subAgentSessions, getWorkdirBase: () => sessionDir(config.cwd, sessionId), // Progress only — not the full event stream. Forwarding every sub-agent @@ -1248,10 +1239,8 @@ export async function runTUI(initialConfig: Config): Promise { onProgress: (info) => { emitter.emit("subagent.progress", info); }, - ...(liveSubAgentSettings.current !== undefined - ? { settings: () => liveSubAgentSettings.current! } - : {}), - catalog: () => liveSubAgentCatalog.current, + settings: liveSubAgent.settings, + catalog: liveSubAgent.catalog, profiles: () => liveAgentProfiles, }, }); @@ -2133,8 +2122,6 @@ export async function runTUI(initialConfig: Config): Promise { }; const providers = await refreshLiveProviderCatalog(onDisk, resolvedForCatalog); config = { ...config, providers, ...(onDisk !== null ? { settings: onDisk } : {}) }; - liveSubAgentCatalog.current = providers; - liveSubAgentSettings.current = config.settings; host.refreshModels( listRecentModels(config.settings ?? { providers: {} }), listFavoriteModels(config.settings ?? { providers: {} }), From b6624d11dbe24025d9e1d3fa03cd541f57889e5a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 12:00:37 -0700 Subject: [PATCH 2/2] Note the sub-agent provider fix in the changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 969f88ebc..ac7ab0cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### Sub-agents + +- **Workers follow a mid-session model switch.** Sub-agents spawned after you + switched models kept running against the provider the session started on, so + switching away from an exhausted or disconnected account left every new + worker failing until a restart. Provider, model catalog, and settings are now + read live at spawn time, so tier settings written mid-session are visible too. + ### Providers - **Named API-key instances.** First-class API-key providers (OpenAI key,