mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
## Thinking Path > - Paperclip lets people assign work to AI agents and read saved results. > - Product journeys must prove that a result is visible and belongs to the completed run. > - The shared observer compares stored Markdown with browser text. > - A document link can render as an issue chip, so those strings differ. > - This PR keeps run and comment attribution, then compares full text and exact link destinations. > - The benefit is a valid browser result without a false timeout or a weaker provenance check. ## Linked Issues or Issue Description Refs: #18248, #18255. **What happened?** The protected-action story completed both decisions and all four native turns. Its final explanation contained a document link. The browser rendered that link as an issue chip. The observer waited for raw Markdown to equal rendered text, so it could not accept the valid result. **Expected behavior** Accept complete matching content from the final run after reload. Keep the exact link destination, task anchor, author, run and durable final-message checks. Reject missing or changed content. **Steps to reproduce** 1. Save an attributed reply with bold text and a Markdown document link. 2. Render the link as an issue chip in the task bubble. 3. Run `readBoundTaskFinalReply`. The original observer times out. The new browser regression preserves that failure. ## What Changed - Add a UI-owned content projection using its existing Markdown renderer and GFM/issue-reference plugins. Keep DOM observation, attribution and destination checks in the task-reply observer. Keep source Markdown and observed text separately. - Read visible DOM text, link labels and destinations. Require the structured projection to match the actual visible text. Allow only recognized product chips to replace a link label. Ordinary links keep their source label. Reuse the production issue-reference and company-route helpers, plus the current task URL. Ordinary root-relative anchors keep their original path. Preserve exact marker checks and all run/comment/native-event attribution checks. - Check visible decline explanations and unread data in preserved native source text. - Add browser regressions and source/content tests. Reuse the UI’s existing `react-markdown` and `remark-gfm` dependencies. The final diff changes no package manifest or lockfile. - Match generated GFM checkbox states and reject changed states. Document supported content and fail-closed behavior. ## Verification - Original observer: the decorated-link browser regression fails at the final-reply predicate. - Fixed observer: all 21 browser regressions pass, without credentials or model calls. - Focused observer, decline, content and journey contract suites: 64 tests pass. - E2E and UI TypeScript checks pass. All UI token gates pass. The unchanged frozen install passes. - Full CI passed on the preceding6eba7ffhead (41003112166). Full CI passed on the precedingb9a2571head (41006205475), with both reviewers at 5/5. Current-head full CI passed atb3d2d08305(41009669966), including typecheck, build and all eight E2E shards. Both reviewers scored this commit 5/5, with no unresolved threads. The initial lockfile policy failure was corrected. The final diff has no manifest or lock changes, and its unchanged frozen install passed. - The optional positive GFM strikethrough assertion also passes. - The Node 24/tsx end-to-end observer probe passes. The callback has no external compiler helper; a browser regression covers actual tsx serialization. - The Paperclip review finding is also fixed: bare URLs and issue keys use the production plugins and the UI’s `scope=accessible` company-prefix context, with RED/GREEN browser coverage. Source Markdown is required in the receipt. - All Greptile findings are fixed, including matching the UI’s accessible-company scope: ordinary link labels, issue-prefix/fragment resolution and ordinary root-relative paths have positive and negative browser coverage. - The deployed attempt remains a failed original measurement. Fresh qualification is required after this repair. ## Risks The comparison normalizes whitespace and inline formatting. It verifies full text and exact link targets, while link labels may become product chips. Images, raw HTML, tables and fenced code fail closed. Those constructs may require a future explicit extension if a journey uses them. No application behavior, model, budget, approval or connection permission changes. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, code execution and browser tools. The host does not expose an exact underlying model version 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 described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal issue identifier - [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>
214 lines
15 KiB
TypeScript
214 lines
15 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
||
import { runnerMatrix, runnerSuites } from "./catalog.js";
|
||
import { everydayTasks } from "./everyday-cases.js";
|
||
import { CONNECTION_GUIDANCE_SUITE, connectionGuidanceTasks, connectionGuidanceDefinitionDigest } from "./connection-guidance-cases.js";
|
||
import { gradeConnectionGuidanceDecline, hasConnectionGuidanceDeclineReply } from "./connection-guidance-evidence.js";
|
||
import { pollUntil } from "./api.js";
|
||
import { parseRunnerSelectors, selectRunnerExecutions } from "./selectors.js";
|
||
|
||
describe("neutral connection guidance selection", () => {
|
||
it("requires explicit selection and admits five stories on exactly three local native profiles", () => {
|
||
const selected = selectRunnerExecutions(parseRunnerSelectors(["--suite", CONNECTION_GUIDANCE_SUITE]));
|
||
expect(selected).toHaveLength(15);
|
||
expect(new Set(selected.map(row => row.profile.id))).toEqual(new Set(["runner-codex", "runner-acpx-claude", "runner-opencode"]));
|
||
for (const row of selected) {
|
||
expect(row.environment.id).toBe("local");
|
||
expect(row.task.automaticRetryPolicy).toBe("single_attempt");
|
||
expect(row.task.attemptTimeoutMs.local).toBe(720_000);
|
||
expect(row.task.expectedRunCount).toBe(row.task.id === "provider-second" ? 3 : 2);
|
||
}
|
||
for (const args of [["--all"], ["--profile", "runner-opencode"]]) {
|
||
expect(selectRunnerExecutions(parseRunnerSelectors(args), runnerMatrix).some(row => row.suite.id === CONNECTION_GUIDANCE_SUITE)).toBe(false);
|
||
}
|
||
expect(runnerSuites.find(suite => suite.id === CONNECTION_GUIDANCE_SUITE)?.definitionMetadata)
|
||
.toMatchObject({ fixtureDigest: connectionGuidanceDefinitionDigest(), companyAndAgentBudgetCents: 1_000, maximumAttemptsPerCell: 1 });
|
||
});
|
||
it("keeps workflow instructions out of new decline prompts and preserves the historical prompts", () => {
|
||
for (const task of connectionGuidanceTasks) {
|
||
const original = everydayTasks.find(row => row.id === task.id)!;
|
||
if (task.id.endsWith("decline")) {
|
||
expect(task.buildPrompt("nonce")).toMatch(/brief explanation is enough/);
|
||
expect(task.buildPrompt("nonce")).not.toMatch(/declin|Not now|None for now|do not|retry|try again|connection_request|connections_search|paperclip_|yield|poll/i);
|
||
expect(task.buildPrompt("nonce")).not.toBe(original.buildPrompt("nonce"));
|
||
} else expect(task.buildPrompt("nonce")).toBe(original.buildPrompt("nonce"));
|
||
}
|
||
expect(everydayTasks.find(task => task.id === "service-decline")!.buildPrompt("nonce")).toContain("do not try again");
|
||
expect(everydayTasks.find(task => task.id === "connection-decline")!.buildPrompt("nonce")).toContain("do not try again");
|
||
});
|
||
});
|
||
|
||
const valid = {
|
||
caseId: "provider-decline" as const, decisionId: "decision", leadAgentId: "lead", issueId: "task",
|
||
decisions: [{ id: "decision", kind: "ask_user_questions", status: "answered", resolvedAt: "2026-10-06T22:00:00Z",
|
||
result: { answers: [{ questionId: "connection-provider:hubspot", optionIds: ["none"] }] } }],
|
||
replies: [{ authorAgentId: "lead", createdByRunId: "run", createdAt: "2026-10-06T22:00:02Z", body: "Contacts are unavailable after your decision." }],
|
||
runs: [{ id: "run", nativeIssueId: "task", agentId: "lead", status: "succeeded", finishedAt: "2026-10-06T22:00:03Z" }],
|
||
calls: 0, marker: "PRIVATE_MARKER", sameConnections: true,
|
||
};
|
||
const passes = (input: Parameters<typeof gradeConnectionGuidanceDecline>[0]) =>
|
||
gradeConnectionGuidanceDecline(input).every(check => check.passed);
|
||
|
||
describe("neutral decline evidence", () => {
|
||
const native = () => ({ ...structuredClone(valid), caseId: "service-decline" as const, replies: [],
|
||
decisions: [{ id: "decision", kind: "request_confirmation", status: "rejected", resolvedAt: "2026-10-06T22:00:00Z" }],
|
||
runs: [{ ...valid.runs[0], runtimeMode: "native", startedAt: "2026-10-06T22:00:01Z" }],
|
||
nativeFinalReply: { runId: "run", agentId: "lead", body: valid.replies[0].body, sourceBody: valid.replies[0].body, source: "native-turn", observedAfterReload: true,
|
||
visible: true, settled: true, nativeFinal: true, anchor: "run:turn", finalEvent: { seq: 10, sourceEventId: "final", sourceInstanceId: "runner",
|
||
sourceSeq: 10, protocolSchemaVersion: 1, body: valid.replies[0].body } },
|
||
});
|
||
it("accepts a proven reloaded native final after the decline without inventing a comment", () => {
|
||
const input = native();
|
||
expect(hasConnectionGuidanceDeclineReply(input)).toBe(true);
|
||
expect(passes(input)).toBe(true);
|
||
expect(input.replies).toEqual([]);
|
||
});
|
||
it("checks both the visible explanation and preserved native Markdown when links become chips", () => {
|
||
const input = native();
|
||
const body = "Contacts are unavailable. See [Connection outcome](/QA/issues/QA-123#document-outcome).";
|
||
Object.assign(input.nativeFinalReply, { sourceBody: body, body: "Contacts are unavailable. See QA-123.", origin: "https://fixture.test", pageUrl: "https://fixture.test/QA/issues/QA-123",
|
||
content: [{ text: "Contacts are unavailable. See " }, { href: "/QA/issues/QA-123#document-outcome", label: "QA-123", decorated: true }, { text: "." }] });
|
||
input.nativeFinalReply.finalEvent.body = body;
|
||
expect(passes(input)).toBe(true);
|
||
Object.assign(input.nativeFinalReply, { sourceBody: body.replace("Connection outcome", "PRIVATE_MARKER") });
|
||
input.nativeFinalReply.finalEvent.body = body.replace("Connection outcome", "PRIVATE_MARKER");
|
||
expect(passes(input)).toBe(false);
|
||
});
|
||
it("excludes an earlier successful native reply when a later successful run exists", () => {
|
||
const input = native();
|
||
input.runs.push({ ...input.runs[0], id: "latest", startedAt: "2026-10-06T22:00:04Z", finishedAt: "2026-10-06T22:00:06Z" });
|
||
expect(hasConnectionGuidanceDeclineReply(input)).toBe(false);
|
||
expect(passes(input)).toBe(false);
|
||
Object.assign(input.nativeFinalReply, { runId: "latest", anchor: "latest:turn" });
|
||
Object.assign(input.nativeFinalReply.finalEvent, { sourceEventId: "latest-final", sourceInstanceId: "latest-runner" });
|
||
expect(passes(input)).toBe(true);
|
||
});
|
||
it("rejects stale, unbound or untruthful native decline output and unread service data", () => {
|
||
for (const change of [{ runId: "other" }, { agentId: "other" }, { source: "summary" }, { observedAfterReload: false },
|
||
{ settled: false }, { nativeFinal: false }, { visible: false }, { anchor: "other:turn" }, { finalEvent: null }, { sourceBody: undefined }]) {
|
||
const input = native(); Object.assign(input.nativeFinalReply, change); expect(passes(input)).toBe(false);
|
||
}
|
||
for (const change of [{ startedAt: "2026-10-06T21:59:00Z" }, { startedAt: "invalid" }, { runtimeMode: "legacy" },
|
||
{ nativeIssueId: "other" }, { agentId: "other" }, { status: "cancelled" }]) {
|
||
const input = native(); Object.assign(input.runs[0], change); expect(passes(input)).toBe(false);
|
||
}
|
||
for (const body of ["Done.", "Unavailable but PRIVATE_MARKER"]) {
|
||
const input = native(); input.nativeFinalReply.body = input.nativeFinalReply.sourceBody = input.nativeFinalReply.finalEvent.body = body;
|
||
expect(hasConnectionGuidanceDeclineReply(input)).toBe(true); expect(passes(input)).toBe(false);
|
||
}
|
||
for (const change of [{ calls: 1 }, { sameConnections: false }, { decisions: [] }]) expect(passes({ ...native(), ...change })).toBe(false);
|
||
});
|
||
it("accepts an attributed explanation after each kind of saved decline", () => {
|
||
expect(passes(valid)).toBe(true);
|
||
for (const [caseId, kind] of [["service-decline", "request_confirmation"], ["connection-decline", "connection_intent"]] as const) {
|
||
expect(passes({ ...valid, caseId, decisions: [{ ...valid.decisions[0], kind, status: "rejected" }] })).toBe(true);
|
||
}
|
||
});
|
||
it("recognizes unavailable-connection contractions without accepting positive or unrelated claims", () => {
|
||
for (const body of [
|
||
"HubSpot isn't connected because you chose None for now.",
|
||
"HubSpot isn’t connected after the provider decision.",
|
||
"I wasn't able to pull your recent HubSpot contacts. You chose None for now, so no connection was made.",
|
||
"The page service approval was rejected, so the data was unavailable.",
|
||
"The pages aren't retrievable after the connection decision.",
|
||
]) expect(passes({ ...valid, replies: [{ ...valid.replies[0], body }] })).toBe(true);
|
||
for (const body of ["HubSpot is connected.", "HubSpot isn't disconnected.", "HubSpot isn't slow.", "Contacts are available.", "The request was not declined.", "The request was not rejected.", "Contacts are not unavailable.", "I wasn't able to finish my coffee."]) {
|
||
expect(passes({ ...valid, replies: [{ ...valid.replies[0], body }] })).toBe(false);
|
||
}
|
||
});
|
||
it("rejects missing, stale, unattributed, wrong-agent, or unsuccessful explanations", () => {
|
||
for (const input of [
|
||
{ ...valid, replies: [] },
|
||
{ ...valid, replies: [{ ...valid.replies[0], body: "Done." }] },
|
||
{ ...valid, replies: [{ ...valid.replies[0], body: { text: "Unavailable" } }] },
|
||
{ ...valid, replies: [{ ...valid.replies[0], createdAt: "2026-10-06T21:59:00Z" }] },
|
||
{ ...valid, replies: [{ ...valid.replies[0], createdByRunId: undefined }] },
|
||
{ ...valid, replies: [{ ...valid.replies[0], createdByRunId: null, runId: "run" }] },
|
||
{ ...valid, replies: [{ ...valid.replies[0], createdByRunId: "other-run" }] },
|
||
{ ...valid, replies: [{ ...valid.replies[0], authorAgentId: "worker" }] },
|
||
{ ...valid, replies: [{ ...valid.replies[0], createdAt: "invalid" }] },
|
||
{ ...valid, runs: [{ ...valid.runs[0], agentId: "worker" }] },
|
||
{ ...valid, runs: [{ ...valid.runs[0], nativeIssueId: "other-task" }] },
|
||
{ ...valid, runs: [{ ...valid.runs[0], nativeIssueId: undefined }] },
|
||
{ ...valid, runs: [{ ...valid.runs[0], status: "failed" }] },
|
||
{ ...valid, runs: [{ ...valid.runs[0], finishedAt: "2026-10-06T21:59:00Z" }] },
|
||
]) expect(passes(input)).toBe(false);
|
||
});
|
||
it("requires output from the final successful task run, not a late comment from an earlier wait", () => {
|
||
const runs = [...valid.runs, { ...valid.runs[0], id: "final", finishedAt: "2026-10-06T22:00:06Z" }];
|
||
expect(hasConnectionGuidanceDeclineReply({ ...valid, runs })).toBe(false);
|
||
expect(passes({ ...valid, runs })).toBe(false);
|
||
const input = { ...valid, runs, replies: [{ ...valid.replies[0], createdByRunId: "final", createdAt: "2026-10-06T22:00:07Z" }] };
|
||
expect(hasConnectionGuidanceDeclineReply(input)).toBe(true);
|
||
expect(passes(input)).toBe(true);
|
||
});
|
||
it.each(["delayed", "missing", "wrong-run", "wrong-content"])("waits for durable output within the original deadline: %s", async variant => {
|
||
vi.useFakeTimers();
|
||
try {
|
||
const start = Date.now();
|
||
const input = structuredClone({ ...valid, replies: [] as typeof valid.replies });
|
||
const pending = pollUntil({ label: "final decline reply", deadlineAt: start + 30_000, intervalMs: 1000,
|
||
load: async () => {
|
||
if (variant !== "missing" && Date.now() >= start + 20_000) input.replies = [{ ...valid.replies[0],
|
||
createdByRunId: variant === "wrong-run" ? "other" : "run",
|
||
body: variant === "wrong-content" ? "Done." : valid.replies[0].body }];
|
||
return input;
|
||
},
|
||
accept: hasConnectionGuidanceDeclineReply,
|
||
}).catch((error: unknown) => error);
|
||
await vi.advanceTimersByTimeAsync(30_001);
|
||
const result = await pending;
|
||
if (variant === "missing" || variant === "wrong-run") expect(result).toBeInstanceOf(Error);
|
||
else {
|
||
expect(result).toEqual(input);
|
||
// Readiness must not wait for favorable wording or silently convert a bad answer into a pass.
|
||
expect(passes(input)).toBe(variant === "delayed");
|
||
}
|
||
} finally { vi.useRealTimers(); }
|
||
});
|
||
it("rejects wrong or repeated decisions, early use, missing call evidence and connection changes", () => {
|
||
for (const input of [
|
||
{ ...valid, decisionId: "other" },
|
||
{ ...valid, decisions: [] },
|
||
{ ...valid, decisions: [...valid.decisions, ...valid.decisions] },
|
||
{ ...valid, decisions: [{ ...valid.decisions[0], resolvedAt: undefined }] },
|
||
{ ...valid, decisions: [{ ...valid.decisions[0], kind: "request_confirmation" }] },
|
||
{ ...valid, decisions: [{ ...valid.decisions[0], status: "pending" }] },
|
||
{ ...valid, decisions: [{ ...valid.decisions[0], result: { answers: [{ questionId: "connection-provider:hubspot", optionIds: ["via:arcade:hubspot"] }] } }] },
|
||
{ ...valid, calls: 1 },
|
||
{ ...valid, calls: undefined },
|
||
{ ...valid, sameConnections: false },
|
||
{ ...valid, replies: [{ ...valid.replies[0], body: "Unavailable, but PRIVATE_MARKER" }] },
|
||
]) expect(passes(input)).toBe(false);
|
||
});
|
||
});
|
||
|
||
|
||
import { requireProviderAccessCard, gradeProviderOutcome } from "./connection-routing-evidence.js";
|
||
const access = { id: "access", kind: "connection_intent", status: "pending", payload: {
|
||
serviceSlug: "arcade", requestingAgentId: "lead", upstreamService: { slug: "hubspot", selectionInteractionId: "choice" },
|
||
accessRequest: { connectionId: "connection", tools: [{ catalogEntryId: "tool", toolName: "Hubspot_ListContacts", permission: "allowed" }] },
|
||
} };
|
||
const accessInput = { rows: [{ id: "choice", kind: "ask_user_questions", status: "answered" }, access],
|
||
decisionId: "choice", connectionId: "connection", agentId: "lead", catalogEntryIds: ["tool"], calls: 0 };
|
||
it("requires the exact scoped access card and rejects early calls, swapped identities and duplicates", () => {
|
||
expect(requireProviderAccessCard(accessInput)).toBe(access);
|
||
for (const input of [
|
||
{ ...accessInput, rows: [] }, { ...accessInput, calls: 1 }, { ...accessInput, catalogEntryIds: [] },
|
||
{ ...accessInput, connectionId: "other" }, { ...accessInput, agentId: "other" }, { ...accessInput, decisionId: "other" },
|
||
{ ...accessInput, rows: [...accessInput.rows, access] },
|
||
{ ...accessInput, rows: [accessInput.rows[0], { ...access, status: "accepted" }] },
|
||
{ ...accessInput, rows: [accessInput.rows[0], { ...access, payload: { ...access.payload, upstreamService: { slug: "hubspot", selectionInteractionId: "other" } } }] },
|
||
]) expect(() => requireProviderAccessCard(input)).toThrow();
|
||
});
|
||
it("requires the real accepted access result without allowing repeated provider questions", () => {
|
||
const choice = { id: "choice", kind: "ask_user_questions", status: "answered", result: { answers: [{ questionId: "connection-provider:hubspot", optionIds: ["via:arcade:hubspot"] }] } };
|
||
const granted = { ...access, status: "accepted", result: { outcome: "connected", connectionId: "connection" } };
|
||
const input = { rows: [choice, granted], decisionId: "choice", selected: "via:arcade:hubspot", calls: 1,
|
||
response: "Ada Fixture MARKER", marker: "MARKER", sameConnections: true, accessDecision: { id: "access", connectionId: "connection" } };
|
||
expect(gradeProviderOutcome(input).every(c => c.passed)).toBe(true);
|
||
for (const bad of [{ ...input, rows: [choice] }, { ...input, rows: [choice, granted, choice] },
|
||
{ ...input, rows: [choice, { ...granted, status: "pending" }] }, { ...input, accessDecision: { id: "access", connectionId: "other" } },
|
||
{ ...input, calls: 0 }, { ...input, calls: 2 }, { ...input, response: "Done" }])
|
||
expect(gradeProviderOutcome(bad).every(c => c.passed)).toBe(false);
|
||
});
|