From 0098da56f4c10f3154ed4ed96b1404fdf7a021ca Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 28 Oct 2026 14:50:59 -0500 Subject: [PATCH] Collect task choices from the bound rendered question context --- .../chat-linked-question-grade.test.ts | 2 +- .../chat-linked-question.test-fixture.ts | 2 +- tests/runner-e2e/chat-linked-question.test.ts | 22 +++++++++++- tests/runner-e2e/chat-linked-question.ts | 35 ++++++++++--------- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/tests/runner-e2e/chat-linked-question-grade.test.ts b/tests/runner-e2e/chat-linked-question-grade.test.ts index b0b2a0e70b8..11a503d5e5d 100644 --- a/tests/runner-e2e/chat-linked-question-grade.test.ts +++ b/tests/runner-e2e/chat-linked-question-grade.test.ts @@ -14,7 +14,7 @@ it("accepts the actual public modern-question contract and its compatibility sto }); it("preserves original no-early-write, exact person selection, mutation actor and final link checks", () => { const input = valid(), binding = linkedQuestionBinding(input)!; - const evidence = { text: binding.prompt, links: binding.links.map(link => link.href) }; + const evidence = { text: binding.prompt, links: ["/RUN/issues/RUN-1", "/RUN/issues/experiment-task"] }; const first = { ...input.observation, reply: evidence.text, links: evidence.links }; const final = { ...structuredClone(input.observation), state: { ...input.observation.state, clarification: first }, tasks: [{ ...input.observation.tasks[0]!, priority: "high" }, structuredClone(input.observation.tasks[1]!)], diff --git a/tests/runner-e2e/chat-linked-question.test-fixture.ts b/tests/runner-e2e/chat-linked-question.test-fixture.ts index d37917e2393..1f9775beeec 100644 --- a/tests/runner-e2e/chat-linked-question.test-fixture.ts +++ b/tests/runner-e2e/chat-linked-question.test-fixture.ts @@ -4,7 +4,7 @@ export function valid(): Input { const task = { id: "launch-task", identifier: "RUN-1", companyId: "company", title: "Landing page", priority: "medium", projectId: "launch" }; const other = { ...task, id: "experiment-task", identifier: "RUN-2", projectId: "experiments" }; const query = "Mark the landing-page task as high priority."; - return { origin: "https://fixture.test", companyPrefix: "RUN", sourceId: "source", leadId: "ceo", query, tasks: [task, other], + return { origin: "https://fixture.test", pageUrl: "https://fixture.test/RUN/chats/ceo", companyPrefix: "RUN", sourceId: "source", leadId: "ceo", query, tasks: [task, other], comments: [{ id: "human", companyId: "company", issueId: "source", body: query, authorUserId: "person", authorAgentId: null }], observation: { scenarioId: "tasks.ambiguous-task", origin: "https://fixture.test", companyId: "company", leadId: "ceo", state: { task, otherTask: other }, tasks: [structuredClone(task), structuredClone(other)], projects: [], goals: [], comments: [], documents: [], revisions: [], links: [], reply: "", humanQueries: [query], diff --git a/tests/runner-e2e/chat-linked-question.test.ts b/tests/runner-e2e/chat-linked-question.test.ts index db9adcd1a66..0797c9e1190 100644 --- a/tests/runner-e2e/chat-linked-question.test.ts +++ b/tests/runner-e2e/chat-linked-question.test.ts @@ -13,6 +13,24 @@ it("collects an actual production question takeover's two visible saved-task lin expect(evidence?.text).toBe("Which Landing page task should be High priority?"); expect(evidence).toMatchObject({ interactionId: "question", questionId: "which-task", anchorCommentId: "human", sourceRunId: "run", leadId: "ceo" }); }); +it("keeps supported unprefixed issue hrefs with exact saved identities and current company page binding", () => { + const input = valid(); + input.interactions[0]!.payload.questionSet!.description = "Choose [Launch](/issues/launch-task) or [Experiments](/issues/experiment-task)."; + const anchors = document.querySelectorAll("a"); + anchors[0]!.setAttribute("href", "/issues/launch-task"); anchors[1]!.setAttribute("href", "/issues/experiment-task"); + expect(collect(input)?.links).toEqual(["/issues/launch-task", "/issues/experiment-task"]); + for (const pageUrl of ["https://fixture.test/OTHER/chats/ceo", "https://foreign.test/RUN/chats/ceo", "https://fixture.test/chats/ceo"]) { + input.pageUrl = pageUrl; expect(collect(input)).toBeUndefined(); + } +}); +it.each([ + "Choose [Launch][launch] or [Experiments][experiments].\n\n[launch]: /RUN/issues/RUN-1\n[experiments]: /RUN/issues/experiment-task", + "Choose [Launch](/RUN/issues/RUN-1) or [Experiments](/RUN/issues/experiment-task). Context: [Launch project](/RUN/projects/launch).", +])("reads actual rendered task hrefs without a second Markdown spelling or context policy: %s", description => { + const input = valid(); input.interactions[0]!.payload.questionSet!.description = description; + document.querySelector('[aria-label="Question context"]')!.insertAdjacentHTML("beforeend", 'Launch projectContext'); + expect(collect(input)?.links).toEqual(["/RUN/issues/RUN-1", "/RUN/issues/experiment-task"]); +}); it.each([ ["wrong company", (v: Input) => { v.interactions[0]!.companyId = "foreign"; }], ["wrong conversation", (v: Input) => { v.interactions[0]!.issueId = "other-source"; }], @@ -43,7 +61,7 @@ it.each([ ["duplicate saved task", (v: Input) => { v.tasks[1] = v.tasks[0]!; }], ["old human request", (v: Input) => { v.comments[0]!.body = "another query"; }], ["nonhuman original request", (v: Input) => { v.comments[0]!.authorAgentId = "ceo"; }], - ["missing linked context", (v: Input) => { v.interactions[0]!.payload.questionSet!.description = "Choose a task."; }], + ["missing linked context", (v: Input) => { v.interactions[0]!.payload.questionSet!.description = ""; }], ["another native action", (v: Input) => { v.interactions.push(structuredClone(v.interactions[0]!)); }], ] as const)("rejects %s metadata rather than any question-card presence", (_, change) => { const input = valid(); change(input); expect(collect(input)).toBeUndefined(); @@ -53,6 +71,7 @@ it.each([ ["task search", "/RUN/issues?search=RUN-1"], ["fragment", "/RUN/issues/RUN-1#unrelated"], ] as const)("rejects %s saved question link", (_, href) => { const input = valid(); input.interactions[0]!.payload.questionSet!.description = input.interactions[0]!.payload.questionSet!.description!.replace("/RUN/issues/RUN-1", href); + document.querySelector("a")!.setAttribute("href", href); expect(collect(input)).toBeUndefined(); }); const domNegatives: Array<[string, () => void]> = [ @@ -62,6 +81,7 @@ const domNegatives: Array<[string, () => void]> = [ ["hidden takeover", () => document.querySelector("section")!.hidden = true], ["hidden prompt", () => document.querySelector("p")!.setAttribute("aria-hidden", "true")], ["hidden linked task", () => document.querySelector("a")!.setAttribute("style", "display:none")], + ["no actual context links", () => document.querySelectorAll("a").forEach(node => node.remove())], ["wrong DOM link", () => document.querySelector("a")!.setAttribute("href", "/RUN/issues/wrong")], ["sidebar link only", () => document.body.append(document.querySelector("a")!)], ["unrelated conversation", () => document.querySelector("main")!.querySelector("#comment-human")!.remove()], diff --git a/tests/runner-e2e/chat-linked-question.ts b/tests/runner-e2e/chat-linked-question.ts index 7157c9a48f1..42dbb47f0f2 100644 --- a/tests/runner-e2e/chat-linked-question.ts +++ b/tests/runner-e2e/chat-linked-question.ts @@ -5,7 +5,7 @@ import type { ChatCatalogContext } from "./chat-catalog-contract.js"; import type { InternalObservation, Entity } from "./chat-catalog-domains/internal-support.js"; interface QuestionInput { - origin: string; companyPrefix: string; sourceId: string; leadId: string; query: string; + origin: string; pageUrl: string; companyPrefix: string; sourceId: string; leadId: string; query: string; observation: InternalObservation & { runs: Array }; comments: Entity[]; interactions: AskUserQuestionsInteraction[]; tasks: Entity[]; } @@ -34,19 +34,9 @@ export function linkedQuestionBinding(input: QuestionInput) { if (candidates.length !== 1) return; const card = candidates[0]!, set = card.payload.questionSet, question = set?.questions[0]; if (set?.schema !== "paperclip.question_set.v1" || !set.description || !question?.id || !question.prompt) return; - const hrefs = [...set.description.matchAll(/\[[^\]]+\]\(([^)\s]+)\)/g)].map(match => match[1]!); - const links = hrefs.map(href => { - try { - const url = new URL(href, input.origin), parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent); - if (url.origin !== new URL(input.origin).origin || url.search || url.hash || parts.length !== 3 || parts[0] !== input.companyPrefix || parts[1] !== "issues") return; - const task = input.tasks.find(task => [task.id, task.identifier].includes(parts[2])); - return task ? { href, taskId: task.id } : undefined; - } catch { return; } - }); - if (!links.length || links.some(link => !link) || new Set(links.map(link => link!.taskId)).size !== 2) return; - return { origin: input.origin, sourceId: input.sourceId, anchorCommentId: human.id, interactionId: card.id, questionId: question.id, + return { origin: input.origin, pageUrl: input.pageUrl, companyPrefix: input.companyPrefix, tasks: input.tasks.map(task => ({ id: task.id, identifier: task.identifier })), sourceId: input.sourceId, anchorCommentId: human.id, interactionId: card.id, questionId: question.id, prompt: question.prompt, sourceRunId: run.id, leadId: input.leadId, - payloadDigest: createHash("sha256").update(JSON.stringify(card.payload)).digest("hex"), links: links as Array<{ href: string; taskId: string }> }; + payloadDigest: createHash("sha256").update(JSON.stringify(card.payload)).digest("hex") }; } export function readLinkedQuestionDom(input: NonNullable>) { const visible = (element: Element) => { @@ -72,9 +62,20 @@ export function readLinkedQuestionDom(input: NonNullable visible(node) && same(node)); if (prompts.length !== 1 || contexts.length !== 1) return; const links = Array.from(contexts[0]!.querySelectorAll("a[href]")).filter(node => visible(node) && same(node)).map(node => node.getAttribute("href")!); - if (!links.length || links.some(href => !input.links.some(link => link.href === href)) - || new Set(input.links.filter(link => links.includes(link.href)).map(link => link.taskId)).size !== 2) return; - return { ...input, links: [...new Set(links)], text: prompts[0]!.textContent ?? "" }; + const qualified = links.flatMap(href => { + try { + const url = new URL(href, input.origin), page = new URL(input.pageUrl); + const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent); + const unprefixed = parts.length === 2 && parts[0] === "issues"; + if (url.origin !== new URL(input.origin).origin || url.search || url.hash + || !(parts.length === 3 && parts[0] === input.companyPrefix && parts[1] === "issues" || unprefixed + && page.origin === url.origin && page.pathname.split("/").filter(Boolean)[0] === input.companyPrefix)) return []; + const id = parts[unprefixed ? 1 : 2], task = input.tasks.find(task => [task.id, task.identifier].includes(id)); + return task ? [{ href, taskId: task.id }] : []; + } catch { return []; } + }); + if (new Set(qualified.map(link => link.taskId)).size !== 2) return; + return { ...input, links: [...new Set(qualified.map(link => link.href))], text: prompts[0]!.textContent ?? "" }; } export type LinkedQuestionEvidence = NonNullable>; export type ClarificationObservation = InternalObservation & { linkedQuestionEvidence?: LinkedQuestionEvidence }; @@ -85,7 +86,7 @@ export async function observeLinkedTaskQuestion(ctx: ChatCatalogContext, state: ctx.input.api.get(`/api/issues/${source.id}/comments?order=asc`), ctx.input.api.get(`/api/issues/${source.id}/interactions`), ]); - const binding = linkedQuestionBinding({ origin: observation.origin, companyPrefix: prefix, sourceId: source.id, leadId: observation.leadId, + const binding = linkedQuestionBinding({ origin: observation.origin, pageUrl: ctx.input.page.url(), companyPrefix: prefix, sourceId: source.id, leadId: observation.leadId, query: ctx.scenario.query, observation, comments, interactions, tasks: [state.task as Entity, state.otherTask as Entity] }); if (!binding) return observation; const evidence = await ctx.input.page.evaluate(readLinkedQuestionDom, binding);