Compare commits

...
Author SHA1 Message Date
78d9a36a16 Keep New workspace plugin metadata when the first agent start is retried
When New workspace hands its draft to the ready workspace's draft tab,
the text and setup move with it but the draft's plugin metadata did
not: it was cleared with the New workspace draft and never written to
the destination draft. A send after a failed first start then created
the agent without the metadata.

Copy the metadata into the destination draft at handoff, before the
New workspace draft is cleared.

Closes #9716

Co-authored-by: Michael Wu <michaelmwu@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 03:26:19 +00:00
6 changed files with 105 additions and 3 deletions
@@ -148,6 +148,23 @@ for (const scenario of [
});
}
test("retrying failed agent initialization keeps the plugin metadata set on New workspace", async ({
creation,
startup,
composerMetadata,
}) => {
await creation.openWorkspaceForm("local");
await composerMetadata.set({ "composer-metadata.tag": "kept" });
await creation.submitPrompt("Start the tagged task.", "Create");
await creation.expectAgentStillStarting();
await startup.fail();
await creation.expectStartupFailure();
await creation.expectOneCreatedWorkspace();
await creation.submitPrompt("Start the tagged task.");
await creation.expectAgentCount(1);
await creation.expectAgentMetadata({ "composer-metadata.tag": "kept" });
});
test("an edited agent draft starts after its first start failed", async ({
creation,
startup,
+50 -2
View File
@@ -1,6 +1,8 @@
import { writeFile, rm, rename } from "node:fs/promises";
import { mkdtemp, writeFile, rm, rename } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { copyPluginExample } from "./helpers/plugin-fixture";
import type { Page } from "@playwright/test";
import { copyPluginExample, pluginRequirements } from "./helpers/plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
import { test as base } from "./fixtures";
import {
@@ -14,6 +16,7 @@ import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{
startup: { release(): Promise<void>; fail(): Promise<void> };
promptRejection: void;
composerMetadata: { set(patch: Record<string, string>): Promise<void> };
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
@@ -98,6 +101,39 @@ export default function contribute(server) {
await client.close();
}
},
composerMetadata: async ({ page }, provide) => {
const client = await connectNewWorkspaceDaemonClient();
const previous = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-metadata-"));
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "composer-metadata", requirements: pluginRequirements }),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`import { useComposer } from "@getpaseo/plugin/client";
function Holder() {
globalThis.metadataComposer = useComposer();
return null;
}
export default function contribute(client) {
client.addComposerItem({ id: "holder", Component: Holder });
return () => {};
}`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await provide({ set: (patch) => setComposerMetadata(page, patch) });
} finally {
await client.removePlugin("composer-metadata");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await rm(directory, { recursive: true, force: true });
await client.close();
}
},
creation: async ({ page }, provide) => {
const creation = await createCreationScenario(page);
try {
@@ -126,3 +162,15 @@ export default function contribute(server) {
}
},
});
type MetadataComposerGlobal = typeof globalThis & {
metadataComposer?: { setMetadata(patch: Record<string, string>): Promise<void> };
};
async function setComposerMetadata(page: Page, patch: Record<string, string>) {
await page.waitForFunction(() => (globalThis as MetadataComposerGlobal).metadataComposer);
await page.evaluate(
(value) => (globalThis as MetadataComposerGlobal).metadataComposer!.setMetadata(value),
patch,
);
}
@@ -258,6 +258,16 @@ export async function createCreationScenario(page: Page) {
.filter(({ agent }) => agent.workspaceId === workspaceId)
.map(({ agent }) => agent.id);
},
async expectAgentMetadata(metadata: Record<string, unknown>) {
await expect
.poll(
async () =>
(await project.client.fetchAgents()).entries.find(
({ agent }) => agent.workspaceId === workspaceId,
)?.agent.metadata,
)
.toEqual(metadata);
},
async expectAgentTitle(title: string) {
await expect
.poll(
@@ -131,6 +131,7 @@ export interface SeedDaemonClient {
currentModeId: string | null;
status: string;
title?: string | null;
metadata?: Record<string, unknown>;
};
}>;
}>;
@@ -121,7 +121,10 @@ import {
type ListTerminalsPayload,
upsertCreatedTerminalPayload,
} from "./workspace/terminals/state";
import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff";
import {
captureWorkspaceDraftCleanup,
handOffDraftMetadata,
} from "./new-workspace/background-handoff";
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
function useIsNewWorkspaceDraftHandoffActive(input: {
@@ -635,6 +638,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome {
allowEmptyText: true,
agentCreation: input.agentCreation,
});
handOffDraftMetadata({ serverId, draftId, metadata: composerState.metadata });
clearDraft("sent");
navigateToWorkspace({
serverId,
@@ -1,7 +1,29 @@
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
/**
* The ready workspace's draft tab owns the draft once New workspace hands it off, including a
* retry after the first agent start fails. Its metadata moves with it, like its text and setup.
*/
export function handOffDraftMetadata(input: {
serverId: string;
draftId: string;
metadata: Readonly<Record<string, JsonValue>>;
}): void {
if (Object.keys(input.metadata).length === 0) return;
useDraftStore.getState().patchDraftMetadata({
draftKey: buildDraftStoreKey({
serverId: input.serverId,
agentId: input.draftId,
draftId: input.draftId,
}),
patch: { ...input.metadata },
});
}
interface WorkspaceDraftCleanupInput {
draftId?: string;
draftKey: string;