mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user