Files
PaperClipAI/tests/runner-e2e/product-journeys/saved-skill-use-evidence.ts
T
DottaandPaperclip b4c69ded9f test(e2e): attribute native OpenCode skill loads (#18929)
## Thinking Path

> - Paperclip lets people manage agents and assign saved company skills.
> - Daily QA must prove that an agent loads the saved procedure it uses.
> - The saved-skill journey accepts several native source-read paths.
> - OpenCode's native proxy changes its built-in skill loader's
normalized operation to unknown.
> - The grader expected execute and rejected a real completed skill
load.
> - This PR accepts that one observed native shape with strict source
and identity checks.
> - Genuine loads can qualify while missing, foreign or incomplete
evidence still fails.

## Linked Issues or Issue Description

Refs: #18783.

**What happened?**

A combined QA run on `0884e73b46ee064b3d242e467035dde6c32e5cb5` failed
`saved-skill-native-read-attributed`. The agent's successful native
skill load exposed the complete saved procedure. The exact immutable
assignment and source checks passed. Core recorded the built-in loader
as `operation: unknown` and `readOnly: false`. The grader accepted this
loader only with `operation: execute`.

**Expected behavior**

A complete native OpenCode skill load can qualify when its actual run,
session, immutable source and paired receipt all match. A generic
unknown operation cannot qualify.

**Steps to reproduce**

Run the saved-skill journey through Core
`09162fe893b464dd2ccdafd14bd3ef1611202a86` with OpenCode. Let the agent
use its native `skill` loader. The direct TypeScript converter emits
execute, but the real proxy forwards a `builtinToolCall` without that
operation. Core's native converter records unknown. The former
TypeScript-only fixture missed this boundary.

## What Changed

- Add one closed native OpenCode skill-loader branch. Require the actual
run, runner, native session, item, turn and paired call.
- Bind the API assignment, admitted native skill, immutable version map
and materialized runtime directory to the same canonical skill.
- Require the exact wrapper name, heading and complete saved procedure.
Require an absolute local base directory and an empty file inventory.
Reject traversal, URI encodings, backslashes, query or fragment markers
and all ASCII controls. Keep valid spaces.
- Require a successful, complete and untruncated native receipt with the
exact output byte count. Reject missing, duplicate, reordered, foreign,
failed, dynamic or MCP carriers.
- Add 65 loader and boundary controls. Drive the real
TypeScript-to-proxy input and use the observed Core native carrier
shape. The JavaScript test does not claim to run the Rust converter.
- Document the native loader path. Preserve every existing accepted
execute, filesystem, first-party and Codex parser path byte-for-byte.
Keep all seven checks, one-run limit, 500-cent guard, task prompt, model
and ordinary Beta-off Latest assignment unchanged.

## Verification

- The sealed original Core receipt reproduces RED: the former grader
returns zero reads. Changing only its normalized operation to execute
reaches the old accepted branch. The narrow correction returns one
attributed read in separate local analysis. The original QA grade
remains failed.
- The actual TypeScript, proxy and native normalization source regions
match between Core091 and the tested baseline0884. The proxy emits
`builtinToolCall` without an operation. The native converter supplies
unknown.
- 334 focused tests pass across nine files, including 65 new loader
controls and all existing saved-skill evidence, flow, contract and
catalog controls. Run `node node_modules/vitest/vitest.mjs run --config
tests/runner-e2e/vitest.config.ts
tests/runner-e2e/product-journeys/saved-skill-use-native-loader.test.ts`
for the narrow regression.
- TAB, ESC and DEL controls fail before the review hardening and pass
after it. The valid-space positive remains green.
- Canonical Runner TypeScript prerequisites, own server declarations and
E2E types pass. The final E2E-only check reuses those same-commit
declarations after only the two test-scope files changed. The checked
candidate blobs match this committed head.
- Credential-free discovery lists 25 catalog journeys, including
saved-skill use. Registration and selection do not change.
- `git diff --check` passes. No Rust build or product-model call ran
locally. Full CI
[41773295359](https://github.com/paperclipai/paperclip/actions/runs/41773295359)
passed on `fb537b598da40176fa1752b49a4f676f468de1f7`: 48 successful jobs
and one skip. Fresh Greptile is 5/5 with zero unresolved threads. The
earlier same-head canceled run remains retained; no retry was requested.
The optional native review ended neutral with a 5/5 source assessment
and explicit execution/qualification coverage limits. Fresh staging
qualification
[41777787338](https://pages.paperclip.ing/evals-qa-smoke-41777787338-1/)
passed on this exact head: all seven checks and all five phases, exactly
one native OpenCode run, independent saved-document reopen/source
preservation, and normal cleanup complete. TestOps recorded a new passed
result and the automatic Slack report succeeded.
- The failed original stopped before independent document, reload and
source-after checks. Those checks remain unqualified for that run. The
original failed grade remains unchanged. A separate genuine fresh QA
pass now verifies the complete journey before daily re-admission.

## Risks

This path depends on OpenCode's observed native wrapper and materialized
directory contract. Missing or changed evidence fails closed. The fix
adds no runtime service, schema or API behavior. The native-loader
branch is covered by strict local regression controls and the original
sealed Core receipt; a separate fresh staging run also passed the
complete saved-skill journey.

## Model Used

OpenAI GPT-6 through Codex with reasoning, tool use and code execution.
The runtime does not expose a more specific deployment ID or
context-window size. Local checks made no product-model calls.

## 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-11-03 23:21:17 -06:00

284 lines
21 KiB
TypeScript

import { createHash } from "node:crypto";
import { isBoundNativePrpEventRow, type NativePrpRunBinding } from "../native-event-envelope.js";
type Row = Record<string, any>;
export interface SavedSkillFixture {
name: string; slug: string; key: "saved-skill-result"; markdown: string; procedure: string;
expected: { reference: string; total: number };
}
/** The public request names the skill; only its saved version contains these inputs. */
export function savedSkillFixture(identity: string, reference: string): SavedSkillFixture {
if (!/^[a-f0-9]{32}$/.test(reference)) throw new Error("invalid_saved_skill_reference");
if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(identity) || identity.length > 48 || identity.includes(reference))
throw new Error("invalid_saved_skill_identity");
const slug = `qa-saved-skill-${identity}`, key = "saved-skill-result";
const seed = createHash("sha256").update(reference).digest();
const first = seed[0]! + 1, second = seed[1]! + 1;
const procedure = `# Saved receipt procedure\n\nThe receipt reference is ${reference}.\n`
+ `Add the two amounts ${first} and ${second}.\n`
+ `Save exactly one Paperclip task document with key ${key}. Its body must be JSON with exactly `
+ `two fields: reference (the receipt reference) and total (the sum of the two amounts).\n`
+ "Check the saved document and finish the task. Do not create other tasks or deliverables.\n";
return { name: slug, slug, key, procedure,
markdown: `---\nname: ${slug}\ndescription: Make a receipt using this saved procedure.\n---\n\n${procedure}`,
expected: { reference, total: first + second } };
}
export function savedSkillSourceMatches(skill: Row | undefined, version: Row | undefined, assignments: Row | undefined,
fixture: SavedSkillFixture, companyId: string): boolean {
if (!skill || !version || !assignments) return false;
const files = version.fileInventory;
const entries = assignments.desiredSkillEntries;
return Boolean(skill.id && skill.companyId === companyId && skill.sourceType === "managed"
&& skill.name === fixture.name && skill.slug === fixture.slug && skill.key === `company/${companyId}/${fixture.slug}`
&& skill.markdown === fixture.markdown && skill.currentVersionId
&& version.id === skill.currentVersionId && version.companyId === companyId && version.companySkillId === skill.id
&& Array.isArray(files) && files.length === 1 && files[0].path === "SKILL.md" && files[0].content === fixture.markdown
&& (files[0].encoding === undefined || files[0].encoding === "utf8")
&& Array.isArray(entries) && entries.filter(row => row.key === skill.key).length === 1
&& entries.find(row => row.key === skill.key)?.versionId === null);
}
export function savedSkillResultMatches(body: unknown, expected: SavedSkillFixture["expected"]): boolean {
if (typeof body !== "string") return false;
const text = body.trim(), json = /^```(?:json)?\s*\n([\s\S]*?)\n```$/.exec(text)?.[1] ?? text;
try {
const value = JSON.parse(json);
return Boolean(value && typeof value === "object" && !Array.isArray(value)
&& Object.keys(value).sort().join(",") === "reference,total"
&& value.reference === expected.reference && value.total === expected.total);
} catch { return false; }
}
export interface SavedSkillReadAssignment { key: string; versionId: string }
/** All native providers share the canonical skill/version selection contract. */
export function savedSkillReadAssignment(skill: Row, assignments: Row, run: Row): SavedSkillReadAssignment | undefined {
const input = run.runnerProfileJson?.nativeExecutionInput;
const assigned = Array.isArray(assignments.entries) ? assignments.entries.filter((row: Row) => row.key === skill.key) : [];
const native = Array.isArray(input?.runtimeContext?.skills)
? input.runtimeContext.skills.filter((row: Row) => row.key === skill.key) : [];
const versions = run.contextSnapshot?.skillVersions;
if (typeof skill.key !== "string" || !skill.key.trim() || assigned.length !== 1 || native.length !== 1
|| typeof skill.currentVersionId !== "string" || !skill.currentVersionId
|| run.contextSnapshot?.skillSelectionSnapshot !== true
|| !versions || typeof versions !== "object" || Array.isArray(versions) || !Object.hasOwn(versions, skill.key)
|| versions[skill.key] !== skill.currentVersionId
|| assigned[0].versionId !== skill.currentVersionId || native[0].versionId !== skill.currentVersionId) return undefined;
return { key: skill.key, versionId: skill.currentVersionId };
}
/** OpenCode's file wrapper also needs the exact materialized directory identity. */
export function savedSkillReadRuntimeName(skill: Row, assignments: Row, run: Row): string | undefined {
if (run.runnerProfileJson?.nativeExecutionInput?.provider?.kind !== "opencode"
|| !savedSkillReadAssignment(skill, assignments, run)) return undefined;
const assigned = assignments.entries.find((row: Row) => row.key === skill.key);
const native = run.runnerProfileJson.nativeExecutionInput.runtimeContext.skills.find((row: Row) => row.key === skill.key);
if (assigned.runtimeName !== native.runtimeName
|| typeof native.runtimeName !== "string" || !/^[a-z0-9][a-z0-9_-]*$/i.test(native.runtimeName)) return undefined;
return native.runtimeName;
}
/** Core's assigned-skill read verifies the pinned manifest and returns one UTF-8 page. */
function assignedSkillReadText(output: string, binding: NativePrpRunBinding, fixture: SavedSkillFixture, versionId: string): string | undefined {
try {
const wrapper = JSON.parse(output);
if (wrapper?.type !== "inputText" || typeof wrapper.text !== "string"
|| Object.keys(wrapper).sort().join(",") !== "text,type") return undefined;
const result = JSON.parse(wrapper.text);
if (Object.keys(result).sort().join(",") !== "byteSize,nextOffset,path,sha256,skill,text,versionId"
|| result.skill !== `company/${binding.companyId}/${fixture.slug}` || result.versionId !== versionId
|| result.path !== "SKILL.md" || result.nextOffset !== null || result.text !== fixture.markdown
|| result.byteSize !== Buffer.byteLength(fixture.markdown)
|| result.sha256 !== createHash("sha256").update(fixture.markdown).digest("hex")) return undefined;
return result.text;
} catch { return undefined; }
}
/** OpenCode's proxy forwards the builtin skill loader without an operation.
* Core091 therefore records unknown/false even though the loader exposes its saved body.
* Only this complete, immutable-version-bound native loader may use that shape.
*/
function nativeOpenCodeSkillLoad(row: Row, bound: Row[], binding: NativePrpRunBinding, fixture: SavedSkillFixture,
runtimeName: string | undefined, assignment: SavedSkillReadAssignment | undefined, run: Row | undefined): boolean {
const event = row.payload.prpEvent, payload = event.payload, input = run?.runnerProfileJson?.nativeExecutionInput;
const skills = Array.isArray(input?.runtimeContext?.skills)
? input.runtimeContext.skills.filter((skill: Row) => skill.key === assignment?.key) : [];
if (!run || run.runtimeMode !== "native" || input?.provider?.kind !== "opencode"
|| run.id !== binding.runId || run.companyId !== binding.companyId || run.agentId !== binding.agentId
|| typeof run.runnerInstanceId !== "string" || !run.runnerInstanceId.trim()
|| typeof run.nativeSessionId !== "string" || !run.nativeSessionId.trim()
|| event.sourceInstanceId !== run.runnerInstanceId || event.normalizedSessionId !== run.nativeSessionId
|| typeof event.itemId !== "string" || !event.itemId.trim() || !Number.isSafeInteger(row.seq)
|| !assignment || assignment.key !== `company/${binding.companyId}/${fixture.slug}`
|| typeof assignment.versionId !== "string" || !assignment.versionId.trim()
|| skills.length !== 1 || skills[0].versionId !== assignment.versionId || skills[0].runtimeName !== runtimeName
|| run.contextSnapshot?.skillSelectionSnapshot !== true
|| run.contextSnapshot?.skillVersions?.[assignment.key] !== assignment.versionId
|| typeof runtimeName !== "string" || !/^[a-z0-9][a-z0-9_-]*$/i.test(runtimeName)
|| payload.name !== "skill" || payload.operation !== "unknown" || payload.readOnly !== false || payload.target !== null) return false;
const wrapper = /^<skill_content name="([^"<>\r\n]+)">\n# Skill: ([^\r\n]+)\n\n([\s\S]*?)\n\nBase directory for this skill: ([^\r\n]+)\n([\s\S]*)\n<\/skill_content>\s*$/.exec(payload.output);
if (!wrapper || wrapper[1] !== fixture.slug || wrapper[2] !== fixture.slug || wrapper[3] !== fixture.procedure.trim()
|| !/<skill_files>\s*<\/skill_files>/.test(wrapper[5]!)
|| (payload.output.match(/<skill_content\b/g) ?? []).length !== 1
|| (payload.output.match(/<\/skill_content>/g) ?? []).length !== 1
|| (payload.output.match(/<skill_files>/g) ?? []).length !== 1
|| (payload.output.match(/<\/skill_files>/g) ?? []).length !== 1) return false;
// The native loader prints an absolute filesystem path, not a URI.
const path = wrapper[4]!;
if (!path.startsWith("/") || /[\\\u0000-\u001f\u007f?#]/.test(path) || /%[a-f0-9]{2}/i.test(path)
|| path.split("/").some(part => part === "." || part === "..")
|| !path.replace(/\/$/, "").endsWith(`/.claude/skills/${runtimeName}`)) return false;
const pair = bound.filter(candidate => ["tool.execution.started", "tool.execution.completed"].includes(candidate.payload.prpEvent.eventType)
&& candidate.payload.prpEvent.payload?.executionId === payload.executionId);
const start = pair.find(candidate => candidate.payload.prpEvent.eventType === "tool.execution.started");
const initial = start?.payload.prpEvent, initialPayload = initial?.payload;
return Boolean(pair.length === 2 && start && Number.isSafeInteger(start.seq) && start.seq < row.seq
&& initial.sourceSeq < event.sourceSeq && initial.normalizedSessionId === event.normalizedSessionId && initial.itemId === event.itemId
&& initialPayload?.readOnly === false && initialPayload.target === null);
}
// Core091 provider_bridge.rs hashes UTF-8 JSON with object keys ordered by UTF-16.
// This oracle reconstructs the complete pinned read result, not provider display text.
function semanticDigest(value: unknown): string {
const canonical = (value: unknown): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
: value && typeof value === "object" ? `{${Object.keys(value).sort().map(key =>
`${JSON.stringify(key)}:${canonical((value as Row)[key])}`).join(",")}}` : JSON.stringify(value) ?? "null";
return `sha256:${createHash("sha256").update(canonical(value), "utf8").digest("hex")}`;
}
/** Codex's raw dynamic name, namespace and optional readOnly hint grant no authority.
* Its admitted first-party read instead has a controller-bound semantic input/result.
* Core's read_assigned_skill operation is read-only and verifies the immutable file.
*/
function codexAssignedSkillReceipts(bound: Row[], binding: NativePrpRunBinding, fixture: SavedSkillFixture,
assignment: SavedSkillReadAssignment | undefined, run: Row | undefined) {
const input = run?.runnerProfileJson?.nativeExecutionInput;
const skills = Array.isArray(input?.runtimeContext?.skills)
? input.runtimeContext.skills.filter((skill: Row) => skill.key === assignment?.key) : [];
if (!run || run.runtimeMode !== "native" || input?.provider?.kind !== "codex"
|| run.id !== binding.runId || run.companyId !== binding.companyId || run.agentId !== binding.agentId
|| typeof run.runnerInstanceId !== "string" || !run.runnerInstanceId.trim()
|| typeof run.nativeSessionId !== "string" || !run.nativeSessionId.trim()
|| !assignment || assignment.key !== `company/${binding.companyId}/${fixture.slug}` || !assignment.versionId
|| skills.length !== 1 || skills[0].versionId !== assignment.versionId
|| run.contextSnapshot?.skillSelectionSnapshot !== true
|| run.contextSnapshot?.skillVersions?.[assignment.key] !== assignment.versionId
|| !fixture.markdown.includes(fixture.procedure.trim()) || !fixture.markdown.includes(fixture.expected.reference)) return [];
const expected = { skill: assignment.key, versionId: assignment.versionId, path: "SKILL.md", text: fixture.markdown,
sha256: createHash("sha256").update(fixture.markdown, "utf8").digest("hex"),
byteSize: Buffer.byteLength(fixture.markdown), nextOffset: null };
const semantic = (row: Row): Row | undefined => row.payload.prpEvent.payload?.semantic_tool;
const content = (value: Row, digest: string) => value.content?.digest === digest
&& value.content.redactionDisposition === "digest_only" && Array.isArray(value.content.references)
&& value.content.references.length === 0
&& Object.keys(value.content).sort().join(",") === "digest,redactionDisposition,references";
const correlated = (row: Row, phase: "input" | "result") => {
const event = row.payload.prpEvent, value = semantic(row), correlation = value?.correlation;
const keys = ["schema", "schemaVersion", "phase", "operationId", "callId", "correlation", "idempotencyKey", "content",
...(phase === "input" ? ["input"] : ["outcome", "code", "retryable", "authorizationBoundary", "operationReceiptId"])];
return Boolean(value && event.eventType === `semantic_tool.${phase}` && value.phase === phase
&& value.schema === "paperclip.prp.semantic_tool.v1" && value.schemaVersion === 1
&& Object.keys(value).sort().join(",") === keys.sort().join(",")
&& value.operationId === "read_assigned_skill" && typeof value.callId === "string" && value.callId.trim()
&& value.idempotencyKey === null && Number.isSafeInteger(row.seq)
&& event.sourceInstanceId === run.runnerInstanceId && event.normalizedSessionId === run.nativeSessionId
&& typeof event.turnId === "string" && event.turnId.trim() && typeof event.itemId === "string" && event.itemId.trim()
&& correlation && Object.keys(correlation).sort().join(",") === "itemId,normalizedSessionId,runId,turnId"
&& correlation.runId === binding.runId && correlation.normalizedSessionId === run.nativeSessionId
&& correlation.turnId === event.turnId && correlation.itemId === event.itemId);
};
return bound.flatMap(start => {
if (!correlated(start, "input")) return [];
const initial = semantic(start)!, args = initial.input;
if (!args || typeof args !== "object" || Array.isArray(args)
|| Object.keys(args).some(key => !["skill", "path", "offset"].includes(key))
|| !(args.skill === assignment.key || typeof skills[0].runtimeName === "string"
&& skills[0].runtimeName.trim() && args.skill === skills[0].runtimeName)
|| Object.hasOwn(args, "path") && args.path !== "SKILL.md"
|| Object.hasOwn(args, "offset") && args.offset !== 0 || !content(initial, semanticDigest(args))) return [];
// Duplicates, reconciled alternatives and foreign same-call carriers cannot supply a second interpretation.
const pair = bound.filter(row => semantic(row)?.callId === initial.callId);
const end = pair.find(row => correlated(row, "result"));
if (pair.length !== 2 || !end || start.seq >= end.seq
|| start.payload.prpEvent.sourceSeq >= end.payload.prpEvent.sourceSeq) return [];
const result = semantic(end)!;
if (semanticDigest(initial.correlation) !== semanticDigest(result.correlation)
|| result.outcome !== "succeeded" || result.code !== "semantic_tool_succeeded" || result.retryable !== false
|| result.authorizationBoundary !== "active_task" || result.operationReceiptId !== `operation_${initial.callId}`
|| !content(result, semanticDigest(expected))) return [];
return [{ runId: binding.runId, executionId: String(initial.callId), seq: Number(end.seq) }];
});
}
/** Successful built-in reads and native skill loads expose the actual skill text.
* A model claim, discovered name, shell echo, failed read or foreign run cannot qualify. */
export function savedSkillReadReceipts(events: Row[], binding: NativePrpRunBinding, fixture: SavedSkillFixture,
runtimeName?: string, assignment?: SavedSkillReadAssignment, run?: Row) {
const bound = events.filter(row => isBoundNativePrpEventRow(row, binding));
const builtin = bound.filter(row => {
const event = row.payload.prpEvent, payload = event.payload;
if (event.eventType !== "tool.execution.completed" || payload?.schema !== "paperclip.tool.execution.v1"
|| payload.transport !== "builtin" || payload.status !== "completed"
|| typeof event.turnId !== "string" || !event.turnId.trim()
|| typeof payload.executionId !== "string" || !payload.executionId.trim()
|| payload.outputTruncated !== false || typeof payload.output !== "string" || payload.outputBytes <= 0
|| payload.outputBytes !== Buffer.byteLength(payload.output)) return false;
const name = String(payload.name ?? "").toLowerCase();
const target = typeof payload.target === "string" ? payload.target.replaceAll("\\", "/") : "";
const reading = payload.operation === "read" && payload.readOnly === true && /^(read|read_file|file_read)$/.test(name)
&& !target.split("/").includes("..") && target.endsWith(`/${fixture.slug}/SKILL.md`);
// OpenCode's skill tool names the loaded skill in its provider-visible result.
const loading = payload.operation === "execute" && name === "skill"
&& payload.output.includes(`<skill_content name="${fixture.slug}">`);
const nativeLoading = name === "skill" && payload.operation === "unknown"
&& nativeOpenCodeSkillLoad(row, bound, binding, fixture, runtimeName, assignment, run);
// Deployed OpenCode reports this read with operation unknown and target null.
// Its successful file-result wrapper supplies the exact materialized source path.
const wrapped = /^<path>([^<>\r\n]+)<\/path>\r?\n<type>file<\/type>\r?\n<content>\r?\n([\s\S]*)\r?\n<\/content>\s*$/.exec(payload.output);
const wrappedPath = wrapped?.[1];
const wrappedRead = name === "read" && payload.readOnly === true && payload.target === null
&& (payload.operation === "unknown" || payload.operation === "read")
&& typeof runtimeName === "string" && /^[a-z0-9][a-z0-9_-]*$/i.test(runtimeName)
&& wrappedPath?.startsWith("/") && !wrappedPath.includes("\\") && !wrappedPath.includes("\0")
&& !wrappedPath.split("/").some(part => part === "." || part === "..")
&& wrappedPath.endsWith(`/.claude/skills/${runtimeName}/SKILL.md`);
// The ordinary semantic tool is another native read path, not a filesystem wrapper.
// Its canonical key/version binding does not depend on a provider's directory name.
const assignedText = name === "paperclip_read_assigned_skill" && payload.readOnly === true && payload.target === null
&& (payload.operation === "unknown" || payload.operation === "read")
&& assignment?.key === `company/${binding.companyId}/${fixture.slug}`
&& typeof assignment.versionId === "string" && assignment.versionId.trim()
? assignedSkillReadText(payload.output, binding, fixture, assignment.versionId) : undefined;
if (!reading && !loading && !nativeLoading && !wrappedRead && assignedText === undefined) return false;
// File readers may prefix each source line with its line number.
const output = (assignedText ?? (wrappedRead ? wrapped![2]! : payload.output)).split("\n").map((line: string) => line.replace(/^\s*\d+(?:[:|]|→)\s?/, "")).join("\n");
if (!output.includes(fixture.procedure.trim())) return false;
return bound.some(start => {
const initial = start.payload.prpEvent, p = initial.payload;
return initial.eventType === "tool.execution.started" && start.seq < row.seq
&& initial.sourceInstanceId === event.sourceInstanceId && initial.turnId === event.turnId
&& p?.schema === payload.schema && p.executionId === payload.executionId && p.name === payload.name
&& p.transport === payload.transport && p.operation === payload.operation && p.status === "running"
&& (!(wrappedRead || assignedText !== undefined) || p.readOnly === true);
});
}).map(row => ({ runId: binding.runId, executionId: String(row.payload.prpEvent.payload.executionId), seq: Number(row.seq) }));
return [...builtin, ...codexAssignedSkillReceipts(bound, binding, fixture, assignment, run)];
}
export function savedSkillDocumentAttributed(document: Row | undefined, revision: Row | undefined, fixture: SavedSkillFixture,
binding: NativePrpRunBinding & { issueId: string }): boolean {
if (!document || !revision) return false;
return Boolean(document.id && document.companyId === binding.companyId && document.issueId === binding.issueId
&& document.key === fixture.key && document.format === "markdown" && document.latestRevisionId
&& Number.isSafeInteger(document.latestRevisionNumber) && document.latestRevisionNumber > 0
&& document.createdByAgentId === binding.agentId && document.createdByUserId === null
&& document.updatedByAgentId === binding.agentId && document.updatedByUserId === null
&& savedSkillResultMatches(document.body, fixture.expected)
&& revision.id === document.latestRevisionId && revision.documentId === document.id
&& revision.companyId === binding.companyId && revision.issueId === binding.issueId && revision.key === fixture.key && revision.format === document.format
&& revision.createdByRunId === binding.runId && revision.createdByAgentId === binding.agentId && revision.createdByUserId === null
&& revision.revisionNumber === document.latestRevisionNumber && savedSkillResultMatches(revision.body, fixture.expected));
}