Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions src/session/runtime-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 = "";
Expand Down
29 changes: 29 additions & 0 deletions src/session/runtime-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
31 changes: 9 additions & 22 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -804,19 +803,11 @@ export async function runTUI(initialConfig: Config): Promise<number> {
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.
Expand Down Expand Up @@ -1238,7 +1229,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
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
Expand All @@ -1248,10 +1239,8 @@ export async function runTUI(initialConfig: Config): Promise<number> {
onProgress: (info) => {
emitter.emit("subagent.progress", info);
},
...(liveSubAgentSettings.current !== undefined
? { settings: () => liveSubAgentSettings.current! }
: {}),
catalog: () => liveSubAgentCatalog.current,
settings: liveSubAgent.settings,
catalog: liveSubAgent.catalog,
profiles: () => liveAgentProfiles,
},
});
Expand Down Expand Up @@ -2133,8 +2122,6 @@ export async function runTUI(initialConfig: Config): Promise<number> {
};
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: {} }),
Expand Down
Loading