From ed762ce875b6fae65ad0d26da2f0b7b1efff33a3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 28 Oct 2026 14:37:29 -0500 Subject: [PATCH] Bind ambiguous-task clarification to visible linked question cards --- .../runner-e2e/chat-catalog-descriptors.json | 3 +- .../chat-catalog-domains/internal-tasks.ts | 24 +++-- .../chat-linked-question-grade.test.ts | 31 ++++++ .../chat-linked-question.test-fixture.ts | 25 +++++ tests/runner-e2e/chat-linked-question.test.ts | 74 ++++++++++++++ tests/runner-e2e/chat-linked-question.ts | 97 +++++++++++++++++++ 6 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 tests/runner-e2e/chat-linked-question-grade.test.ts create mode 100644 tests/runner-e2e/chat-linked-question.test-fixture.ts create mode 100644 tests/runner-e2e/chat-linked-question.test.ts create mode 100644 tests/runner-e2e/chat-linked-question.ts diff --git a/tests/runner-e2e/chat-catalog-descriptors.json b/tests/runner-e2e/chat-catalog-descriptors.json index 687cc12b500..093958e223a 100644 --- a/tests/runner-e2e/chat-catalog-descriptors.json +++ b/tests/runner-e2e/chat-catalog-descriptors.json @@ -5637,7 +5637,8 @@ "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/internal-tasks.ts", "chat-scenarios/tasks.yaml", - "chat-catalog-domains/internal-distinct-fixtures.ts" + "chat-catalog-domains/internal-distinct-fixtures.ts", + "chat-linked-question.ts" ], "oracleVersion": "paperclip.chat-catalog.internal.v1", "coverageGaps": [], diff --git a/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts b/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts index 764ddc4eb89..a12fe9ed525 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-tasks.ts @@ -1,4 +1,5 @@ import { savedDesignInstruction, withRenderedCommentReference, validRenderedCommentReference } from "./internal-comment-reference.js"; +import { linkedQuestionTakeover, observeLinkedTaskQuestion } from "../chat-linked-question.js"; import { withEditOwnership } from "./internal-edit-ownership.js"; import { verifyDistinctTaskFixtures } from "./internal-distinct-fixtures.js"; import { delegatedTaskCases } from "./internal-task-creation.js"; @@ -90,28 +91,37 @@ function ambiguousTask(): ChatCatalogCase { await verifyDistinctTaskFixtures(ctx, [t, otherTask]); return { project: p, task: t, otherProject, otherTask }; }); - return { ...base, sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-distinct-fixtures.ts"], expectedRunCount: 2, expectedHumanMessageCount: 2, attemptTimeoutMs: 420_000, + return { ...base, sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-distinct-fixtures.ts", "chat-linked-question.ts"], expectedRunCount: 2, expectedHumanMessageCount: 2, attemptTimeoutMs: 420_000, async act(ctx, state) { - const first = await pollUntil({ label: "Task ambiguity clarification", deadlineAt: Date.now() + 150_000, + const plain = await pollUntil({ label: "Task ambiguity clarification", deadlineAt: Date.now() + 150_000, load: () => observeInternal(ctx, state), accept: o => o.runs.length === 1 && o.runs.every(row => row.status === "succeeded") && ctx.issue()?.conversationState === "waiting" }); + const first = await observeLinkedTaskQuestion(ctx, state, plain); state.clarification = { ...first, state: structuredClone(state) }; await ctx.input.evidence("chat-catalog-task-clarification.json", state.clarification); const selected = entity(state, "task"); const other = entity(state, "otherTask"); if (!sameTask(selected, first.tasks.find(row => row.id === selected.id)) || !sameTask(other, first.tasks.find(row => row.id === other.id))) throw new Error("Ambiguous task changed before the person chose a source"); const answer = `The Landing page task in Launch (${selected.identifier ?? selected.id}).`; - const textInput = ctx.input.page.getByTestId("question-text-answer-composer").last(); + const card = first.linkedQuestionEvidence; + if (card) { + const fresh = (await observeLinkedTaskQuestion(ctx, state, plain)).linkedQuestionEvidence; + if (!fresh || fresh.interactionId !== card.interactionId || fresh.questionId !== card.questionId || fresh.payloadDigest !== card.payloadDigest) + throw new Error("The actual linked clarification question changed before the person's selection"); + } + const answerRoot = card ? linkedQuestionTakeover(ctx.input.page, card) : ctx.input.page; + const textInput = answerRoot.getByTestId("question-text-answer-composer").last(); if (await textInput.isVisible()) { await textInput.locator('[contenteditable="true"],textarea').first().fill(answer); - await ctx.input.page.getByRole("button", { name: /Submit answers|Submit|Continue/, exact: true }).last().click(); + await answerRoot.getByRole("button", { name: /Submit answers|Submit|Continue/, exact: true }).last().click(); } else { - const otherChoice = ctx.input.page.getByRole("radio", { name: "Other", exact: true }).last(); + const otherChoice = answerRoot.getByRole("radio", { name: "Other", exact: true }).last(); if (await otherChoice.isVisible()) { await otherChoice.click(); - await ctx.input.page.getByTestId("question-other-answer-composer").last().locator('[contenteditable="true"],textarea').first().fill(answer); - await ctx.input.page.getByRole("button", { name: /Submit answers|Submit|Continue/, exact: true }).last().click(); + await answerRoot.getByTestId("question-other-answer-composer").last().locator('[contenteditable="true"],textarea').first().fill(answer); + await answerRoot.getByRole("button", { name: /Submit answers|Submit|Continue/, exact: true }).last().click(); } else { + if (card) throw new Error("The linked question has no supported explicit clarification input"); const { sendChatMessage } = await import("../chat-flow.js"); await sendChatMessage(ctx.input.page, answer); } } diff --git a/tests/runner-e2e/chat-linked-question-grade.test.ts b/tests/runner-e2e/chat-linked-question-grade.test.ts new file mode 100644 index 00000000000..b0b2a0e70b8 --- /dev/null +++ b/tests/runner-e2e/chat-linked-question-grade.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from "vitest"; +import { linkedQuestionBinding } from "./chat-linked-question.js"; +import { valid } from "./chat-linked-question.test-fixture.js"; +import { grade_tasks } from "./chat-catalog-domains/internal-tasks.js"; +import { createIssueThreadInteractionSchema } from "../../packages/shared/src/validators/issue.js"; +it("accepts the actual public modern-question contract and its compatibility storage projection", () => { + const input = valid(); + const parsed = createIssueThreadInteractionSchema.parse({ kind: "ask_user_questions", payload: input.interactions[0]!.payload }); + expect(parsed.kind).toBe("ask_user_questions"); + if (parsed.kind !== "ask_user_questions") throw new Error("Unexpected question contract"); + expect(parsed.payload.questions[0]?.id).toBe("which-task"); + input.interactions[0]!.payload = parsed.payload; + expect(linkedQuestionBinding(input)?.questionId).toBe("which-task"); +}); +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 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]!)], + links: ["/RUN/issues/RUN-1"], activity: [{ id: "update", action: "issue.updated", entityId: "launch-task", agentId: "ceo" }] }; + expect(grade_tasks({ ...final, state: { ...final.state, clarification: { ...first, reply: "", links: [] } } }).passed).toBe(false); + expect(grade_tasks(final).passed).toBe(true); + const early = structuredClone(final); early.state.clarification.tasks[0]!.priority = "high"; + expect(grade_tasks(early).passed).toBe(false); + const wrong = structuredClone(final); wrong.tasks[0]!.priority = "medium"; wrong.tasks[1]!.priority = "high"; + expect(grade_tasks(wrong).passed).toBe(false); + const actor = structuredClone(final); actor.activity[0]!.agentId = "other"; + expect(grade_tasks(actor).passed).toBe(false); + expect(grade_tasks({ ...final, links: [] }).passed).toBe(false); +}); diff --git a/tests/runner-e2e/chat-linked-question.test-fixture.ts b/tests/runner-e2e/chat-linked-question.test-fixture.ts new file mode 100644 index 00000000000..d37917e2393 --- /dev/null +++ b/tests/runner-e2e/chat-linked-question.test-fixture.ts @@ -0,0 +1,25 @@ +import type { linkedQuestionBinding } from "./chat-linked-question.js"; +export type Input = Parameters[0]; +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], + 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], + runs: [{ id: "run", companyId: "company", agentId: "ceo", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "source" }, startedAt: "2026-10-28T12:00:00Z", finishedAt: "2026-10-28T12:01:00Z" }], + activity: [{ id: "action", companyId: "company", action: "issue.thread_interaction_created", entityType: "issue", entityId: "source", actorType: "agent", actorId: "ceo", agentId: "ceo", runId: "run", details: { interactionId: "question", interactionKind: "ask_user_questions" } } as never] }, + interactions: [{ id: "question", companyId: "company", issueId: "source", kind: "ask_user_questions", status: "pending", createdByAgentId: "ceo", createdByUserId: null, + sourceRunId: "run", originCommentIds: ["human"], addresseeUserId: "person", addresseeAgentId: null, effectiveResolverPolicy: "human_only", createdAt: "2026-10-28T12:00:30Z", + payload: { version: 1, questionSet: { schema: "paperclip.question_set.v1", description: "Choose [Launch Landing page](/RUN/issues/RUN-1) or [Experiments Landing page](/RUN/issues/experiment-task) for High priority.", + questions: [{ id: "which-task", prompt: "Which Landing page task should be High priority?", answerMode: "text", required: true }] } } } as never] }; +} +export function dom() { + return `
Mark the landing-page task as high priority.
+
+
+
Choose Launch Landing page or Experiments Landing page for High priority.
+

Which Landing page task should be High priority?

+
`; +} diff --git a/tests/runner-e2e/chat-linked-question.test.ts b/tests/runner-e2e/chat-linked-question.test.ts new file mode 100644 index 00000000000..db9adcd1a66 --- /dev/null +++ b/tests/runner-e2e/chat-linked-question.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { linkedQuestionBinding, readLinkedQuestionDom } from "./chat-linked-question.js"; +import { valid, dom, type Input } from "./chat-linked-question.test-fixture.js"; + +beforeEach(() => { document.body.innerHTML = dom(); vi.spyOn(Element.prototype, "getClientRects").mockReturnValue([{ width: 20, height: 20 }] as unknown as DOMRectList); }); +afterEach(() => vi.restoreAllMocks()); +const collect = (input = valid()) => { const bound = linkedQuestionBinding(input); return bound && readLinkedQuestionDom(bound); }; +it("collects an actual production question takeover's two visible saved-task links with no ordinary reply", () => { + expect(document.querySelectorAll('[id^="comment-"] a')).toHaveLength(0); + const evidence = collect(); + expect(evidence?.links).toEqual(["/RUN/issues/RUN-1", "/RUN/issues/experiment-task"]); + 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.each([ + ["wrong company", (v: Input) => { v.interactions[0]!.companyId = "foreign"; }], + ["wrong conversation", (v: Input) => { v.interactions[0]!.issueId = "other-source"; }], + ["wrong CEO", (v: Input) => { v.interactions[0]!.createdByAgentId = "another-agent"; }], + ["human-created card", (v: Input) => { v.interactions[0]!.createdByUserId = "person"; }], + ["wrong run", (v: Input) => { v.interactions[0]!.sourceRunId = "old-run"; }], + ["stale card", (v: Input) => { v.interactions[0]!.createdAt = "2026-10-27T12:00:00Z"; }], + ["future card", (v: Input) => { v.interactions[0]!.createdAt = "2026-10-29T12:00:00Z"; }], + ["resolved card", (v: Input) => { v.interactions[0]!.status = "accepted"; }], + ["dismissed card", (v: Input) => { v.interactions[0]!.dismissedAt = "2026-10-28T12:00:40Z"; }], + ["wrong addressed person", (v: Input) => { v.interactions[0]!.addresseeUserId = "other"; }], + ["agent addressed", (v: Input) => { v.interactions[0]!.addresseeAgentId = "ceo"; }], + ["invalid resolver", (v: Input) => { v.interactions[0]!.effectiveResolverPolicy = "invalid" as never; }], + ["wrong origin question", (v: Input) => { v.interactions[0]!.originCommentIds = ["old-human"]; }], + ["foreign origin comment", (v: Input) => { v.interactions[0]!.originCommentIds!.push("foreign-comment"); }], + ["wrong source comment", (v: Input) => { v.interactions[0]!.sourceCommentId = "other-human"; }], + ["missing create action", (v: Input) => { v.observation.activity = []; }], + ["wrong create actor", (v: Input) => { v.observation.activity[0]!.actorId = "other"; }], + ["wrong create company", (v: Input) => { v.observation.activity[0]!.companyId = "foreign"; }], + ["wrong action run", (v: Input) => { Object.assign(v.observation.activity[0]!, { runId: "old-run" }); }], + ["wrong action resource kind", (v: Input) => { Object.assign(v.observation.activity[0]!, { entityType: "project" }); }], + ["wrong action question", (v: Input) => { v.observation.activity[0]!.details!.interactionId = "other"; }], + ["wrong action kind", (v: Input) => { v.observation.activity[0]!.details!.interactionKind = "request_confirmation"; }], + ["legacy run", (v: Input) => { v.observation.runs[0]!.runtimeMode = "legacy"; }], + ["unrelated run issue", (v: Input) => { v.observation.runs[0]!.contextSnapshot = { issueId: "other" }; }], + ["foreign saved task", (v: Input) => { v.tasks[0]!.companyId = "foreign"; }], + ["wrong actual task", (v: Input) => { v.observation.tasks[0]!.id = "other"; }], + ["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."; }], + ["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(); +}); +it.each([ + ["foreign company", "/OTHER/issues/RUN-1"], ["foreign origin", "https://foreign.test/RUN/issues/RUN-1"], ["wrong task", "/RUN/issues/wrong"], + ["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); + expect(collect(input)).toBeUndefined(); +}); +const domNegatives: Array<[string, () => void]> = [ + ["missing exact interaction", () => document.querySelector("button")!.id = "interaction-other"], + ["wrong visible question", () => document.querySelector("p")!.textContent = "Another question?"], + ["wrong question binding", () => document.querySelector("p")!.id = "question-other-prompt"], + ["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")], + ["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()], + ["ambiguous conversation", () => document.body.insertAdjacentHTML("beforeend", dom())], + ["unrelated takeover", () => document.querySelector("section")!.setAttribute("data-testid", "another-takeover")], +]; +it.each(domNegatives)("rejects %s DOM evidence", (_, change) => { + change(); + expect(collect()).toBeUndefined(); +}); diff --git a/tests/runner-e2e/chat-linked-question.ts b/tests/runner-e2e/chat-linked-question.ts new file mode 100644 index 00000000000..7157c9a48f1 --- /dev/null +++ b/tests/runner-e2e/chat-linked-question.ts @@ -0,0 +1,97 @@ +import { createHash } from "node:crypto"; +import type { Locator, Page } from "@playwright/test"; +import type { AskUserQuestionsInteraction } from "../../packages/shared/src/types/issue.js"; +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; + observation: InternalObservation & { runs: Array }; + comments: Entity[]; interactions: AskUserQuestionsInteraction[]; tasks: Entity[]; +} +export function linkedQuestionBinding(input: QuestionInput) { + const o = input.observation, run = o.runs[0]; + if (o.runs.length !== 1 || !run || run.companyId !== o.companyId || run.agentId !== input.leadId || run.runtimeMode !== "native" || run.status !== "succeeded" + || run.contextSnapshot?.issueId !== input.sourceId || input.tasks.length !== 2 || new Set(input.tasks.map(task => task.id)).size !== 2 + || input.tasks.some(task => task.companyId !== o.companyId || !o.tasks.some(saved => saved.id === task.id && saved.companyId === task.companyId))) return; + const humans = input.comments.filter(comment => comment.companyId === o.companyId && comment.issueId === input.sourceId && !comment.authorAgentId + && !!comment.authorUserId && comment.body === input.query); + if (humans.length !== 1 || o.humanQueries.length !== 1 || o.humanQueries[0] !== input.query) return; + const human = humans[0]!, start = Date.parse(run.startedAt ?? ""), end = Date.parse(run.finishedAt ?? ""); + if (!Number.isFinite(start) || !Number.isFinite(end)) return; + const candidates = input.interactions.filter(card => card.kind === "ask_user_questions" && card.status === "pending" && !card.dismissedAt && !card.resolvedAt + && card.companyId === o.companyId && card.issueId === input.sourceId && card.createdByAgentId === input.leadId && !card.createdByUserId + && card.sourceRunId === run.id && !card.addresseeAgentId && (!card.addresseeUserId || card.addresseeUserId === human.authorUserId) + && ["anyone", "human_only", "not_creator"].includes(card.effectiveResolverPolicy) + && (card.originCommentIds?.includes(human.id) || card.sourceCommentId === human.id) + && (card.originCommentIds ?? []).every(id => input.comments.some(comment => comment.id === id && comment.companyId === o.companyId && comment.issueId === input.sourceId)) + && (!card.sourceCommentId || card.sourceCommentId === human.id) + && Date.parse(String(card.createdAt)) >= start && Date.parse(String(card.createdAt)) <= end + && o.activity.some(event => event.companyId === o.companyId && event.action === "issue.thread_interaction_created" + && (event as Entity & { entityType?: string }).entityType === "issue" && event.entityId === input.sourceId + && event.actorType === "agent" && event.actorId === input.leadId && event.agentId === input.leadId + && (event as Entity & { runId?: string }).runId === run.id && event.details?.interactionId === card.id && event.details?.interactionKind === "ask_user_questions")); + 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, + 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 }> }; +} +export function readLinkedQuestionDom(input: NonNullable>) { + const visible = (element: Element) => { + for (let node: Element | null = element; node; node = node.parentElement) { + if (node.hasAttribute("hidden") || node.hasAttribute("inert") || node.getAttribute("aria-hidden") === "true") return false; + const style = window.getComputedStyle(node); + if (style.display === "none" || ["hidden", "collapse"].includes(style.visibility) || style.opacity === "0") return false; + } + return element.getClientRects().length > 0; + }; + const selector = '[data-testid="task-chat-thread"]', clean = (value: string) => value.replace(/\s+/g, " ").trim(); + const roots = Array.from(document.querySelectorAll(selector)).filter(root => visible(root) && Array.from(root.querySelectorAll("[id]")).some(node => + node.id === `comment-${input.anchorCommentId}` && node.closest(selector) === root)); + if (roots.length !== 1) return; + const root = roots[0]!, same = (node: Element) => node.closest(selector) === root; + const button = Array.from(root.querySelectorAll('[data-testid="task-chat-unanswered-question"]')).filter(node => + node.id === `interaction-${input.interactionId}` && same(node) && visible(node) && node.getAttribute("aria-label") === `Answer question: ${input.prompt}`); + if (button.length !== 1) return; + const takeovers = Array.from(root.querySelectorAll('[data-testid="task-chat-composer-takeover"]')).filter(node => visible(node) && same(node)); + if (takeovers.length !== 1) return; + const takeover = takeovers[0]!, prompts = Array.from(takeover.querySelectorAll("[id]")).filter(node => node.id === `${input.interactionId}-${input.questionId}-prompt` + && visible(node) && same(node) && clean(node.textContent ?? "") === clean(input.prompt)); + const contexts = Array.from(takeover.querySelectorAll('[role="region"][aria-label="Question context"]')).filter(node => 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 ?? "" }; +} +export type LinkedQuestionEvidence = NonNullable>; +export type ClarificationObservation = InternalObservation & { linkedQuestionEvidence?: LinkedQuestionEvidence }; +export async function observeLinkedTaskQuestion(ctx: ChatCatalogContext, state: Record, observation: InternalObservation): Promise { + const source = ctx.issue(), prefix = ctx.input.fixtures.company.issuePrefix; + if (!source || !prefix || ctx.scenario.id !== "tasks.ambiguous-task") return observation; + const [comments, interactions] = await Promise.all([ + 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, + 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); + return evidence ? { ...observation, reply: [observation.reply, evidence.text].filter(Boolean).join("\n"), links: [...new Set([...observation.links, ...evidence.links])], linkedQuestionEvidence: evidence } : observation; +} +export function linkedQuestionTakeover(page: Page, evidence: LinkedQuestionEvidence): Locator { + return page.getByTestId("task-chat-thread").filter({ has: page.locator(`[id=${JSON.stringify(`comment-${evidence.anchorCommentId}`)}]`) }) + .getByTestId("task-chat-composer-takeover").filter({ has: page.locator(`[id=${JSON.stringify(`${evidence.interactionId}-${evidence.questionId}-prompt`)}]`) }); +}