Files
PaperClipAI/tests/runner-e2e/native-task-reply.ts
T
DottaandPaperclip b3c2172d41 fix(evals): verify native protected-action outcomes (#18255)
## Thinking Path

> - Paperclip manages work assigned to agents.
> - Product E2E stories ask for useful user-facing results.
> - Classic runner and deployed QA reuse the same service
approve/decline task prompts.
> - The service returns a receipt marker that belongs in a saved
briefing document.
> - State that destination explicitly and ask for the document link when
complete.
> - The deployed decline path also needs the same durable final-reply
proof as Plan and Stop.

## Linked Issues or Issue Description

Refs #18222. Builds on the merged reply observer in #18248.

**What happened?**
The shared service tasks ask for titles and a “verification code”
without specifying that the code belongs in the document or asking for
its final link. A native QA attempt created its briefing but then
repeatedly failed the completion tool’s array validation. Those invalid
arguments remain a separate observed failure; the wording change does
not establish their cause or relax validation. Source inspection also
found that the declined-action oracle requires a projected comment even
when the settled native thread renders a durable final message.

**Expected behavior**
Ask for page titles and the service receipt code in the saved briefing,
then a document link. After a declined action, require the existing
explanation-only fallback without another service call.

**Steps to reproduce**
Run the shared service-approve/service-decline stories through the
classic runner or QA Product E2E adapter.

## What Changed

- Clarify the natural-language task for both shared service stories: put
page titles and service receipt code in the document and provide its
link.
- Use the reloaded run-bound final reply for QA service declines. Pass
an explicit native receipt to the existing decline oracle; do not
synthesize issue comments. Require the final native run to start after
the saved decline and keep exact decision, truthful explanation, no
unread marker, zero calls and unchanged connection checks.
- Keep classic callers on their existing attributed-comment path. No
internal tool instructions are added to the user prompt; ownership,
service data and model/runtime limits stay strict.

## Verification

- Decline regression observed RED: two cases reject a valid saved native
explanation without projected comments. After the repair, 79 focused
tests passed across native reply, decline guidance, agent evidence and
journey contracts. A two-successful-run control rejects the earlier
receipt and accepts the later one. Negatives cover stale/wrong run,
wrong agent/task, missing final proof, summary/unsettled/hidden output,
false explanations and unread service data.
- Product E2E TypeScript and `git diff --check` passed.
- Live protected-action qualification is required after merging this and
the companion private service/cleanup fixes. The previous failed result
remains failed; this is a bounded task-clarity experiment, not a claimed
model or completion-schema repair.
- Current-head Greptile and Paperclip reviews are 5/5 with zero open
threads. CI initially failed the unrelated agent-chat-projects cleanup
POST with a PostgreSQL lock timeout after its assertions completed. One
bounded retry passed on unchanged source; all current-head gates passed
at `64bc169e147290ed4dbaa18221e0268362ce81ee`. The original failing
attempt and its logs remain available. The task-clarity change is still
a bounded experiment; the observer repair addresses a demonstrated
storage-assumption defect.

## Risks

A clearer task can still fail with the selected model. All acceptance
checks and completion input validation remain strict; a saved briefing
alone cannot qualify the complete journey. Only test prompts and test
evidence code change. Missing or contradictory native evidence fails
closed.

## Model Used

OpenAI Codex, GPT-6 family. The exact serving model ID and context
window are not exposed by this session. Used reasoning, source editing,
shell execution and test inspection.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-29 22:14:37 -05:00

84 lines
6.0 KiB
TypeScript

import { expect, type Page } from "@playwright/test";
import { isValidNativePrpEnvelope } from "./native-event-envelope.js";
type Row = Record<string, any>;
export interface TaskReplyObservation {
body: string; anchor: string | null; settled: boolean; nativeFinal: boolean; visible: boolean;
}
export type BoundTaskFinalReply = Awaited<ReturnType<typeof readBoundTaskFinalReply>>;
/** Native task replies can render from a saved turn before a completion comment
* is projected. Respect an explicit presentation decision when one exists. */
export function nativeFinalReplyEvents(events: Row[], run: Row) {
return events.flatMap(event => {
const envelope = event.payload?.prpEvent, payload = envelope?.payload, item = payload?.item;
if (!envelope || !Number.isSafeInteger(event.seq) || event.seq < 1 || !isValidNativePrpEnvelope(envelope, event.protocolSchemaVersion)
|| event.runId !== run.id || event.companyId !== run.companyId || event.agentId !== run.agentId
|| envelope.runId !== run.id || envelope.sourceKind !== "runner" || event.eventType !== "item.completed" || envelope.eventType !== event.eventType
|| typeof envelope.sourceEventId !== "string" || !envelope.sourceEventId || event.sourceEventId !== envelope.sourceEventId
|| typeof envelope.sourceInstanceId !== "string" || !envelope.sourceInstanceId || event.sourceInstanceId !== envelope.sourceInstanceId
|| !Number.isSafeInteger(envelope.sourceSeq) || envelope.sourceSeq < 1 || event.sourceSeq !== envelope.sourceSeq
|| !["agentMessage", "assistant_message"].includes(payload?.kind ?? item?.type) || (payload.channel ?? item?.channel) !== "final"
|| payload.kind != null && item?.type != null && payload.kind !== item.type
|| payload.channel != null && item?.channel != null && payload.channel !== item.channel) return [];
const text = typeof payload.text === "string" ? payload.text : item?.text;
if (typeof text !== "string" || !text.trim() || typeof payload.text === "string" && typeof item?.text === "string" && payload.text !== item.text) return [];
return [{ seq: event.seq, sourceEventId: envelope.sourceEventId, sourceInstanceId: envelope.sourceInstanceId,
sourceSeq: envelope.sourceSeq, protocolSchemaVersion: event.protocolSchemaVersion, body: text.trim() }];
});
}
export function taskReplyBelongsToRun(observation: TaskReplyObservation, run: Row, agentId: string, comments: Row[], events: Row[] = []) {
if (typeof run.id !== "string" || !run.id || !agentId || run.agentId !== agentId || run.status !== "succeeded" || run.runtimeMode !== "native" || !observation.body || !observation.visible) return false;
const decision = run.resultJson?.presentationDecision;
if (decision != null && (typeof decision !== "object" || Array.isArray(decision))) return false;
if (decision?.commentId != null && typeof decision.commentId !== "string") return false;
const projected = typeof decision?.commentId === "string" ? comments.find(comment => comment.id === decision.commentId) : null;
if (projected) {
return observation.anchor === projected.id && projected.authorAgentId === agentId
&& projected.createdByRunId === run.id && typeof projected.body === "string" && projected.body.trim() === observation.body;
}
const finals = nativeFinalReplyEvents(events, run);
return finals.length === 1 && finals[0]!.body === observation.body && observation.settled && observation.nativeFinal
&& (observation.anchor === `${run.id}:turn` || observation.anchor?.startsWith(`${run.id}:turn:`) === true);
}
export function uniqueBoundTaskReply(all: TaskReplyObservation[], run: Row, agentId: string, comments: Row[], expectedText?: string, events: Row[] = []) {
const matches = all.filter(row => taskReplyBelongsToRun(row, run, agentId, comments, events)
&& (expectedText === undefined || row.body === expectedText));
return matches.length === 1 && (expectedText === undefined || all.filter(row => row.visible && row.body === expectedText).length === 1)
? matches[0]! : null;
}
/** Observe the real task thread after a fresh load, rather than a finish summary
* or a provider's claimed result. The caller separately validates task/run scope. */
export async function readBoundTaskFinalReply(input: {
page: Page; runId: string; agentId: string; expectedText?: string; deadlineAt: number;
load(): Promise<{ run: Row; comments: Row[]; events: Row[] }>;
}) {
await input.page.reload();
const replies = input.page.getByTestId("task-chat-thread").getByTestId("task-chat-agent-bubble");
const observe = () => replies.evaluateAll(elements => elements.map(element => ({
body: (element as HTMLElement).innerText.trim(),
anchor: element.closest("[data-thread-anchor]")?.getAttribute("data-thread-anchor") ?? null,
settled: element.closest('[data-testid="task-chat-turn"]')?.getAttribute("data-settled") === "true",
nativeFinal: element.closest('[data-testid="task-chat-final-response"]') !== null,
visible: element.getClientRects().length > 0 && getComputedStyle(element).visibility !== "hidden" && getComputedStyle(element).display !== "none",
})));
const read = async () => {
const state = await input.load(), all = await observe();
const match = state.run.id === input.runId ? uniqueBoundTaskReply(all, state.run, input.agentId, state.comments, input.expectedText, state.events) : null;
return { ...state, match };
};
const timeout = input.deadlineAt - Date.now();
if (timeout <= 0) throw new Error("native_reply_deadline");
await expect.poll(async () => {
return Boolean((await read()).match);
}, { timeout, intervals: [1000, 2000, 5000] }).toBe(true);
const { match, run, events } = await read();
if (!match) throw new Error("native_reply_changed");
const source = match.anchor === run.resultJson?.presentationDecision?.commentId ? "posted-comment" : "native-turn";
return { runId: input.runId, agentId: input.agentId, observedAfterReload: true,
source, finalEvent: source === "native-turn" ? nativeFinalReplyEvents(events, run)[0] : null, ...match };
}