From b965a3db7c6f4789bb4d92094f45a69f6d8a0c86 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 07:14:20 +0100 Subject: [PATCH] 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. --- .../app/src/composer/agent-controls/index.tsx | 7 +++++++ .../src/composer/draft/composer-agent.test.ts | 1 + .../app/src/composer/draft/composer-agent.ts | 3 ++- packages/app/src/composer/index.tsx | 3 +++ packages/app/src/composer/session.tsx | 4 ++++ .../plugins/client-state/snapshots.test.ts | 3 +++ .../app/src/plugins/client-state/snapshots.ts | 3 ++- .../src/plugins/composer-items/model.test.ts | 21 +++++++++++++++++++ .../app/src/plugins/composer-items/model.ts | 10 ++++----- 9 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index fb23fa1eb5..47be4ef5e5 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -95,6 +95,7 @@ interface AgentControlOption { const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = []; const EMPTY_OPTIONS: ProviderOptions = {}; const EMPTY_METADATA: Record = {}; +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, diff --git a/packages/app/src/composer/draft/composer-agent.test.ts b/packages/app/src/composer/draft/composer-agent.test.ts index 209bedd0c0..6fd21d3b81 100644 --- a/packages/app/src/composer/draft/composer-agent.test.ts +++ b/packages/app/src/composer/draft/composer-agent.test.ts @@ -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: "", diff --git a/packages/app/src/composer/draft/composer-agent.ts b/packages/app/src/composer/draft/composer-agent.ts index a7e4f7dca3..3a64b37dc8 100644 --- a/packages/app/src/composer/draft/composer-agent.ts +++ b/packages/app/src/composer/draft/composer-agent.ts @@ -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, diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index a41716a13f..0c9e2aa5d0 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1433,9 +1433,12 @@ function ComposerContentImpl({ }, [onChangeText, textSource], ); + const isComposerLockedRef = useRef(isComposerLocked); + isComposerLockedRef.current = isComposerLocked; const composerInput = useMemo( () => ({ locked: isComposerLocked, + isLocked: () => isComposerLockedRef.current, textSource, setText: replaceUserInput, insertText: insertUserInput, diff --git a/packages/app/src/composer/session.tsx b/packages/app/src/composer/session.tsx index 73892f0d44..52d52d0c7d 100644 --- a/packages/app/src/composer/session.tsx +++ b/packages/app/src/composer/session.tsx @@ -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; diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index ab73182340..8ac6ca5e1d 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -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"); diff --git a/packages/app/src/plugins/client-state/snapshots.ts b/packages/app/src/plugins/client-state/snapshots.ts index f67d3c62d6..a3edfff1b2 100644 --- a/packages/app/src/plugins/client-state/snapshots.ts +++ b/packages/app/src/plugins/client-state/snapshots.ts @@ -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(); const agentSnapshots = new WeakMap>(); @@ -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(); byWorkspace.set(workspaceId, snapshot); diff --git a/packages/app/src/plugins/composer-items/model.test.ts b/packages/app/src/plugins/composer-items/model.test.ts index aa5549e03c..4e5f3dee18 100644 --- a/packages/app/src/plugins/composer-items/model.test.ts +++ b/packages/app/src/plugins/composer-items/model.test.ts @@ -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] } }; diff --git a/packages/app/src/plugins/composer-items/model.ts b/packages/app/src/plugins/composer-items/model.ts index 8b7b73f27e..f4c502a996 100644 --- a/packages/app/src/plugins/composer-items/model.ts +++ b/packages/app/src/plugins/composer-items/model.ts @@ -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): Promise { - 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 }) {