mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip lets users configure agents and verify their work. > - Daily QA must exercise saved instructions on the QA staging instance. > - A fresh owned agent can test an instruction change without changing a shared agent. > - The test must prove that saved instructions caused the next run's output. > - It must also prove that the original instructions were restored. > - This pull request brings the qualified settings journey onto the current shared registry layout. > - The benefit is a reusable daily QA candidate with independent evidence and fresh current-source checks. ## Linked Issues or Issue Description Refs #18788. This is the replacement review PR for the same settings journey after integration with the merged lifecycle registry layout in #18782. **What happened?** The settings journey passed its original staging qualification at source898ae8ab4e49d2408688088ce82e3316b91cc09c. The earlier PR's review workspace remains retained for file preservation. That workspace and its recovery evidence remain intact. **Expected behavior** Review the integrated settings source through this PR's own normal CI and fresh reviews. The journey saves instructions through ordinary UI controls, verifies their effect on one current native run, and restores the original bytes. **Steps to reproduce** Use the documented dedicated agent-settings-persistence campaign. The provider must supply a fresh owned agent and the agent-settings-write capability. Save the instruction edit, reload it, complete the ordinary assigned task, verify its independently saved result and current-run attribution, then restore and verify the original instruction entry. ## What Changed - Add the settings browser journey, its independent evidence grader, fixtures, retained evidence contract and focused controls from #18788. - Require one fresh dedicated agent. Select its exact configured instruction entry through the file tree. Confirm both board saves through persisted source and audit evidence. - Require one committed native run on the configured provider, model and environment. Keep the synthetic instruction reference out of the task request. - Restore the original instruction bytes through the same ordinary UI and verify the saved result after reload. - Normally merge actual lifecycle squash8771606fd87f69ed4057779e3dce3a9bca8088e6 once. Place only settings registrations at the documented distinct anchors. Preserve all eight dedicated source/oracle/fixture/docs blobs from qualified898 exactly. ## Verification - Current integrated head: 330 focused checks pass across eight settings, catalog, contract, observation, result/report and operations test files. - Current E2E types pass: `node scripts/typecheck-test-projects.mjs --low-memory --reuse-declarations tests/runner-e2e`. - Declaration reuse was bound to all11091 matching package/script/server/UI/root inputs, installed lock and TypeScript, plus all source and target generated-output hashes. No source or module-resolution links were copied. All copied output hashes remain unchanged after local checks. - Maintained product-journey discovery passes and includes both lifecycle and settings. The integrated catalog has22 entries, including the separate fresh-cloud onboarding case. - Static preservation checks pass for seven shared registry ASTs after only declared order changes. The settings dispatch body, required seven checks, one-run bound and all dedicated implementation/oracle blobs remain intact. - Historical staging evidence: [QA workflow41689186453](https://github.com/paperclipai/paperclip-cloud/actions/runs/41689186453) passed all seven settings checks and all five phases at source898ae8ab4e49d2408688088ce82e3316b91cc09c. This is the original qualification receipt. It is not a new run of this integrated head. - This PR must earn its own current-head full CI, fresh5/5 review, resolved threads and terminal passing normal check contexts. Those checks are pending. No prior review or check status was copied. - Full workspace checks and a new live QA/model run were not run locally. The coordinator owns current-source acceptance and daily-plan admission. ## Risks - The journey has one paid provider run and needs a fresh dedicated agent. It rejects a campaign that shares this agent with another paid journey. - Browser saves and restoration must persist before navigation. Wrong, missing, foreign or incomplete evidence fails the independent grader. Failed or active work is preserved for normal owned cleanup. - Registration and hash-input order changed during integration, so the definition/source identity changes. The historical QA receipt remains bound to its original source. - The retained earlier review workspace is still held for file preservation. This PR does not recover, reset, replace or destroy it, and submits no turn to it. - No product runtime, permission, model, shared agent, configuration, feature map, QA pin or TestOps-plan changes are included. ## Model Used - OpenAI GPT-6 (Codex), with reasoning, tool use and code execution. The host does not expose the exact deployment identifier or context-window size. ## 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; historical QA workflow is linked as qualification evidence) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
175 lines
12 KiB
TypeScript
175 lines
12 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { expect } from "@playwright/test";
|
|
import { createTaskThroughUi } from "../user-actions.js";
|
|
import { waitFor } from "./agent-flows.js";
|
|
import { createObservedReader } from "./observation-read.js";
|
|
import { openDocumentAuthoring } from "./task-document-flow.js";
|
|
import { AGENT_SETTINGS_CHECKS, gradeAgentSettings, instructionDigest, matchedBoardSaveAudit, settingsInstructionContent,
|
|
settingsDocumentKey, settingsProofHeading, settingsTaskPrompt, type AgentSettingsEvidence, type AgentSettingsScope,
|
|
type SettingsSnapshot } from "./agent-settings-evidence.js";
|
|
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
|
|
|
|
type Row = Record<string, any>;
|
|
const id = "agent-settings-persistence";
|
|
|
|
export async function runAgentSettings(context: JourneyContext, session: TargetSession, campaignId: string,
|
|
evidence: JourneyEvidence, options: { deadlineAt: number; directory: string }) {
|
|
const agent = context.agent;
|
|
if (!agent || agent.runtimeMode !== "native" || !context.environmentId || !context.capabilities.includes("agent-settings-write")) {
|
|
throw new JourneyError("unsupported", "owned_agent_settings_capability_required");
|
|
}
|
|
const scope: AgentSettingsScope = { campaignId, companyId: context.company.id, userId: context.user.id,
|
|
agentId: agent.id, agentName: agent.name, provider: agent.provider, model: agent.model,
|
|
environmentId: context.environmentId, reference: randomBytes(16).toString("hex") };
|
|
const observations: AgentSettingsEvidence = {};
|
|
const read = createObservedReader(context.api, options.deadlineAt), agentRoute = `/api/agents/${agent.id}`;
|
|
const agentRunsRoute = `/api/companies/${scope.companyId}/heartbeat-runs?agentId=${agent.id}&limit=100`;
|
|
const activityRoute = `/api/companies/${scope.companyId}/activity?entityType=agent&entityId=${agent.id}&limit=200`;
|
|
const snapshot = async (): Promise<SettingsSnapshot> => {
|
|
const [current, bundle, runs, activity] = await Promise.all([
|
|
read.get<Row>(agentRoute), read.get<Row>(`${agentRoute}/instructions-bundle`),
|
|
read.get<Row[]>(agentRunsRoute), read.get<Row[]>(activityRoute),
|
|
]);
|
|
if (typeof bundle?.entryFile !== "string" || !bundle.entryFile || !Array.isArray(runs) || runs.length >= 100
|
|
|| !Array.isArray(activity) || activity.length >= 200
|
|
|| activity.some(row => row.companyId !== scope.companyId || row.entityType !== "agent" || row.entityId !== agent.id)) {
|
|
throw new JourneyError("failed", "agent_settings_read_coverage_unavailable");
|
|
}
|
|
const file = await read.get<Row>(`${agentRoute}/instructions-bundle/file?path=${encodeURIComponent(bundle.entryFile)}`);
|
|
return { agent: current, bundle, file, runs, activity };
|
|
};
|
|
const openInstructions = async (entry: string, expectedContent: string, reload = false) => {
|
|
await context.page.goto(`${context.api.origin}/${context.company.issuePrefix}/agents/${agent.id}/instructions`, { waitUntil: "domcontentloaded" });
|
|
if (reload) await context.page.reload();
|
|
const segments = entry.split("/");
|
|
const treeRow = (filePath: string) => context.page.locator(`[role="treeitem"][data-file-tree-path=${JSON.stringify(filePath)}]`);
|
|
for (let depth = 1; depth < segments.length; depth++) {
|
|
const directory = treeRow(segments.slice(0, depth).join("/"));
|
|
await expect(directory).toBeVisible();
|
|
if (await directory.getAttribute("aria-expanded") !== "true") await directory.click();
|
|
}
|
|
const entryRow = treeRow(entry);
|
|
await expect(entryRow).toBeVisible();
|
|
await entryRow.click();
|
|
const edit = context.page.getByRole("button", { name: `Edit ${entry}`, exact: true });
|
|
await expect(edit).toBeVisible();
|
|
if (!expectedContent.trim()) {
|
|
await edit.click();
|
|
const editor = context.page.getByRole("textbox", { name: "Instruction file editor", exact: true });
|
|
await expect(editor).toHaveValue(expectedContent);
|
|
const renderedContent = await editor.inputValue();
|
|
await context.page.getByRole("button", { name: `Stop editing ${entry}`, exact: true }).click();
|
|
return { edit, renderedContent };
|
|
}
|
|
const raw = context.page.getByTestId("instructions-raw-source");
|
|
await expect(raw).toBeVisible();
|
|
const renderedContent = await raw.textContent();
|
|
if (renderedContent !== expectedContent) throw new JourneyError("failed", "agent_settings_rendered_source_mismatch");
|
|
return { edit, renderedContent };
|
|
};
|
|
const saveThroughUi = async (beforePoint: SettingsSnapshot, content: string) => {
|
|
const entry = beforePoint.bundle.entryFile, before = beforePoint.file.content;
|
|
const { edit } = await openInstructions(entry, before);
|
|
await edit.click();
|
|
const editor = context.page.getByRole("textbox", { name: "Instruction file editor", exact: true });
|
|
await expect(editor).toHaveValue(before);
|
|
await editor.fill(content);
|
|
const save = context.page.getByRole("button", { name: "Save changes", exact: true });
|
|
await expect(save).toBeEnabled();
|
|
await save.click();
|
|
await expect(save).toBeDisabled({ timeout: 30_000 });
|
|
// Disabled can also mean saving. Confirm persisted bytes and the new audit
|
|
// before navigating, including during restoration.
|
|
return waitFor(options.deadlineAt, snapshot, point => point.file.content === content
|
|
&& Boolean(matchedBoardSaveAudit(point, beforePoint, scope)), "agent_settings_save_not_persisted");
|
|
};
|
|
const check = (name: typeof AGENT_SETTINGS_CHECKS[number]) => {
|
|
const result = gradeAgentSettings(scope, observations).find(row => row.id === name)!;
|
|
evidence.checks.push(result);
|
|
if (!result.passed) throw new JourneyError("failed", name.replaceAll("-", "_"));
|
|
};
|
|
let failed = false;
|
|
try {
|
|
observations.original = await snapshot();
|
|
const original = observations.original, entry = original.bundle.entryFile;
|
|
if (original.agent.id !== agent.id || original.agent.companyId !== scope.companyId || original.agent.name !== agent.name
|
|
|| !agent.name.includes(campaignId) || original.agent.adapterType !== "paperclip_runner"
|
|
|| original.agent.adapterConfig?.provider !== agent.provider || original.agent.adapterConfig?.model !== agent.model
|
|
|| original.bundle.mode !== "managed" || original.bundle.editable !== true || original.file.editable !== true
|
|
|| typeof original.file.content !== "string" || original.file.content.includes("\r")
|
|
|| original.file.content.includes(settingsProofHeading) || original.runs.length !== 0) {
|
|
throw new JourneyError("failed", "agent_settings_fixture_not_fresh_owned");
|
|
}
|
|
const edited = settingsInstructionContent(original.file.content, scope.reference);
|
|
observations.saved = await saveThroughUi(original, edited);
|
|
check("agent-settings-browser-save");
|
|
const reopenedSource = await openInstructions(entry, edited, true);
|
|
observations.reloaded = { ...await snapshot(), renderedContent: reopenedSource.renderedContent };
|
|
check("agent-settings-reload-persisted");
|
|
|
|
const title = `Journey QA agent settings ${campaignId}`;
|
|
const task = await createTaskThroughUi({ page: context.page, origin: context.api.origin, issuePrefix: context.company.issuePrefix,
|
|
agentName: agent.name, title, prompt: settingsTaskPrompt, workMode: "standard", requireExplicitTitle: true,
|
|
beforeDispatch: () => session.own({ kind: "issue", name: title, journeyId: id }) });
|
|
await session.own({ kind: "issue", name: title, id: task.issueId, journeyId: id });
|
|
const route = `/api/issues/${task.issueId}`, documentRoute = `${route}/documents/${settingsDocumentKey}`;
|
|
const state = await waitFor(options.deadlineAt, async () => {
|
|
const [issue, summaries, interactions] = await Promise.all([
|
|
read.get<Row>(route), read.get<Row[]>(`${route}/runs`), read.get<Row[]>(`${route}/interactions`),
|
|
]);
|
|
if (!Array.isArray(summaries) || summaries.length > 1 || !Array.isArray(interactions)) throw new JourneyError("failed", "agent_settings_run_coverage_mismatch");
|
|
const runs = await Promise.all(summaries.map(row => read.get<Row>(`/api/heartbeat-runs/${row.runId}`)));
|
|
if (runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status))) throw new JourneyError("failed", "agent_settings_native_run_failed");
|
|
return { issue, runs, interactions };
|
|
}, point => point.issue.status === "done" && point.runs.length === 1 && point.runs[0]!.status === "succeeded"
|
|
&& point.runs[0]!.nativePhase === "committed" && point.interactions.every(row => row.status !== "pending"), "agent_settings_task_not_completed");
|
|
const document = await read.get<Row>(documentRoute);
|
|
const revision = await read.get<Row>(`${documentRoute}/revisions/${document.latestRevisionId}`);
|
|
observations.task = { ...state, document, revision };
|
|
observations.afterWork = await snapshot();
|
|
check("agent-settings-instruction-only-input");
|
|
check("configured-model-and-environment");
|
|
check("agent-settings-result-attributed");
|
|
await context.page.goto(`${context.api.origin}/${context.company.issuePrefix}/issues/${state.issue.identifier ?? task.issueId}`, { waitUntil: "domcontentloaded" });
|
|
await context.page.reload();
|
|
await openDocumentAuthoring(context);
|
|
const displayed = context.page.locator(`#document-${settingsDocumentKey}`);
|
|
await expect(displayed).toContainText(scope.reference);
|
|
observations.task.reopenedDocument = await read.get<Row>(documentRoute);
|
|
observations.task.reopenedRevision = await read.get<Row>(`${documentRoute}/revisions/${document.latestRevisionId}`);
|
|
observations.task.renderedReference = (await displayed.textContent())?.includes(scope.reference) ? scope.reference : undefined;
|
|
check("agent-settings-result-reopened");
|
|
|
|
await saveThroughUi(observations.afterWork, original.file.content);
|
|
const restoredSource = await openInstructions(entry, original.file.content);
|
|
observations.restored = { ...await snapshot(), renderedContent: restoredSource.renderedContent };
|
|
check("agent-settings-original-restored");
|
|
// The shared screenshot policy permits only the task result, not settings.
|
|
await context.page.goto(`${context.api.origin}/${context.company.issuePrefix}/issues/${state.issue.identifier ?? task.issueId}`, { waitUntil: "domcontentloaded" });
|
|
await openDocumentAuthoring(context);
|
|
await expect(context.page.locator(`#document-${settingsDocumentKey}`)).toContainText(scope.reference);
|
|
} catch (error) { failed = true; throw error; }
|
|
finally {
|
|
try {
|
|
await read.preserve(options.directory);
|
|
await mkdir(options.directory, { recursive: true, mode: 0o700 });
|
|
await writeFile(path.join(options.directory, "agent-settings-observations.json"), JSON.stringify({
|
|
companyId: scope.companyId, agentId: scope.agentId, reference: scope.reference,
|
|
checkpoints: Object.fromEntries(["original", "saved", "reloaded", "afterWork", "restored"].map(key => {
|
|
const point = observations[key as Exclude<keyof AgentSettingsEvidence, "task">];
|
|
return [key, point ? { entryFile: point.file.path, contentHash: point.file.contentHash,
|
|
independentlyComputedHash: typeof point.file.content === "string" ? instructionDigest(point.file.content) : null,
|
|
revisionId: point.file.revision?.id ?? null, runIds: point.runs.map(row => row.id ?? row.runId) } : null];
|
|
})),
|
|
issueId: observations.task?.issue.id ?? null, runIds: observations.task?.runs.map(row => row.id) ?? [],
|
|
documentId: observations.task?.document.id ?? null, documentRevisionId: observations.task?.revision.id ?? null,
|
|
saveAudits: { saved: matchedBoardSaveAudit(observations.saved, observations.original, scope),
|
|
restored: matchedBoardSaveAudit(observations.restored, observations.afterWork, scope) },
|
|
checks: gradeAgentSettings(scope, observations),
|
|
}, null, 2), { mode: 0o600 });
|
|
} catch (error) { if (!failed) throw error; }
|
|
}
|
|
}
|