mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user