mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 06:02:18 +02:00
## Thinking Path > - Paperclip manages AI agents and the tools they may use. > - Connection setup separates provider preference from permission to use a tool. > - The first native connection baseline could not exercise its intended decisions. > - The browser used mutable task titles, and the provider fixture already granted access. > - Native provider-choice instructions also disagreed with the preferred question format. Schema rejection gave no field guidance. > - This pull request repairs those test preconditions and native guidance, then fixes restart/approval defects exposed by the corrected baseline. It also restores missing OpenCode tool-error evidence. > - The benefit is an inspectable baseline before any further instruction reduction. ## Linked Issues or Issue Description Refs #15407. The original 15-cell baseline remains 0 PASS / 15 FAIL. Ten cells stopped on stale titles, two Codex cells had schema denials, two OpenCode cells used already-granted tools, and one Claude cell returned no native result. No intended user decisions were submitted. The exact invalid Codex field and underlying Claude failure cause remain unknown. [Original campaign](https://github.com/paperclipai/paperclip/actions/runs/37562577199) · [Original report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-37562577199-1/index.html) ## What Changed - Match the browser's task route and visible identifier instead of a title the agent can change. - Start native provider-choice fixtures with no agent tool access. Verify the public effective-access records. - In the positive case, select Arcade, then grant its exact HubSpot tool through the real access card. Require both saved decisions and exactly one observed call. - Return a canonical `providerQuestionSet` for native input and retain the equivalent legacy `providerQuestion`. - Keep invalid input rejected. Return bounded schema locations and required field names without submitted values. - Preserve Claude's exact session/content identity while allowing authenticated registered instruction-copy paths to rotate on a new run. - Reject duplicate approval reports for an existing exact tool-action card before they create another human review. - Wait for a recorded service-approval continuation within the existing deadline; retain missing or failed continuation grades. - Forward OpenCode tool activity through the runner facade, preserving bounded errors and execution-part identity without inventing host-call joins or exposing arguments. - Preserve original grades, costs, scope limits and diagnoses in the dated repair report. ## Verification - Eval typecheck passes. Support suite: 1,801 PASS, one intentional skip; Node checks: 128 PASS. - Connection/schema tests: 51 PASS. Real-server public fixture setup: one PASS with zero providers. - Browser support regression: five PASS, including renamed and wrong tasks. - Focused Rust safe-feedback test: one PASS. - Repository typecheck and build pass before the latest master replay. Post-replay connection/shared/real-server fixture checks: 52 PASS; eval typecheck passes. The browser review fix additionally passes all five browser checks and seven suite checks. - The full local repository run was interrupted incomplete after about 45 minutes, with five integration failures retained. All five pass in a separate targeted invocation (1,250 unrelated tests skipped). No full local-suite pass or root cause for the initial local failures is claimed. - Corrected frozen source `162cc90fdabe7f505b88ae095044531b82784c92`: **10 PASS / 5 FAIL** across the [passing Codex canary](https://github.com/paperclipai/paperclip/actions/runs/37575158761) and [remaining 14 cells](https://github.com/paperclipai/paperclip/actions/runs/37576261807). The canary passes all 17 checks. Claude's two provider-choice continuations fail on restart, Claude service approval exposes an early evaluator rejection, Codex service approval creates a duplicate approval, and OpenCode provider-second times out after both decisions with no HubSpot call. No original result is regraded. - Final ledgers count 31 actual runs: 27 succeeded, two failed, two cancelled during cleanup. All 15 cleanup/budget checks pass. The late Claude continuation is absent from its earlier workflow snapshot; it remains in the result/API/final ledger. Original evidence retains 279 hashes. Recorded LLM subtotal $0.04553787 is incomplete billing, not actual total cost; local runtime is unmetered. - New repair regressions reproduce the Claude attach failure, duplicate approval acceptance and dropped OpenCode tool events before their respective fixes. Nine Rust attachment checks, 127 ACPX host/adapter tests, 33 completion/control-plane checks, nine eval deadline tests, 59 OpenCode proxy/driver tests, one Rust tool-error/redaction check, and TypeScript/Rust composer parity pass. Eval typecheck, repository typecheck and build pass. Existing support coverage is 1,802 PASS plus 128 Node PASS, one intentional support skip; two additional deadline tests also pass. - New-source full CI/review and live canaries are pending. The next bounded selection is Claude provider-decline, Codex service-approve and one OpenCode provider-second diagnostic with repaired event evidence. No broader campaign or instruction-reduction qualification is claimed. - Initial corrected campaign [37574251834](https://github.com/paperclipai/paperclip/actions/runs/37574251834) was cancelled during shared build after review found the breadcrumb whitespace assumption. Its matrix job has zero steps and no provider execution. The real adjacent-span browser regression now reproduces the old failure and passes after the fix. ## Risks - The corrected baseline remains 10/15. The new restart/approval fixes require live qualification; OpenCode evidence forwarding does not itself establish or fix its prior behavioral failure. - The positive provider case now expects three runs, including separate access approval. Its new results are distinct from the original invalid fixture. - The old Claude missing-result cause and rejected Codex field are unknown. These repairs do not retroactively explain or erase either failure. - Path rotation must preserve prompt, custom instruction, skill/content identity and protected provider settings; regression checks reject stale or changed content. No connection authorization, JSON schema, budget, cleanup, or final-result requirement is relaxed. Historical Everyday prompts and gateway setup remain unchanged. ## Model Used OpenAI Codex, GPT-6. The exact deployment variant and context window are not exposed in this session. Used repository inspection, code editing, test execution and retained-evidence analysis. ## 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 `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] 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 - [ ] All Paperclip CI gates are green - [ ] 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>
131 lines
9.2 KiB
TypeScript
131 lines
9.2 KiB
TypeScript
import { describe, expect, it } 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 } from "./connection-guidance-evidence.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", () => {
|
||
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.",
|
||
"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."]) {
|
||
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("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);
|
||
});
|