feat(plugins): reach a running agent's composer by reference

useComposers(selector) in any plugin component and composers(selector) on every command context return the composer of a running agent, named by id or by focus, whether or not its tab is open.

A running agent now has one store-backed composer model apart from any mounted tab. The mounted composer plugs its cursor-aware insert in while mounted; otherwise insertText and setPrompt write the agent's saved prompt. model and thinking resolve through the provider catalog with the picker's own function, so a read equals what the picker shows.

The host reads one ComposerModel from context and no longer knows whether it is in an agent, a draft or New workspace. The daemon rejects an unlisted model or thinking option for a running agent, replacing the app-side guard.
This commit is contained in:
Mohamed Boudra
2026-11-04 14:10:40 +01:00
parent a182e47a9b
commit e64b0709d2
42 changed files with 1621 additions and 272 deletions
@@ -20,6 +20,7 @@ import {
type ManagedAgent,
} from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { validateAgentConfigurationAgainstProvider } from "./agent-configuration-validator.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import { toAgentPayload } from "./agent-projections.js";
@@ -54,6 +55,7 @@ import type {
AgentTimelineItem,
ImportProviderSessionInput,
ImportProviderSessionContext,
ProviderSnapshotEntry,
ResolveAgentDefaultModeInput,
} from "./agent-sdk-types.js";
import type { PaseoToolCatalog } from "./tools/types.js";
@@ -5389,6 +5391,51 @@ test("later explicit config mutations win over events emitted by earlier mutatio
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high");
});
test("a running agent rejects a model or thinking option its provider does not list", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-selection-validation-"));
const provider: ProviderSnapshotEntry = {
provider: "codex",
status: "ready",
enabled: true,
models: [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
isDefault: true,
thinkingOptions: [{ id: "low", label: "Low" }],
},
],
modes: [],
};
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
validateAgentSelection: async (input) =>
validateAgentConfigurationAgainstProvider({ input, provider }),
logger,
idFactory: () => "00000000-0000-4000-8000-000000000136",
});
const snapshot = await manager.createAgent(
{ provider: "codex", cwd: workdir, model: "gpt-5.4" },
undefined,
{ workspaceId: undefined },
);
await expect(manager.setAgentModel(snapshot.id, "nope")).rejects.toThrow(
"Model 'nope' is not available for provider 'codex'",
);
await expect(manager.setAgentThinkingOption(snapshot.id, "extreme")).rejects.toThrow(
"Thinking option 'extreme' is not available for provider 'codex'",
);
expect(manager.getAgent(snapshot.id)?.config.model).toBe("gpt-5.4");
await manager.setAgentThinkingOption(snapshot.id, "low");
await manager.setAgentModel(snapshot.id, null);
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("low");
expect(manager.getAgent(snapshot.id)?.config.model).toBeUndefined();
});
test("setAgentThinkingOption adopts the session's effective clamped level", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-"));
class ClampingSession extends TestAgentSession {
@@ -3,6 +3,8 @@ import { ProviderOptionsValidationError } from "./provider-options.js";
import type { AgentToolResult } from "@getpaseo/plugin/server";
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import type { AgentConfigurationValidationInput } from "./agent-configuration-validator.js";
import type { HubExecutionAgentValidationIssue } from "@getpaseo/protocol/messages";
import { projectTimelineRows } from "./timeline-projection.js";
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
@@ -358,6 +360,13 @@ export interface AgentManagerOptions {
pluginLifecycle?: PluginLifecycle;
/** An internal workspace makes every agent created inside it internal. */
clients?: ProviderClientMap;
/**
* Asks whether the provider lists a model or thinking option. A running agent rejects a
* selection it does not; omitted, any value is accepted.
*/
validateAgentSelection?: (
input: AgentConfigurationValidationInput,
) => Promise<HubExecutionAgentValidationIssue[]>;
providerDefinitions?: ProviderEnabledMap;
idFactory?: () => string;
registry?: AgentStorage;
@@ -830,6 +839,7 @@ export class AgentManager {
private logger: Logger;
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
private readonly beforeSteerUnavailableFallback?: AgentManagerOptions["beforeSteerUnavailableFallback"];
private readonly validateAgentSelection?: AgentManagerOptions["validateAgentSelection"];
private acceptingAgentRegistrations = true;
constructor(options: AgentManagerOptions) {
@@ -854,6 +864,7 @@ export class AgentManager {
options.rescueTimeouts?.interruptSessionMs ?? INTERRUPT_SESSION_TIMEOUT_MS,
};
this.beforeSteerUnavailableFallback = options.beforeSteerUnavailableFallback;
this.validateAgentSelection = options.validateAgentSelection;
this.agentStreamCoalescer = new AgentStreamCoalescer({
windowMs: options.agentStreamCoalesceWindowMs ?? AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
timers: { setTimeout, clearTimeout },
@@ -2286,10 +2297,21 @@ export class AgentManager {
return notice;
}
private async assertSelectionListed(input: AgentConfigurationValidationInput): Promise<void> {
const issues = (await this.validateAgentSelection?.(input)) ?? [];
if (issues.length > 0) throw new Error(issues.map((issue) => issue.message).join("; "));
}
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
const agent = await this.ensureInteractiveSession(agentId);
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
if (normalizedModelId) {
await this.assertSelectionListed({
provider: agent.provider,
model: normalizedModelId,
});
}
if (agent.session.setModel) {
await agent.session.setModel(normalizedModelId);
@@ -2314,6 +2336,13 @@ export class AgentManager {
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
if (normalizedThinkingOptionId) {
await this.assertSelectionListed({
provider: agent.provider,
model: agent.config.model,
thinkingOptionId: normalizedThinkingOptionId,
});
}
let notice: AgentProviderNotice | null = null;
if (agent.session.setThinkingOption) {
+1
View File
@@ -1017,6 +1017,7 @@ export async function createPaseoDaemon(
clients: initialAgentManagerState.clients,
providerDefinitions: initialAgentManagerState.providerDefinitions,
registry: agentStorage,
validateAgentSelection: (input) => providerSnapshotManager.validateAgentConfiguration(input),
appendSystemPrompt: config.appendSystemPrompt,
onWorkspaceStateMayHaveChanged: ({ cwd }) => {
workspaceGitService.onWorkspaceStateMayHaveChanged(cwd);
@@ -49,13 +49,13 @@ describe("snapshot mutation ownership boundary", () => {
const applySnapshotSpy = vi.spyOn(daemonHandle.daemon.agentStorage, "applySnapshot");
await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4");
await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4-mini");
await daemonHandle.daemon.agentManager.flush();
expect(applySnapshotSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
const persisted = await daemonHandle.daemon.agentStorage.get(snapshot.id);
expect(persisted?.config?.model).toBe("gpt-5.4");
expect(persisted?.config?.model).toBe("gpt-5.4-mini");
} finally {
rmSync(cwd, { recursive: true, force: true });
await daemonHandle.close();