fix(plugins): composers by reference follow the focused agent and reach retained composers

- `{ focused: true }` is the agent Command Center hands to agent items, published by its
  registration through plugins/focused-agent.ts and empty outside a workspace. The composer source
  reads that module instead of the remembered workspace selection, the navigation store and the
  layout store, which also removes the require cycles those imports added.
- A composer the app retains in a workspace the user left has no live input. `insertText` and
  `setPrompt` now store the text with `replaceDraftText`, which asks the mounted input to show it;
  before, the text reached the store but the retained input never displayed it.
- `model` and `thinking` read exactly what the picker resolves before the catalog loads (no
  models), then re-resolve when it lands, instead of reading null.
- The daemon validates a thinking option against the model the agent runs: the runtime model when
  the provider lists it, else the configured one.
- The composer sources subscribe to query-cache updates only. A query another component creates
  during its render is announced as `added`, and a subscriber that updates then made React warn
  about updating a component while rendering a different one. A catalog read no longer starts its
  load synchronously either.
This commit is contained in:
Mohamed Boudra
2026-11-05 23:25:42 +01:00
parent f665cc697c
commit ded20eb85c
21 changed files with 650 additions and 107 deletions
@@ -5436,6 +5436,81 @@ test("a running agent rejects a model or thinking option its provider does not l
expect(manager.getAgent(snapshot.id)?.config.model).toBeUndefined();
});
test("a thinking option is checked against the model the agent runs, as the picker chooses it", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-runtime-model-"));
const provider: ProviderSnapshotEntry = {
provider: "codex",
status: "ready",
enabled: true,
models: [
{
provider: "codex",
id: "configured",
label: "Configured",
isDefault: true,
thinkingOptions: [{ id: "configured-only", label: "Configured only" }],
},
{
provider: "codex",
id: "runtime",
label: "Runtime",
thinkingOptions: [{ id: "runtime-only", label: "Runtime only" }],
},
],
modes: [],
};
class RuntimeModelSession extends TestAgentSession {
constructor(
config: AgentSessionConfig,
private readonly runtimeModel: string,
) {
super(config);
}
override async getRuntimeInfo() {
return { ...(await super.getRuntimeInfo()), model: this.runtimeModel };
}
}
class RuntimeModelClient extends TestAgentClient {
constructor(private readonly runtimeModel: string) {
super();
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new RuntimeModelSession(config, this.runtimeModel);
}
}
async function agentRunning(runtimeModel: string) {
const manager = new AgentManager({
clients: { codex: new RuntimeModelClient(runtimeModel) },
registry: new AgentStorage(join(workdir, `agents-${runtimeModel}`), logger),
validateAgentSelection: async (input) =>
validateAgentConfigurationAgainstProvider({ input, provider }),
logger,
idFactory: () =>
`00000000-0000-4000-8000-${runtimeModel === "runtime" ? "000000000137" : "000000000138"}`,
});
const snapshot = await manager.createAgent(
{ provider: "codex", cwd: workdir, model: "configured" },
undefined,
{ workspaceId: undefined },
);
return { manager, id: snapshot.id };
}
// The session runs a listed model other than the configured one: its options are the ones shown.
const listed = await agentRunning("runtime");
await expect(listed.manager.setAgentThinkingOption(listed.id, "configured-only")).rejects.toThrow(
"Thinking option 'configured-only' is not available",
);
await listed.manager.setAgentThinkingOption(listed.id, "runtime-only");
// The session reports a model the catalog does not list: the picker falls back to the configured one.
const unlisted = await agentRunning("unlisted");
await unlisted.manager.setAgentThinkingOption(unlisted.id, "configured-only");
await expect(
unlisted.manager.setAgentThinkingOption(unlisted.id, "runtime-only"),
).rejects.toThrow("Thinking option 'runtime-only' is not available");
});
test("setAgentThinkingOption adopts the session's effective clamped level", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-"));
class ClampingSession extends TestAgentSession {
@@ -2302,6 +2302,35 @@ export class AgentManager {
if (issues.length > 0) throw new Error(issues.map((issue) => issue.message).join("; "));
}
/**
* Checks the option against the model the agent runs, chosen as the model picker chooses it: the
* model the session reports when the provider lists it, otherwise the configured model, otherwise
* the provider's default.
*/
private async assertThinkingOptionListed(
agent: ActiveManagedAgent,
thinkingOptionId: string,
): Promise<void> {
const reported = agent.runtimeInfo?.model;
const candidates = [reported, agent.config.model].filter(
(model): model is string => typeof model === "string" && model.length > 0,
);
for (const model of candidates) {
const issues =
(await this.validateAgentSelection?.({
provider: agent.provider,
model,
thinkingOptionId,
})) ?? [];
const modelListed = !issues.some((issue) => issue.path[0] === "model");
if (modelListed) {
if (issues.length > 0) throw new Error(issues.map((issue) => issue.message).join("; "));
return;
}
}
await this.assertSelectionListed({ provider: agent.provider, thinkingOptionId });
}
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
const agent = await this.ensureInteractiveSession(agentId);
const normalizedModelId =
@@ -2337,11 +2366,7 @@ export class AgentManager {
? thinkingOptionId
: null;
if (normalizedThinkingOptionId) {
await this.assertSelectionListed({
provider: agent.provider,
model: agent.config.model,
thinkingOptionId: normalizedThinkingOptionId,
});
await this.assertThinkingOptionListed(agent, normalizedThinkingOptionId);
}
let notice: AgentProviderNotice | null = null;