mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Bind runner browser fixtures to prompt-only task creation
Use current composer controls and creation response IDs so generated titles cannot break task lookup. Verify standard, Plan, and Ask creation with paused agents before paid qualification. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
3d0c9b7761
commit
d0341c36b9
13 files changed
+93
-54
No files matched your search
@@ -1629,6 +1629,13 @@ Recovery-state retention uses raw cleanup results before evidence publication. A
|
||||
|
||||
## Public installed release smoke
|
||||
|
||||
The shared task-creation helper uses the current prompt-only composer and binds
|
||||
each task to its actual HTTP creation response ID, since its generated title may
|
||||
change during execution. The provider-free `tests/e2e/runner-task-creation.spec.ts`
|
||||
regression verifies assignee, project, and all three work modes with paused agents.
|
||||
Explicit title-preservation cases still require a visible title input and fail
|
||||
before provider execution when that surface is unavailable.
|
||||
|
||||
Set `PAPERCLIP_RUNNER_E2E_INSTALLED_CLI` to the absolute public consumer's
|
||||
`paperclipai/dist/index.js` for an installed-product acceptance run. Install the
|
||||
public package graph and run its ordinary `runtime setup cursor` first. The
|
||||
|
||||
@@ -63,10 +63,10 @@ export async function runAccountingFlow(input: {
|
||||
try {
|
||||
if (execution.profile.generation === "legacy") await prepareLegacyContinuationSkill(api, fixtures.company.id, fixtures.agent.id);
|
||||
await api.patch("/api/instance/settings/experimental", { enableClassicTaskInterface: false });
|
||||
await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name,
|
||||
const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name,
|
||||
title: execution.task.buildTitle(nonce), prompt: execution.task.buildPrompt(nonce), workMode: "standard" });
|
||||
issue = await pollUntil({ label: "accounting task creation", deadlineAt: input.deadlineAt,
|
||||
load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(i => i.title === execution.task.buildTitle(nonce)), accept: Boolean });
|
||||
load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(i => i.id === createdTask.issueId), accept: Boolean });
|
||||
if (!issue) throw new Error("Missing created accounting task");
|
||||
if (probe.kind === "productive") {
|
||||
for (let step = 1; step <= 5; step++) {
|
||||
|
||||
@@ -88,10 +88,10 @@ export async function runBlockerFlow(input: {
|
||||
if (hash !== hashes[file]) throw new Error(`Bundled operational skill differs from evaluated source: ${file}`);
|
||||
}
|
||||
await api.patch("/api/instance/settings/experimental", { enableClassicTaskInterface: false });
|
||||
await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name,
|
||||
const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name,
|
||||
title: execution.task.buildTitle(input.nonce), prompt: scenario.prompt, workMode: "standard" });
|
||||
issue = await pollUntil({ label: "browser-created blocker task", deadlineAt: input.deadlineAt,
|
||||
load: async () => (await api.get<Row[]>(`${company}/issues`)).find(i => i.title === execution.task.buildTitle(input.nonce)), accept: Boolean });
|
||||
load: async () => (await api.get<Row[]>(`${company}/issues`)).find(i => i.id === createdTask.issueId), accept: Boolean });
|
||||
if (!issue) throw new Error("No browser-created task");
|
||||
checkpoints.push(await settle("waiting"));
|
||||
await open();
|
||||
|
||||
@@ -179,8 +179,8 @@ export async function runContextIntegrityFlow(input: {
|
||||
? `${scenario.prompt}\n\nUse /${scenario.skillKey} for this request.`
|
||||
: scenario.prompt;
|
||||
skillRequestText = taskPrompt;
|
||||
await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: taskPrompt, workMode: "standard" });
|
||||
issue = await pollUntil({ label: "context-integrity task created", deadlineAt: input.deadlineAt, load: async () => (await api.get<Row[]>(`${companyPath}/issues?limit=100`)).find((row) => row.title === execution.task.buildTitle(nonce)), accept: Boolean });
|
||||
const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: taskPrompt, workMode: "standard" });
|
||||
issue = await pollUntil({ label: "context-integrity task created", deadlineAt: input.deadlineAt, load: async () => (await api.get<Row[]>(`${companyPath}/issues?limit=100`)).find((row) => row.id === createdTask.issueId), accept: Boolean });
|
||||
if (!issue) throw new Error("Missing context-integrity task");
|
||||
if (scenario.id === "ordered-comment-continuation") {
|
||||
await pollUntil({
|
||||
|
||||
@@ -223,7 +223,7 @@ export async function runContinuationFlow(input: {
|
||||
await api.patch("/api/instance/settings/experimental", {
|
||||
enableClassicTaskInterface: false,
|
||||
});
|
||||
await createTaskThroughUi({
|
||||
const createdTask = await createTaskThroughUi({
|
||||
page,
|
||||
issuePrefix: fixtures.company.issuePrefix!,
|
||||
agentName: fixtures.agent.name,
|
||||
@@ -236,7 +236,7 @@ export async function runContinuationFlow(input: {
|
||||
deadlineAt: input.deadlineAt,
|
||||
load: async () =>
|
||||
(await api.get<Row[]>(tasksPath)).find(
|
||||
(t) => t.title === execution.task.buildTitle(input.nonce),
|
||||
(t) => t.id === createdTask.issueId,
|
||||
),
|
||||
accept: Boolean,
|
||||
});
|
||||
|
||||
@@ -263,8 +263,8 @@ export async function runCursorNativeFlow(input: {
|
||||
let expectedMarker = execution.task.buildVisibleMarker(nonce);
|
||||
try {
|
||||
if (!remote && design.id === "native-write-deny-reconnect") await sampleDenied("before-request");
|
||||
await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : execution.task.buildPrompt(nonce) + (watch ? `\nExact native shell command (copy verbatim):\n${deniedCommand!.command}` : ""), workMode: "standard", projectName: project.name });
|
||||
issue = await pollUntil({ label: "browser-created Cursor task", deadlineAt: input.deadlineAt, load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.title === execution.task.buildTitle(nonce)), accept: Boolean }) ?? {};
|
||||
const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : execution.task.buildPrompt(nonce) + (watch ? `\nExact native shell command (copy verbatim):\n${deniedCommand!.command}` : ""), workMode: "standard", projectName: project.name });
|
||||
issue = await pollUntil({ label: "browser-created Cursor task", deadlineAt: input.deadlineAt, load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.id === createdTask.issueId), accept: Boolean }) ?? {};
|
||||
if (!issue.id) throw new Error("Browser-created Cursor task is absent");
|
||||
if (remote) {
|
||||
const started = await pollUntil({ label: "exact Cursor bootstrap run", deadlineAt: input.deadlineAt, load, reject,
|
||||
|
||||
@@ -667,7 +667,7 @@ export async function runEverydayFlow(input: Input) {
|
||||
if (state.connections.length)
|
||||
throw new Error("New-connection story requires an unconnected company");
|
||||
}
|
||||
await createTaskThroughUi({
|
||||
const createdTask = await createTaskThroughUi({
|
||||
page,
|
||||
issuePrefix: prefix,
|
||||
agentName: fixtures.agent.name,
|
||||
@@ -682,9 +682,9 @@ export async function runEverydayFlow(input: Input) {
|
||||
load: () =>
|
||||
api.get<StoryIssue[]>(`/api/companies/${fixtures.company.id}/issues`),
|
||||
accept: (rows) =>
|
||||
rows.some((i) => i.title === execution.task.buildTitle(nonce)),
|
||||
rows.some((i) => i.id === createdTask.issueId),
|
||||
}).then((rows) =>
|
||||
rows.find((i) => i.title === execution.task.buildTitle(nonce))!,
|
||||
rows.find((i) => i.id === createdTask.issueId)!,
|
||||
);
|
||||
input.observe(parent!, []);
|
||||
note("task-submitted", { issueId: parent!.id });
|
||||
|
||||
@@ -68,9 +68,9 @@ export async function runInstructionPersistenceFlow(input: {
|
||||
let issue: Row = {};
|
||||
let runs: Row[] = [];
|
||||
async function create(title: string, prompt: string) {
|
||||
await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title, prompt, workMode: "standard", projectName: fixtures.project?.name });
|
||||
const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title, prompt, workMode: "standard", projectName: fixtures.project?.name });
|
||||
const found = await pollUntil({ label: `instruction task ${title}`, deadlineAt: input.deadlineAt,
|
||||
load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.title === title), accept: row => Boolean(row) });
|
||||
load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.id === createdTask.issueId), accept: row => Boolean(row) });
|
||||
if (!found) throw new Error("Browser-created instruction task missing");
|
||||
issue = found;
|
||||
input.observe(issue, runs);
|
||||
|
||||
@@ -136,8 +136,8 @@ export async function runNativeActiveStopFlow(input: {
|
||||
check("explicit-per-turn-policy", configured.adapterConfig?.acpxPermissionMode === "approve-reads" && configured.adapterConfig?.lifecycleMode === "per_turn" && (provider !== "cursor" || configured.adapterConfig?.acpxSessionMode === "agent"), "Agent mode and per-turn restrictive permission policy selected before startup");
|
||||
const project = await api.post<Row>(`/api/companies/${fixtures.company.id}/projects`, { name: `Native active Stop ${nonce}`, executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize", allowIssueOverride: false, environmentId: fixtures.environment.id, workspaceStrategy: { type: "project_primary" } }, workspace: { name: "Primary", sourceType: "local_path", cwd: input.workspacePath, isPrimary: true } });
|
||||
if (!remote) await sample("before-request");
|
||||
await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : prompt(), workMode: "standard", projectName: project.name });
|
||||
issue = (await pollUntil({ label: "browser-created active Stop task", deadlineAt: input.deadlineAt, load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(i => i.title === execution.task.buildTitle(nonce)), accept: Boolean }))!;
|
||||
const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : prompt(), workMode: "standard", projectName: project.name });
|
||||
issue = (await pollUntil({ label: "browser-created active Stop task", deadlineAt: input.deadlineAt, load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(i => i.id === createdTask.issueId), accept: Boolean }))!;
|
||||
if (remote) {
|
||||
await pollUntil({ label: "active Stop remote bootstrap", deadlineAt: input.deadlineAt, load, accept: state => state.run.status === "running" });
|
||||
const bound = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId: runs[0]!.id, targets: [target], actionPrompt: async value => {
|
||||
|
||||
@@ -104,9 +104,9 @@ export async function runNativeProviderLossFlow(input: {
|
||||
await api.patch(`/api/agents/${fixtures.agent.id}`, { adapterConfig: { ...agent.adapterConfig, acpxPermissionMode: "approve-reads", acpxSessionMode: "agent", timeoutSec: 120, lifecycleMode: "per_turn" } });
|
||||
const project = await api.post<Row>(`/api/companies/${fixtures.company.id}/projects`, { name: `Native provider loss ${nonce}`, executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize", allowIssueOverride: false, environmentId: fixtures.environment.id, workspaceStrategy: { type: "project_primary" } }, workspace: { name: "Primary", sourceType: "local_path", cwd: input.workspacePath, isPrimary: true } });
|
||||
if (!remote) await sample("before-request");
|
||||
await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : prompt(), workMode: "standard", projectName: project.name });
|
||||
const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title: execution.task.buildTitle(nonce), prompt: remote ? input.remoteBootstrap!.prompt(nonce) : prompt(), workMode: "standard", projectName: project.name });
|
||||
issue = (await pollUntil({ label: "browser-created provider-loss task", deadlineAt: input.deadlineAt,
|
||||
load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.title === execution.task.buildTitle(nonce)), accept: Boolean }))!;
|
||||
load: async () => (await api.get<Row[]>(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.id === createdTask.issueId), accept: Boolean }))!;
|
||||
if (remote) {
|
||||
await pollUntil({ label: "provider-loss bootstrap", deadlineAt: input.deadlineAt, load, accept: value => value.run.status === "running" });
|
||||
fixture = await input.remoteBootstrap!.bindAndRelease({ issueId: issue.id, runId: runs[0]!.id, targets: [target], actionPrompt: async value => {
|
||||
|
||||
@@ -1079,9 +1079,9 @@ for (const execution of executions) {
|
||||
matcherResults = evidence.checks.map(check => ({ matcher: { kind: "json_path" as const, path: `firstTask.checks.${check.id}`, expected: true }, passed: check.passed, detail: check.detail }));
|
||||
},
|
||||
createOrdinary: async (taskTitle, taskPrompt) => {
|
||||
await createTaskThroughUi({ page, issuePrefix: fixtures!.company.issuePrefix!, agentName: fixtures!.agent.name, title: taskTitle, prompt: taskPrompt, workMode: "standard" });
|
||||
const createdTask = await createTaskThroughUi({ page, issuePrefix: fixtures!.company.issuePrefix!, agentName: fixtures!.agent.name, title: taskTitle, prompt: taskPrompt, workMode: "standard" });
|
||||
const created = await pollUntil({ label: "ordinary UI-created task", deadlineAt: startedAtMs + deadlineMs,
|
||||
load: async () => (await api.get<IssueRecord[]>(`/api/companies/${fixtures!.company.id}/issues?limit=100`)).find(row => row.title === taskTitle), accept: row => Boolean(row) });
|
||||
load: async () => (await api.get<IssueRecord[]>(`/api/companies/${fixtures!.company.id}/issues?limit=100`)).find(row => row.id === createdTask.issueId), accept: row => Boolean(row) });
|
||||
if (!created) throw new Error("Missing ordinary task");
|
||||
return created;
|
||||
},
|
||||
@@ -1106,8 +1106,7 @@ for (const execution of executions) {
|
||||
{ timeout: Math.max(1, startedAtMs + deadlineMs - Date.now()) })
|
||||
.catch((cause: unknown) => new Error("Could not capture task creation response", { cause }))
|
||||
: null;
|
||||
turnSubmissionTimesMs.push(
|
||||
await createTaskThroughUi({
|
||||
const createdTask = await createTaskThroughUi({
|
||||
page,
|
||||
issuePrefix,
|
||||
agentName: fixtures.agent.name,
|
||||
@@ -1115,8 +1114,9 @@ for (const execution of executions) {
|
||||
prompt,
|
||||
workMode: execution.task.workMode,
|
||||
projectName: fixtures.project?.name,
|
||||
}),
|
||||
);
|
||||
requireExplicitTitle: execution.suite.id === "task-titles" && Boolean(title),
|
||||
});
|
||||
turnSubmissionTimesMs.push(createdTask.submittedAtMs);
|
||||
|
||||
const deadlineAt = startedAtMs + deadlineMs;
|
||||
if (titleCreationResponse) {
|
||||
@@ -1131,9 +1131,9 @@ for (const execution of executions) {
|
||||
deadlineAt,
|
||||
load: async () => {
|
||||
const issues = await api.get<IssueRecord[]>(
|
||||
`/api/companies/${fixtures!.company.id}/issues?q=${encodeURIComponent(title)}&limit=50`,
|
||||
`/api/companies/${fixtures!.company.id}/issues?limit=50`,
|
||||
);
|
||||
return issues.find((candidate) => candidate.title === title);
|
||||
return issues.find((candidate) => candidate.id === createdTask.issueId);
|
||||
},
|
||||
accept: (candidate): candidate is IssueRecord => Boolean(candidate),
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export async function createTaskThroughUi(input: {
|
||||
prompt: string;
|
||||
workMode: "standard" | "planning" | "ask";
|
||||
projectName?: string;
|
||||
requireExplicitTitle?: boolean;
|
||||
}) {
|
||||
const issuesUrl = `/${encodeURIComponent(input.issuePrefix)}/issues`;
|
||||
const newTask = input.page.getByRole("button", { name: "New Task" }).first();
|
||||
@@ -33,41 +34,42 @@ export async function createTaskThroughUi(input: {
|
||||
);
|
||||
}
|
||||
await newTask.click();
|
||||
await input.page.getByPlaceholder("Task title").fill(input.title);
|
||||
await input.page
|
||||
.getByRole("dialog")
|
||||
const dialog = input.page.getByRole("dialog", { name: "New task", exact: true });
|
||||
const titleInput = dialog.getByRole("textbox", { name: "Task title", exact: true });
|
||||
if (await titleInput.isVisible()) await titleInput.fill(input.title);
|
||||
else if (input.requireExplicitTitle) throw new Error("This title-preservation case requires a visible explicit title input");
|
||||
await dialog
|
||||
.getByRole("textbox", { name: "editable markdown", exact: true })
|
||||
.fill(input.prompt);
|
||||
if (input.workMode !== "standard") {
|
||||
await input.page
|
||||
.getByRole("dialog")
|
||||
.locator(`[data-issue-work-mode-chip="standard"]`)
|
||||
.click();
|
||||
await input.page
|
||||
.locator(`[data-issue-work-mode="${input.workMode}"]`)
|
||||
.click();
|
||||
await dialog.getByRole("button", { name: "Add to composer", exact: true }).click();
|
||||
await input.page.getByTestId(input.workMode === "planning" ? "composer-add-plan" : "composer-add-ask").click();
|
||||
}
|
||||
await input.page
|
||||
.getByRole("button", { name: "Assignee", exact: true })
|
||||
await dialog
|
||||
.getByRole("button", { name: "Select assignee", exact: true })
|
||||
.click();
|
||||
await input.page
|
||||
.getByPlaceholder("Search assignees...")
|
||||
.fill(input.agentName);
|
||||
await input.page.getByText(input.agentName, { exact: true }).last().click();
|
||||
await input.page.getByRole("searchbox", { name: "Search assignees", exact: true }).fill(input.agentName);
|
||||
await input.page.getByRole("option").filter({ has: input.page.getByText(input.agentName, { exact: true }) }).click();
|
||||
await expect(input.page.getByRole("searchbox", { name: "Search assignees", exact: true })).toBeHidden();
|
||||
if (input.projectName) {
|
||||
const dialog = input.page.getByRole("dialog");
|
||||
// Selecting the assignee advances focus to this selector and opens it.
|
||||
// Focus is idempotent here; clicking would toggle an already-open popover
|
||||
// closed before the search field can be filled.
|
||||
await dialog.getByRole("button", { name: "Project", exact: true }).focus();
|
||||
await dialog.getByPlaceholder("Search projects...").fill(input.projectName);
|
||||
await dialog.getByText(input.projectName, { exact: true }).last().click();
|
||||
await dialog.getByRole("button", { name: "Project", exact: true }).click();
|
||||
await input.page.getByPlaceholder("Search projects...").fill(input.projectName);
|
||||
await input.page.getByText(input.projectName, { exact: true }).last().click();
|
||||
}
|
||||
const submittedAtMs = Date.now();
|
||||
await input.page
|
||||
.getByRole("button", { name: "Create Task", exact: true })
|
||||
.click();
|
||||
return submittedAtMs;
|
||||
// The prompt-only composer generates a provisional title which the provider
|
||||
// can rename immediately. Bind the fixture to the actual creation response.
|
||||
const [response] = await Promise.all([
|
||||
input.page.waitForResponse(response => response.request().method() === "POST"
|
||||
&& /^\/api\/companies\/[^/]+\/issues$/.test(new URL(response.url()).pathname)),
|
||||
dialog.getByRole("button", { name: "Create task", exact: true }).click(),
|
||||
]);
|
||||
expect(response.status()).toBe(201);
|
||||
const issue = await response.json() as { id: string; companyId: string };
|
||||
expect(new URL(response.url()).pathname).toBe(`/api/companies/${issue.companyId}/issues`);
|
||||
expect(issue.id).toEqual(expect.any(String));
|
||||
expect(issue.id.length).toBeGreaterThan(0);
|
||||
return { submittedAtMs, issueId: issue.id };
|
||||
}
|
||||
|
||||
export async function submitTaskReply(
|
||||
|
||||
Reference in new issue
Block a user