Collect task choices from the bound rendered question context

This commit is contained in:
Dotta committed 2026-10-28 14:50:59 -05:00
1 parent ed762ce875
commit 0098da56f4
4 files changed
+41 -20

No files matched your search

@@ -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]!)],
@@ -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],
+21 -1
View File
@@ -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", '<a href="/RUN/projects/launch">Launch project</a><a href="https://docs.example.test/context">Context</a>');
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()],
+18 -17
View File
@@ -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<InternalObservation["runs"][number] & { finishedAt?: string | null }> };
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<ReturnType<typeof linkedQuestionBinding>>) {
const visible = (element: Element) => {
@@ -72,9 +62,20 @@ export function readLinkedQuestionDom(input: NonNullable<ReturnType<typeof linke
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 ?? "" };
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<ReturnType<typeof readLinkedQuestionDom>>;
export type ClarificationObservation = InternalObservation & { linkedQuestionEvidence?: LinkedQuestionEvidence };
@@ -85,7 +86,7 @@ export async function observeLinkedTaskQuestion(ctx: ChatCatalogContext, state:
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,
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);