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, 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: {} }),