mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
## Thinking Path > - Paperclip manages agent work through tasks, projects, and routines. > - Daily QA must exercise the staging app through normal browser and API surfaces. > - A schedule must create real work and retain its result and history. > - Pausing that schedule must prevent the next invocation. > - The routine journey in #18652 passed in QA, while its separate completion journey has not passed. > - This PR delivers the routine test independently and preserves its measured flow and assertions. ## Linked Issues or Issue Description Refs: #18652. This is the routine-only delivery from that PR. The original completion implementation and its failed attempts remain on #18652. ## What Changed - Add the target-independent `routine-scheduled-run` journey with one native run and seven required checks. - Start from the measured routine source `ac4efb7c6f4782ea806661683c656fd2b4cc3189`. Keep its request, seven success assertions, one native-run limit, and 65-second pause check. - Bound each observed GET by the original remaining journey deadline. Preserve the normal API timeout for other callers. Keep the original routine error if final trigger disabling also fails, save a closed secondary receipt, and fail an otherwise successful journey when disabling fails. - Narrow the shared output-reader type to its one-argument GET capability without changing its runtime code. - Register only this case in the catalog, types, feature associations, dispatcher, and definition hash. Keep the strict catalog expectation in sync. - Retain the real Routines component controls and the existing project-flow coverage for the shared browser helper. - Document resource cleanup and the 65-second pause observation. Preserve the current routine map content. ## Verification - The original source passed [QA staging run 41567574185](https://pages.paperclip.ing/evals-qa-smoke-41567574185-1/). TestOps launch 1657 / result 3431 maps to case 718. All seven checks and five phases passed. One scheduler-bound native run, saved output attribution, the full pause window, and cleanup were verified. - Current source `8f62137f130f59fd3e39276e3d60185ddff24517` passed [fresh QA staging run 41582706162](https://pages.paperclip.ing/evals-qa-smoke-41582706162-1/) against Core `09162fe893b464dd2ccdafd14bd3ef1611202a86`. TestOps launch 1855. All seven checks, five phases, and all three workflow jobs passed. The run used one scheduler-bound native turn and observed 65.352 seconds after pause and completion. Canonical evidence collection and normal cleanup completed, with no active operations and the owned agent terminal. - This fresh pass covers the reviewed deadline and failure-reporting changes in the current source. The earlier ac4 pass and the byte-preserving b940 extraction remain historical records under their original conditions; neither failed attempts nor prior source conditions are regraded. - Causal controls: six assertions failed on the old behavior, then all 62 focused controls passed after the repair. Local: 834 related support tests, 41 shared chat/output-reader tests, and 26 unchanged real component controls passed. Runner E2E typecheck, recursive typecheck, build, maps, token gates, and `git diff --check` passed. The temporary output-reader type mismatch exposed by typecheck was corrected by narrowing the capability type. - The first restricted build/typecheck invocations failed because local tsx IPC was denied. A restricted support run also lacked the report CLI output. The same source passed with normal owned local IPC allowed; the original logs remain retained. - The original head's hosted e2e shard 2/8 failed at the unchanged mobile preference frame-count test: expected more than five frames, observed five. The cause is unknown. Its original log remains retained; it is not labeled a proven flake. No CI rerun was requested. - Current-head full CI run 41580423918, attempt 1, passed. The fresh review is 5/5 with zero unresolved threads. The broad local suite was not repeated. The earlier full local run recorded in #18643 had four failures and did not reach 164 serialized groups; this PR does not claim an all-local pass. - Prior failed routine attempts remain recorded. They exposed composer focus, collapsed schedule controls, pending browser events, and Markdown serialization problems. The measured later pass does not regrade those attempts. ## Risks - The shared browser-event helper also serves task creation and project creation. Its regression tests and existing consumers remain included. - The staging pass qualifies the captured source and configuration. The coordinator retains source merging, daily pinning, and plan selection. - The repository test and the unpassed completion test are not registered by this PR. ## Model Used OpenAI GPT-6 through Codex, with tool use and code execution. The exact deployment ID and context window are not exposed by this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub issue/PR references) --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
162 lines
7.6 KiB
TypeScript
162 lines
7.6 KiB
TypeScript
import { expect, type Locator, type Page } from "@playwright/test";
|
|
|
|
/** Own a browser event and its initiating action in the same error boundary. */
|
|
export async function withBrowserEvent<T>(observe: () => Promise<T>, action: () => Promise<unknown>): Promise<T> {
|
|
// Attach both rejection handlers before the action can fail or the event can
|
|
// time out. A synchronous action failure must own the pending event too.
|
|
const observed = observe(), initiated = Promise.resolve().then(action);
|
|
try {
|
|
const [event] = await Promise.all([observed, initiated]);
|
|
return event;
|
|
} catch (error) {
|
|
// A failed observation must not let a late click mutate owned resources
|
|
// after normal cleanup has begun. Keep the first failure and settle both.
|
|
await Promise.allSettled([observed, initiated]);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Rich editors interpret pasted Markdown; fill inserts literal paragraph text. */
|
|
export async function fillTaskPrompt(editor: Locator, prompt: string): Promise<void> {
|
|
if (!/^ {0,3}(?:`{3,}|~{3,})/m.test(prompt)
|
|
|| await editor.evaluate(element => element.tagName === "TEXTAREA")) {
|
|
await editor.fill(prompt);
|
|
return;
|
|
}
|
|
await editor.fill("");
|
|
await editor.focus();
|
|
await editor.evaluate((element, text) => {
|
|
const clipboardData = new DataTransfer();
|
|
clipboardData.setData("text/plain", text);
|
|
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
|
|
element.dispatchEvent(event);
|
|
if (!event.defaultPrevented) throw new Error("Task editor did not accept the Markdown paste");
|
|
}, prompt);
|
|
}
|
|
|
|
export async function createTaskThroughUi(input: {
|
|
page: Page;
|
|
origin?: string;
|
|
issuePrefix: string;
|
|
agentName: string;
|
|
title: string;
|
|
prompt: string;
|
|
workMode: "standard" | "planning" | "ask";
|
|
projectName?: string;
|
|
requireExplicitTitle?: boolean;
|
|
attachments?: readonly string[];
|
|
beforeDispatch?: () => void | Promise<void>;
|
|
}) {
|
|
// Search's public creation action exposes the explicit title field. Strict
|
|
// native-operation fixtures use it so automatic task naming is not an extra
|
|
// provider mutation before the operation whose permission is under test.
|
|
const issuesUrl = input.requireExplicitTitle
|
|
? `/${encodeURIComponent(input.issuePrefix)}/search?scope=issues&q=${encodeURIComponent(`"${input.title}"`)}`
|
|
: `/${encodeURIComponent(input.issuePrefix)}/issues`;
|
|
const newTask = input.requireExplicitTitle
|
|
? input.page.getByRole("button", { name: "Create task from this query", exact: true })
|
|
: input.page.getByRole("button", { name: /^New task$/i }).first();
|
|
let bootstrapError: unknown;
|
|
for (let bootstrapAttempt = 1; bootstrapAttempt <= 3; bootstrapAttempt += 1) {
|
|
try {
|
|
await input.page.goto(input.origin ? new URL(issuesUrl, input.origin).href : issuesUrl, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 30_000,
|
|
});
|
|
await newTask.waitFor({ state: "visible", timeout: 20_000 });
|
|
bootstrapError = undefined;
|
|
break;
|
|
} catch (error) {
|
|
bootstrapError = error;
|
|
if (bootstrapAttempt < 3) await input.page.waitForTimeout(1_000);
|
|
}
|
|
}
|
|
if (bootstrapError) {
|
|
throw new Error(
|
|
`Browser bootstrap failed before task creation: ${bootstrapError instanceof Error ? bootstrapError.message : String(bootstrapError)}`,
|
|
{ cause: bootstrapError },
|
|
);
|
|
}
|
|
await newTask.click();
|
|
const dialog = input.page.getByRole("dialog", { name: "New task", exact: true });
|
|
await expect(dialog).toBeVisible();
|
|
const titleInput = dialog.getByRole("textbox", { name: "Task title", exact: true });
|
|
if (input.requireExplicitTitle) {
|
|
await expect(titleInput).toBeVisible();
|
|
await titleInput.fill(input.title);
|
|
} else if (await titleInput.isVisible()) await titleInput.fill(input.title);
|
|
await fillTaskPrompt(dialog.getByRole("textbox", { name: "editable markdown", exact: true }), input.prompt);
|
|
if (input.workMode !== "standard") {
|
|
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 dialog
|
|
.getByRole("button", { name: "Select assignee", exact: true })
|
|
.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) {
|
|
// 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.
|
|
const projectTrigger = dialog.getByRole("group", { name: "Task project and worktrees", exact: true }).getByRole("button").first();
|
|
await projectTrigger.focus();
|
|
const projectSearch = input.page.getByPlaceholder(/^Search projects(?:…|\.\.\.)?$/i);
|
|
await expect(projectSearch).toBeVisible({ timeout: 2_000 }).catch(async () => {
|
|
if (await projectTrigger.getAttribute("aria-expanded") !== "true") await projectTrigger.click();
|
|
await expect(projectSearch).toBeVisible();
|
|
});
|
|
await projectSearch.fill(input.projectName);
|
|
await input.page.getByText(input.projectName, { exact: true }).last().click();
|
|
}
|
|
if (input.attachments?.length) {
|
|
const chooser = await withBrowserEvent(() => input.page.waitForEvent("filechooser"), async () => {
|
|
const upload = dialog.getByRole("button", { name: "Upload", exact: true });
|
|
if (await upload.count()) await upload.click();
|
|
else {
|
|
await dialog.getByRole("button", { name: "Add to composer", exact: true }).click();
|
|
await input.page.getByRole("menuitem", { name: /^Files and images/ }).click();
|
|
}
|
|
});
|
|
await chooser.setFiles([...input.attachments]);
|
|
// Upload finishes as part of task creation; the dialog retains the selected files.
|
|
}
|
|
// The prompt-only composer generates a provisional title which the provider
|
|
// can rename immediately. Bind the fixture to the actual creation response.
|
|
await input.beforeDispatch?.();
|
|
const submittedAtMs = Date.now();
|
|
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; title: string; titleNeedsGeneration: boolean };
|
|
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);
|
|
if (input.requireExplicitTitle) {
|
|
expect(issue.title).toBe(input.title);
|
|
expect(issue.titleNeedsGeneration).toBe(false);
|
|
}
|
|
// Creation precedes uploads; allow them to finish before navigation.
|
|
await expect(dialog).not.toBeVisible({ timeout: 30_000 });
|
|
return { submittedAtMs, issueId: issue.id };
|
|
}
|
|
|
|
export async function submitTaskReply(
|
|
page: Page,
|
|
body: string,
|
|
): Promise<number> {
|
|
const composer = page.getByTestId("task-chat-composer-input").last();
|
|
await expect(composer).toBeVisible({ timeout: 30_000 });
|
|
await composer
|
|
.locator('[contenteditable="true"], textarea')
|
|
.first()
|
|
.fill(body);
|
|
const submittedAtMs = Date.now();
|
|
await page.getByTestId("task-chat-composer-send").last().click();
|
|
return submittedAtMs;
|
|
}
|