mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Bind ambiguous-task clarification to visible linked question cards
This commit is contained in:
1 parent
bcdcd0b242
commit
ed762ce875
6 files changed
+246
-8
No files matched your search
@@ -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": [],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { linkedQuestionBinding } from "./chat-linked-question.js";
|
||||
export type Input = Parameters<typeof linkedQuestionBinding>[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 `<main data-testid="task-chat-thread"><div id="comment-human">Mark the landing-page task as high priority.</div>
|
||||
<div data-thread-anchor="question" data-thread-item-kind="interaction"><button id="interaction-question" data-testid="task-chat-unanswered-question" aria-label="Answer question: Which Landing page task should be High priority?">Unanswered question</button></div>
|
||||
<section data-testid="task-chat-composer-takeover"><div data-testid="task-chat-composer-takeover-body">
|
||||
<div role="region" aria-label="Question context">Choose <a href="/RUN/issues/RUN-1">Launch Landing page</a> or <a href="/RUN/issues/experiment-task">Experiments Landing page</a> for High priority.</div>
|
||||
<p id="question-which-task-prompt">Which Landing page task should be High priority?</p><div data-testid="question-text-answer-composer" aria-labelledby="question-which-task-prompt"></div><button>Submit answers</button>
|
||||
</div></section></main>`;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<InternalObservation["runs"][number] & { finishedAt?: string | null }> };
|
||||
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<ReturnType<typeof linkedQuestionBinding>>) {
|
||||
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<ReturnType<typeof readLinkedQuestionDom>>;
|
||||
export type ClarificationObservation = InternalObservation & { linkedQuestionEvidence?: LinkedQuestionEvidence };
|
||||
export async function observeLinkedTaskQuestion(ctx: ChatCatalogContext, state: Record<string, unknown>, observation: InternalObservation): Promise<ClarificationObservation> {
|
||||
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<Entity[]>(`/api/issues/${source.id}/comments?order=asc`),
|
||||
ctx.input.api.get<AskUserQuestionsInteraction[]>(`/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`)}]`) });
|
||||
}
|
||||
Reference in new issue
Block a user