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-04 13:15:08 +01:00
parent bf2aac9f61
commit 37ac245f8c
9 changed files with 48 additions and 7 deletions
@@ -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 }) {