mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 11:46:36 +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>
181 lines
14 KiB
TypeScript
181 lines
14 KiB
TypeScript
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { JourneyContext, JourneyEvidence, TargetSession } from "./types.js";
|
|
import { settingsEvidenceFixture } from "./agent-settings-test-fixture.js";
|
|
import { settingsTaskPrompt } from "./agent-settings-evidence.js";
|
|
|
|
const controls = vi.hoisted(() => ({ createTask: vi.fn(), openDocument: vi.fn() }));
|
|
vi.mock("../user-actions.js", () => ({ createTaskThroughUi: controls.createTask }));
|
|
vi.mock("./task-document-flow.js", () => ({ openDocumentAuthoring: controls.openDocument }));
|
|
vi.mock("@playwright/test", () => ({ expect: (locator: any) => ({
|
|
async toBeVisible() { expect(locator.visible).toBe(true); },
|
|
async toBeEnabled() { expect(locator.enabled()).toBe(true); },
|
|
async toBeDisabled() { expect(locator.enabled()).toBe(false); },
|
|
async toHaveValue(value: string) { expect(locator.value()).toBe(value); },
|
|
async toContainText(value: string) { expect(await locator.textContent()).toContain(value); },
|
|
}) }));
|
|
import { runAgentSettings } from "./agent-settings-flow.js";
|
|
|
|
const directories: string[] = [];
|
|
afterEach(async () => { await Promise.all(directories.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); });
|
|
|
|
async function fixture(nativeFailure = false, unscopedActivity = false, delayedRestore = false, original?: string, entryFile = "AGENTS.md") {
|
|
let f = settingsEvidenceFixture(undefined, original, entryFile), content = f.original, rendered = content, draft = content, dirty = false, dispatched = false;
|
|
const originalContent = f.original;
|
|
const availablePaths = new Set(f.evidence.original!.bundle.files.map((file: any) => file.path));
|
|
const neighbor = "# AGENTS.md sibling must remain unchanged\n";
|
|
let selectedFile = "AGENTS.md";
|
|
const expandedDirs = new Set<string>(), selectedPaths: string[] = [], savedPaths: string[] = [];
|
|
let pendingContent: string | undefined;
|
|
const saves: string[] = [], owns: any[] = [], apiWrites: string[] = [], activityReads: string[] = [];
|
|
const unrelatedActivity = Array.from({ length: 600 }, (_, index) => ({ id: `unrelated-${index}`,
|
|
companyId: f.scope.companyId, entityType: "agent", entityId: "another-agent", details: {} }));
|
|
const current = () => content === f.original
|
|
? dispatched ? f.evidence.restored! : f.evidence.original!
|
|
: dispatched ? f.evidence.afterWork! : f.evidence.saved!;
|
|
const editor = { visible: true, value: () => draft, async inputValue() { return draft; }, async fill(value: string) { draft = value; dirty = true; } };
|
|
const raw = { get visible() { return Boolean(rendered.trim()); }, async textContent() { return rendered; } };
|
|
const edit = { get visible() { return selectedFile === entryFile; }, async click() { draft = content; } };
|
|
const save = { enabled: () => dirty, async click() {
|
|
expect(selectedFile).toBe(entryFile); savedPaths.push(selectedFile);
|
|
saves.push(draft); dirty = false;
|
|
if (delayedRestore && saves.length === 2) pendingContent = draft;
|
|
else content = draft;
|
|
const nonce = /reference equal to ([a-f0-9]+),/.exec(draft)?.[1];
|
|
if (nonce) f = settingsEvidenceFixture(nonce, originalContent, entryFile);
|
|
} };
|
|
const document = { visible: true, async textContent() { return f.evidence.task!.document.body; } };
|
|
const page = { async goto() {
|
|
if (pendingContent !== undefined) throw new Error("navigation_before_save_committed");
|
|
selectedFile = "AGENTS.md"; expandedDirs.clear(); rendered = entryFile === "AGENTS.md" ? content : neighbor;
|
|
}, async reload() { selectedFile = "AGENTS.md"; expandedDirs.clear(); rendered = entryFile === "AGENTS.md" ? content : neighbor; },
|
|
getByTestId(id: string) { expect(id).toBe("instructions-raw-source"); return raw; },
|
|
getByRole(role: string, options: { name: string }) {
|
|
if (role === "button" && options.name === `Edit ${entryFile}`) return edit;
|
|
if (role === "button" && options.name === `Stop editing ${entryFile}`) return { async click() {} };
|
|
if (role === "button" && options.name === "Save changes") return save;
|
|
if (role === "textbox" && options.name === "Instruction file editor") return editor;
|
|
throw new Error("unexpected_locator");
|
|
}, locator(selector: string) {
|
|
if (selector === "#document-settings-proof") return document;
|
|
const match = /^\[role="treeitem"\]\[data-file-tree-path=("(?:\\.|[^"\\])*")\]$/.exec(selector);
|
|
if (!match) throw new Error("unexpected_selector:" + selector);
|
|
const filePath: string = JSON.parse(match[1]!);
|
|
const directory = entryFile.startsWith(`${filePath}/`);
|
|
const parents = filePath.split("/").slice(0, -1);
|
|
return { get visible() { return (directory || availablePaths.has(filePath))
|
|
&& parents.every((_, index) => expandedDirs.has(parents.slice(0, index + 1).join("/"))); },
|
|
async getAttribute(name: string) { expect(name).toBe("aria-expanded"); return directory ? String(expandedDirs.has(filePath)) : null; },
|
|
async click() {
|
|
if (directory) expandedDirs.add(filePath);
|
|
else { selectedFile = filePath; selectedPaths.push(filePath); rendered = filePath === entryFile ? content : neighbor; }
|
|
} };
|
|
} };
|
|
const api = { origin: "https://qa.example.test", async get(route: string) {
|
|
if (route === "/api/agents/agent") return structuredClone(current().agent);
|
|
if (route === "/api/agents/agent/instructions-bundle") return structuredClone(current().bundle);
|
|
if (route === `/api/agents/agent/instructions-bundle/file?path=${encodeURIComponent(entryFile)}`) {
|
|
if (pendingContent !== undefined) { content = pendingContent; pendingContent = undefined; }
|
|
return structuredClone(current().file);
|
|
}
|
|
if (route.includes("/heartbeat-runs?agentId=agent")) return structuredClone(current().runs);
|
|
if (route.startsWith("/api/companies/company/activity?")) {
|
|
activityReads.push(route);
|
|
const query = new URL(route, api.origin).searchParams;
|
|
expect(query.get("entityType")).toBe("agent"); expect(query.get("entityId")).toBe("agent");
|
|
expect(query.get("limit")).toBe("200");
|
|
if (unscopedActivity) return [unrelatedActivity[0]];
|
|
return structuredClone([...unrelatedActivity, ...current().activity]
|
|
.filter(row => row.entityType === query.get("entityType") && row.entityId === query.get("entityId"))
|
|
.slice(0, Number(query.get("limit"))).map(row => ({ ...row,
|
|
details: { ...row.details, privateFixturePayload: "must-not-be-retained" } })));
|
|
}
|
|
if (route === "/api/issues/task") return structuredClone(f.evidence.task!.issue);
|
|
if (route === "/api/issues/task/runs") return [{ runId: "run" }];
|
|
if (route === "/api/issues/task/interactions") return [];
|
|
if (route === "/api/heartbeat-runs/run") return { ...structuredClone(f.run), ...(nativeFailure ? { status: "failed" } : {}) };
|
|
if (route === "/api/issues/task/documents/settings-proof") return structuredClone(f.evidence.task!.document);
|
|
if (route === "/api/issues/task/documents/settings-proof/revisions/document-revision") return structuredClone(f.evidence.task!.revision);
|
|
throw new Error("unexpected_read:" + route);
|
|
}, async put() { apiWrites.push("put"); throw new Error("tested save must use browser"); },
|
|
async patch() { apiWrites.push("patch"); throw new Error("tested save must use browser"); } };
|
|
const context = { page, api, company: { id: "company", issuePrefix: "QA" }, user: { id: "user", name: "QA" },
|
|
agent: { id: "agent", name: "Owned agent campaign-1", provider: "opencode", model: "model", runtimeMode: "native" },
|
|
environmentId: "environment", capabilities: ["company", "board-user", "task-write", "agent", "agent-settings-write"] } as unknown as JourneyContext;
|
|
controls.createTask.mockReset(); controls.openDocument.mockReset();
|
|
controls.createTask.mockImplementation(async (input: any) => {
|
|
expect(saves).toHaveLength(1); expect(input.prompt).toBe(settingsTaskPrompt);
|
|
expect(input.prompt).not.toContain(f.scope.reference);
|
|
await input.beforeDispatch(); dispatched = true; return { issueId: "task" };
|
|
});
|
|
const session = { async own(resource: any) { owns.push(resource); } } as TargetSession;
|
|
const evidence: JourneyEvidence = { outcome: "passed", startingState: "recipe", phases: [], checks: [], resourceDisposition: "retained", privacy: "private" };
|
|
const directory = await mkdtemp(path.join(os.tmpdir(), "qa-settings-flow-")); directories.push(directory);
|
|
return { context, session, evidence, directory, saves, savedPaths, selectedPaths, availablePaths, owns, apiWrites, activityReads, unrelatedActivity, original: f.original,
|
|
currentContent: () => content, options: { directory, deadlineAt: Date.now() + 30000 } };
|
|
}
|
|
|
|
describe("agent settings production-surface orchestration", () => {
|
|
it.each(["WORKFLOW.md", "team guide/qa [settings].md", "team/nested/INSTRUCTIONS.md"])(
|
|
"selects configured entry %s when AGENTS.md also exists and is initially selected", async entryFile => {
|
|
const f = await fixture(false, false, false, undefined, entryFile);
|
|
expect(f.availablePaths).toEqual(new Set(["AGENTS.md", entryFile]));
|
|
await runAgentSettings(f.context, f.session, "campaign-1", f.evidence, f.options);
|
|
expect(f.evidence.checks.every(row => row.passed)).toBe(true);
|
|
expect(f.savedPaths).toEqual([entryFile, entryFile]);
|
|
expect(f.selectedPaths.length).toBeGreaterThan(2); expect(new Set(f.selectedPaths)).toEqual(new Set([entryFile]));
|
|
expect(f.currentContent()).toBe(f.original); expect(f.apiWrites).toEqual([]);
|
|
const receipt = JSON.parse(await readFile(path.join(f.directory, "agent-settings-observations.json"), "utf8"));
|
|
expect(receipt.saveAudits.saved.path).toBe(entryFile); expect(receipt.saveAudits.restored.path).toBe(entryFile);
|
|
});
|
|
it.each(["", " \n\t\n"])("reads and restores the exact empty-file editor value %j", async original => {
|
|
const f = await fixture(false, false, false, original);
|
|
await runAgentSettings(f.context, f.session, "campaign-1", f.evidence, f.options);
|
|
expect(f.saves).toHaveLength(2); expect(f.saves[1]).toBe(original); expect(f.currentContent()).toBe(original);
|
|
expect(f.evidence.checks.every(row => row.passed)).toBe(true);
|
|
});
|
|
it("saves and restores only through browser controls, then leaves the result ready for screenshot", async () => {
|
|
const f = await fixture();
|
|
await runAgentSettings(f.context, f.session, "campaign-1", f.evidence, f.options);
|
|
expect(f.evidence.checks).toHaveLength(7); expect(f.evidence.checks.every(row => row.passed)).toBe(true);
|
|
expect(f.saves).toHaveLength(2); expect(f.saves[1]).toBe(f.original); expect(f.apiWrites).toEqual([]);
|
|
expect(f.owns.map(row => row.id ?? "intent")).toEqual(["intent", "task"]);
|
|
const artifact = await readFile(path.join(f.directory, "agent-settings-observations.json"), "utf8");
|
|
expect(artifact).not.toContain(f.original); expect(artifact).not.toContain("adapterConfig");
|
|
expect(artifact).not.toContain("must-not-be-retained");
|
|
const saved = JSON.parse(artifact); expect(saved.checks.every((row: any) => row.passed)).toBe(true);
|
|
expect(saved.checkpoints.original.contentHash).toBe(saved.checkpoints.restored.contentHash);
|
|
expect(saved.saveAudits).toEqual({ saved: { id: "save", companyId: "company", actorType: "user", actorId: "user",
|
|
agentId: null, runId: null, action: "agent.files_updated", entityType: "agent", entityId: "agent", path: "AGENTS.md",
|
|
contentHash: saved.checkpoints.saved.contentHash, source: "board" }, restored: { id: "restore", companyId: "company",
|
|
actorType: "user", actorId: "user", agentId: null, runId: null, action: "agent.files_updated", entityType: "agent",
|
|
entityId: "agent", path: "AGENTS.md", contentHash: saved.checkpoints.restored.contentHash, source: "board" } });
|
|
expect(f.unrelatedActivity).toHaveLength(600); expect(f.activityReads.length).toBeGreaterThan(0);
|
|
});
|
|
it("rejects a wrongly unscoped activity response before any settings edit", async () => {
|
|
const f = await fixture(false, true);
|
|
await expect(runAgentSettings(f.context, f.session, "campaign-1", f.evidence, f.options)).rejects.toThrow("agent_settings_read_coverage_unavailable");
|
|
expect(f.saves).toEqual([]); expect(f.owns).toEqual([]);
|
|
});
|
|
it("confirms a delayed restore audit and bytes before navigating away from the editor", async () => {
|
|
const f = await fixture(false, false, true);
|
|
await runAgentSettings(f.context, f.session, "campaign-1", f.evidence, f.options);
|
|
expect(f.saves).toHaveLength(2); expect(f.currentContent()).toBe(f.original);
|
|
expect(f.evidence.checks.every(row => row.passed)).toBe(true);
|
|
});
|
|
it("requires scoped settings-write capability before any configuration edit", async () => {
|
|
const f = await fixture(); f.context.capabilities = ["company", "board-user", "task-write", "agent"];
|
|
await expect(runAgentSettings(f.context, f.session, "campaign-1", f.evidence, f.options)).rejects.toThrow("owned_agent_settings_capability_required");
|
|
expect(f.saves).toEqual([]); expect(f.owns).toEqual([]);
|
|
});
|
|
it("preserves a native failure and does not restore instructions while failed work may require cleanup", async () => {
|
|
const f = await fixture(true);
|
|
await expect(runAgentSettings(f.context, f.session, "campaign-1", f.evidence, f.options)).rejects.toThrow("agent_settings_native_run_failed");
|
|
expect(f.saves).toHaveLength(1); expect(f.currentContent()).not.toBe(f.original);
|
|
expect(JSON.parse(await readFile(path.join(f.directory, "agent-settings-observations.json"), "utf8")).checks.every((row: any) => row.passed)).toBe(false);
|
|
expect(f.apiWrites).toEqual([]);
|
|
});
|
|
});
|