fix(plugins): a kept command honors the composer lock, and useAgent metadata is frozen

A plugin action that awaits an RPC holds the composer it was created with. The lock was captured
at that render, so a submit that started meanwhile did not stop its insertText. Commands now ask
the composer whether it is locked when they run.

The agent snapshot a plugin gets from useAgent froze only the outer metadata record. It now uses
the same deep frozen copy as the composer.
This commit is contained in:
Mohamed Boudra
2026-11-02 07:14:20 +01:00
parent c1f64d45e1
commit b965a3db7c
9 changed files with 48 additions and 7 deletions
@@ -95,6 +95,7 @@ interface AgentControlOption {
const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [];
const EMPTY_OPTIONS: ProviderOptions = {};
const EMPTY_METADATA: Record<string, JsonValue> = {};
const neverLocked = () => false;
interface ControlledAgentControlsProps {
provider: string;
@@ -1140,6 +1141,7 @@ export const AgentControls = memo(function AgentControls({
return {
context: "agent",
locked: false,
isLocked: neverLocked,
provider: agent.provider,
model: activeModelId,
mode: modeControl?.selectedModeId ?? null,
@@ -1356,11 +1358,15 @@ export function DraftAgentControls({
[selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled],
);
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const isLocked = useCallback(() => disabledRef.current, []);
const composerAgent = useMemo(
() =>
draftComposerAgent({
context,
disabled,
isLocked,
selectedProvider,
selectedModel,
selectedMode,
@@ -1377,6 +1383,7 @@ export function DraftAgentControls({
[
context,
disabled,
isLocked,
effectiveSelectedThinkingOption,
metadata,
onSelectMode,
@@ -57,6 +57,7 @@ function openDraft(context: "draft" | "new-workspace") {
selectedModel: current.model,
selectedMode: current.modeId,
thinking: current.thinkingOptionId || null,
isLocked: () => false,
options: shownDraftOptions(options.getState(), {
provider: current.provider,
requestKey: "",
@@ -21,11 +21,12 @@ export function draftComposerAgent(
| "onSelectThinkingOption"
| "onSetOption"
| "onSetMetadata"
> & { thinking: string | null },
> & { thinking: string | null; isLocked: () => boolean },
): ComposerAgent {
return {
context: props.context ?? "draft",
locked: props.disabled ?? false,
isLocked: props.isLocked,
provider: props.selectedProvider,
model: props.selectedModel || null,
mode: props.selectedMode || null,
+3
View File
@@ -1433,9 +1433,12 @@ function ComposerContentImpl({
},
[onChangeText, textSource],
);
const isComposerLockedRef = useRef(isComposerLocked);
isComposerLockedRef.current = isComposerLocked;
const composerInput = useMemo<ComposerInput>(
() => ({
locked: isComposerLocked,
isLocked: () => isComposerLockedRef.current,
textSource,
setText: replaceUserInput,
insertText: insertUserInput,
+4
View File
@@ -10,6 +10,8 @@ import type { ComposerTextSource } from "@/composer/text-source";
export interface ComposerInput {
/** The prompt is held while the composer submits; the built-in input is read-only meanwhile. */
locked: boolean;
/** `locked` as it is now. A command a plugin kept from an earlier render asks this. */
isLocked: () => boolean;
textSource: ComposerTextSource;
setText: (text: string) => void;
/** Inserts at the cursor, replacing the selection. */
@@ -29,6 +31,8 @@ export interface ComposerAgent {
context: "agent" | "draft" | "new-workspace";
/** The agent is being created; the built-in controls are disabled meanwhile. */
locked: boolean;
/** `locked` as it is now. A command a plugin kept from an earlier render asks this. */
isLocked: () => boolean;
provider: string | null;
model: string | null;
mode: string | null;
@@ -115,6 +115,9 @@ describe("plugin context snapshots", () => {
expect(Object.isFrozen(snapshot.labels)).toBe(true);
expect(Reflect.set(snapshot, "title", "Hostile rename")).toBe(false);
expect(Reflect.set(snapshot.labels, "phase", "hostile")).toBe(false);
const issue = snapshot.metadata["linear.issue"] as { id: string };
expect(Reflect.set(issue, "id", "hostile")).toBe(false);
expect(agent.metadata?.["linear.issue"]).toEqual({ id: "ENG-1" });
expect(snapshot.title).toBe("Implement plugin panels");
expect(snapshot.labels).toEqual({ phase: "implementation" });
expect(agent.title).toBe("Implement plugin panels");
@@ -1,5 +1,6 @@
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { cloneAndFreeze } from "../freeze";
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
@@ -49,7 +50,7 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
attentionReason: agent.attentionReason ?? null,
parentAgentId: agent.parentAgentId,
labels: Object.freeze({ ...agent.labels }),
metadata: Object.freeze({ ...agent.metadata }),
metadata: cloneAndFreeze(agent.metadata ?? {}),
});
const byWorkspace = agentSnapshots.get(agent) ?? new Map<string, PluginAgentSnapshot>();
byWorkspace.set(workspaceId, snapshot);
@@ -64,6 +64,7 @@ function fakeAgent(context: ComposerAgent["context"], locked = false) {
const agent: ComposerAgent = {
context,
locked,
isLocked: () => locked,
provider: "claude",
model: "sonnet",
mode: "default",
@@ -89,6 +90,7 @@ function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
const state = { text: "", attachments: initial };
const input: ComposerInput = {
locked,
isLocked: () => locked,
textSource: { getSnapshot: () => state.text, subscribe: () => () => {} },
setText: (text) => {
state.text = text;
@@ -213,6 +215,25 @@ describe("toPluginComposer", () => {
expect(calls).toEqual([]);
});
it("refuses a command a plugin kept from before the composer locked", async () => {
const { agent } = fakeAgent("new-workspace");
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
// The plugin's action was awaiting an RPC when the user submitted.
const lock = { locked: true };
agent.isLocked = () => lock.locked;
input.isLocked = () => lock.locked;
expect(() => composer.insertText("late")).toThrow("The composer is locked");
await expect(composer.setOptions({ effort: "low" })).rejects.toThrow("The composer is locked");
});
it("hands out options and metadata a plugin cannot change", () => {
const { agent } = fakeAgent("draft");
agent.metadata = { nested: { list: [1] } };
@@ -22,12 +22,12 @@ function isPluginAttachment(
const LOCKED_MESSAGE = "The composer is locked while the agent is being created.";
function assertUnlocked(locked: boolean): void {
if (locked) throw new Error(LOCKED_MESSAGE);
function assertUnlocked(isLocked: () => boolean): void {
if (isLocked()) throw new Error(LOCKED_MESSAGE);
}
async function whenUnlocked(agent: ComposerAgent, command: () => Promise<void>): Promise<void> {
assertUnlocked(agent.locked);
assertUnlocked(agent.isLocked);
await command();
}
@@ -60,11 +60,11 @@ export function toPluginComposer(input: {
metadata: cloneAndFreeze(agent.metadata),
},
setPrompt: (text) => {
assertUnlocked(composerInput.locked);
assertUnlocked(composerInput.isLocked);
composerInput.setText(text);
},
insertText: (text) => {
assertUnlocked(composerInput.locked);
assertUnlocked(composerInput.isLocked);
composerInput.insertText(text);
},
addAttachment({ sourceId, item }) {