mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat eval catalog runs real chat journeys against real agents
and grades saved product state and rendered links, never reply wording
> - Full chat-eval cohort 4 had two cases whose graders passed replies
with a structural flaw that the grader could see but did not check
> - `connection-setup.wrong-person-resolves` promises that chat explains
whose action is required, but its grader had no reply check at all
> - `navigation-preferences.find-project-view` passed a reply with a
link labelled COM-2 that opened COM-1
> - This pull request adds two structural reply checks, with calibration
tests for valid, reworded-valid and broken replies
> - The benefit is that a reply that does not point to the request, or
that sends a person to the wrong task, fails the eval
## Linked Issues or Issue Description
**What happened?**
- `connection-setup.wrong-person-resolves`: another member said "I will
finish Alex's connection request for them". The product refused that
member (403 on setup options, phase, complete and decline) and kept
Alex's card pending. The grader checked only that. The reply's content
was not checked in any form.
- `navigation-preferences.find-project-view`: the saved reply contained
`[COM-2 Launch open website](/COM/issues/COM-1)`. The grader passed it.
**Expected behavior**
- The wrong-resolver reply points the person at Alex's actual pending
request. The card on that page says "Waiting for Alex", which is whose
action is required. The grader checks the link from saved state, and the
browser step follows the reply's own link to that card.
- Every task link in the find-project-view reply opens the task that its
visible identifier names.
**Steps to reproduce**
1. Run the two cases (OpenCode with
`openrouter/deepseek/deepseek-v4.1-flash-1017`) on master at f1aef0cb58.
2. Read the saved replies and the grader checks in the evidence.
## What Changed
- `external-wrong-resolver.ts`: new check
`reply-links-alex-pending-request`. The reply must render a same-origin
link to the related task that holds Alex's card (`/<company>/issues/<id
or identifier>`). `verifyResult` now follows that rendered link (not a
constructed URL) and confirms the "Waiting for Alex" card. Setup records
the company prefix. Oracle `paperclip.chat-catalog.external.v2` -> `v3`.
Reply wording is still not graded.
- `chat-rendered-links.ts`: new shared helper
`mislabeledIssueLinks(text, identifierFor?)`. It returns inline Markdown
links whose label names a task identifier (same prefix as the target)
other than the task the link opens. Labels without such a token, and
non-task targets, are not judged. Task-id targets are compared through
an optional resolver.
- `identity-navigation-preferences.ts`: find-project-view adds
`task-links-match-their-labels` with that helper. Oracle
`paperclip.chat-catalog.identity.v1` ->
`paperclip.chat-catalog.identity.find-project-view.v2`;
`chat-rendered-links.ts` joins its source files.
- `chat-catalog-descriptors.json`: the two descriptors carry the new
oracle versions and source files.
- The scenario YAML does not change, so the testops manifest has no
`changedFields` entry.
## Verification
- `npx vitest run --config tests/runner-e2e/vitest.config.ts
tests/runner-e2e/chat-catalog-domains/external-wrong-resolver.test.ts
tests/runner-e2e/chat-rendered-links.test.ts
tests/runner-e2e/chat-catalog-domains/identity.test.ts`: 92 tests pass.
- Calibration, wrong resolver: valid (links CMP-1), reworded valid
(links by task id, absolute same-origin link) pass; no link, another
task, another company prefix, another origin, and a sub-path fail.
Red/green: with the master grader, the "does not point at Alex's
request" test fails.
- Calibration, labels: valid, reworded (label without an identifier,
document anchor, task-id target resolved), and a different-prefix token
pass; the recorded cohort-4 link `[COM-2 Launch open
website](/COM/issues/COM-1)` fails. The helper flags exactly that link
in the retained cohort-4 reply.
- Retained cohort-4 wrong-resolver reply links `/COM/issues/COM-1` (the
related task), so it passes the new link check. Its wording ("completing
it there is the right move") is a guidance problem, fixed in #21027, not
something saved state can grade.
- `npx vitest run --config tests/runner-e2e/vitest.config.ts
chat-campaign chat-catalog`: 3856 tests pass. `node --test
tests/runner-e2e/chat-full-catalog-testops-manifest.test.mjs`: 10 pass.
`tsc -p tests/runner-e2e/tsconfig.json` passes.
- A paid run of both cases with the product fixes is linked in a comment
below.
## Risks
- A valid reply that names Alex but links nothing now fails
wrong-person-resolves. The case contract says chat explains whose action
is required; the card is where Paperclip shows it, so pointing to it is
the structural form of that promise.
- The label check could flag a label that intentionally names two tasks
of the same company and links only one. That reply would still be
misleading, so failing it is acceptable.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.
## 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
- [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
- [ ] 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
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
15 KiB
TypeScript
160 lines
15 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { readFileSync } from "node:fs";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { collectConversationLinks, conversationLinkBindings, mislabeledIssueLinks, readConversationDomLinks, readLatestConversationReplyRender, waitForLatestConversationReplyRender, waitForSavedTurnReplyRender, type ConversationLinkInput } from "./chat-rendered-links.js";
|
|
|
|
const card = '<div data-thread-item-kind="project_created" data-thread-anchor="project-created:project"><article aria-label="Project created: Launch"><div><p>Project created</p><a href="/RUN/projects/project">Launch</a></div></article></div>';
|
|
const thread = (content = card) => `<div data-testid="task-chat-thread"><div data-testid="task-chat-history-content"><div data-testid="task-chat-scroller"><div id="comment-human" data-thread-item-kind="human">Create a project called Launch for the new website.</div>${content}<div id="comment-reply" data-thread-item-kind="agent">Created the Launch project. No repositories are connected yet.</div></div></div></div>`;
|
|
function input(): ConversationLinkInput {
|
|
return { origin: "https://fixture.test", companyId: "company", companyPrefix: "RUN", sourceId: "source", leadId: "ceo",
|
|
comments: [{ id: "human", issueId: "source", companyId: "company" }, { id: "reply", issueId: "source", companyId: "company", authorAgentId: "ceo", createdByRunId: "run" }],
|
|
runs: [{ id: "run", companyId: "company", agentId: "ceo", status: "succeeded", contextSnapshot: { issueId: "source" } }],
|
|
projects: [{ id: "project", companyId: "company", name: "Launch" }],
|
|
activity: [{ id: "created", companyId: "company", action: "project.created", entityType: "project", entityId: "project", actorType: "agent", actorId: "ceo", agentId: "ceo", runId: "run" }] };
|
|
}
|
|
const collect = (value = input()) => readConversationDomLinks(conversationLinkBindings(value));
|
|
beforeEach(() => { document.body.innerHTML = thread(); vi.spyOn(Element.prototype, "getClientRects").mockReturnValue([{ width: 20, height: 20 }] as unknown as DOMRectList); });
|
|
afterEach(() => vi.restoreAllMocks());
|
|
it("collects the real supported sibling project-card structure when reply-only collection has no link", () => {
|
|
expect(document.querySelectorAll('#comment-reply a[href]')).toHaveLength(0);
|
|
expect(collect()).toEqual([{ threadTestId: "task-chat-thread", href: "/RUN/projects/project", source: "project-created-card", conversationId: "source", anchorCommentId: "human", resourceId: "project", cardLabel: "Project created: Launch", createdByRunId: "run" }]);
|
|
});
|
|
it("keeps the original creation card usable on an idempotent retry without requiring another card", () => {
|
|
const value = input(); value.comments.push({ id: "retry", issueId: "source", companyId: "company", authorAgentId: "ceo", createdByRunId: "retry-run" });
|
|
value.runs.push({ id: "retry-run", companyId: "company", agentId: "ceo", status: "succeeded", contextSnapshot: { issueId: "source" } });
|
|
document.querySelector('[data-testid="task-chat-scroller"]')!.insertAdjacentHTML('beforeend', '<div id="comment-retry">The project already exists.</div>');
|
|
expect(collect(value).map(row => row.href)).toEqual(["/RUN/projects/project"]);
|
|
});
|
|
it("retains visible ordinary reply links including task, goal and artifact links", () => {
|
|
document.querySelector('#comment-reply')!.innerHTML = '<a href="/RUN/issues/task">Task</a><a href="/RUN/goals/goal">Goal</a><a href="/api/attachments/file/content">Artifact</a><a href="https://github.com/owner/repository/issues/12">External issue</a>';
|
|
expect(collect().filter(row => row.source === 'reply').map(row => row.href)).toEqual(['/RUN/issues/task','/RUN/goals/goal','/api/attachments/file/content','https://github.com/owner/repository/issues/12']);
|
|
});
|
|
it.each([
|
|
['wrong saved company', (v:ConversationLinkInput) => { v.projects[0]!.companyId='other'; }],
|
|
['wrong saved resource', (v:ConversationLinkInput) => { v.projects[0]!.id='other'; }],
|
|
['wrong saved name', (v:ConversationLinkInput) => { v.projects[0]!.name='Other'; }],
|
|
['missing creation audit', (v:ConversationLinkInput) => { v.activity=[]; }],
|
|
['wrong audit company', (v:ConversationLinkInput) => { v.activity[0]!.companyId='other'; }],
|
|
['wrong audit actor type', (v:ConversationLinkInput) => { v.activity[0]!.actorType='user'; }],
|
|
['wrong audit actor', (v:ConversationLinkInput) => { v.activity[0]!.actorId='other'; }],
|
|
['wrong audit agent', (v:ConversationLinkInput) => { v.activity[0]!.agentId='other'; }],
|
|
['wrong audit entity type', (v:ConversationLinkInput) => { v.activity[0]!.entityType='issue'; }],
|
|
['wrong audit resource', (v:ConversationLinkInput) => { v.activity[0]!.entityId='other'; }],
|
|
['unrelated historical run', (v:ConversationLinkInput) => { v.activity[0]!.runId='old'; }],
|
|
['run in another company', (v:ConversationLinkInput) => { v.runs[0]!.companyId='other'; }],
|
|
['run for another conversation', (v:ConversationLinkInput) => { v.runs[0]!.contextSnapshot={issueId:'other'}; }],
|
|
['wrong reply author', (v:ConversationLinkInput) => { v.comments[1]!.authorAgentId='other'; }],
|
|
['reply from another conversation', (v:ConversationLinkInput) => { v.comments[1]!.issueId='other'; }],
|
|
['comments from another company', (v:ConversationLinkInput) => { v.comments.forEach(row=>row.companyId='other'); }],
|
|
] as const)('rejects %s instead of treating any matching project link as evidence', (_label, mutate) => { const value=input(); mutate(value); expect(collect(value)).toEqual([]); });
|
|
it.each([
|
|
['sidebar only', () => { document.body.innerHTML=thread('')+`<aside>${card}</aside>`; }],
|
|
['unrelated conversation', () => { document.body.innerHTML=thread('')+thread().replaceAll('comment-human','comment-other-human').replaceAll('comment-reply','comment-other-reply'); }],
|
|
['hidden sibling', () => { document.querySelector('article')!.setAttribute('hidden',''); }],
|
|
['hidden ancestor', () => { document.querySelector('[data-testid="task-chat-history-content"]')!.setAttribute('style','visibility:hidden'); }],
|
|
['inert ancestor', () => { document.querySelector('[data-testid="task-chat-history-content"]')!.setAttribute('inert',''); }],
|
|
['aria-hidden ancestor', () => { document.querySelector('[data-testid="task-chat-history-content"]')!.setAttribute('aria-hidden','true'); }],
|
|
['display:none', () => { document.querySelector('article')!.setAttribute('style','display:none'); }],
|
|
['transparent card', () => { document.querySelector('article')!.setAttribute('style','opacity:0'); }],
|
|
['missing layout box', () => { vi.mocked(Element.prototype.getClientRects).mockReturnValue([] as unknown as DOMRectList); }],
|
|
['missing supported card wrapper', () => { document.querySelector('[data-thread-item-kind="project_created"]')!.removeAttribute('data-thread-item-kind'); }],
|
|
['wrong item resource', () => { document.querySelector('[data-thread-item-kind="project_created"]')!.setAttribute('data-thread-anchor','project-created:other'); }],
|
|
['wrong card kind', () => { document.querySelector('article')!.setAttribute('aria-label','Goal created: Launch'); }],
|
|
['wrong card resource', () => { document.querySelector('article a')!.setAttribute('href','/RUN/projects/other'); }],
|
|
['wrong company route', () => { document.querySelector('article a')!.setAttribute('href','/OTHER/projects/project'); }],
|
|
['other origin', () => { document.querySelector('article a')!.setAttribute('href','https://other.test/RUN/projects/project'); }],
|
|
['wrong resource kind', () => { document.querySelector('article a')!.setAttribute('href','/RUN/goals/project'); }],
|
|
['extra destination path', () => { document.querySelector('article a')!.setAttribute('href','/RUN/projects/project/other'); }],
|
|
['duplicate visible source conversation', () => { document.body.insertAdjacentHTML('beforeend',thread()); }],
|
|
] as const)('rejects %s in actual DOM scope', (_label, mutate) => { mutate(); expect(collect()).toEqual([]); });
|
|
it("excludes hidden, wrong-company and unrelated inline anchors while keeping the current reply", () => {
|
|
document.querySelector('#comment-reply')!.innerHTML='<a href="/RUN/issues/task">Task</a><a hidden href="/RUN/issues/hidden">Hidden</a><a href="/OTHER/issues/task">Other company</a>';
|
|
document.body.insertAdjacentHTML('beforeend','<aside><a href="/RUN/issues/sidebar">Sidebar</a></aside>');
|
|
expect(collect().filter(row=>row.source==='reply').map(row=>row.href)).toEqual(['/RUN/issues/task']);
|
|
});
|
|
|
|
it("treats a link inside an authenticated reply as an ordinary inline link, not a sibling creation receipt", () => {
|
|
document.querySelector('#comment-reply')!.append(document.querySelector('article')!);
|
|
expect(collect().map(row=>row.source)).toEqual(['reply']);
|
|
});
|
|
|
|
it("recognizes the minimized authentic retained DOM and server identities without changing the original measurement", () => {
|
|
const fixture = JSON.parse(readFileSync(`${import.meta.dirname}/chat-rendered-links.retained-project.json`, "utf8"));
|
|
document.body.innerHTML = fixture.html;
|
|
const reply = fixture.input.comments.find((row: { authorAgentId?: string }) => row.authorAgentId);
|
|
expect(document.getElementById(`comment-${reply.id}`)!.querySelectorAll("a[href]")).toHaveLength(0);
|
|
expect(collect(fixture.input).map(row => row.href)).toEqual([fixture.expectedHref]);
|
|
});
|
|
|
|
it("retains ordinary inline links in the supported classic issue-chat root", () => {
|
|
document.body.innerHTML=thread('').replace('task-chat-thread','thread-root');
|
|
document.querySelector('#comment-reply')!.innerHTML='<a href="/RUN/issues/task">Task</a>';
|
|
expect(collect().map(row=>[row.threadTestId,row.source,row.href])).toEqual([['thread-root','reply','/RUN/issues/task']]);
|
|
});
|
|
|
|
// Full cohort on 9296c74e8d (choose-primary, case-history, set-budget): the API saw the saved reply ~2s before
|
|
// the open conversation rendered it, so links read in between were empty although the reply linked correctly.
|
|
describe("waits for the latest saved reply to render before reading links", () => {
|
|
const page = () => ({ evaluate: async <T, A>(read: (arg: A) => T, arg: A) => read(arg) }) as unknown as Parameters<typeof collectConversationLinks>[0];
|
|
it("reads the reply's links once the reply renders after the API saw it", async () => {
|
|
document.querySelector('#comment-reply')!.remove();
|
|
setTimeout(() => document.querySelector('[data-testid="task-chat-scroller"]')!.insertAdjacentHTML("beforeend", '<div id="comment-reply"><a href="/RUN/issues/task">Task</a></div>'), 600);
|
|
const value = input(); value.projects = []; value.activity = [];
|
|
expect((await collectConversationLinks(page(), value)).map(row => row.href)).toEqual(["/RUN/issues/task"]);
|
|
});
|
|
it("does not wait when the conversation is not open, and waits at most once for a reply that never renders", async () => {
|
|
const bindings = conversationLinkBindings(input());
|
|
document.body.innerHTML = "<main>Other page</main>";
|
|
expect(readLatestConversationReplyRender({ anchors: bindings.anchors, latest: "reply" })).toBe("closed");
|
|
document.body.innerHTML = thread(); document.querySelector('#comment-reply')!.remove();
|
|
const p = page();
|
|
expect(await waitForLatestConversationReplyRender(p, bindings, 300, 50)).toBe("pending");
|
|
const started = Date.now(); expect(await waitForLatestConversationReplyRender(p, bindings, 5_000, 50)).toBe("pending"); expect(Date.now() - started).toBeLessThan(1_000);
|
|
});
|
|
// Cohort 3 on dfd2a6efca (access.other-company, teams.install-team and six more): the final evidence read
|
|
// ran on a freshly loaded conversation whose wait had expired in an earlier document, so it read instantly
|
|
// and saved no links although the reply rendered them. A new document gets its own bounded wait.
|
|
it("waits again in a newly loaded document after an earlier document's wait expired", async () => {
|
|
const bindings = conversationLinkBindings(input());
|
|
document.body.innerHTML = thread(); document.querySelector('#comment-reply')!.remove();
|
|
const p = page();
|
|
expect(await waitForLatestConversationReplyRender(p, bindings, 200, 50)).toBe("pending");
|
|
delete (window as typeof window & { __paperclipConversationLinkDocument?: string }).__paperclipConversationLinkDocument;
|
|
setTimeout(() => document.querySelector('[data-testid="task-chat-scroller"]')!.insertAdjacentHTML("beforeend", '<div id="comment-reply"><a href="/RUN/issues/task">Task</a></div>'), 300);
|
|
expect(await waitForLatestConversationReplyRender(p, bindings, 5_000, 50)).toBe("rendered");
|
|
});
|
|
});
|
|
|
|
// Full cohort 3 (fast-responses, choose-primary, set-budget): the run was recorded finished 0.2-1.3s before its
|
|
// reply comment was saved, so a link read right after the run finished saw no reply at all.
|
|
describe("waits for a finished run's reply to be saved, then rendered, before its links are read", () => {
|
|
const page = () => ({ evaluate: async <T, A>(read: (arg: A) => T, arg: A) => read(arg) }) as unknown as Parameters<typeof collectConversationLinks>[0];
|
|
it("waits for the reply saved after the run finished and for its render", async () => {
|
|
const saved = input().comments, before = saved.filter(row => row.id !== "reply");
|
|
document.querySelector('#comment-reply')!.remove();
|
|
let reads = 0; const comments = async () => (reads++ < 3 ? before : saved);
|
|
setTimeout(() => document.querySelector('[data-testid="task-chat-scroller"]')!.insertAdjacentHTML("beforeend", '<div id="comment-reply"><a href="/RUN/issues/task">Task</a></div>'), 400);
|
|
expect(await waitForSavedTurnReplyRender(page(), { comments, leadId: "ceo", runIds: ["run"] }, 3_000, 50)).toBe("rendered");
|
|
expect(reads).toBeGreaterThan(3);
|
|
});
|
|
it("reports a run that saved no reply in time, without waiting past its bound", async () => {
|
|
const before = input().comments.filter(row => row.id !== "reply");
|
|
const started = Date.now();
|
|
expect(await waitForSavedTurnReplyRender(page(), { comments: async () => before, leadId: "ceo", runIds: ["run"] }, 300, 50)).toBe("unsaved");
|
|
expect(Date.now() - started).toBeLessThan(1_500);
|
|
});
|
|
});
|
|
|
|
describe("task links whose visible identifier names another task", () => {
|
|
it("accepts links whose label names the task they open, or names no task", () => {
|
|
expect(mislabeledIssueLinks("Open [COM-1](/COM/issues/COM-1) in [Launch](/COM/projects/launch).")).toEqual([]);
|
|
expect(mislabeledIssueLinks("The [open COM-1 task](/COM/issues/com-1#document-plan) and [this one](/COM/issues/COM-2).")).toEqual([]);
|
|
expect(mislabeledIssueLinks("[COM-1 Launch open website](/COM/issues/task-uuid)", id => id === "task-uuid" ? "COM-1" : undefined)).toEqual([]);
|
|
expect(mislabeledIssueLinks("[SHA-256 notes for COM-1](/COM/issues/COM-1)")).toEqual([]);
|
|
});
|
|
it("rejects a link whose label names a different task than it opens", () => {
|
|
expect(mislabeledIssueLinks("Open tasks: [COM-2 Launch open website](/COM/issues/COM-1)... correction:")).toEqual(["[COM-2 Launch open website](/COM/issues/COM-1)"]);
|
|
expect(mislabeledIssueLinks("[COM-2](/COM/issues/task-uuid)", id => id === "task-uuid" ? "COM-1" : undefined)).toHaveLength(1);
|
|
});
|
|
});
|