diff --git a/doc/evals.md b/doc/evals.md index 4ff1928ec35..9c0474e9f16 100644 --- a/doc/evals.md +++ b/doc/evals.md @@ -77,6 +77,15 @@ Daytona paths. Its [fixture contract](../tests/runner-e2e/README.md) distinguish startup cancellation from active response cancellation and HTTP send replay from ambiguous provider action recovery. Select it explicitly; `--all` excludes it. +The explicit-only [production hiring templates suite](../tests/runner-e2e/README.md#production-hiring-templates) +adds two local native Codex/Claude cells. It exercises API-created production +CEO defaults, an explicitly requested hiring skill/reference read, a permanent +coder hire, independently computed saved JSON fixtures and worker reuse. +Each cell expects five turns. Source/read coverage and workflow outcome are +separate: missing read provenance leaves the candidate/baseline pair +uncomparable even if work succeeds. Baseline bundles and coder examples derive +from their own source revision, without requiring candidate wording or length. + The explicit-only `context-integrity` Product E2E suite covers ordered public comment continuation and explicit invocation of an assigned pinned skill across the seven selected legacy/native local profiles. Select it by suite or exact diff --git a/doc/plans/2026-10-02-hiring-template-prompt-diff.md b/doc/plans/2026-10-02-hiring-template-prompt-diff.md index 01a57ddce17..c62e5eb3a6d 100644 --- a/doc/plans/2026-10-02-hiring-template-prompt-diff.md +++ b/doc/plans/2026-10-02-hiring-template-prompt-diff.md @@ -29,6 +29,88 @@ Baseline: `d6d88b9de2fc766637422cc43f985c747455a1b0`. Word counts below measure - This branch starts from master and is separate from draft PR #14948. The generic non-CEO fallback and shared runtime prompt reductions remain in that PR. It must land for hires using the server fallback to receive the eight-word generic manual. - Both native and legacy managed-bundle creation use the shared defaults. No native tool procedure or legacy API procedure is embedded in these role prompts. +## How hires are drafted + +| Before | After | +| --- | --- | +| Role-specific templates were the default drafting path. | Templates are optional role examples. | +| Unknown roles required a 60–150-line manual with eight sections. | Start with a short identity and responsibility paragraph. | +| Every new agent needed an execution contract and repeated task procedures. | Keep reporting lines, capabilities, and skills in their configuration fields. | +| Templates prescribed reviewers, per-touch comments, and broad domain checklists. | Add detail only for a concrete requirement that the task, repository, skills, or configuration do not already express. | +| The generated baseline could crowd out company instructions. | Preserve explicit requester instructions. | + +The hiring skill still checks authority, adapter schemas, reporting lines, installed skills, timer settings, confidential workflows, and approval state. Its native-tool and legacy-API transport guidance stays separate. + +
Hiring skill drafting rules: before → after + +```diff +diff --git a/skills/paperclip-create-agent/SKILL.md b/skills/paperclip-create-agent/SKILL.md +index 6b2a0da0e..fa9e7b358 100644 +--- a/skills/paperclip-create-agent/SKILL.md ++++ b/skills/paperclip-create-agent/SKILL.md +@@ -72,21 +72,24 @@ curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-configura + + Note naming, icon, reporting-line, and adapter conventions the company already follows. + +-### 4. Choose the instruction source (required) ++### 4. Describe the role + +-This is the single most important decision for hire quality. Pick exactly one path: ++Use a short role paragraph for a new agent: its identity and the responsibility ++it owns. The [role examples](references/agent-instruction-templates.md) are ++optional starting points; for other roles, use the ++[baseline role guide](references/baseline-role-guide.md). + +-- **Exact template** — the role matches an entry in the template index. Use the matching file under `references/agents/` as the starting point. +-- **Adjacent template** — no exact match, but an existing template is close (for example, a "Backend Engineer" hire adapted from `coder.md`, or a "Content Designer" adapted from `uxdesigner.md`). Copy the closest template and adapt deliberately: rename the role, rewrite the role charter, swap domain lenses, and remove sections that do not fit. +-- **Generic fallback** — no template is close. Use the baseline role guide to construct a new `AGENTS.md` from scratch, filling in each recommended section for the specific role. ++Company-specific instructions supplied by the requester take precedence. Do not ++expand a role description into a generic operating manual. The harness supplies ++Paperclip coordination, skill discovery, and task lifecycle guidance; repository ++instructions and installed skills carry applicable work procedures. Avoid ++adding heartbeat pointers, execution contracts, mandatory per-touch comments, ++fixed reviewer routes, or catalogs of domain concepts to the hire's instructions. + +-Template index and when-to-use guidance: +-`skills/paperclip-create-agent/references/agent-instruction-templates.md` +- +-Generic fallback for no-template hires: +-`skills/paperclip-create-agent/references/baseline-role-guide.md` +- +-State which path you took in your hire-request comment so the board can see the reasoning. ++Keep reporting lines in `reportsTo`, capabilities in `capabilities`, and skills ++in `desiredSkills`. Add instruction detail only for a concrete company or role ++requirement that those fields, the task, repository instructions, or installed ++skills do not already express. + + ### 5. Discover allowed agent icons + +@@ -107,9 +110,7 @@ curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt" \ + - leave timer heartbeats off by default; only set `runtimeConfig.heartbeat.enabled=true` with an `intervalSec` when the role genuinely needs scheduled recurring work or the user explicitly asked for it + - if the role may handle private advisories or sensitive disclosures, confirm a confidential workflow exists first (dedicated skill or documented manual process) + - capabilities +-- managed instructions bundle (`AGENTS.md`) for adapters that support it; avoid durable `promptTemplate` config +-- for coding or execution agents, include the Paperclip execution contract: start actionable work in the same heartbeat; do not stop at a plan unless planning was requested; leave durable progress with a clear next action; use child issues for long or parallel delegated work instead of polling; mark blocked work with owner/action; respect budget, pause/cancel, approval gates, and company boundaries +-- instruction text such as `AGENTS.md` built from step 4; for local managed-bundle adapters, send this as top-level `instructionsBundle.files["AGENTS.md"]`. Do not set `adapterConfig.promptTemplate` or `bootstrapPromptTemplate` for new agents. ++- when supplying role instructions from step 4, send them as top-level `instructionsBundle.files["AGENTS.md"]` for managed-bundle adapters. Otherwise use the server default. Do not set `adapterConfig.promptTemplate` or `bootstrapPromptTemplate` for new agents. + - source issue linkage (`sourceIssueId` or `sourceIssueIds`) when this hire came from an issue + + ### 7. Review the draft against the quality checklist +@@ -180,8 +181,8 @@ For each linked issue, either: + + ## References + +-- Template index and how to apply a template: `skills/paperclip-create-agent/references/agent-instruction-templates.md` ++- Optional role examples: `skills/paperclip-create-agent/references/agent-instruction-templates.md` + - Individual role templates: `skills/paperclip-create-agent/references/agents/` +-- Generic baseline role guide (no-template fallback): `skills/paperclip-create-agent/references/baseline-role-guide.md` ++- Short role drafting guide: `skills/paperclip-create-agent/references/baseline-role-guide.md` + - Pre-submit draft-review checklist: `skills/paperclip-create-agent/references/draft-review-checklist.md` + - Endpoint payload shapes and full examples: `skills/paperclip-create-agent/references/api-reference.md` +``` + +
+ ## Actual role prompts ### Default CEO bundle @@ -117,7 +199,7 @@ You plan content themes, keep the editorial calendar current, and turn company u ## Full instruction diffs -The following diffs compare the actual role bodies. The CEO `AGENTS.md` diff is shown here; removing its three siblings from default selection accounts for the remainder of the bundle reduction. Their compatibility source files are unchanged. +The following diffs compare the actual role bodies. The CEO `AGENTS.md` diff is shown here; removing its three siblings from default selection accounts for the remainder of the bundle reduction. Their compatibility source files are unchanged. The previous bundle also selected [HEARTBEAT.md](https://github.com/paperclipai/paperclip/blob/d6d88b9de2fc766637422cc43f985c747455a1b0/server/src/onboarding-assets/ceo/HEARTBEAT.md), [SOUL.md](https://github.com/paperclipai/paperclip/blob/d6d88b9de2fc766637422cc43f985c747455a1b0/server/src/onboarding-assets/ceo/SOUL.md), and [TOOLS.md](https://github.com/paperclipai/paperclip/blob/d6d88b9de2fc766637422cc43f985c747455a1b0/server/src/onboarding-assets/ceo/TOOLS.md); those links show their complete previous contents.
Default CEO bundle: before → after @@ -577,9 +659,9 @@ The following diffs compare the actual role bodies. The CEO `AGENTS.md` diff is - -# Chat hygiene - --- Everything you post is read by the user. Keep it terse and written for them. Speak simply and be easy to understand. For technical topics speak close to ASD-STE100 so that people understand you. +-- Everything you post is read by the user. Keep it terse and written for them. Speak simply and be easy to understand. For technical topics speak close to ASD-STE100 so that people understand you. -- Lead with the answer. Never narrate tool calls, API steps, or your own thinking. --- Ask about material ambiguity that prevents useful work. +-- Ask about material ambiguity that prevents useful work. -- You have tools from Paperclip, use them +You are {{agentName}}, chief of staff for {{organizationName}}. You are the user's main point of contact for carrying out requests and coordinating the company's work. ``` @@ -849,6 +931,8 @@ The following diffs compare the actual role bodies. The CEO `AGENTS.md` diff is The [independent three-request drafting simulation](2026-10-02-hiring-skill-drafting-evidence.json) produced short backend-engineer and release-coordinator role text, retained requested skills and disabled timers, used inherited authentication without literal credentials, and preserved requester-supplied security instructions exactly. It explicitly left schema confirmation and approval reconciliation pending. No API calls or hires occurred; this is drafting evidence, not live runtime qualification. -Focused verification passes 99 server tests and eight shipped-catalog tests. The full build and repository typecheck pass. The branch includes configuration and import checks for default CEO selection, explicit custom bundles, first-agent rendering, catalog role/reporting/skill metadata, generated catalog bytes, and prepared import sources. The hiring skill also receives a separate drafting simulation with synthetic discovery data. These are not live provider comparisons. +Focused verification passes 99 server tests and eight shipped-catalog tests. The full build and repository typecheck pass. Credential-free E2E support checks pass 63 files / 842 tests, the E2E typecheck, two-cell hiring discovery, existing 50-cell everyday discovery, and the full 438-cell catalog. The full local `pnpm test:run` was started and stopped before replaying onto newer master, with its original output retained; it is not a completed full-suite pass. Complete checks on the submitted source head run in GitHub CI. The branch includes configuration and import checks for default CEO selection, explicit custom bundles, first-agent rendering, catalog role/reporting/skill metadata, generated catalog bytes, and prepared import sources. The hiring skill also receives a separate drafting simulation with synthetic discovery data. These are not live provider comparisons. + +The explicit-only `hiring-templates` suite starts from the source revision's real default CEO, requests the installed hiring skill and coder example, and checks one permanent coder hire, two independently scored saved JSON fixtures, and reuse of that same worker. Its local Codex and ACPX Claude cells expect five provider turns each. Successful source reads before the hire, served source hashes, saved instruction bundles, task ownership, account inheritance, and preservation of the first artifact form the evidence. Long historical templates remain admissible; absent or unrecognized read receipts make coverage uncomparable. These two cells have not yet run live. Behavioral qualification of the removed CEO delegation/memory policy and QA/UX/security procedures is still outstanding. Existing live `hire-delegate-reuse` starts from a custom CEO studio prompt, so it does not directly qualify the shipped default CEO. The wizard-backed `first-task` suite records the actual first-agent persona and skill snapshots; it covers that selection path but does not by itself qualify hiring from the chief of staff. Preserve that distinction when interpreting existing results. No improved or equivalent outcomes are claimed from smaller prompts or configuration tests. diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md index f28d59b0f20..2ff1eace509 100644 --- a/tests/runner-e2e/FIXTURES.md +++ b/tests/runner-e2e/FIXTURES.md @@ -294,3 +294,34 @@ cleanup include unexpected manager runs. The grader checks saved human input, requester identity for scope questions, ownership history, no additional work or hires, and the browser-answer continuation. See [Direct blocker guidance](README.md#direct-blocker-guidance) for coverage boundaries and run commands. + + +## Source-derived hiring template fixture + +The explicit `hiring-templates` suite reuses the public company/agent, personal +managed account and browser chat fixtures. `hiringTemplateProfile` removes the +custom instruction bundle from the ordinary profile; the real agent creation +route selects the evaluated revision's CEO bundle. Keep its two local native +profiles, five expected runs and 15-minute deadline stable for paired runs. +`isManagedHiringCase` requests the account fixture and `chatNeedsApiTools` +enables only the existing API-tool path. It adds no private fixture endpoint, +provider fake or database write. + +`hiring-template-flow.ts` reads the production instructions and company skill +files through public APIs before dispatch and verifies their hashes against the +checkout. The public run-events API supplies paginated read evidence after +execution. `hiring-template-scoring.ts` grades deterministic child documents, +actual worker identity/account, reuse and source coverage independently of the +agents' claims. `hiring-template.test.ts` calibrates production Codex/ACPX event +shapes, wrong/missing/late reads, incorrect/default bundles, source mismatch, +wrong hire/output, missing durable state, and an admissible historical four-file +CEO with a long coder role. + +Preserve both dimensions in a comparison: `outcomePassed` describes the work; +`comparisonStatus` describes whether the expected sources and reads were proven. +Unprovable provider event shapes are coverage gaps. They must not become a +passing template comparison or a claimed behavior regression. The existing +report matcher paths carry the dimension and private final evidence carries the +explicit status. Provider runs are separately authorized; unit results establish +oracle calibration only. See the [suite contract](README.md#production-hiring-templates) +for evidence, budgets, cleanup and exact IDs. diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 0ee35e5517d..1d09fb4b633 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -1486,3 +1486,78 @@ the evaluated checkout byte for byte. The skill snapshot and provider run eviden are retained privately alongside the grading checkpoints for failure diagnosis. Claude receives a fresh provider home and config directory inside the disposable workspace so a user's installed skill cannot shadow the managed skill under test. + + +## Production hiring templates + +`hiring-templates` adds two explicit-only local cells: + +- `hiring-templates.runner-codex.local.hire-coder-template-reuse` +- `hiring-templates.runner-acpx-claude.local.hire-coder-template-reuse` + +The fixture creates a CEO through the public API without an instructions bundle +override, using production permission defaults and a personal managed AI +connection. Chromium sends the same user request on candidate and baseline: +use `paperclip-create-agent`, read its skill, drafting guide, review checklist +and coder example, hire one permanent coder with that example, and delegate a +saved JSON label-normalization fixture. Reading the optional references is an +explicit fixture user request. It is not an additional production requirement. +A follow-up delegates a second fixture to the same coder with underscore +separators while preserving the original. A final read-only chat turn requests +recorded task status. Three CEO turns and two actual worker executions make +**five expected turns per cell**, with a **15-minute deadline** and 1,000-cent +company/CEO budget hard stops. Normal managed-account fixture cleanup and +company-wide cancellation apply. Both cells opt into the existing native API +tools. No model-authored code is executed by the grading host. + +The independent oracle checks every JSON input and computed value, authorship, +two distinct completed tasks, project/reporting identity, exact five-run count, +managed execution-account attribution, original document preservation, and +worker reuse. It separately checks the production CEO bundle, assigned hiring +skill, source hashes, completed pre-hire read receipts, the saved source-derived +coder example, and durable instruction/skill selections. + +`loadDefaultAgentInstructionsBundle("ceo")` determines the expected files and +bytes on each evaluated revision. A historical four-file CEO bundle and long +coder example are admissible; the candidate is not imposed on the baseline. +Instruction bytes and word counts are measurements, without a size pass/fail +threshold. The definition digest fingerprints the cases, flow and grader; +source evidence also fingerprints the loader, generic execution contract, +selected CEO files and production hiring references. Use the same fixture +revision, scenario nonce, profile/model, managed account method and local +environment when comparing candidate and baseline, and record each evaluated +source SHA. Porting the fixture to a baseline is harness preparation, not a +baseline runtime qualification. + +`hiring-template-source.json`, `hiring-template-initial.json`, and +`hiring-template.json` retain source/bundle bytes, hashes, saved child documents, +assigned skills, completed public run events, read receipts, budgets and grades +inside the access-controlled evidence package. The final grade separates +`outcomePassed` from `comparisonStatus` (`comparable` or `uncomparable`), with +`outcome` and `coverage` matcher paths in the normal report. Missing, wrong, +failed, post-hire or unidentifiable reads make source coverage uncomparable even +when task outcomes pass. The existing machine failure classifier remains +unchanged: a coverage-only failed attempt must be counted as an uncomparable +pair, not presented as a workflow behavior regression or template equivalence. +The new marked screenshot shows only the synthetic chat/task state; private +snapshots follow the existing publication boundary. + +Read receipt support deliberately recognizes bounded direct shell reads and +canonical file-read events with a preserved relative skill path and completed +output. ACPX redacts absolute file locations from canonical events; a read whose +path no longer survives is unprovable and remains uncomparable. Echoing or +listing a filename and successful task output do not prove a source read. No +adapter event changes are part of this suite. Unit calibration and discovery do +not qualify either live provider cell. + +```sh +pnpm test:e2e:runner -- --list --suite hiring-templates +# Only after separate approval for the bounded live run: +pnpm test:e2e:runner -- --id hiring-templates.runner-codex.local.hire-coder-template-reuse --max-automatic-retries 0 +pnpm test:e2e:runner -- --id hiring-templates.runner-acpx-claude.local.hire-coder-template-reuse --max-automatic-retries 0 +``` + +The existing `first-task` suite uses the actual onboarding wizard and captures +the changed chief-of-staff persona and skill selections; it needs no fixture +change for that default selection. Hiring from that wizard-created chief of +staff remains a separate follow-up qualification. diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index b22f2e227a1..1e2a7a6561f 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -136,10 +136,10 @@ describe("runner E2E catalog", () => { expect(localIntegrityTasks).toHaveLength(2); expect(openRouterBreadthTasks).toHaveLength(3); expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([ - 6, 30, 3, 16, 16, 2, 6, 8, 46, 23, 50, 20, 52, 28, 18, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, + 6, 30, 3, 16, 16, 2, 6, 8, 46, 23, 50, 20, 52, 28, 18, 2, 6, 6, 12, 10, 48, 16, 10, 2, 1, 1, ]); - expect(validateRunnerCatalog()).toHaveLength(436); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(436); + expect(validateRunnerCatalog()).toHaveLength(438); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(438); expect( runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"), ).toHaveLength(48); diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index b94cf60b708..15038c55de9 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -1,4 +1,5 @@ import { chatConfirmationTasks } from "./chat-cases.js"; +import { hiringTemplateTasks, hiringTemplateProfile, hiringTemplateDefinitionDigest } from "./hiring-template-cases.js"; import { instructionPersistenceTask } from "./instruction-persistence.js"; import { apiResponseReadingTask } from "./api-response-reading.js"; import { taskTitleTasks, taskTitleDefinitionDigest, TASK_TITLE_BUDGET_CENTS } from "./task-titles.js"; @@ -1239,6 +1240,16 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ ["stop-startup-new-resume", "hire-delegate-reuse", "blocked-status-review"].map(task => `agent-chat-hardening.${profile}.daytona.${task}`)), definitionMetadata: { version: 5, permissions: "production-defaults", instructions: "production", grading: "durable-state-and-source-evidence", scheduling: "explicit-only", restartMemory: "required-after-restart", statusEvidence: "structured-current-blocker-and-active-run-count", readOnlyState: "public-mutation-contract-and-relations", hiringReference: "neutral-document-reference-line" }, }, + { + id: "hiring-templates", label: "Production Hiring Templates", manualOnly: true, + description: "Production CEO and hiring skill/reference discovery, one coder hire, independently checked JSON artifacts and worker reuse.", + groups: ["chat", "native"], + profiles: runnerProfiles.filter(profile => ["runner-codex", "runner-acpx-claude"].includes(profile.id)) + .map(profile => hiringTemplateProfile(defaultPermissionProfile(profile))), + environments: [localEnvironment], tasks: hiringTemplateTasks, expectedMatrixSize: 2, + definitionMetadata: { version: 1, definitionDigest: hiringTemplateDefinitionDigest, instructions: "source-revision-default-ceo", scheduling: "explicit-only", + grading: "independent-json-and-read-receipts", baselineComparison: "same-fixture-source-derived-bundles", providerTurns: 5 }, + }, { id: "agent-chat-stories", label: "Agent Chat Setup and Interruptions", manualOnly: true, description: "Experimental settings lifecycle and user follow-ups during active native work.", diff --git a/tests/runner-e2e/chat-cases.ts b/tests/runner-e2e/chat-cases.ts index 2b46749ff0a..50d22658b4e 100644 --- a/tests/runner-e2e/chat-cases.ts +++ b/tests/runner-e2e/chat-cases.ts @@ -54,10 +54,10 @@ export const chatStoryTasks = buildChatTasks([ ]).map(task => ({ ...task, ...(task.id === "enable-disable-resume" ? {} : { minimumExpectedRunCount: 1 }) })); export function chatNeedsApiTools(suiteId: string, caseId: string): boolean { - return (suiteId === "agent-chat-qualification" && caseId === "grounded-answer-quality") || suiteId === "agent-chat-hardening" && ["hire-delegate-reuse", "blocked-status-review"].includes(caseId); + return suiteId === "hiring-templates" || (suiteId === "agent-chat-qualification" && caseId === "grounded-answer-quality") || suiteId === "agent-chat-hardening" && ["hire-delegate-reuse", "blocked-status-review"].includes(caseId); } export function isManagedHiringCase(suiteId: string, caseId: string): boolean { - return (suiteId === "everyday-workflows" && caseId === "hire-reuse") || + return (suiteId === "hiring-templates" && caseId === "hire-coder-template-reuse") || (suiteId === "everyday-workflows" && caseId === "hire-reuse") || (suiteId === "agent-chat-hardening" && caseId === "hire-delegate-reuse"); } diff --git a/tests/runner-e2e/chat-flow.ts b/tests/runner-e2e/chat-flow.ts index 8c3818a1e7e..cd48e0bd23f 100644 --- a/tests/runner-e2e/chat-flow.ts +++ b/tests/runner-e2e/chat-flow.ts @@ -1,3 +1,4 @@ +import { runHiringTemplateFlow } from "./hiring-template-flow.js"; import { runAmbiguousConfirmationReply, runUnansweredQuestionReturn } from "./confirmation-replies.js"; import { expect, type Page } from "@playwright/test"; import { pollUntil, type RunnerApi } from "./api.js"; @@ -439,6 +440,8 @@ export async function runChatFlow(input: ChatFlowInput) { } else if (caseId.startsWith("handoff-completion-")) { await runChatCompletionUpdate({ input, marker, allRuns, issue: () => issue!, refreshIssue: async () => { issue = await api.get(chatPath); if (issue) input.observe(issue, await allRuns()); } }); + } else if (execution.suite.id === "hiring-templates") { + await runHiringTemplateFlow({ input, issue: () => issue!, turn, tasks, allRuns }); } else if (execution.suite.id === "agent-chat-qualification") { const context = { input, marker, issue: () => issue!, idle, allRuns, comments, expectedStops, refreshIssue: async () => { issue = await api.get(chatPath); input.observe(issue, await allRuns()); } }; diff --git a/tests/runner-e2e/hiring-template-cases.ts b/tests/runner-e2e/hiring-template-cases.ts new file mode 100644 index 00000000000..b11b369edfc --- /dev/null +++ b/tests/runner-e2e/hiring-template-cases.ts @@ -0,0 +1,59 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import type { RunnerProfileFixture, RunnerTaskFixture } from "./types.js"; + +export const HIRING_TEMPLATE_GRADER_VERSION = "paperclip.hiring-templates.v1"; +export const HIRING_TEMPLATE_SKILL_KEY = "paperclipai/paperclip/paperclip-create-agent"; +export const HIRING_TEMPLATE_READ_FILES = [ + "SKILL.md", + "references/agent-instruction-templates.md", + "references/draft-review-checklist.md", + "references/agents/coder.md", +] as const; +export const HIRING_TEMPLATE_SOURCE_FILES = [ + "server/src/services/default-agent-instructions.ts", + "server/src/onboarding-assets/default/AGENTS.md", + ...HIRING_TEMPLATE_READ_FILES.map(file => `skills/paperclip-create-agent/${file}`), + "skills/paperclip-create-agent/references/baseline-role-guide.md", +] as const; +export const hiringTemplateDefinitionDigest = createHash("sha256").update( + ["cases", "scoring", "flow"].map(part => readFileSync(new URL(`./hiring-template-${part}.ts`, import.meta.url), "utf8")).join("\n"), +).digest("hex"); + +export function hiringTemplateProfile(profile: RunnerProfileFixture): RunnerProfileFixture { + return { + ...profile, + buildAgent(input) { + const { instructionsBundle: _fixtureInstructions, ...payload } = profile.buildAgent(input); + return { ...payload, name: `Hiring Lead ${input.executionId}`, role: "ceo", title: "CEO" }; + }, + }; +} + +export const hiringTemplateInputs = [" Launch Queue!! ", "API__Key Rotation", "Mixed CASE / 42", "---Already--Ready---"] as const; +export function hiringTemplateScenario(nonce: string, projectName: string) { + const marker = `HIRE${nonce.replace(/[^a-zA-Z0-9]/g, "")}`; + const hireName = `Casey Coder ${nonce}`; + return { + marker, hireName, inputs: hiringTemplateInputs, + initialPrompt: [ + `Use the paperclip-create-agent skill to hire exactly one coding teammate named ${hireName}, reporting to you, with your native runner, model, and available AI connection.`, + "Before hiring, read that skill's SKILL.md, references/agent-instruction-templates.md, references/draft-review-checklist.md, and references/agents/coder.md. Use the AGENTS.md example from that coder reference as the teammate's managed instructions, filling all agentName, companyName, and managerTitle placeholders from this hire, company, and your actual title. Keep skills and reporting information in their configuration.", + `Have that coder prepare a JSON test fixture for a label normalizer in one assigned task in ${projectName}. Save the fixture as a Paperclip document with an object containing reference: ${JSON.stringify(marker)} and entries, an ordered array of objects with input and value. Inputs: ${JSON.stringify(hiringTemplateInputs)}.`, + "For each value, trim its input, lowercase it, replace each sequence outside ASCII a-z and 0-9 with one hyphen, then trim leading and trailing hyphens. Link the task here and let the coder complete it.", + ].join("\n\n"), + reusePrompt: (identifier: string) => `Have the existing ${hireName} prepare a second saved JSON fixture in one new task in ${projectName}, assigned to that same coder. Use the inputs and normalization rule from ${identifier}, but use underscores instead of hyphens for the value separators and trim leading and trailing underscores. Use reference: ${JSON.stringify(`REUSE${marker}`)}. Include the original fixture in the handoff. Preserve the original document and task, and let the coder complete the new task.`, + statusPrompt: (first: string, second: string) => `Briefly report the recorded owner and status of ${first} and ${second}. Just report; do not create or change work.`, + }; +} + +export const hiringTemplateTasks: readonly RunnerTaskFixture[] = [{ + id: "hire-coder-template-reuse", label: "Hire with production templates and reuse the coder", + groups: ["chat"], flow: "agent_chat", workMode: "standard", expectedRunCount: 5, + attemptTimeoutMs: { local: 15 * 60_000, daytona: 15 * 60_000 }, + expectedTerminalState: { issue: "in_review", run: "succeeded" }, + buildTitle: nonce => `Production hiring templates ${nonce}`, + buildPrompt: nonce => hiringTemplateScenario(nonce, "the selected project").initialPrompt, + buildVisibleMarker: nonce => hiringTemplateScenario(nonce, "").marker, + buildMatchers: () => [{ kind: "issue_status", expected: "in_review" }], +}]; diff --git a/tests/runner-e2e/hiring-template-flow.ts b/tests/runner-e2e/hiring-template-flow.ts new file mode 100644 index 00000000000..233ca0b72b8 --- /dev/null +++ b/tests/runner-e2e/hiring-template-flow.ts @@ -0,0 +1,141 @@ +import { readFile } from "node:fs/promises"; +import { loadDefaultAgentInstructionsBundle } from "../../server/src/services/default-agent-instructions.js"; +import { readChatOutputDocument, type ChatFlowInput, type ChatIssue, type ChatRun } from "./chat-flow.js"; +import { collectRunEvents } from "./run-observations.js"; +import { HIRING_TEMPLATE_GRADER_VERSION, HIRING_TEMPLATE_READ_FILES, HIRING_TEMPLATE_SKILL_KEY, + HIRING_TEMPLATE_SOURCE_FILES, hiringTemplateDefinitionDigest, hiringTemplateScenario } from "./hiring-template-cases.js"; +import { gradeHiringTemplate, hiringTemplateHash, hiringTemplateSize, type HiringAgent, type HiringDocument, + type HiringInstructionSnapshot, type HiringTemplateEvidence } from "./hiring-template-scoring.js"; +import type { RunnerApi } from "./api.js"; + +export async function readHiringInstructions(api: Pick, agentId: string): Promise { + const bundle = await api.get<{ mode: string | null; entryFile: string; files: Array<{ path: string; binary?: boolean }> }>(`/api/agents/${agentId}/instructions-bundle`); + const files: Record = {}; + // Agent-home notes may be added by a run. Retain the entry and legacy CEO + // policy files so the baseline and candidate default bundles can be compared. + for (const file of bundle.files.filter(file => !file.binary && (file.path === bundle.entryFile || ["HEARTBEAT.md", "SOUL.md", "TOOLS.md"].includes(file.path)))) { + const detail = await api.get<{ content: string }>(`/api/agents/${agentId}/instructions-bundle/file?path=${encodeURIComponent(file.path)}`); + files[file.path] = detail.content; + } + return { entryFile: bundle.entryFile, mode: bundle.mode, files }; +} + +async function readHiringSkillSelections(api: Pick, companyId: string, agentId: string) { + const snapshot = await api.get<{ desiredSkills: string[]; desiredSkillEntries: unknown[] }>(`/api/agents/${agentId}/skills?companyId=${companyId}`); + return { desiredSkills: snapshot.desiredSkills, desiredSkillEntries: snapshot.desiredSkillEntries }; +} + +export async function readHiringTemplateSources(api: Pick, companyId: string, leadId: string) { + const expectedCeoFiles = await loadDefaultAgentInstructionsBundle("ceo"); + const expectedSourceHashes: Record = {}, servedSourceHashes: Record = {}; + const sourceSizes: Record> = {}; + for (const relative of [...HIRING_TEMPLATE_SOURCE_FILES, ...Object.keys(expectedCeoFiles).map(file => `server/src/onboarding-assets/ceo/${file}`)]) { + const content = await readFile(new URL(`../../${relative}`, import.meta.url), "utf8"); + expectedSourceHashes[relative] = hiringTemplateHash(content); + sourceSizes[relative] = hiringTemplateSize(content); + } + const leadInstructions = await readHiringInstructions(api, leadId); + for (const [file, content] of Object.entries(leadInstructions.files)) servedSourceHashes[`server/src/onboarding-assets/ceo/${file}`] = hiringTemplateHash(content); + const skills = await api.get>(`/api/companies/${companyId}/skills`); + const creator = skills.find(skill => skill.key === HIRING_TEMPLATE_SKILL_KEY); + if (!creator) throw new Error("Production hiring skill is absent from the company library"); + const assigned = await api.get<{ desiredSkills: string[] }>(`/api/agents/${leadId}/skills?companyId=${companyId}`); + let coderReference = ""; + for (const file of [...HIRING_TEMPLATE_READ_FILES, "references/baseline-role-guide.md"]) { + const detail = await api.get<{ content: string }>(`/api/companies/${companyId}/skills/${creator.id}/files?path=${encodeURIComponent(file)}`); + servedSourceHashes[`skills/paperclip-create-agent/${file}`] = hiringTemplateHash(detail.content); + if (file === "references/agents/coder.md") coderReference = detail.content; + } + // The loader and execution contract are source provenance, not served skill + // reads. Their bytes are retained separately from the equality checks. + delete expectedSourceHashes["server/src/services/default-agent-instructions.ts"]; + delete expectedSourceHashes["server/src/onboarding-assets/default/AGENTS.md"]; + return { expectedCeoFiles, leadInstructions, expectedSourceHashes, servedSourceHashes, + sourceSizes, assignedSkills: assigned.desiredSkills, coderReference, creatorSkillId: creator.id, + loaderHash: hiringTemplateHash(await readFile(new URL("../../server/src/services/default-agent-instructions.ts", import.meta.url), "utf8")), + executionContractHash: hiringTemplateHash(await readFile(new URL("../../server/src/onboarding-assets/default/AGENTS.md", import.meta.url), "utf8")) }; +} + +export function renderHiringCoderExample(reference: string, agentName: string, companyName: string, managerTitle: string) { + const example = reference.match(/```md\s*\n([\s\S]*?)\n```/)?.[1]; + if (!example?.trim()) throw new Error("Production coder reference has no AGENTS.md example"); + return example.replaceAll("{{agentName}}", agentName).replaceAll("{{companyName}}", companyName).replaceAll("{{managerTitle}}", managerTitle); +} + +export async function runHiringTemplateFlow(context: { + input: ChatFlowInput; issue(): ChatIssue; turn(message: string, count: number): Promise; + tasks(): Promise; allRuns(): Promise; +}) { + const { input, turn, tasks, allRuns } = context; + const { api, fixtures: f } = input; + const company = `/api/companies/${f.company.id}`; + const account = f.aiConnection; + if (!account) throw new Error("Hiring-template fixture requires a managed execution account"); + const evidence: HiringTemplateEvidence = { + leadId: f.agent.id, chatIssueId: "", hireName: "", marker: "", projectId: "", inputs: [], expectedCeoFiles: {}, + expectedSourceHashes: {}, servedSourceHashes: {}, assignedSkills: [], coderExample: "", + agents: [], tasks: [], runs: [], readRuns: [], connectionId: account.connectionId, binding: account.binding, + }; + let source: Awaited> | undefined; + let scenario: ReturnType | undefined; + async function refresh() { + const observed = await Promise.all([api.get(`${company}/agents`), tasks(), allRuns()]); + [evidence.agents, evidence.tasks, evidence.runs] = observed; + evidence.readRuns = await Promise.all(evidence.runs.map(async run => { + const events = await collectRunEvents<{ seq?: number; eventType?: string; payload?: unknown; createdAt?: string }>( + (afterSeq, limit) => api.get(`/api/heartbeat-runs/${run.id}/events?afterSeq=${afterSeq}&limit=${limit}`), + ); + return { runId: run.id, agentId: run.agentId, events }; + })); + } + try { + await api.patch(`${company}/budgets`, { budgetMonthlyCents: 1_000 }); + await api.patch(`/api/agents/${f.agent.id}/budgets`, { budgetMonthlyCents: 1_000 }); + source = await readHiringTemplateSources(api, f.company.id, f.agent.id); + Object.assign(evidence, source); + await input.evidence("hiring-template-source.json", { ...source, definitionDigest: hiringTemplateDefinitionDigest }); + if (Object.entries(source.expectedSourceHashes).some(([file, hash]) => source!.servedSourceHashes[file] !== hash)) { + throw new Error("Hiring-template served source differs from the evaluated revision; comparison is uncomparable"); + } + const project = await api.post<{ id: string; name: string }>(`${company}/projects`, { + name: `Label fixtures ${input.nonce}`, description: "Repository-free JSON normalization fixtures.", + }); + scenario = hiringTemplateScenario(input.nonce, project.name); + Object.assign(evidence, { hireName: scenario.hireName, marker: scenario.marker, inputs: scenario.inputs, projectId: project.id, + coderExample: renderHiringCoderExample(source.coderReference, scenario.hireName, f.company.name, String((await api.get<{ title: string }>(`/api/agents/${f.agent.id}`)).title)) }); + await turn(scenario.initialPrompt, 2); + evidence.chatIssueId = context.issue().id; + const initial = await tasks(); + if (initial.length !== 1) throw new Error("Hiring-template first turn must create exactly one coder task"); + evidence.first = await readChatOutputDocument(api, initial[0]!.id, scenario.marker) as HiringDocument; + await refresh(); + const hired = evidence.agents.find(agent => agent.name === scenario!.hireName); + if (!hired) throw new Error("Hiring-template coder was not created"); + evidence.hiredInstructions = await readHiringInstructions(api, hired.id); + evidence.hiredSkills = await readHiringSkillSelections(api, f.company.id, hired.id); + await input.capture("hiring-template-created", "Production CEO hired a coder and delivered its first fixture", "hiring-template-created.png"); + await input.evidence("hiring-template-initial.json", evidence); + await turn(scenario.reusePrompt(initial[0]!.identifier ?? initial[0]!.id), 4); + const second = (await tasks()).find(task => task.id !== evidence.first!.issueId); + if (!second) throw new Error("Hiring-template reuse task is absent"); + evidence.second = await readChatOutputDocument(api, second.id, `REUSE${scenario.marker}`) as HiringDocument; + evidence.firstAfterReuse = await api.get(`/api/issues/${evidence.first.issueId}/documents/${encodeURIComponent(evidence.first.key)}`); + await turn(scenario.statusPrompt(initial[0]!.identifier ?? initial[0]!.id, second.identifier ?? second.id), 5); + evidence.hiredInstructionsAfterReuse = await readHiringInstructions(api, hired.id); + evidence.hiredSkillsAfterReuse = await readHiringSkillSelections(api, f.company.id, hired.id); + await refresh(); + const result = gradeHiringTemplate(evidence); + for (const check of result.checks) input.check?.(`hiringTemplates.${check.dimension}.${check.id}`, check.passed, check.detail); + await input.evidence("hiring-template.json", { schema: HIRING_TEMPLATE_GRADER_VERSION, definitionDigest: hiringTemplateDefinitionDigest, + budgetGuard: { companyMonthlyCents: 1_000, leadMonthlyCents: 1_000 }, scenario, source, evidence, result }); + const failed = result.checks.filter(check => !check.passed); + if (!result.outcomePassed) throw new Error(`Hiring-template workflow outcome failed: ${failed.filter(check => check.dimension === "outcome").map(check => check.id).join(", ")}`); + if (result.comparisonStatus === "uncomparable") throw new Error(`Hiring-template source coverage is uncomparable: ${failed.filter(check => check.dimension === "coverage").map(check => check.id).join(", ")}`); + } finally { + let observationError: string | undefined; + try { await refresh(); } catch (error) { observationError = String(error); } + await input.evidence("hiring-template.json", { schema: HIRING_TEMPLATE_GRADER_VERSION, definitionDigest: hiringTemplateDefinitionDigest, + budgetGuard: { companyMonthlyCents: 1_000, leadMonthlyCents: 1_000 }, + scenario, source, evidence, result: gradeHiringTemplate(evidence), observationError }); + } +} diff --git a/tests/runner-e2e/hiring-template-scoring.ts b/tests/runner-e2e/hiring-template-scoring.ts new file mode 100644 index 00000000000..21366460595 --- /dev/null +++ b/tests/runner-e2e/hiring-template-scoring.ts @@ -0,0 +1,148 @@ +import { createHash } from "node:crypto"; +import { HIRING_TEMPLATE_READ_FILES, HIRING_TEMPLATE_SKILL_KEY } from "./hiring-template-cases.js"; +import type { ChatIssue, ChatRun } from "./chat-flow.js"; + +const record = (value: unknown): Record => + value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +export const hiringTemplateHash = (content: string) => createHash("sha256").update(content).digest("hex"); +export const hiringTemplateSize = (content: string) => ({ bytes: Buffer.byteLength(content), words: content.trim().split(/\s+/).filter(Boolean).length }); +export interface HiringInstructionSnapshot { entryFile: string; mode: string | null; files: Record } +export interface HiringReadRun { runId: string; agentId: string; events: Array<{ eventType?: string; payload?: unknown; createdAt?: string }> } +export interface HiringAgent { + id: string; name: string; role?: string; reportsTo?: string | null; adapterType?: string; createdAt?: string; + adapterConfig?: Record; runtimeConfig?: Record; +} +export interface HiringDocument { issueId: string; key: string; body: string; latestRevisionId: string; createdByAgentId?: string } +export interface HiringTemplateEvidence { + leadId: string; chatIssueId: string; hireName: string; marker: string; projectId: string; inputs: readonly string[]; + expectedCeoFiles: Record; leadInstructions?: HiringInstructionSnapshot; + expectedSourceHashes: Record; servedSourceHashes: Record; + assignedSkills: string[]; coderExample: string; hiredInstructions?: HiringInstructionSnapshot; + hiredInstructionsAfterReuse?: HiringInstructionSnapshot; hiredSkills?: unknown; hiredSkillsAfterReuse?: unknown; agents: HiringAgent[]; + connectionId: string; binding: unknown; tasks: ChatIssue[]; runs: ChatRun[]; + first?: HiringDocument; firstAfterReuse?: HiringDocument; second?: HiringDocument; + readRuns: HiringReadRun[]; +} + +function expectedFixture(inputs: readonly string[], marker: string, separator: "-" | "_") { + return { reference: marker, entries: inputs.map(input => ({ + input, value: input.trim().toLowerCase().replace(/[^a-z0-9]+/g, separator).replace(/^[-_]+|[-_]+$/g, ""), + })) }; +} +function sameJson(left: unknown, right: unknown): boolean { + if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((v, i) => sameJson(v, right[i])); + if (left && right && typeof left === "object" && typeof right === "object") { + const keys = Object.keys(left).sort(); + return sameJson(keys, Object.keys(right).sort()) && keys.every(k => sameJson(record(left)[k], record(right)[k])); + } + return left === right; +} +function documentMatches(document: HiringDocument | undefined, expected: unknown) { + try { return sameJson(JSON.parse((document?.body ?? "").trim().replace(/^```(?:json)?\s*/, "").replace(/\s*```$/, "")), expected); } + catch { return false; } +} + +function payload(event: { payload?: unknown }) { + const outer = record(event.payload); + return record(outer.prpEvent).payload ? record(record(outer.prpEvent).payload) : outer; +} +function matchingReadPath(value: unknown, file: string) { + return typeof value === "string" && value.replace(/\\/g, "/").endsWith(`/paperclip-create-agent/${file}`); +} +function commandReads(command: unknown, file: string) { + if (typeof command !== "string") return false; + // Recognize bounded, direct read commands. Echoed paths, searches, writes, + // arbitrary scripts and unresolved shell variables are not read evidence. + const tokens = command.match(/"[^"]*"|'[^']*'|[^\s]+/g)?.map(s => s.replace(/^['"]|['"]$/g, "")) ?? []; + return tokens.some((token, i) => matchingReadPath(token, file) + && (/^(?:cat|head|tail|sed)$/.test(tokens[0] ?? "")) && i > 0 + && !tokens.some(t => /[|;&><]|\$/.test(t))); +} + +/** Only completed provider read operations count; prose and discovery listings do not. */ +export function hiringTemplateReadReceipts(readRuns: HiringReadRun[], leadId: string) { + const receipts: Array<{ file: string; runId: string; itemId: string; createdAt?: string }> = []; + for (const run of readRuns.filter(run => run.agentId === leadId)) { + const starts = new Map>(); + for (const event of run.events) { + const p = payload(event), item = record(p.item); + if (event.eventType === "item.started" && item.id) starts.set(item.id, item); + if (event.eventType !== "item.completed" && event.eventType !== "tool.execution.completed") continue; + const start = starts.get(item.tool_use_id ?? item.id) ?? item; + const name = String(start.name ?? "").toLowerCase(); + const input = record(start.input); + const result = record(item.result); + const toolRead = item.type === "tool_result" && /^(read|read_file|file_read)$/.test(name) + && item.isError !== true && item.is_error !== true && result.isError !== true + && result.is_error !== true && !result.error + && Object.keys(result).length > 0; + const commandRead = item.type === "commandExecution" && item.exitCode === 0 && item.status === "completed" + && typeof item.aggregatedOutput === "string" && item.aggregatedOutput.length > 0; + const executionRead = event.eventType === "tool.execution.completed" && p.status === "completed"; + const canonicalFileRead = executionRead && p.operation === "read" && p.readOnly === true + && typeof p.outputBytes === "number" && p.outputBytes > 0; + for (const file of HIRING_TEMPLATE_READ_FILES) { + if ((toolRead && [input.file_path, input.path, input.filePath].some(path => matchingReadPath(path, file))) + || (commandRead && commandReads(item.command, file)) + || (executionRead && p.exitCode === 0 && p.outputBytes > 0 && commandReads(p.name, file)) + || (canonicalFileRead && matchingReadPath(p.target, file))) { + receipts.push({ file, runId: run.runId, itemId: String(item.id ?? p.executionId ?? ""), createdAt: event.createdAt }); + } + } + } + } + return receipts; +} + +export function gradeHiringTemplate(e: HiringTemplateEvidence) { + const checks: Array<{ id: string; dimension: "outcome" | "coverage"; passed: boolean; detail: string }> = []; + const check = (id: string, dimension: "outcome" | "coverage", passed: boolean, detail: string) => checks.push({ id, dimension, passed, detail }); + const hires = e.agents.filter(a => a.id !== e.leadId), hired = hires[0]; + const lead = e.agents.find(a => a.id === e.leadId); + check("one-coder-hire", "outcome", hires.length === 1 && hired?.name === e.hireName && hired.role === "engineer" + && hired.reportsTo === e.leadId && hired.adapterType === "paperclip_runner", "Exactly one permanent coding teammate reports to the lead."); + check("execution-account", "outcome", Boolean(e.connectionId) && Boolean(hired && lead) + && hired?.adapterConfig?.model === lead?.adapterConfig?.model + && sameJson(hired?.runtimeConfig?.aiConnection, e.binding), "Hire keeps the lead model and managed account binding."); + const ids = [e.first?.issueId, e.second?.issueId]; + check("two-worker-tasks", "outcome", ids.every(Boolean) && new Set(ids).size === 2 && e.tasks.length === 2 + && ids.every(id => e.tasks.some(t => t.id === id && t.status === "done" && t.assigneeAgentId === hired?.id + && t.projectId === e.projectId && !t.parentId)), "Both independent tasks are completed by the same coder in the chosen project."); + check("five-successful-turns", "outcome", e.runs.length === 5 && e.runs.every(r => r.status === "succeeded" && r.runtimeMode === "native") + && e.runs.filter(r => r.agentId === e.leadId && r.contextSnapshot?.issueId === e.chatIssueId).length === 3 + && ids.every(id => { + const runs = e.runs.filter(r => r.contextSnapshot?.issueId === id); + return runs.length === 1 && runs[0]?.agentId === hired?.id + && record(runs[0]?.contextSnapshot?.aiConnection).connectionId === e.connectionId; + }), "Five turns include one actual coder execution for each task, attributed to the managed account."); + check("initial-json-artifact", "outcome", documentMatches(e.first, expectedFixture(e.inputs, e.marker, "-")) + && e.first?.createdByAgentId === hired?.id, "Independent computation checks each original input/value and worker authorship."); + check("reused-json-artifact", "outcome", documentMatches(e.second, expectedFixture(e.inputs, `REUSE${e.marker}`, "_")) + && e.second?.createdByAgentId === hired?.id, "The reused coder applies the changed separator to every input."); + check("original-preserved", "outcome", Boolean(e.first) && sameJson(e.first, e.firstAfterReuse), "Reuse preserves the original document, revision and task identity."); + const requiredSources = [...HIRING_TEMPLATE_READ_FILES.map(file => `skills/paperclip-create-agent/${file}`), + "skills/paperclip-create-agent/references/baseline-role-guide.md", + ...Object.keys(e.expectedCeoFiles).map(file => `server/src/onboarding-assets/ceo/${file}`)]; + check("source-fingerprints", "coverage", requiredSources.every(file => /^[a-f0-9]{64}$/.test(e.expectedSourceHashes[file] ?? "")) + && Object.entries(e.expectedSourceHashes).every(([file, hash]) => e.servedSourceHashes[file] === hash), "Served instruction and hiring source bytes match the evaluated revision."); + check("production-ceo-bundle", "coverage", e.leadInstructions?.mode === "managed" && e.leadInstructions.entryFile === "AGENTS.md" + && Object.keys(e.expectedCeoFiles).length > 0 && sameJson(e.leadInstructions.files, e.expectedCeoFiles), "The API-created lead receives this revision's default CEO bundle, without a fixture override."); + check("assigned-hiring-skill", "coverage", e.assignedSkills.includes(HIRING_TEMPLATE_SKILL_KEY), "Production CEO defaults assign the hiring skill."); + const receipts = hiringTemplateReadReceipts(e.readRuns, e.leadId); + check("production-source-reads", "coverage", HIRING_TEMPLATE_READ_FILES.every(file => receipts.some(receipt => receipt.file === file && receipt.itemId + && Date.parse(receipt.createdAt ?? "") <= Date.parse(hired?.createdAt ?? ""))), "Completed lead read receipts before the hire prove the explicitly requested skill, guide, checklist and coder example paths. Unrecognized or missing reads leave coverage uncomparable."); + const instruction = e.hiredInstructions?.files["AGENTS.md"] ?? ""; + // The same fixture can compare a long historical role and a short candidate: + // it requires the supplied role example, never a fixed size or new wording. + const expectedCoder = e.coderExample.trim(); + check("supplied-coder-instructions", "coverage", expectedCoder.length > 0 && e.hiredInstructions?.mode === "managed" + && e.hiredInstructions.entryFile === "AGENTS.md" && instruction.trim() === expectedCoder, "Saved hired instructions use the source revision's coder example with its company/name placeholders filled."); + check("hired-instructions-durable", "coverage", Boolean(e.hiredInstructions) && sameJson(e.hiredInstructions, e.hiredInstructionsAfterReuse), "The same saved instruction bundle survives the reused worker execution."); + check("hired-skills-durable", "coverage", Boolean(e.hiredSkills) && sameJson(e.hiredSkills, e.hiredSkillsAfterReuse), "The saved skill selections survive the reused worker execution."); + return { + checks, readReceipts: receipts, + outcomePassed: checks.filter(c => c.dimension === "outcome").every(c => c.passed), + comparisonStatus: checks.filter(c => c.dimension === "coverage").every(c => c.passed) ? "comparable" : "uncomparable", + instructionSizes: { ceo: Object.fromEntries(Object.entries(e.leadInstructions?.files ?? {}).map(([file, content]) => [file, hiringTemplateSize(content)])), coder: hiringTemplateSize(instruction) }, + }; +} diff --git a/tests/runner-e2e/hiring-template.test.ts b/tests/runner-e2e/hiring-template.test.ts new file mode 100644 index 00000000000..d9802e623b0 --- /dev/null +++ b/tests/runner-e2e/hiring-template.test.ts @@ -0,0 +1,201 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { runnerMatrix, runnerSuites } from "./catalog.js"; +import { buildRunnerE2EProcessEnvironment } from "./harness-env.js"; +import { canonicalProviderEventsFromAcpxRuntimeEvent, canonicalProviderEventsFromCodex } from "../../packages/paperclip-runner/src/provider-events.js"; +import { loadDefaultAgentInstructionsBundle } from "../../server/src/services/default-agent-instructions.js"; +import { HIRING_TEMPLATE_READ_FILES, HIRING_TEMPLATE_SKILL_KEY, hiringTemplateInputs, hiringTemplateScenario } from "./hiring-template-cases.js"; +import { readHiringInstructions, readHiringTemplateSources, renderHiringCoderExample } from "./hiring-template-flow.js"; +import { gradeHiringTemplate, hiringTemplateHash, hiringTemplateReadReceipts, type HiringTemplateEvidence } from "./hiring-template-scoring.js"; +import type { RunnerApi } from "./api.js"; + +const beforeHire = "2026-10-01T12:00:00.000Z", hiredAt = "2026-10-01T12:01:00.000Z"; +const binding = { provider: "openai", method: "api_key", mode: "responsible_user" }; +const ceo = { "AGENTS.md": "You are the CEO. Lead the company." }; +const coder = "You are Casey, a software engineer at Fixture Company. Own software implementation and maintenance."; +// Expected values are stated independently of the implementation under test. +const values = ["launch-queue", "api-key-rotation", "mixed-case-42", "already-ready"]; +const reuseValues = ["launch_queue", "api_key_rotation", "mixed_case_42", "already_ready"]; +function commandEvents(file: string) { + return canonicalProviderEventsFromCodex("item/completed", { item: { + type: "commandExecution", id: `read-${file}`, status: "completed", exitCode: 0, + command: `cat /workspace/.agents/skills/paperclip-create-agent/${file}`, aggregatedOutput: "source bytes", + } }).map(event => ({ eventType: event.eventType, createdAt: beforeHire, payload: { prpEvent: event } })); +} +function validEvidence(): HiringTemplateEvidence { + const sourceFiles = [...HIRING_TEMPLATE_READ_FILES.map(file => `skills/paperclip-create-agent/${file}`), + "skills/paperclip-create-agent/references/baseline-role-guide.md", "server/src/onboarding-assets/ceo/AGENTS.md"]; + const hashes = Object.fromEntries(sourceFiles.map(file => [file, hiringTemplateHash(file)])); + const tasks = ["first", "second"].map(id => ({ id, companyId: "company", title: id, status: "done", assigneeAgentId: "coder", projectId: "project", parentId: null })); + const runs = ["lead-1", "lead-2", "lead-3", "first", "second"].map(id => ({ id, companyId: "company", agentId: id.startsWith("lead") ? "lead" : "coder", + status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: id.startsWith("lead") ? "chat" : id, aiConnection: { connectionId: "account" } } })); + const document = (issueId: string, reference: string, outputs: string[]) => ({ issueId, key: "fixture", latestRevisionId: `${issueId}-revision`, createdByAgentId: "coder", + body: JSON.stringify({ reference, entries: hiringTemplateInputs.map((input, index) => ({ input, value: outputs[index] })) }) }); + const first = document("first", "HIREfixture", values); + return { leadId: "lead", chatIssueId: "chat", hireName: "Casey", marker: "HIREfixture", projectId: "project", inputs: hiringTemplateInputs, + expectedCeoFiles: ceo, leadInstructions: { mode: "managed", entryFile: "AGENTS.md", files: ceo }, + expectedSourceHashes: hashes, servedSourceHashes: { ...hashes }, assignedSkills: [HIRING_TEMPLATE_SKILL_KEY], coderExample: coder, + hiredInstructions: { mode: "managed", entryFile: "AGENTS.md", files: { "AGENTS.md": coder } }, + hiredInstructionsAfterReuse: { mode: "managed", entryFile: "AGENTS.md", files: { "AGENTS.md": coder } }, + hiredSkills: [], hiredSkillsAfterReuse: [], + agents: [{ id: "lead", name: "CEO", adapterConfig: { model: "model" } }, + { id: "coder", name: "Casey", role: "engineer", reportsTo: "lead", adapterType: "paperclip_runner", createdAt: hiredAt, + adapterConfig: { model: "model" }, runtimeConfig: { aiConnection: binding } }], + connectionId: "account", binding, tasks, runs, first, firstAfterReuse: { ...first }, second: document("second", "REUSEHIREfixture", reuseValues), + readRuns: [{ runId: "lead-1", agentId: "lead", events: HIRING_TEMPLATE_READ_FILES.flatMap(commandEvents) }] }; +} +function fails(evidence: HiringTemplateEvidence, id: string) { + expect(gradeHiringTemplate(evidence).checks.find(check => check.id === id)?.passed, id).toBe(false); +} + +describe("production hiring template oracle", () => { + it("independently grades the child fixtures and accepts JSON object key order", () => { + const e = validEvidence(); + expect(gradeHiringTemplate(e)).toMatchObject({ outcomePassed: true, comparisonStatus: "comparable" }); + e.first!.body = `\`\`\`json\n${JSON.stringify({ entries: hiringTemplateInputs.map((input, index) => ({ value: values[index], input })), reference: e.marker })}\n\`\`\``; + e.firstAfterReuse = { ...e.first! }; + expect(gradeHiringTemplate(e).outcomePassed).toBe(true); + for (const wrongBody of ["{}", "not JSON", JSON.stringify({ reference: e.marker, entries: [{ input: hiringTemplateInputs[0], value: "launch-queue" }] }), + JSON.stringify({ reference: e.marker, entries: hiringTemplateInputs.map(input => ({ input, value: input.toLowerCase() })) })]) { + fails({ ...e, first: { ...e.first!, body: wrongBody } }, "initial-json-artifact"); + } + fails({ ...e, second: { ...e.second!, body: e.first!.body } }, "reused-json-artifact"); + fails({ ...e, first: { ...e.first!, createdByAgentId: "lead" } }, "initial-json-artifact"); + }); + + it("requires the real hired identity, account, two distinct tasks and exactly five successful turns", () => { + const e = validEvidence(); + fails({ ...e, agents: [...e.agents, { ...e.agents[1]!, id: "replacement" }] }, "one-coder-hire"); + for (const wrong of [{ role: "qa" }, { reportsTo: "somebody" }, { adapterType: "codex_local" }, { name: "Another coder" }]) { + fails({ ...e, agents: [e.agents[0]!, { ...e.agents[1]!, ...wrong }] }, "one-coder-hire"); + } + fails({ ...e, agents: [e.agents[0]!, { ...e.agents[1]!, adapterConfig: { model: "different" } }] }, "execution-account"); + fails({ ...e, agents: [e.agents[0]!, { ...e.agents[1]!, runtimeConfig: { aiConnection: { ...binding, mode: "company" } } }] }, "execution-account"); + for (const patch of [{ assigneeAgentId: "lead" }, { parentId: "chat" }, { projectId: "other" }, { status: "backlog" }]) { + fails({ ...e, tasks: [e.tasks[0]!, { ...e.tasks[1]!, ...patch }] }, "two-worker-tasks"); + } + fails({ ...e, runs: e.runs.slice(1) }, "five-successful-turns"); + fails({ ...e, runs: [...e.runs, { ...e.runs[0]!, id: "extra" }] }, "five-successful-turns"); + fails({ ...e, runs: e.runs.map(r => r.id === "second" ? { ...r, contextSnapshot: { issueId: "second", aiConnection: { connectionId: "other" } } } : r) }, "five-successful-turns"); + fails({ ...e, runs: e.runs.map(r => r.id === "lead-3" ? { ...r, agentId: "coder" } : r) }, "five-successful-turns"); + fails({ ...e, firstAfterReuse: { ...e.first!, latestRevisionId: "modified" } }, "original-preserved"); + }); + + it("compares each revision's bundle and example without imposing candidate length on baseline", () => { + const historical = validEvidence(); + const oldFiles = { "AGENTS.md": "A long historical CEO role.\n".repeat(40), "HEARTBEAT.md": "Heartbeat", "SOUL.md": "Identity", "TOOLS.md": "Tools" }; + historical.expectedCeoFiles = oldFiles; + historical.leadInstructions!.files = oldFiles; + for (const file of Object.keys(oldFiles)) historical.expectedSourceHashes[`server/src/onboarding-assets/ceo/${file}`] = historical.servedSourceHashes[`server/src/onboarding-assets/ceo/${file}`] = hiringTemplateHash(oldFiles[file as keyof typeof oldFiles]); + historical.coderExample = "A long historical coder role.\n".repeat(60); + historical.hiredInstructions!.files["AGENTS.md"] = historical.hiredInstructionsAfterReuse!.files["AGENTS.md"] = historical.coderExample; + const result = gradeHiringTemplate(historical); + expect(result).toMatchObject({ outcomePassed: true, comparisonStatus: "comparable" }); + expect(result.instructionSizes.coder.words).toBeGreaterThan(200); + const e = validEvidence(); + fails({ ...e, leadInstructions: { ...e.leadInstructions!, files: { "AGENTS.md": "Custom fixture lead" } } }, "production-ceo-bundle"); + fails({ ...e, leadInstructions: { ...e.leadInstructions!, files: { ...ceo, "SOUL.md": "unexpected legacy file" } } }, "production-ceo-bundle"); + fails({ ...e, hiredInstructions: { ...e.hiredInstructions!, files: { "AGENTS.md": "Generic default worker instructions" } } }, "supplied-coder-instructions"); + fails({ ...e, hiredInstructionsAfterReuse: undefined }, "hired-instructions-durable"); + fails({ ...e, hiredSkillsAfterReuse: ["changed"] }, "hired-skills-durable"); + }); + + it("separates successful workflow outcomes from missing or mismatched source coverage", () => { + const e = validEvidence(); + for (const patch of [{ readRuns: [] }, { assignedSkills: [] }, { expectedSourceHashes: {} }, { servedSourceHashes: {} }, + { servedSourceHashes: { ...e.servedSourceHashes, "skills/paperclip-create-agent/SKILL.md": hiringTemplateHash("other checkout") } }]) { + expect(gradeHiringTemplate({ ...e, ...patch })).toMatchObject({ outcomePassed: true, comparisonStatus: "uncomparable" }); + } + const incomplete = { ...e.expectedSourceHashes }; + delete incomplete["skills/paperclip-create-agent/references/agents/coder.md"]; + fails({ ...e, expectedSourceHashes: incomplete }, "source-fingerprints"); + }); + + it("requires a completed pre-hire lead read rather than an echoed path, failed read or listing", () => { + const e = validEvidence(), good = e.readRuns[0]!; + for (const command of ["echo /workspace/.agents/skills/paperclip-create-agent/SKILL.md", "ls /workspace/.agents/skills/paperclip-create-agent/SKILL.md", + "cat /workspace/.agents/skills/wrong-skill/SKILL.md", "cat $SKILL/SKILL.md", "cat /workspace/.agents/skills/paperclip-create-agent/SKILL.md > /dev/null"]) { + const events = commandEvents("SKILL.md"); + const payload = events[0]!.payload.prpEvent.payload as Record; + payload.name = command; + expect(hiringTemplateReadReceipts([{ ...good, events }], "lead")).toHaveLength(0); + } + fails({ ...e, readRuns: [{ ...good, agentId: "coder" }] }, "production-source-reads"); + fails({ ...e, readRuns: [{ ...good, events: good.events.slice(1) }] }, "production-source-reads"); + fails({ ...e, readRuns: [{ ...good, events: good.events.map(event => ({ ...event, createdAt: "2026-10-01T12:02:00Z" })) }] }, "production-source-reads"); + fails({ ...e, readRuns: [{ ...good, events: good.events.map(event => ({ ...event, createdAt: undefined })) }] }, "production-source-reads"); + const failed = commandEvents("SKILL.md"); + (failed[0]!.payload.prpEvent.payload as Record).exitCode = 1; + expect(hiringTemplateReadReceipts([{ ...good, events: failed }], "lead")).toHaveLength(0); + const noOutput = commandEvents("SKILL.md"); + (noOutput[0]!.payload.prpEvent.payload as Record).outputBytes = 0; + expect(hiringTemplateReadReceipts([{ ...good, events: noOutput }], "lead")).toHaveLength(0); + }); + + it("uses the production ACPX event mapper and leaves redacted absolute read paths uncomparable", () => { + const events = HIRING_TEMPLATE_READ_FILES.flatMap(file => canonicalProviderEventsFromAcpxRuntimeEvent({ + type: "tool_call", tag: "tool_call_update", toolCallId: `read-${file}`, title: "Read", kind: "read", status: "completed", + locations: [{ path: `.agents/skills/paperclip-create-agent/${file}` }], rawOutput: "Source bytes", + } as never, `read-${file}`).map(event => ({ eventType: event.eventType, createdAt: beforeHire, payload: { prpEvent: event } }))); + expect(gradeHiringTemplate({ ...validEvidence(), readRuns: [{ runId: "lead-1", agentId: "lead", events }] }).comparisonStatus).toBe("comparable"); + const absolute = canonicalProviderEventsFromAcpxRuntimeEvent({ type: "tool_call", tag: "tool_call_update", toolCallId: "read", title: "Read", kind: "read", status: "completed", + locations: [{ path: "/workspace/.agents/skills/paperclip-create-agent/SKILL.md" }], rawOutput: "Source bytes" } as never, "read"); + expect(hiringTemplateReadReceipts([{ runId: "lead-1", agentId: "lead", events: absolute.map(event => ({ eventType: event.eventType, payload: { prpEvent: event } })) }], "lead")).toHaveLength(0); + }); +}); + +describe("production hiring fixture wiring and source observations", () => { + it("keeps two explicit local native cells, production permissions and five-turn scope", () => { + const cells = runnerMatrix.filter(cell => cell.suite.id === "hiring-templates"); + expect(cells.map(cell => cell.id)).toEqual(["hiring-templates.runner-codex.local.hire-coder-template-reuse", "hiring-templates.runner-acpx-claude.local.hire-coder-template-reuse"]); + expect(runnerSuites.find(suite => suite.id === "hiring-templates")?.manualOnly).toBe(true); + for (const cell of cells) { + expect(cell.task.expectedRunCount).toBe(5); + expect(cell.task.attemptTimeoutMs?.local).toBe(15 * 60_000); + expect(buildRunnerE2EProcessEnvironment({}, [cell]).PAPERCLIP_RUNNER_API_TOOLS_ENABLED).toBe("true"); + const payload = cell.profile.buildAgent({ executionId: "fixture", workspacePath: "/workspace", environmentId: "env", environmentFixtureId: "local", + secretRefs: { [cell.profile.credential]: { type: "secret_ref", secretId: "secret", version: "latest" } } }); + expect(payload).toMatchObject({ role: "ceo", adapterType: "paperclip_runner" }); + expect(payload).not.toHaveProperty("instructionsBundle"); + expect(payload.adapterConfig).not.toHaveProperty("instructionsBundleMode"); + expect(payload.adapterConfig).not.toHaveProperty("codexPermissionMode"); + expect(payload.adapterConfig).not.toHaveProperty("acpxPermissionMode"); + } + const scenario = hiringTemplateScenario("matched-fixture", "Project"); + expect(scenario.initialPrompt).toBe(hiringTemplateScenario("matched-fixture", "Project").initialPrompt); + expect(scenario.reusePrompt("TASK-1")).toBe(hiringTemplateScenario("matched-fixture", "Project").reusePrompt("TASK-1")); + }); + + it("reads the actual default CEO selection and served hiring files through public APIs", async () => { + const files = await loadDefaultAgentInstructionsBundle("ceo"); + const get = async (path: string) => { + if (path.endsWith("/instructions-bundle")) return { mode: "managed", entryFile: "AGENTS.md", files: Object.keys(files).map(path => ({ path })) }; + if (path.includes("instructions-bundle/file?")) return { content: files[new URL(path, "http://fixture").searchParams.get("path")!] }; + if (path === "/api/companies/company/skills") return [{ id: "creator", key: HIRING_TEMPLATE_SKILL_KEY }]; + if (path === "/api/agents/lead/skills?companyId=company") return { desiredSkills: [HIRING_TEMPLATE_SKILL_KEY] }; + if (path.startsWith("/api/companies/company/skills/creator/files?")) return { content: await readFile(new URL(`../../skills/paperclip-create-agent/${new URL(path, "http://fixture").searchParams.get("path")}`, import.meta.url), "utf8") }; + throw new Error(`Unexpected public read ${path}`); + }; + const api = { get } as Pick; + const source = await readHiringTemplateSources(api, "company", "lead"); + expect(source.leadInstructions.files).toEqual(files); + expect(source.expectedCeoFiles).toEqual(files); + expect(source.servedSourceHashes).toEqual(source.expectedSourceHashes); + expect(source.assignedSkills).toContain(HIRING_TEMPLATE_SKILL_KEY); + expect(source.loaderHash).toMatch(/^[a-f0-9]{64}$/); + expect(renderHiringCoderExample(source.coderReference, "Casey", "Company", "CEO")).not.toContain("{{"); + const wrongApi = { get: async (path: string) => path.includes("/files?") ? { content: "different revision" } : get(path) } as Pick; + const wrongSource = await readHiringTemplateSources(wrongApi, "company", "lead"); + expect(wrongSource.servedSourceHashes).not.toEqual(wrongSource.expectedSourceHashes); + }); + + it("retains baseline policy files while excluding binary files and generated personal notes", async () => { + const api = { get: async (path: string) => path.endsWith("/instructions-bundle") + ? { mode: "managed", entryFile: "AGENTS.md", files: ["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md", "notes/today.md"].map(path => ({ path, binary: false })).concat([{ path: "image.png", binary: true }]) } + : { content: new URL(path, "http://fixture").searchParams.get("path") } } as Pick; + expect(Object.keys((await readHiringInstructions(api, "lead")).files)).toEqual(["AGENTS.md", "HEARTBEAT.md", "SOUL.md", "TOOLS.md"]); + expect(renderHiringCoderExample("Historical example\n```md\nYou are {{agentName}} at {{companyName}}. Report to {{managerTitle}}.\nLarge manual content.\n```", "Casey", "Company", "CEO")) + .toBe("You are Casey at Company. Report to CEO.\nLarge manual content."); + expect(() => renderHiringCoderExample("No example", "Casey", "Company", "CEO")).toThrow(); + }); +}); diff --git a/tests/runner-e2e/live-fixtures.test.ts b/tests/runner-e2e/live-fixtures.test.ts index 17422768c91..cf99914f16b 100644 --- a/tests/runner-e2e/live-fixtures.test.ts +++ b/tests/runner-e2e/live-fixtures.test.ts @@ -23,6 +23,8 @@ describe("live runner fixtures", () => { }); it.each([ + ["runner-codex", "hiring-templates", "hire-coder-template-reuse"], + ["runner-acpx-claude", "hiring-templates", "hire-coder-template-reuse"], ["runner-codex", "everyday-workflows", "hire-reuse"], ["runner-acpx-claude", "everyday-workflows", "hire-reuse"], ["runner-codex", "agent-chat-hardening", "hire-delegate-reuse"],