mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The chat eval catalog drives real chat journeys in a browser and grades saved product state > - In full cohort 3, four cases acted on the page before the page was ready, or did not step through a card > - The product was correct in each case, so these failures hide real product failures > - This pull request makes the harness wait for the saved, rendered reply and for a settled form, and step through multi-question cards > - The benefit is fewer false failures, with the same product requirements ## Linked Issues or Issue Description Refs #21000 Refs #21008 **What happened?** - `companies.fast-responses`, `agents.choose-primary`, `budgets-costs.set-budget`: the server recorded the chat run as finished 0.2 to 1.3 seconds before it saved the reply comment. The harness read the reply links right after the run finished. It found no reply, so it found no links. - `budgets-costs.set-budget`: the budget card remounted after the harness typed the new amount. The remount reset the draft, so the Update budget button stayed disabled until the click timed out. - `questions-and-approvals.necessary-question`: the agent asked the required product-name question and one optional question. The card showed Next on its first page. The harness only clicked Submit answers. The grade also required exactly one question on the card. **Expected behavior** The harness reads a turn's links after its reply is saved and rendered. It saves the budget only on a settled form. It pages through a card, answers its one required question and skips optional ones. **Steps to reproduce** Run the three link cases when the reply is saved after the run finishes. Run set-budget when the budget card reloads after typing. Run necessary-question when the card has an optional second question. ## What Changed - `chat-rendered-links.ts`: new shared `waitForSavedTurnReplyRender`. It waits (bounded, 15 s) until each finished lead run's reply is saved, then uses the existing render wait for the latest reply. It returns `unsaved` when no reply arrives. The caller then fails with its own error. - `identity-support.ts`: new `waitForTurnReplyLinks` uses that wait for the source conversation. `companies.fast-responses` (v3), `agents.choose-primary` (v4) and `budgets-costs.set-budget` (v3) call it before they read reply links. - `budgets-costs.set-budget`: `settledInput` resolves when the same input node stays on the page for one second. The harness types and saves on that node. If the card still remounts and drops the amount, it types it again, at most three times. - `questions-and-approvals.necessary-question` (v2): the harness pages through a multi-question card. It answers the one required question and clicks Skip on each optional question. The grade now requires exactly one required question, and that question must ask for the product name as free text. Other questions must be optional. An optional necessary question, a second required question, or a required question about something else still fails. - Descriptors are updated. No scenario yaml changed, so the TestOps manifest does not change. ## Verification - `npx vitest run --config tests/runner-e2e/vitest.config.ts tests/runner-e2e/chat-rendered-links.test.ts tests/runner-e2e/chat-catalog-consistency.test.ts tests/runner-e2e/chat-catalog-fingerprint.test.ts tests/runner-e2e/chat-catalog-domains/identity.test.ts`: 100 tests pass. - New tests: a reply saved after the run finished is waited for and then rendered; a run that saves no reply returns `unsaved` within its bound. The necessary-question grade accepts the recorded card (required name plus optional follow-up) and rejects the broken cards above. - `npx tsc -p tests/runner-e2e/tsconfig.json --noEmit` passes. - `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check` reports no change. - One paid run on a combined branch with #21008. The result is in a comment on this pull request. ## Risks - A turn whose run saves no reply now waits up to 15 seconds before the case continues. Only the three named cases call the wait. - `settledInput` marks the input node with a `data-e2e-settle-mark` attribute to detect a remount. It does not change product behavior. - #21000 changes the render-wait expiry in the same file. Whichever merges second rebases. ## 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>
147 lines
14 KiB
TypeScript
147 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { readFileSync } from "node:fs";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { collectConversationLinks, conversationLinkBindings, 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);
|
|
});
|
|
});
|