test(e2e): verify native hiring, delegation and teammate reuse (#18351)

## Thinking Path

> - Paperclip manages real work by AI agents.
> - Daily QA must verify that work on the staging deployment.
> - Classic Agent Chat coverage already exercises hiring and teammate
reuse.
> - The deployment journey should share that behavior and its ownership
oracle.
> - Saved documents, exact callback replies and physical cleanup
establish success.
> - The corrected journey and complete daily suite now pass on QA.

## Linked Issues or Issue Description

**Subsystem affected**

Product E2E deployment journeys and their shared browser controls.

**Problem or motivation**

QA must prove that one newly hired teammate completes two different
tasks and returns both saved results to the lead conversation. Names,
chat claims and successful-run counts alone cannot establish authorship
or completion.

**Proposed solution**

Reuse the classic hiring oracle and production composer. Verify
immutable creation audits, initial assignment, current project and
ownership, saved document revisions, exact native authors, durable
callbacks and their own task links after reload. Bound execution to five
lead turns and two child turns.

**Alternatives considered**

A separate QA implementation would duplicate the classic journey. Name
matching or later reassignment would accept broken initial ownership.
Links elsewhere in the conversation cannot substitute for either
callback's own result link.

**Roadmap alignment**

This verifies existing features. Related merged repairs: #18389, #18465,
#18459, #18463, #18502, #18503, #18504, #18506, #18507 and #18514.
Separate dependency #18517 repairs file preservation after lifecycle
cancellation and is merged as Core `050365e`, which also contains
#18514. This primary PR now differs from that protected mainline in only
16 unchanged H harness/documentation paths; it adds no application
production delta. The measured QA deployment serves Core `050365e`, with
its matching bundled provider pack and unchanged approved model/ROM.

## What Changed

- Add the shared `hire-delegate-reuse` journey, catalog, types,
dispatch, launch fingerprints, journey-feature entries and
documentation. Hire one teammate, delegate a checklist, then reuse the
same teammate for a saved review. Require seven canonical native runs:
five lead and two child turns.
- Require exact company, lead, chat and native creation receipts. Both
tasks must be created for the intended teammate using immutable
creation-audit assignee fields. Independently verify current project and
ownership. Creation-time project is not recorded in that audit and is
not claimed by this assertion.
- Verify both current document revisions belong to the exact child runs
and preserve the original checklist.
- Use ordinary production rich-editor agent/project autocomplete
controls. Verify exact reference chips, then send once. Preserve the
shared default sender.
- Require each durable, run-owned callback reply to contain its own
exact completed-task link. Collect links through the actual production
Markdown renderer and issue-reference decoration, then apply the
existing company-scoped route authority. An unrelated table or code
fence cannot hide a rendered link; foreign, wrong-task and code-only
links remain failures.
- Preserve approved connection, account, grant and responsible-user
attribution. Verify the child's independent installation and retain the
immutable binding snapshot. Do not copy credentials or broaden grants.
- Record ownership before later observations can fail. Retry only typed
transient observation GET failures. Preserve authority and cleanup
failures.
- Retain all 14 daily journeys, the model matrix, deadlines, seven-run
cap and 500-cent aggregate allowance split 300/200.

## Verification

- **Current composed head:** `afa3f5552ab0c1a9f0ee7327dfa5665d57cc7bcb`.
It normally merges mainline `1e8e322` and exact #18517 source `adb1d4e`,
retaining the intermediate `b2d9eb4` and `6fd1042` ancestry. The only
merge conflict was the Playwright support registration; it is resolved
to the exact previously qualified union. All 16 H harness/documentation
paths are byte-identical to `5c0830f`; all four #18517 paths are
byte-identical to `adb1d4e`. Every other tracked path matches actual
protected mainline `050365e`. A clean merge preview of `050365e` and
this head equals the current head tree, so no additional source commit
is needed. GitHub's three-dot display still lists the four already
merged helper files because #18517 used a squash merge; those blobs
equal protected mainline and add no net application change. No journey,
oracle, cap or model change accompanies this composition. A final clean
merge preview on intervening mainline
`006d5691a6245d5a710e25ba3df6d609754e163a` preserves all unrelated
changes and adds exactly the same 16 measured H blobs, with no net App
production delta. That later mainline is not represented as the AppCore
measured by the QA run.
- **Local qualification:** 149/149 focused tests across six suites,
including the report-catalog case, and 11/11 real Chromium checks passed
on `b2d9eb4`. All H modules, browser inputs and parser/package
dependencies are byte-identical at `afa3f55`; those unchanged suites
were not repeated. Current-source transitive types, maps, module
boundaries and diff checks pass. Official Node24 QA runtime imports pass
again using exact public `afa3f55` and private `8671fa9`, with a clean
credential-free environment and network disabled. The initial local
report-catalog failure is retained: the child `tsx` process was denied
its local IPC socket before importing the report or writing an artifact.
The same source and selected case pass after correcting only sandbox IPC
permission; no source workaround or deselection was used.
- **Current canonical CI and reviews pass.** [CI
41275109691](https://github.com/paperclipai/paperclip/actions/runs/41275109691)
completed successfully at exact `afa3f55`: all 59 contexts are terminal
and clean (55 success, three skipped, one neutral). Fresh exact-head
Greptile and official Paperclip reviews both scored 5/5; all 27 review
threads are resolved with pagination complete. The official review is
static and its check is neutral; it did not execute tests or live QA. A
separate exact-head independent composition assessment found no
actionable issues at 4/5 confidence and performed no live actions.
**Fresh QA qualification passed: all 14 daily journeys, including the
corrected H7, passed against deployed Core `050365e`.** Prior check
conclusions and review scores retain their actual heads. The previous
`5c0830f` completed all 59 contexts cleanly, actual exact-head Greptile
and official Paperclip reviews both scored 5/5, and all 27 review
threads were resolved. Its separate independent journey-source review at
`2b803f08` remained 4/5 with a unit-helper authorship caveat.
Intermediate `b2d9eb4` received an actual exact-head Greptile 5/5 with
zero open findings, then was updated to compose the valid dependency
review fix before live QA. That score and its pending CI are not
attributed to `afa3f55`.
- Existing causal controls remain unchanged: invalid immutable assignee,
either missing callback link, foreign/code/wrong-task links and an
earlier handoff fail against the relevant prior source. Renderer
controls prove that tables and fenced code elsewhere in a valid callback
do not hide its actual task link. Both composer and document-navigation
browser registrations remain present.
- #18503 merged after its actual QA document case passed four canonical
checks and five phases, positively established zero native work, and
completed cleanup. Its measured files are unchanged in this composition.
#18506 and #18507 merged after their own checks; their causal
worker-settlement controls remain intact.
- #18514 is merged as `1e8e322`. It reproduces the selected-task
metadata-only-project startup failure against deployed `756599e` and
passes 19 real-PostgreSQL selected controls after repair. Both direct
and assembled baseline controls fail before it. Real source, explicit
selection, policy and workspace-isolation guards remain enforced. It is
included in measured Core `050365e`; fresh H7 reached and completed both
delegated native tasks.
- #18517 initial source `6fd1042` passed 320/320 focused and sibling
Stop/recovery tests, including 20 preservation controls. Its actual
official review was 4/5 and found a valid follow-up: the new
live-controller guard should wait for actual lease expiry rather than
repeat locked checks every 30 seconds. Both an already-live lease and a
real under-lock renewal reproduce that issue against `6fd1042`.
Corrected source `adb1d4e` passes all 22 preservation controls and 124
directly affected tests across three suites, exact-head server
build/stamp, typecheck and structural checks. It defers before locking
and rechecks renewed expiry afterward, preserving the transactional
owner guard. The old score/failure and successful owner tests remain
attributed to their actual sources. Independent exact-head review of
`adb1d4e` found no actionable issues at 4/5 confidence. This is
local/source evidence, not physical QA recovery. Its current [CI
41274963476](https://github.com/paperclipai/paperclip/actions/runs/41274963476)
completed successfully with all 59 contexts clean; both actual current
reviews are 5/5 with zero unresolved threads. It merged normally as
`050365e` after those gates. QA now runs that image. The historical
allocation is positively stopped and retained; its files-only export
remains a separately tracked repair, not a passing-test assertion.
- The historical [first H7
pass](https://pages.paperclip.ing/evals-qa-smoke-41226065043-1/) remains
attributed to public `5d3f143`, private `dabad62` and App `b7f1c37`: all
checks and five phases passed, seven native runs succeeded, both saved
documents and exact callbacks were verified, and physical cleanup
completed. An optional runtime-metadata receipt was inconclusive and was
not grade authority.

Material failed attempts remain failed:

| Attempt | Actual result and correction |
| --- | --- |
|
[41231853638](https://pages.paperclip.ing/evals-qa-smoke-41231853638-1/)
| 13 passed; hiring omitted initial teammate/project assignment and
started extra work. The production composer repair retains the ownership
negative. Later recovery stopped physical work without changing the
failed grade. |
|
[41238711372](https://pages.paperclip.ing/evals-qa-smoke-41238711372-1/)
| All seven native runs succeeded, but the final callback had no durable
chat reply. Cleanup passed. #18502 repairs premature completion-outbox
retargeting; the fresh measured H7 now verifies both durable, run-owned
callback replies and their own task links. |
|
[41253428446](https://pages.paperclip.ing/evals-qa-smoke-41253428446-1/)
| 12 passed; hiring rejected a mentioned metadata-only project before
delegation, and document reopening hit an immediate-control-probe race.
#18504 and #18503 repair those separate boundaries. Historical
missing-task absence remains quarantined; physical termination does not
manufacture absence evidence. |
|
[41266385118](https://pages.paperclip.ing/evals-qa-smoke-41266385118-1/)
| On `5c0830f` and App `756599e`, 13 passed, including the exact
composed document helper. Hiring created the correctly assigned task,
but its child admission failed before runtime selection because the
organizational project had no repository source. #18514 repairs that
selected-task boundary. The cancelled native lead retained an active
prepared-workspace lease; cleanup 41271035231 failed with
`owned_work_stop_unconfirmed`. #18517 addresses its file-preservation
admission. Launch 1110, original evidence and automatic Slack FAIL
remain failed. |
| [CI
41257779002](https://github.com/paperclipai/paperclip/actions/runs/41257779002)
/ [CI
41263476916](https://github.com/paperclipai/paperclip/actions/runs/41263476916)
| Existing enrollment and snapshot-retention fixtures observed
intermediate I/O before worker settlement. #18506 and #18507 have
controlled causal failures, complete-settlement observers and preserved
deadlines/negative controls. Runtime scheduling behavior is unchanged. |

**Fresh measured acceptance:** [QA run
41285131220](https://github.com/paperclipai/paperclip-cloud/actions/runs/41285131220)
and its [permanent
report](https://pages.paperclip.ing/evals-qa-smoke-41285131220-1/)
passed **14/14**, zero failed or incomplete, with all five phases and
complete cleanup for every case. The strict verifier independently
matched original grades, current provenance and completed manifests:
public harness `afa3f5552ab0c1a9f0ee7327dfa5665d57cc7bcb`, private
workflow `8671fa93e27501fabbfffa377bec6dea453b5db7`, AppCore
`050365e09d88411960fc3bacc1c08c46c0d9b6fa` and matching Cloud
image/provider pack. All model, ROM, deadline and run-budget controls
remained unchanged. All three workflow jobs completed successfully; the
automatic Slack notification reported PASS with links to this run. The
passing TestOps launch was closed normally after evidence verification.

The hiring case passed all 11 check entries across nine IDs and five
phases, with exactly five lead and two child native runs. Both saved
documents match their live current revision/body hashes and exact
hired-agent run attribution. The immutable original checklist remains
intact; the same teammate authored the review. Both own callback replies
contain their exact delegated task link. Independent canonical readback
confirmed all seven native runs succeeded and committed their accepted
result, assessment and decision, with no live owner and zero pending or
failed effects; each of the seven exact owned leases has its own bound
DESTROYED receipt and successful cleanup. All seven final snapshot
operations match their source run, workspace, task, epoch, SHA,
generation and export location. The four earlier lead snapshots are
retained SUPERSEDED ancestors of the current committed snapshot; the
latest lead and both child snapshots are COMMITTED. The initial
diagnostic required current COMMITTED status for every historical
snapshot and correctly remained false until the separate lineage
diagnostic established this supported supersession. The product oracle
and original grade were not changed. Canonical Suite metadata is
`qa-basics`; the daily plan was saved with its standard suite-only query
and synchronized, retaining all 14 ACTIVE cases.

The older failed allocation remains separately stopped, retained,
isolated from reuse and owned by terminated actors. That permits fresh
campaigns without replaying old work. Its same-allocation files export
is still unresolved and has separate bounded files-only recovery work;
it is not claimed by this successful campaign. Original failed grades,
reports and automatic FAIL notifications remain unchanged. No failed
result was closed, relabelled, hidden or retried unchanged to obtain
green.

## Risks

The journey requires Agent Chat, normal hiring permission, a scoped lead
and a provider that finalizes owned resources. Missing or extra work,
wrong ownership, absent saved documents, missing callbacks and
unconfirmed cleanup must fail.

The deployed app and bundled runner pack must match the verified Core.
The old run's cancellation ACK and terminal database row do not prove
physical termination or saved files. Its coordinator has no accepted
result or decision; pending-effect absence cannot be inferred from that
missing decision. File recovery must preserve the exact allocation and
original destroy disposition without a provider turn.

Current source checks, reviews, fresh H7 and full-14 aggregate all pass.
Canonical Suite metadata and actual 14-case daily-plan synchronization
were verified after closing the genuine passing launch. The original
failed allocation still has a separate files-export debt; its STOPPED
receipt is not a SAVED or DESTROYED claim.

## Model Used

OpenAI Codex, GPT-6 family. The session does not expose a more specific
backend model ID or fixed context window. Used reasoning, repository
tools, TypeScript and browser test execution.

## 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 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
- [x] Historical passes and material failed attempts retain their actual
source and outcome
- [x] The composed document helper passed normal QA with complete
cleanup on its actual source
- [x] The corrected hiring journey passes fresh normal H7 with complete
cleanup
- [x] The active hiring case has canonical Suite and daily-plan
membership after the passing launch is closed

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-01 02:33:59 -06:00
1 parent 006d5691a6
commit 7e595fd055
16 files changed
+1311 -7

No files matched your search

+62
View File
@@ -36,6 +36,7 @@ never start a server or depend on a tenant filesystem.
| `create-skill-studio` | Native agent with skill-write permission; one work run | Create a uniquely named company skill, open its activity card, edit in Studio, reload and verify task-card readback |
| `stop-redirect` | Native agent; three work runs; qualified Docker oracle | Download a verified checkpoint, observe an active native command with current-turn usage, Stop it, verify cancellation and settled accounting, then send one new direction and complete without original-run activity |
| `followup-while-running` | Native agent; a maximum of two work runs | Save a follow-up while an independently observed native shell command is still active, then require one attributed final reply with the late requirement after reload; in-turn steering and one normal successor are accepted |
| `hire-delegate-reuse` | Native lead with a fresh user chat and scoped hiring permission; Agent Chat enabled; direct hiring policy; seven work runs | Hire one teammate, delegate a saved checklist, reuse the same teammate for a saved review, verify native run and revision authorship, preserve the original, and follow the lead's saved result link after reload |
| `cloud-onboarding` | Signed-in admitted Cloud account; fresh workspace; qualified bounded provider route | Create workspace in the portal, connect API key and native Codex agent in the wizard, complete a task, download and independently check its artifact |
The deployment-targeted Stop journey asks the revision to finish one harmless
@@ -87,6 +88,67 @@ The shared Studio helper waits for the original saved file body before typing.
An editor control can mount before the file query completes; editing that empty
draft can otherwise lose the original content when the file arrives.
The hiring journey uses the existing Agent Chat prompts and native hiring oracle.
It gives each disposable project, agent, and task a campaign name. The first
turn only hires. The target provider can then verify the native creation audit
and prepare that exact teammate's budget before the next turn delegates work.
Both handoffs ask for the task to be created already assigned to that teammate.
They use the normal composer agent and project mention formats, with the exact
IDs from the independently verified hire and owned project. Missing, foreign or
mismatched context fails before constructing the handoff. The mentions provide
the intended resources without prescribing a tool call or bypassing normal delegation.
The handoff selects these references through the normal rich-editor mention menu
and verifies each chip target before sending once. Typing Markdown link syntax
as plain rich-editor text escapes it and is not a mention. The saved user body
must still match the requested body exactly.
Creating work for the lead and later reassigning it starts additional native work
and fails the existing assignee and run checks.
When the lead uses a delegated personal connection, normal hiring selects the
responsible user's default rather than copying the manager's identity. Before
registering any chat or child-creation intent or sending the first turn, the
journey reads the actual signed-in workflow user's provider default and requires
the exact approved connection and grant, connected and owned by that user.
After hiring it independently checks the exact child installation. The underlying
connection's creator is not the owner of the user's default selection. Missing,
different or ambiguous authority fails closed; no credential, default or grant is
changed. A responsible-user hire stores the canonical `api_key` placeholder even
when the approved default uses a subscription. The resolved native method must
still match that independently approved default. Every successful native run must also retain the approved connection,
grant and responsible-user attribution in its canonical execution metadata.
Those references are compared privately and omitted from observation evidence.
The first independently approved child binding becomes an immutable expected
snapshot for the final shared hiring oracle; later binding changes fail. Only
typed transient observation GET failures may poll again within the original
deadline. Approval, authentication and ownership failures are never replayed.
The checklist and review each require one successful child run and a current
document revision authored by that run. The lead uses three requested turns and
two separate server completion callbacks,
for seven work runs in total. Each callback must identify exactly one of the two
completed child tasks with a distinct server delivery receipt; arbitrary extra
lead turns fail. The journey waits for each callback and its exact run-authored
saved reply before the next user turn. A terminal run can precede its final
comment; missing materialization polls within the existing deadline, while a
wrong author or binding fails immediately.
The lead has a maximum of five work runs and the teammate has two.
A 500-cent case allowance is split into 300 cents for the lead and 200 for the
teammate; the allocation must not duplicate the declared case allowance.
Setup probes and unpriced usage remain separate accounting
uncertainties. The journey does not change company hiring policy or instance
features. The provider must own and clean up the hired agent, both child tasks,
the chat, and the project. Before the first send, a fresh-chat intent records the
company, user and owned lead after a normal GET confirms no existing chat.
Recovery can attach only that exact fresh conversation. Saved replies and links
are allowed to render after reload within the journey deadline. Cleanup includes
unexpected campaign-owned children after
failure. A new deployment must qualify the journey before scheduled admission.
Child discovery reads the exact native creation audit after the run snapshot,
then fetches each audited agent/task ID directly. It never depends on the first
page of a shared company list. Each proven child is recorded before the next
lookup can fail. Observation retries are limited to typed transient GET transport failures within
the existing deadline. Ownership callbacks and grading failures are never
retried. Audited same-company children are recorded before manager/project
checks fail; foreign or malformed rows remain unowned and fail the journey.
The task-document journey uses the normal default-chat Artifacts pane and the
shared document editor. Classic mode retains its editor in the task body. The
general editor excludes Plans, which retain their conversation and approval
+1 -1
View File
@@ -118,7 +118,7 @@ describe("runner E2E catalog", () => {
});
it("exports deployment journeys separately from the model matrix and default selection", () => {
expect(productJourneys.map(journey => journey.id)).toEqual(["task-create", "project-task-context", "ask-question", "question-answer-resume", "task-properties", "task-document-persistence", "task-search-filter", "plan-revise-accept", "build-revise", "protected-actions", "create-skill-studio", "stop-redirect", "followup-while-running", "cloud-onboarding"]);
expect(productJourneys.map(journey => journey.id)).toEqual(["task-create", "project-task-context", "ask-question", "question-answer-resume", "task-properties", "task-document-persistence", "task-search-filter", "plan-revise-accept", "build-revise", "protected-actions", "create-skill-studio", "stop-redirect", "followup-while-running", "hire-delegate-reuse", "cloud-onboarding"]);
expect(runnerSuites.some(suite => suite.id === "product-journeys")).toBe(false);
expect(runnerMatrix.some(execution => execution.suite.id === "product-journeys")).toBe(false);
expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(execution => execution.suite.id === "product-journeys")).toBe(false);
@@ -0,0 +1,66 @@
import { createRequire } from "node:module";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { IncomingMessage, ServerResponse } from "node:http";
export const composerResources = {
companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", leadId: "33333333-3333-4333-8333-333333333333",
agentId: "11111111-1111-4111-8111-111111111111", projectId: "22222222-2222-4222-8222-222222222222",
campaignId: "journey-1-deadbeef",
};
export async function startHireComposerFixture() {
const root = fileURLToPath(new URL("../../", import.meta.url)), ui = path.join(root, "ui");
const require = createRequire(path.join(ui, "package.json"));
const { createServer } = await import(require.resolve("vite"));
const { default: react } = await import(require.resolve("@vitejs/plugin-react"));
const cacheDir = await mkdtemp(path.join(tmpdir(), "hire-composer-vite-"));
const saved = new Map<string, Array<{ id: string; body: string }>>();
const entry = `import React from "react";
import {createRoot} from "react-dom/client";
import {TaskChatComposer} from "/src/components/task-chat/TaskChatComposer.tsx";
import {TaskChatPresentationProvider} from "/src/components/task-chat/presentation-mode.tsx";
import {buildMarkdownMentionOptions} from "/src/lib/company-members.ts";
const r=${JSON.stringify(composerResources)}, q=new URLSearchParams(location.search);
const agents=[{id:q.get("variant")==="wrong-agent"?"44444444-4444-4444-8444-444444444444":r.agentId,name:"Morgan Reviewer "+r.campaignId,status:"idle",icon:"code"}];
if(q.get("variant")==="duplicate")agents.push({...agents[0],id:"55555555-5555-4555-8555-555555555555"});
const projects=q.get("variant")==="missing-project"?[]:[{id:r.projectId,name:"Journey QA hire-delegate-reuse "+r.campaignId,color:"#336699"}];
const mentions=buildMarkdownMentionOptions({agents,projects});
createRoot(document.getElementById("root")).render(React.createElement(TaskChatPresentationProvider,{mode:q.get("mode")||"production"},React.createElement(TaskChatComposer,{workMode:"standard",conversationMode:true,mentions,onAdd:async body=>{const response=await fetch("/__saved/"+encodeURIComponent(q.get("case")),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({body})});if(!response.ok)throw Error("fixture_save_failed");}})));`;
const server = await createServer({
configFile: false, root: ui, cacheDir, logLevel: "error",
plugins: [react(), {
name: "hire-production-composer-fixture",
resolveId(id: string) { if (id === "/__hire-entry.tsx") return "\0hire-entry.tsx"; },
load(id: string) { if (id === "\0hire-entry.tsx") return entry; },
configureServer(instance: any) {
instance.middlewares.use(async (req: IncomingMessage, res: ServerResponse, next: () => void) => {
const pathname = new URL(req.url ?? "/", "http://fixture.local").pathname;
if (pathname === "/__hire.html") {
res.setHeader("Content-Type", "text/html");
res.end(await instance.transformIndexHtml(pathname, '<div id="root"></div><script type="module" src="/__hire-entry.tsx"></script>'));
} else if (pathname.startsWith("/__saved/")) {
const key = decodeURIComponent(pathname.slice("/__saved/".length));
const rows = saved.get(key) ?? [];
if (req.method === "POST") {
let body = ""; for await (const chunk of req) body += chunk;
rows.push({ id: String(rows.length + 1), body: JSON.parse(body).body }); saved.set(key, rows);
}
res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(rows));
} else next();
});
},
}],
resolve: { alias: [
{ find: "@", replacement: path.join(ui, "src") },
{ find: /^@paperclipai\/shared$/, replacement: path.join(root, "packages/shared/src/index.ts") },
{ find: /^@paperclipai\/shared\/(.+)$/, replacement: path.join(root, "packages/shared/src/$1") },
{ find: "lexical", replacement: path.join(ui, "node_modules/lexical/dist/Lexical.mjs") },
] },
server: { host: "127.0.0.1", port: 0, fs: { allow: [root] } },
});
await server.listen();
return { url: server.resolvedUrls.local[0] as string, async close() { await server.close(); await rm(cacheDir, { recursive: true, force: true }); } };
}
@@ -0,0 +1,49 @@
import { expect, test, type Page } from "@playwright/test";
import { sendChatMessage } from "./chat-flow.js";
import { hireHandoffMentions } from "./product-journeys/hire-delegate-flow.js";
import { sendHireHandoffMessage } from "./product-journeys/hire-delegate-composer.js";
import { composerResources as r, startHireComposerFixture } from "./hire-delegate-composer.fixture.js";
test.setTimeout(45_000);
let fixture: Awaited<ReturnType<typeof startHireComposerFixture>>;
test.beforeAll(async () => { fixture = await startHireComposerFixture(); });
test.afterAll(async () => { await fixture?.close(); });
const references = hireHandoffMentions({ companyId: r.companyId, leadId: r.leadId, projectId: r.projectId, campaignId: r.campaignId }, r.agentId,
{ id: r.agentId, companyId: r.companyId, reportsTo: r.leadId, name: `Morgan Reviewer ${r.campaignId}`, icon: "code" },
{ id: r.projectId, companyId: r.companyId, name: `Journey QA hire-delegate-reuse ${r.campaignId}`, color: "#336699" });
const message = (review = false) => `Have the existing ${references.teammate} ${review ? "review the checklist on QA-1 and write a separate saved review" : "write a concise launch checklist as a saved Paperclip document"}. Create exactly one task in ${references.project}, already assigned to that teammate when it is created. Link the result here.`;
async function open(page: Page, key: string, variant = "valid", mode = "production") {
const url = new URL("/__hire.html", fixture.url); url.searchParams.set("case", key); url.searchParams.set("variant", variant); url.searchParams.set("mode", mode);
await page.goto(url.href);
await expect(page.getByTestId("task-chat-composer-input").locator('[contenteditable="true"]')).toBeVisible({ timeout: 30_000 });
}
async function saved(page: Page, key: string) { return (await page.request.get(new URL(`/__saved/${encodeURIComponent(key)}`, fixture.url).href)).json() as Promise<Array<{ id: string; body: string }>>; }
test("raw fill escapes mention syntax in the real production composer", async ({ page }) => {
await open(page, "raw"); const text = message(); await sendChatMessage(page, text);
await expect.poll(async () => (await saved(page, "raw")).length).toBe(1);
const [row] = await saved(page, "raw"); expect(row!.body).not.toBe(text); expect(row!.body).toContain("\\[@");
});
for (const [review, mode] of [[false, "production"], [true, "streamlined"]] as const) {
test(`normal mention controls preserve the exact ${review ? "review" : "checklist"} body in ${mode} composer`, async ({ page }) => {
const key = mode; await open(page, key, "valid", mode); const text = message(review);
await sendHireHandoffMessage(page, text, references, Date.now() + 10_000);
await expect.poll(async () => (await saved(page, key)).length).toBe(1);
expect(await saved(page, key)).toEqual([{ id: "1", body: text }]);
});
}
for (const variant of ["wrong-agent", "duplicate", "missing-project"] as const) {
test(`normal mention controls reject ${variant} without sending`, async ({ page }) => {
await open(page, variant, variant);
const send = sendHireHandoffMessage(page, message(), references, Date.now() + (variant === "missing-project" ? 5_000 : 10_000));
if (variant !== "missing-project") await expect(send).rejects.toMatchObject({
code: variant === "wrong-agent" ? "hire_handoff_mention_mismatch" : "hire_handoff_mention_ambiguous",
});
else {
await expect(send).rejects.toThrow();
await expect(page.locator('a[data-mention-kind="agent"]')).toHaveCount(1);
await expect(page.locator('a[data-mention-kind="project"]')).toHaveCount(0);
}
expect(await saved(page, variant)).toEqual([]);
});
}
+1
View File
@@ -21,6 +21,7 @@ function associate(suites: readonly string[], cases: Record<string, readonly str
associate(["product-journeys"], { "task-create": ["tasks/create"], "ask-question": ["questions-and-approvals/questions"],
"question-answer-resume": ["questions-and-approvals/questions", "questions-and-approvals/continuation"],
"followup-while-running": ["steering/queue"],
"hire-delegate-reuse": ["agents/hire", "documents-artifacts/documents"],
"project-task-context": ["projects/lifecycle", "projects/task-context", "tasks/create"],
"task-properties": ["tasks/properties"], "task-document-persistence": ["documents-artifacts/documents"], "task-search-filter": ["tasks/organize"], "cloud-onboarding": ["onboarding/first-task"] });
associate(["antigravity-agent-journey"], {
@@ -3,7 +3,7 @@ import { defineConfig } from "@playwright/test";
/** Browser-only harness regressions: no Paperclip instance or provider credentials. */
export default defineConfig({
testDir: ".",
testMatch: ["question-resume-ui.spec.ts", "clarification-readback.spec.ts", "project-context-readback.spec.ts", "skill-studio-flow.spec.ts", "native-task-reply.spec.ts", "screenshot-readiness.spec.ts", "service-worker-reload.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts", "settings-toggle.spec.ts", "browser-bootstrap-diagnostics.spec.ts", "service-worker-module-loading.spec.ts", "browser-bootstrap-recovery.spec.ts", "blocker-input.spec.ts", "native-blocker-visible.spec.ts", "copilot-permission-card.spec.ts", "kimi-permission-card.spec.ts", "github-review-check.spec.ts", "antigravity-journey-browser.spec.ts", "task-search-filter.spec.ts", "task-document-navigation.spec.ts"],
testMatch: ["hire-delegate-composer.spec.ts", "question-resume-ui.spec.ts", "clarification-readback.spec.ts", "project-context-readback.spec.ts", "skill-studio-flow.spec.ts", "native-task-reply.spec.ts", "screenshot-readiness.spec.ts", "service-worker-reload.spec.ts", "lost-send.spec.ts", "chat-restart.spec.ts", "settings-toggle.spec.ts", "browser-bootstrap-diagnostics.spec.ts", "service-worker-module-loading.spec.ts", "browser-bootstrap-recovery.spec.ts", "blocker-input.spec.ts", "native-blocker-visible.spec.ts", "copilot-permission-card.spec.ts", "kimi-permission-card.spec.ts", "github-review-check.spec.ts", "antigravity-journey-browser.spec.ts", "task-search-filter.spec.ts", "task-document-navigation.spec.ts"],
workers: 1,
retries: 0,
timeout: 10_000,
@@ -1,3 +1,4 @@
import { hireDelegateReuse } from "./hire-delegate-flow.js";
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
@@ -58,6 +59,7 @@ export async function waitFor<T>(deadlineAt: number, load: () => Promise<T>, acc
export async function runAgentJourney(id: AgentJourney, context: JourneyContext, session: TargetSession,
campaignId: string, evidence: JourneyEvidence, options: Options) {
if (id === "hire-delegate-reuse") return hireDelegateReuse(context, session, campaignId, evidence, options);
const agent = context.agent;
if (!agent || agent.runtimeMode !== "native" || !context.environmentId) throw new JourneyError("unsupported", "native_agent_and_environment_required");
const nonce = campaignId.replaceAll("-", "_");
@@ -15,6 +15,7 @@ export const productJourneys = [
{ id: "create-skill-studio", label: "Create a company skill and persist a Skill Studio edit", requires: ["company", "board-user", "task-write", "agent", "skill-write"], capabilities: ["skills/authoring"], paid: true },
{ id: "stop-redirect", label: "Stop active work and send a new direction once", requires: ["company", "board-user", "task-write", "agent"], capabilities: ["steering/stop-and-pause"], paid: true },
{ id: "followup-while-running", label: "Send a follow-up during active work and verify its saved result", requires: ["company", "board-user", "task-write", "agent"], capabilities: ["steering/queue"], paid: true },
{ id: "hire-delegate-reuse", label: "Hire, delegate and reuse one teammate", requires: ["company", "board-user", "task-write", "agent"], capabilities: ["agents/hire", "documents-artifacts/documents"], paid: true },
{ id: "cloud-onboarding", label: "Create a Cloud workspace and complete a first task", requires: ["cloud-account", "fresh-workspace", "provider-key"], capabilities: ["onboarding/first-task"], paid: true },
] as const;
@@ -0,0 +1,46 @@
import { expect, type Page } from "@playwright/test";
import { parseAgentMentionHref, parseProjectMentionHref } from "../../../packages/shared/src/project-mentions.js";
import { JourneyError } from "./types.js";
export async function sendHireHandoffMessage(page: Page, text: string, references: Readonly<{ teammate: string; project: string }>, deadlineAt: number) {
const timeout = () => {
const remaining = Math.min(5_000, deadlineAt - Date.now());
if (remaining <= 0) throw new JourneyError("failed", "hire_handoff_editor_not_ready");
return remaining;
};
const tokens = [references.teammate, references.project].map(markdown => {
const match = /^\[@([^\]]+)\]\(([^)]+)\)$/.exec(markdown);
const href = match?.[2], agent = href && parseAgentMentionHref(href), project = href && parseProjectMentionHref(href);
const at = text.indexOf(markdown);
if (!match || !href || (!agent && !project) || at < 0 || text.indexOf(markdown, at + markdown.length) >= 0) {
throw new JourneyError("failed", "hire_handoff_context_unverified");
}
return { markdown, href, name: match[1]!, kind: agent ? "agent" : "project", at };
}).sort((left, right) => left.at - right.at);
if (tokens[0]!.kind === tokens[1]!.kind) throw new JourneyError("failed", "hire_handoff_context_unverified");
const composer = page.getByTestId("task-chat-composer-input").last();
const editable = composer.locator('[contenteditable="true"]').first();
await expect(editable).toBeVisible({ timeout: timeout() });
await editable.fill("");
let offset = 0;
for (const token of tokens) {
const prefix = text.slice(offset, token.at);
if (prefix) await editable.pressSequentially(prefix);
await editable.pressSequentially(`@${token.name}`);
const menu = page.getByTestId("mention-autocomplete-menu");
const option = menu.getByRole("button").filter({ has: page.getByText(token.name, { exact: true }) });
await expect(option.first()).toBeVisible({ timeout: timeout() });
if (await option.count() !== 1) throw new JourneyError("failed", "hire_handoff_mention_ambiguous");
await option.click({ timeout: timeout() });
const chip = editable.locator(`a[data-mention-kind="${token.kind}"]`).last();
await expect(chip).toBeVisible({ timeout: timeout() });
if (await chip.getAttribute("href") !== token.href) throw new JourneyError("failed", "hire_handoff_mention_mismatch");
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
offset = token.at + token.markdown.length;
}
const suffix = text.slice(offset);
if (suffix) await editable.pressSequentially(suffix);
const hrefs = await editable.locator("a[data-mention-kind]").evaluateAll(nodes => nodes.map(node => node.getAttribute("href")));
if (JSON.stringify(hrefs) !== JSON.stringify(tokens.map(token => token.href))) throw new JourneyError("failed", "hire_handoff_mention_mismatch");
await page.getByTestId("task-chat-composer-send").last().click({ timeout: timeout() });
}
@@ -0,0 +1,239 @@
import { describe, expect, it } from "vitest";
import { assertHireAuditCoverage, assertHireBinding, assertHireDocument, assertHireRuns, assertHireTaskCreation, hireDelegateNames, observedHireResources, readHireConnectionChoice, type HireScope } from "./hire-delegate-evidence.js";
import { defaultAiConnectionForHire } from "../../../server/src/services/agent-ai-connection-default.js";
import { agentJourneyRuns, journeyRequiredChecks } from "./success-contract.js";
type Row = Record<string, any>;
const scope: HireScope = { companyId: "company", leadId: "lead", chatId: "chat", projectId: "project", campaignId: "journey-1-deadbeef",
origin: "https://qa.example.test", issuePrefix: "TEST",
model: "fixed-model", provider: "opencode", environmentId: "daytona", connectionBinding: { connectionId: "connection" } };
function fixture() {
const names = hireDelegateNames(scope.campaignId);
const agent = { id: "hire", companyId: scope.companyId, name: names.hire, reportsTo: scope.leadId, adapterType: "paperclip_runner",
adapterConfig: { model: scope.model }, runtimeConfig: { aiConnection: scope.connectionBinding } };
const task = { id: "task", title: names.checklist, identifier: "TEST-1", companyId: scope.companyId, projectId: scope.projectId, status: "done", assigneeAgentId: agent.id };
const run = (id: string, issueId: string, agentId: string) => ({ id, companyId: scope.companyId, agentId, issueId, status: "succeeded", runtimeMode: "native",
contextSnapshot: { issueId, paperclipEnvironment: { id: scope.environmentId } }, runnerProfileJson: { nativeExecutionInput: { provider: { kind: scope.provider, model: scope.model } } } });
const leadRun = run("lead-run", scope.chatId, scope.leadId), taskRun = run("task-run", task.id, agent.id);
const event = (action: string, entityId: string): Row => ({ companyId: scope.companyId, actorType: "agent", actorId: scope.leadId, agentId: scope.leadId, runId: leadRun.id, action, entityId, entityType: action.startsWith("agent.") ? "agent" : "issue",
details: { assigneeAgentId: agent.id, assigneeUserId: null, status: "todo" } });
const state = { agents: [agent], tasks: [task], leadRuns: [leadRun], activity: [event("agent.hire_created", agent.id), event("issue.created", task.id)] };
return { agent, task, leadRun, taskRun, state, event, run };
}
function completionFixture() {
const f = fixture(), second = { ...f.task, id: "review", identifier: "TEST-2" };
const requested = ["message-1", "message-2", "message-3"].map((id, i) => ({ ...f.run(`lead-${i}`, scope.chatId, scope.leadId),
contextSnapshot: { ...f.leadRun.contextSnapshot, source: "issue.comment", wakeCommentId: id } }));
const callbacks = [f.task, second].map((task, i) => ({ ...f.run(`completion-${i}`, scope.chatId, scope.leadId), invocationSource: "automation", triggerDetail: "system",
contextSnapshot: { ...f.leadRun.contextSnapshot, wakeReason: "chat_task_completed", wakeSource: "automation", wakeTriggerDetail: "system",
chatCompletionDeliveryIds: [`delivery-${i}`], chatCompletionUpdates: [{ id: task.id, status: "done" }] } }));
const runs = [...requested, ...callbacks, f.taskRun, f.run("review-run", second.id, f.agent.id)];
const comments = callbacks.map(run => ({ companyId: scope.companyId, issueId: scope.chatId, authorAgentId: scope.leadId, createdByRunId: run.id, body: `Completed [saved result](/TEST/issues/${[f.task, second][callbacks.indexOf(run)]!.identifier})` }));
const grade = (value = runs, replies = comments) => assertHireRuns(scope, f.agent.id, [f.task, second], value, 7, ["message-1", "message-2", "message-3"], replies);
return { f, second, requested, callbacks, runs, comments, grade };
}
describe("deployment hiring qualification", () => {
it("uses seven bounded native runs and has closed retained-result success requirements", () => {
expect(agentJourneyRuns["hire-delegate-reuse"]).toBe(7);
expect(journeyRequiredChecks["hire-delegate-reuse"]).toContain("review-authored-by-same-hired-run");
});
it("accepts old activity only when the page covers the exact current lead runs", () => {
const activity = Array.from({ length: 500 }, () => ({ createdAt: "2026-10-29T00:00:00Z" }));
expect(() => assertHireAuditCoverage(activity, [{ startedAt: "2026-10-30T00:00:00Z" }])).not.toThrow();
expect(() => assertHireAuditCoverage(activity, [{ startedAt: "2026-10-28T00:00:00Z" }])).toThrow();
expect(() => assertHireAuditCoverage(activity, [{}])).toThrow();
expect(() => assertHireAuditCoverage(activity, [])).not.toThrow();
});
it("trusts the native actor's actual hire/task receipts, not matching names in a shared company", () => {
const f = fixture();
expect(observedHireResources(scope, f.state).hires).toEqual([f.agent]);
for (const property of ["companyId", "actorType", "actorId", "agentId", "runId"]) {
const changed = structuredClone(f.state); changed.activity[0]![property as keyof typeof changed.activity[0]] = "foreign";
const observed = observedHireResources(scope, changed);
expect(() => assertHireBinding(scope, observed.agents)).toThrow();
}
const unrelated = { ...f.agent, id: "unrelated", name: "Other work" };
expect(observedHireResources(scope, { ...f.state, agents: [...f.state.agents, unrelated] }).agents).toEqual([f.agent]);
expect(observedHireResources(scope, { ...f.state, activity: [] }).agents).toEqual([]);
});
it("rejects duplicate hires, altered runtime/account binding and a replaced teammate", () => {
const f = fixture(); expect(assertHireBinding(scope, [f.agent]).id).toBe("hire");
expect(() => assertHireBinding(scope, [f.agent], "old-hire")).toThrow();
expect(() => assertHireBinding(scope, [f.agent, { ...f.agent, id: "duplicate" }])).toThrow();
for (const change of [{ companyId: "other" }, { reportsTo: "other" }, { adapterType: "legacy" },
{ adapterConfig: { model: "other" } }, { runtimeConfig: { aiConnection: { connectionId: "other" } } }]) {
expect(() => assertHireBinding(scope, [{ ...f.agent, ...change }])).toThrow();
}
});
it("accepts the canonical responsible-user fallback only for the independently approved same credential choice", () => {
const f = fixture(), delegated = { provider: "openrouter", method: "api_key", mode: "delegated", connectionId: "approved", grantId: "grant" };
const scoped = { ...scope, connectionBinding: delegated,
connectionChoice: { connectionId: "approved", grantId: "grant", provider: "openrouter", method: "api_key", responsibleUserId: "user" } };
Object.assign(f.agent.runtimeConfig, { aiConnection: { provider: "openrouter", method: "api_key", mode: "responsible_user" } });
expect(() => assertHireBinding(scoped, [f.agent])).not.toThrow();
expect(() => assertHireBinding({ ...scope, connectionBinding: delegated }, [f.agent])).toThrow();
expect(() => assertHireBinding({ ...scoped, connectionChoice: { ...scoped.connectionChoice, grantId: "foreign" } }, [f.agent])).toThrow();
});
it("uses the production delegated fallback and the actual requesting user's same approved personal default", async () => {
const binding = { provider: "openrouter", method: "api_key", mode: "delegated", connectionId: "00000000-0000-4000-8000-000000000001", grantId: "00000000-0000-4000-8000-000000000002" };
expect(defaultAiConnectionForHire("paperclip_runner", { provider: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017" }, binding))
.toEqual({ provider: "openrouter", method: "api_key", mode: "responsible_user" });
const selected = { id: binding.connectionId, grantId: binding.grantId, companyId: scope.companyId, provider: binding.provider,
method: binding.method, ownership: "personal", ownerUserId: "user", isDefault: true, status: "connected" };
const listed = { currentUserId: "user", connections: [selected] };
const installed = { connectionId: binding.connectionId, installs: [{ companyId: scope.companyId, targetType: "agent", targetId: "hire" }] };
const api = { async get<T>(route: string): Promise<T> { return (route.endsWith("/installs") ? installed : listed) as T; } };
const input = { companyId: scope.companyId, responsibleUserId: "user", binding, hiredId: "hire" };
await expect(readHireConnectionChoice(api, input)).resolves.toMatchObject({ connectionId: binding.connectionId, grantId: binding.grantId, responsibleUserId: "user" });
for (const mutate of [() => { listed.currentUserId = "operator"; }, () => { selected.grantId = "foreign"; },
() => { selected.ownerUserId = "operator"; }, () => { selected.status = "revoked"; },
() => { selected.isDefault = false; }, () => { installed.installs[0]!.targetId = "foreign"; }]) {
const before = structuredClone({ listed, installed }); mutate();
await expect(readHireConnectionChoice(api, input)).rejects.toMatchObject({ code: "hire_connection_choice_mismatch" });
Object.assign(listed, before.listed); Object.assign(selected, before.listed.connections[0]); listed.connections = [selected]; Object.assign(installed, before.installed);
}
});
it("distinguishes the subscription hire placeholder from the approved resolved native method", async () => {
const f = fixture(), binding = { provider: "openai", method: "subscription", mode: "delegated",
connectionId: "00000000-0000-4000-8000-000000000001", grantId: "00000000-0000-4000-8000-000000000002" };
const placeholder = defaultAiConnectionForHire("paperclip_runner", { provider: "codex", model: "fixed-model" }, binding);
expect(placeholder).toEqual({ provider: "openai", method: "api_key", mode: "responsible_user" });
const selected = { id: binding.connectionId, grantId: binding.grantId, companyId: scope.companyId, provider: binding.provider,
method: binding.method, ownership: "personal", ownerUserId: "user", isDefault: true, status: "connected" };
const installed = { connectionId: binding.connectionId, installs: [{ companyId: scope.companyId, targetType: "agent", targetId: f.agent.id }] };
const api = { async get<T>(route: string): Promise<T> { return (route.endsWith("/installs") ? installed : { currentUserId: "user", connections: [selected] }) as T; } };
const input = { companyId: scope.companyId, responsibleUserId: "user", binding, hiredId: f.agent.id };
const connectionChoice = await readHireConnectionChoice(api, input);
const scoped = { ...scope, provider: "codex", connectionBinding: binding, connectionChoice };
const hired = { ...f.agent, runtimeConfig: { aiConnection: placeholder } };
expect(() => assertHireBinding(scoped, [hired])).not.toThrow();
expect(() => assertHireBinding({ ...scoped, connectionChoice: { ...connectionChoice!, grantId: "unapproved" } }, [hired])).toThrow("hire_native_binding_mismatch");
for (const altered of [{ ...placeholder, provider: "anthropic" }, { ...placeholder, mode: "delegated" }, { ...placeholder, method: "subscription" }]) {
expect(() => assertHireBinding(scoped, [{ ...hired, runtimeConfig: { aiConnection: altered } }])).toThrow("hire_native_binding_mismatch");
}
selected.grantId = "unapproved";
await expect(readHireConnectionChoice(api, input)).rejects.toMatchObject({ code: "hire_connection_choice_mismatch" });
const run = { ...f.taskRun, runnerProfileJson: { nativeExecutionInput: { provider: { kind: "codex", model: scope.model } } },
contextSnapshot: { ...f.taskRun.contextSnapshot, aiConnection: { ...binding, mode: "responsible_user", responsibleUserId: "user" } } };
expect(() => assertHireRuns(scoped, hired.id, [f.task], [run])).not.toThrow();
expect(() => assertHireRuns(scoped, hired.id, [f.task], [{ ...run,
contextSnapshot: { ...run.contextSnapshot, aiConnection: { ...run.contextSnapshot.aiConnection, method: "api_key" } } }])).toThrow("hire_run_credential_mismatch");
});
it("requires actual execution attribution to keep the approved credential and responsible user", () => {
const f = fixture(), connectionChoice = { connectionId: "approved", grantId: "grant", provider: "openrouter", method: "api_key", responsibleUserId: "user" };
const scoped = { ...scope, connectionChoice }, run = { ...f.taskRun, contextSnapshot: { ...f.taskRun.contextSnapshot,
aiConnection: { ...connectionChoice, mode: "responsible_user" } } };
expect(() => assertHireRuns(scoped, f.agent.id, [f.task], [run])).not.toThrow();
for (const field of ["connectionId", "grantId", "provider", "method", "responsibleUserId", "mode"]) {
expect(() => assertHireRuns(scoped, f.agent.id, [f.task], [{ ...run,
contextSnapshot: { ...run.contextSnapshot, aiConnection: { ...run.contextSnapshot.aiConnection, [field]: "foreign" } } }])).toThrow("hire_run_credential_mismatch");
}
});
it("keeps unexpected authored children visible for failure cleanup without grading them as the requested work", () => {
const f = fixture(), extra = { ...f.task, id: "extra", title: `Unexpected ${scope.campaignId}` };
const observed = observedHireResources(scope, { ...f.state, tasks: [...f.state.tasks, extra], activity: [...f.state.activity, f.event("issue.created", extra.id)] });
expect(observed.tasks).toHaveLength(2); expect(observed.expectedTasks).toEqual([f.task]);
const mismatch = observedHireResources(scope, { ...f.state, tasks: [{ ...f.task, projectId: "other" }] });
expect(mismatch.tasks).toHaveLength(1); expect(mismatch.scopeMismatch).toBe(true);
});
it("binds lead and worker runs to their exact tasks, provider, model and environment", () => {
const f = fixture(); expect(() => assertHireRuns(scope, f.agent.id, [f.task], [f.leadRun, f.taskRun])).not.toThrow();
for (const change of [{ companyId: "other" }, { agentId: scope.leadId }, { issueId: "other" }, { status: "failed" },
{ runtimeMode: "legacy" }, { contextSnapshot: { paperclipEnvironment: { id: "local" } } },
{ runnerProfileJson: { nativeExecutionInput: { provider: { kind: "opencode", model: "other" } } } }]) {
expect(() => assertHireRuns(scope, f.agent.id, [f.task], [f.leadRun, { ...f.taskRun, ...change }])).toThrow();
}
expect(() => assertHireRuns(scope, f.agent.id, [f.task], [f.taskRun, f.taskRun])).toThrow();
expect(() => assertHireRuns(scope, f.agent.id, [f.task], [f.leadRun, f.taskRun], 5)).toThrow();
});
it("rejects a lead task run from create-self-then-reassign even after the saved assignee is correct", () => {
const f = fixture();
const delegation = { ...f.run("delegate-chat", scope.chatId, scope.leadId), status: "running" };
const extra = { ...f.run("self-assigned-task", f.task.id, scope.leadId), status: "cancelled", errorCode: "issue_reassigned" };
const child = { ...f.taskRun, status: "running" };
expect(f.task.assigneeAgentId).toBe(f.agent.id);
expect(() => assertHireRuns(scope, f.agent.id, [f.task], [f.leadRun, delegation, extra, child])).toThrow("hire_run_scope_mismatch");
expect(() => assertHireRuns(scope, f.agent.id, [f.task], [f.leadRun, f.taskRun])).not.toThrow();
});
it("accounts for three requested lead turns, two exact completion callbacks and two child runs", () => {
const { f, second, callbacks, runs, comments, grade } = completionFixture();
expect(() => grade()).not.toThrow();
expect(() => grade(runs, [])).toThrow("hire_completion_reply_missing");
expect(assertHireRuns(scope, f.agent.id, [f.task, second], runs, undefined, ["message-1", "message-2", "message-3"], [])).toBe(false);
expect(() => assertHireRuns(scope, f.agent.id, [f.task, second], runs, undefined, ["message-1", "message-2", "message-3"],
comments.map(comment => ({ ...comment, authorAgentId: "foreign" })))).toThrow("hire_completion_reply_mismatch");
for (const change of [{ invocationSource: "on_demand" }, { contextSnapshot: { ...callbacks[0]!.contextSnapshot, chatCompletionUpdates: [{ id: "foreign", status: "done" }] } },
{ contextSnapshot: { ...callbacks[0]!.contextSnapshot, chatCompletionDeliveryIds: ["delivery-1"] } },
{ contextSnapshot: { ...callbacks[0]!.contextSnapshot, wakeReason: "arbitrary_extra_turn" } }]) {
const corrupted = runs.map(row => row.id === "completion-0" ? { ...row, ...change } : row);
expect(() => grade(corrupted)).toThrow();
}
expect(() => grade([...runs, f.run("extra", scope.chatId, scope.leadId)])).toThrow();
});
it.each([0, 1])("requires callback %s to save its own completed task link", index => {
const { runs, comments, grade } = completionFixture();
const changed = comments.map((comment, i) => i === index ? { ...comment, body: "Completed saved result" } : comment);
expect(() => grade(runs, changed)).toThrow("hire_completion_reply_missing");
});
it.each(["foreign", "code", "fenced-code", "wrong-task", "earlier-handoff-only"])("rejects a %s completion link despite seven successful native runs", variant => {
const { f, requested, runs, comments, grade } = completionFixture();
const changed = comments.map((comment, i) => ({ ...comment, body: variant === "foreign"
? `[TEST-${i + 1}](https://foreign.test/TEST/issues/TEST-${i + 1})`
: variant === "code" ? `\`[saved result](/TEST/issues/TEST-${i + 1})\``
: variant === "fenced-code" ? `\`\`\`markdown\n[saved result](/TEST/issues/TEST-${i + 1})\n\`\`\``
: variant === "wrong-task" ? "[other result](/TEST/issues/TEST-999)" : "Completed saved result" }));
if (variant === "earlier-handoff-only") changed.push({ ...comments[0]!, createdByRunId: requested[1]!.id,
body: `[first](/TEST/issues/${f.task.identifier}) and [second](/TEST/issues/TEST-2)` });
expect(runs).toHaveLength(7);
expect(() => grade(runs, changed)).toThrow("hire_completion_reply_missing");
});
it("requires immutable native creation ownership independently of current assignment and run count", () => {
const f = fixture();
expect(() => assertHireTaskCreation(scope, f.agent.id, [f.task], f.state.activity, [f.leadRun])).not.toThrow();
for (const mutate of [
(event: Row) => { event.details.assigneeAgentId = scope.leadId; event.details.status = "backlog"; },
(event: Row) => { delete event.details.assigneeAgentId; },
(event: Row) => { event.details.assigneeUserId = "user"; },
(event: Row) => { event.actorId = "foreign"; },
(event: Row) => { event.companyId = "foreign"; },
(event: Row) => { event.runId = "foreign"; },
]) {
const activity = structuredClone(f.state.activity); mutate(activity[1]!);
expect(f.task.assigneeAgentId).toBe(f.agent.id);
expect(() => assertHireTaskCreation(scope, f.agent.id, [f.task], activity, [f.leadRun])).toThrow("hire_initial_assignee_mismatch");
}
});
it("accepts the actual UI's task auto-links and reference-style links in each callback", () => {
const { runs, comments, grade } = completionFixture();
expect(() => grade(runs, comments.map((comment, i) => ({ ...comment, body: `Completed TEST-${i + 1}.` })))).not.toThrow();
expect(() => grade(runs, comments.map((comment, i) => ({ ...comment,
body: `Completed [saved result][task].\n\n[task]: /TEST/issues/TEST-${i + 1}` })))).not.toThrow();
});
it.each(["heading", "list", "table", "fence", "table-link"])("accepts callback task links in %s replies without grading unrelated Markdown", shape => {
const { runs, comments, grade } = completionFixture();
const changed = comments.map((comment, i) => {
const link = `[saved result](/TEST/issues/TEST-${i + 1})`;
const table = "| Check | Result |\n| --- | --- |\n| File | Saved |";
return { ...comment, body: shape === "heading" ? `## Completed\n\n${link}`
: shape === "list" ? `- Completed\n - ${link}`
: shape === "table" ? `${link}\n\n${table}`
: shape === "fence" ? `\`\`\`markdown\n[not a link](/TEST/issues/TEST-999)\n\`\`\`\n\n${link}`
: `| Result |\n| --- |\n| ${link} |` };
});
expect(() => grade(runs, changed)).not.toThrow();
});
it("requires the saved document revision to come from the successful hired-agent work run", () => {
const f = fixture(), body = "Reference: independent-marker";
const document = { id: "doc", issueId: f.task.id, companyId: scope.companyId, key: "checklist", body, latestRevisionId: "revision" };
const revision: Row = { id: "revision", body, createdByAgentId: f.agent.id, createdByRunId: f.taskRun.id };
const grade = (doc = document, revisions = [revision], runs = [f.taskRun]) => assertHireDocument(scope, f.task, doc, revisions, runs, "independent-marker", f.agent.id);
expect(() => grade()).not.toThrow();
for (const change of [{ body: "old output" }, { createdByAgentId: "board" }, { createdByRunId: f.leadRun.id }, { id: "stale-revision" }]) {
expect(() => grade(document, [{ ...revision, ...change }])).toThrow();
}
expect(() => grade({ ...document, issueId: "other" })).toThrow();
expect(() => grade({ ...document, key: "plan" })).toThrow();
expect(() => grade(document, [])).toThrow();
expect(() => grade(document, [revision], [])).toThrow();
});
});
@@ -0,0 +1,209 @@
import { isDeepStrictEqual } from "node:util";
import { createRequire } from "node:module";
import { applyCompanyPrefix } from "../../../ui/src/lib/company-routes.js";
import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../../../ui/src/lib/issue-reference.js";
import { JourneyError } from "./types.js";
type Row = Record<string, any>;
const uiRequire = createRequire(new URL("../../../ui/package.json", import.meta.url));
const [markdown, gfm, react] = await Promise.all([import(uiRequire.resolve("react-markdown")),
import(uiRequire.resolve("remark-gfm")), import(uiRequire.resolve("react"))]);
const Markdown = markdown.default as (input: { children: string; remarkPlugins: unknown[] }) => unknown;
const isValidElement = react.isValidElement as (node: unknown) => node is { type: unknown; props: { href?: unknown; children?: unknown } };
function hireReplyLinks(body: string, issuePrefix: string): string[] {
const nodes: unknown[] = [Markdown({ children: body, remarkPlugins: [gfm.default, [remarkLinkIssueReferences, { knownPrefixes: [issuePrefix] }]] })];
const links: string[] = [];
while (nodes.length) {
const node = nodes.pop();
if (Array.isArray(node)) { nodes.push(...node); continue; }
if (!isValidElement(node) || node.type === "pre" || node.type === "code") continue;
if (node.type === "a" && typeof node.props.href === "string") links.push(node.props.href);
nodes.push(node.props.children);
}
return links;
}
export const HIRE_DELEGATE_JOURNEY = "hire-delegate-reuse" as const;
export function hireDelegateNames(campaignId: string) {
return { project: `Journey QA hire-delegate-reuse ${campaignId}`, hire: `Morgan Reviewer ${campaignId}`,
checklist: `Checklist ${campaignId}`, review: `Review ${campaignId}` };
}
export interface HireScope {
companyId: string; leadId: string; chatId: string; projectId: string; campaignId: string;
origin: string; issuePrefix: string;
model: string; provider: string; environmentId: string; connectionBinding: unknown;
connectionChoice?: HireConnectionChoice;
}
export interface HireConnectionChoice {
connectionId: string; grantId: string; provider: string; method: string; responsibleUserId: string;
}
export async function readHireConnectionChoice(api: { get<T = Row>(route: string): Promise<T> },
input: { companyId: string; responsibleUserId: string; binding: unknown; hiredId?: string }): Promise<HireConnectionChoice | undefined> {
const binding = input.binding as Row | undefined;
if (binding?.mode !== "delegated") return;
const fail = (): never => { throw new JourneyError("failed", "hire_connection_choice_mismatch"); };
if (![binding.connectionId, binding.grantId, binding.provider, binding.method, input.responsibleUserId]
.every(value => typeof value === "string" && value)) fail();
const listed = await api.get<Row>(`/api/companies/${input.companyId}/ai-connections`);
if (listed.currentUserId !== input.responsibleUserId || !Array.isArray(listed.connections)
|| listed.connections.some((row: Row) => !row || row.companyId !== input.companyId)) fail();
const defaults = listed.connections.filter((row: Row) => row.provider === binding.provider && row.isDefault === true);
const selected = defaults[0];
if (defaults.length !== 1 || selected.companyId !== input.companyId || selected.id !== binding.connectionId
|| selected.grantId !== binding.grantId || selected.method !== binding.method || selected.status !== "connected"
|| selected.ownership !== "personal" || selected.ownerUserId !== input.responsibleUserId || selected.unavailableReason) fail();
if (input.hiredId) {
const installed = await api.get<Row>(`/api/tool-connections/${binding.connectionId}/installs`);
if (installed.connectionId !== binding.connectionId || !Array.isArray(installed.installs)
|| installed.installs.some((row: Row) => row.companyId !== input.companyId)
|| installed.installs.filter((row: Row) => row.targetType === "agent" && row.targetId === input.hiredId).length !== 1) fail();
}
return { connectionId: binding.connectionId, grantId: binding.grantId, provider: binding.provider,
method: binding.method, responsibleUserId: input.responsibleUserId };
}
function approvedFallback(scope: HireScope, hiredBinding: unknown) {
const binding = scope.connectionBinding as Row | undefined, choice = scope.connectionChoice;
return binding?.mode === "delegated" && choice && choice.connectionId === binding.connectionId && choice.grantId === binding.grantId
&& choice.provider === binding.provider && choice.method === binding.method && typeof choice.responsibleUserId === "string"
&& Boolean(choice.responsibleUserId) && isDeepStrictEqual(hiredBinding,
{ provider: choice.provider, method: "api_key", mode: "responsible_user" });
}
export const hireRunIssueId = (run: Row) => run.issueId ?? run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId;
export function assertHireAuditCoverage(activity: Row[], leadRuns: Row[]) {
if (!Array.isArray(activity) || activity.length > 500) throw new JourneyError("failed", "hire_activity_coverage_unavailable");
if (activity.length < 500) return;
if (!leadRuns.length) return;
const starts = leadRuns.map(run => Date.parse(run.startedAt ?? run.createdAt));
const oldest = Math.min(...activity.map(event => Date.parse(event.createdAt)));
if (!starts.every(Number.isFinite) || !Number.isFinite(oldest) || oldest > Math.min(...starts)) {
throw new JourneyError("failed", "hire_activity_coverage_unavailable");
}
}
export function observedHireResources<A extends Row, T extends Row>(scope: HireScope, state: { agents: A[]; tasks: T[]; activity: Row[]; leadRuns: Row[] }) {
const names = hireDelegateNames(scope.campaignId);
const authored = (row: Row, actions: string[]) => state.activity.some(event => event.companyId === scope.companyId
&& event.actorType === "agent" && event.actorId === scope.leadId && event.agentId === scope.leadId
&& event.entityId === row.id && actions.includes(event.action)
&& state.leadRuns.some(run => run.id === event.runId && run.agentId === scope.leadId
&& run.companyId === scope.companyId && hireRunIssueId(run) === scope.chatId));
const agents = state.agents.filter(agent => authored(agent, ["agent.hire_created", "agent.created"]));
const tasks = state.tasks.filter(task => authored(task, ["issue.created", "issue.child_created"]));
const ownable = (row: Row, name: unknown) => row.companyId === scope.companyId && typeof row.id === "string" && row.id
&& typeof name === "string" && Boolean(name);
const ownedAgents = agents.filter(agent => ownable(agent, agent.name)), ownedTasks = tasks.filter(task => ownable(task, task.title));
return { agents: ownedAgents, tasks: ownedTasks, hires: ownedAgents.filter(agent => agent.name === names.hire),
expectedTasks: ownedTasks.filter(task => [names.checklist, names.review].includes(task.title)),
scopeMismatch: state.activity.some(event => [...agents, ...tasks].some(row => row.id === event.entityId)
&& state.leadRuns.some(run => run.id === event.runId && run.contextSnapshot?.wakeReason === "chat_task_completed"))
|| agents.some(agent => !ownable(agent, agent.name) || agent.reportsTo !== scope.leadId)
|| tasks.some(task => !ownable(task, task.title) || task.projectId !== scope.projectId) };
}
export function assertHireBinding(scope: HireScope, agents: Row[], hiredId?: string) {
const names = hireDelegateNames(scope.campaignId);
const hires = agents.filter(agent => agent.name === names.hire);
if (agents.length !== 1 || hires.length !== 1) throw new JourneyError("failed", "exactly_one_native_hire_required");
const hired = hires[0]!;
if (hired.id === scope.leadId || hiredId && hired.id !== hiredId || hired.companyId !== scope.companyId
|| hired.reportsTo !== scope.leadId || hired.adapterType !== "paperclip_runner" || hired.adapterConfig?.model !== scope.model
|| !scope.connectionBinding || (!isDeepStrictEqual(hired.runtimeConfig?.aiConnection, scope.connectionBinding)
&& !approvedFallback(scope, hired.runtimeConfig?.aiConnection))) {
throw new JourneyError("failed", "hire_native_binding_mismatch");
}
return hired;
}
export function assertHireTaskCreation(scope: HireScope, hiredId: string | undefined, tasks: Row[], activity: Row[], leadRuns: Row[]) {
for (const task of tasks) {
const created = activity.filter(event => event.companyId === scope.companyId && event.actorType === "agent"
&& event.actorId === scope.leadId && event.agentId === scope.leadId && event.entityType === "issue"
&& event.entityId === task.id && ["issue.created", "issue.child_created"].includes(event.action)
&& leadRuns.some(run => run.id === event.runId && run.companyId === scope.companyId && run.agentId === scope.leadId
&& run.runtimeMode === "native" && hireRunIssueId(run) === scope.chatId));
if (!hiredId || task.companyId !== scope.companyId || !created.length
|| created.some(event => event.details?.assigneeAgentId !== hiredId || event.details?.assigneeUserId !== null)) {
throw new JourneyError("failed", "hire_initial_assignee_mismatch");
}
}
}
export function hireResultLinkMatches(scope: Pick<HireScope, "origin" | "issuePrefix">, task: Row, href: string, source = false) {
try {
const reference = source ? parseIssueReferenceFromHref(href) : null;
const url = new URL(reference ? applyCompanyPrefix(reference.href, scope.issuePrefix) : href, scope.origin);
return url.origin === scope.origin && [task.id, task.identifier].some(id => typeof id === "string"
&& url.pathname === `/${scope.issuePrefix}/issues/${id}`);
} catch { return false; }
}
export function assertHireRuns(scope: HireScope, hiredId: string | undefined, tasks: Row[], runs: Row[], count?: number, requestedCommentIds?: string[], comments?: Row[]) {
const bindings = new Map([[scope.chatId, scope.leadId], ...tasks.map(task => [task.id, hiredId] as [string, string | undefined])]);
if (runs.length > 7 || count !== undefined && runs.length !== count || new Set(runs.map(run => run.id)).size !== runs.length) {
throw new JourneyError("failed", "hire_run_limit_mismatch");
}
const requests = new Set<string>(), deliveries = new Set<string>(), completedTasks = new Set<string>();
let callbackCount = 0, completionRepliesReady = true;
for (const run of runs) {
const issueId = hireRunIssueId(run), provider = run.runnerProfileJson?.nativeExecutionInput?.provider;
if (!bindings.has(issueId) || !bindings.get(issueId) || run.companyId !== scope.companyId || run.agentId !== bindings.get(issueId)) {
throw new JourneyError("failed", "hire_run_scope_mismatch");
}
if (requestedCommentIds && run.agentId === scope.leadId) {
const context = run.contextSnapshot ?? {};
if (context.wakeReason === "chat_task_completed") {
callbackCount++;
const ids = context.chatCompletionDeliveryIds, updates = context.chatCompletionUpdates;
if (callbackCount > 2 || run.invocationSource !== "automation" || run.triggerDetail !== "system"
|| context.wakeSource !== "automation" || context.wakeTriggerDetail !== "system" || context.wakeCommentId
|| !Array.isArray(ids) || ids.length !== 1 || typeof ids[0] !== "string" || !ids[0] || deliveries.has(ids[0])) {
throw new JourneyError("failed", "hire_completion_binding_mismatch");
}
deliveries.add(ids[0]);
if (run.status === "succeeded" || updates !== undefined) {
if (!Array.isArray(updates) || updates.length !== 1 || updates[0]?.status !== "done"
|| !tasks.some(task => task.id === updates[0]?.id && task.status === "done") || completedTasks.has(updates[0].id)) {
throw new JourneyError("failed", "hire_completion_binding_mismatch");
}
if (run.status === "succeeded" && comments) {
const replies = comments.filter(comment => comment.createdByRunId === run.id);
if (replies.some(comment => comment.companyId !== scope.companyId || comment.issueId !== scope.chatId
|| comment.authorAgentId !== scope.leadId || comment.authorUserId)) throw new JourneyError("failed", "hire_completion_reply_mismatch");
const task = tasks.find(task => task.id === updates[0].id)!;
if (!replies.some(comment => typeof comment.body === "string"
&& hireReplyLinks(comment.body, scope.issuePrefix).some(href => hireResultLinkMatches(scope, task, href, true)))) {
completionRepliesReady = false;
}
}
completedTasks.add(updates[0].id);
}
} else {
if (context.source !== "issue.comment" || !requestedCommentIds.includes(context.wakeCommentId)
|| requests.has(context.wakeCommentId) || requests.size >= 3) throw new JourneyError("failed", "hire_requested_turn_mismatch");
requests.add(context.wakeCommentId);
}
}
if (!["queued", "running", "succeeded"].includes(run.status)) throw new JourneyError("failed", "hire_native_run_failed");
if (run.status === "succeeded" && (run.runtimeMode !== "native" || provider?.kind !== scope.provider || provider?.model !== scope.model
|| run.contextSnapshot?.paperclipEnvironment?.id !== scope.environmentId)) throw new JourneyError("failed", "hire_run_runtime_mismatch");
if (run.status === "succeeded" && scope.connectionChoice) {
const actual = run.contextSnapshot?.aiConnection, expected = scope.connectionChoice;
if (actual?.connectionId !== expected.connectionId || actual.grantId !== expected.grantId
|| actual.provider !== expected.provider || actual.method !== expected.method || actual.responsibleUserId !== expected.responsibleUserId
|| !(run.agentId === scope.leadId ? actual.mode === "delegated" : ["delegated", "responsible_user"].includes(actual.mode))) {
throw new JourneyError("failed", "hire_run_credential_mismatch");
}
}
}
if (requestedCommentIds && count !== undefined && (requests.size !== requestedCommentIds.length
|| callbackCount !== tasks.length || completedTasks.size !== tasks.length)) throw new JourneyError("failed", "hire_turn_accounting_mismatch");
if (count !== undefined && !completionRepliesReady) throw new JourneyError("failed", "hire_completion_reply_missing");
return completionRepliesReady;
}
export function assertHireDocument(scope: HireScope, task: Row, document: Row, revisions: Row[], runs: Row[], marker: string, hiredId: string) {
const work = runs.filter(run => hireRunIssueId(run) === task.id);
const revision = revisions.find(row => row.id === document.latestRevisionId);
if (task.companyId !== scope.companyId || task.assigneeAgentId !== hiredId || task.status !== "done" || task.projectId !== scope.projectId
|| work.length !== 1 || work[0]?.status !== "succeeded" || document.issueId !== task.id || document.companyId !== scope.companyId
|| document.key === "plan" || !document.body?.includes(marker) || !revision || revision.body !== document.body
|| revision.createdByAgentId !== hiredId || revision.createdByRunId !== work[0]?.id) {
throw new JourneyError("failed", "hired_document_provenance_mismatch");
}
}
@@ -0,0 +1,357 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { parseAgentMentionHref, parseProjectMentionHref } from "../../../packages/shared/src/project-mentions.js";
import { ConnectionFailure } from "../connection-config.js";
import { sendChatMessage, readChatOutputDocument } from "../chat-flow.js";
import { hireDelegateReuse, hireHandoffMentions, openSavedHireResult } from "./hire-delegate-flow.js";
import { hireDelegateNames } from "./hire-delegate-evidence.js";
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
vi.mock("@playwright/test", () => ({ expect: (value: unknown) => Object.assign(expect(value), { toBeVisible: async () => {}, toHaveURL: async (predicate: (url: URL) => boolean) => { expect(predicate(new URL((value as Row).currentUrl ?? "https://qa.example.test/QA/issues/QA-2"))).toBe(true); } }) }));
vi.mock("../chat-flow.js", () => ({ sendChatMessage: vi.fn(), readChatOutputDocument: vi.fn() }));
vi.mock("./hire-delegate-composer.js", () => ({ sendHireHandoffMessage: (page: any, text: string) => sendChatMessage(page, text) }));
type Row = Record<string, any>;
const directories: string[] = [];
afterEach(async () => { vi.mocked(sendChatMessage).mockReset(); await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))); });
async function fixture(options: { readError?: Error; mutate?(agents: Row[], tasks: Row[]): void; ownError?: Error; firstSendError?: Error; connectionList?: Row; connectionReadError?: Error } = {}) {
const campaignId = "journey-1-deadbeef", names = hireDelegateNames(campaignId), owned: Row[] = [], comments: Row[] = [];
const binding = { connectionId: "approved" }, lead = { id: "lead", companyId: "company", name: `Lead ${campaignId}`, permissions: { canCreateAgents: true }, runtimeConfig: { aiConnection: binding } };
const hire = { id: "hired", companyId: "company", name: names.hire, reportsTo: lead.id, adapterType: "paperclip_runner",
adapterConfig: { model: "fixed-model" }, runtimeConfig: { aiConnection: binding } };
const agents: Row[] = [lead, hire], tasks: Row[] = [];
const chat = { id: "chat", companyId: "company", title: `Chat with ${lead.name}`, assigneeAgentId: lead.id, conversationAgentId: lead.id,
conversationUserId: "user", createdByUserId: "user", status: "in_review", conversationState: "waiting" };
const run = { id: "lead-run", companyId: "company", agentId: lead.id, issueId: chat.id, status: "succeeded", runtimeMode: "native",
contextSnapshot: { source: "issue.comment", wakeCommentId: "user-comment", paperclipEnvironment: { id: "environment" } }, runnerProfileJson: { nativeExecutionInput: { provider: { kind: "opencode", model: "fixed-model" } } } };
options.mutate?.(agents, tasks);
if ((lead.runtimeConfig.aiConnection as Row).mode === "delegated") Object.assign(run.contextSnapshot,
{ aiConnection: { ...lead.runtimeConfig.aiConnection, responsibleUserId: "user" } });
const audit = (action: string, entityId: string) => ({ companyId: "company", actorType: "agent", actorId: lead.id, agentId: lead.id, runId: run.id, action, entityId, entityType: action.startsWith("agent.") ? "agent" : "issue" });
const activity = [audit("agent.hire_created", hire.id), ...tasks.map(task => audit("issue.created", task.id))];
let sent = false, failedRead = false, failedConnectionRead = false, runReads = 0;
const stop = new JourneyError("failed", "test_stop_before_delegation");
vi.mocked(sendChatMessage).mockImplementation(async (_page, text) => {
if (sent) throw stop;
sent = true; if (options.firstSendError) throw options.firstSendError; comments.push({ id: "user-comment", authorUserId: "user", body: text });
});
const api = { origin: "https://qa.example.test", async get(route: string) {
if (route === "/api/instance/settings/experimental") return { enableAgentChat: true };
if (route === "/api/companies/company") return { requireBoardApprovalForNewAgents: false };
if (route === "/api/agents/lead") return lead;
if (route === "/api/companies/company/ai-connections") {
if (sent && options.connectionReadError && !failedConnectionRead) { failedConnectionRead = true; throw options.connectionReadError; }
return options.connectionList ?? { currentUserId: "user", connections: [] };
}
if (route.endsWith("/installs")) return { connectionId: "approved", installs: [{ companyId: "company", targetType: "agent", targetId: "hired" }] };
if (route === "/api/companies/company/chats/lead") return sent ? chat : null;
if (route === "/api/issues/chat/runs") {
runReads++;
if (options.readError && !failedRead) { failedRead = true; throw options.readError; }
return [{ runId: run.id }];
}
if (route === "/api/heartbeat-runs/lead-run") return run;
if (route === "/api/agents/hired") return agents.find(row => row.id === "hired");
if (route.startsWith("/api/issues/") && tasks.some(row => route === `/api/issues/${row.id}`)) return tasks.find(row => route === `/api/issues/${row.id}`);
if (route === "/api/companies/company/agents") return agents;
if (route === "/api/companies/company/issues") return tasks;
if (route === "/api/companies/company/activity?agentId=lead&limit=500") return activity;
if (route === "/api/issues/chat/comments?order=asc") return structuredClone(comments);
throw new Error(`Unexpected read ${route}`);
}, async post(route: string) {
expect(route).toBe("/api/companies/company/projects"); return { id: "project", companyId: "company", name: names.project };
} };
const context = { api, page: { async goto() {}, getByTestId() { return {}; } }, user: { id: "user" }, company: { id: "company", issuePrefix: "QA" },
agent: { id: lead.id, model: "fixed-model", provider: "opencode", runtimeMode: "native" }, environmentId: "environment" } as unknown as JourneyContext;
const session = { async own(row: Row) { if (row.id === hire.id && options.ownError) throw options.ownError; owned.push(row); } } as unknown as TargetSession;
const evidence = { checks: [] } as unknown as JourneyEvidence;
const directory = await mkdtemp(path.join(os.tmpdir(), "hire-observation-")); directories.push(directory);
return { owned, evidence, stop, directory, runReads: () => runReads,
run: () => hireDelegateReuse(context, session, campaignId, evidence, { deadlineAt: Date.now() + 10_000, directory, capture: async () => {} }) };
}
function handoffTargets(text: string) {
const hrefs = [...text.matchAll(/\]\(([^)]+)\)/g)].map(match => match[1]!);
return { agents: hrefs.map(parseAgentMentionHref).filter(value => value !== null).map(value => value.agentId),
projects: hrefs.map(parseProjectMentionHref).filter(value => value !== null).map(value => value.projectId) };
}
it("carries the independently verified teammate and project into the actual delegation message", async () => {
const f = await fixture();
await expect(f.run()).rejects.toBe(f.stop);
expect(handoffTargets(vi.mocked(sendChatMessage).mock.calls[1]![1])).toEqual({ agents: ["hired"], projects: ["project"] });
expect(sendChatMessage).toHaveBeenCalledTimes(2);
});
it.each(["missing-agent", "missing-project", "foreign-agent", "foreign-project", "wrong-agent", "wrong-project", "missing-scope", "malformed-id"])(
"rejects %s handoff context instead of emitting an unbound mention", mismatch => {
const scope = { companyId: "company", leadId: "lead", projectId: "project", campaignId: "journey-1-deadbeef" };
const names = hireDelegateNames(scope.campaignId), hired = { id: "hired", companyId: "company", reportsTo: "lead", name: names.hire };
const project = { id: "project", companyId: "company", name: names.project };
if (mismatch === "foreign-agent") hired.companyId = "foreign";
if (mismatch === "foreign-project") project.companyId = "foreign";
if (mismatch === "wrong-agent") hired.id = "other";
if (mismatch === "wrong-project") project.id = "other";
if (mismatch === "missing-scope") scope.companyId = "";
if (mismatch === "malformed-id") hired.id = "hired?i=code";
expect(() => hireHandoffMentions(scope, mismatch === "malformed-id" ? hired.id : "hired",
mismatch === "missing-agent" ? undefined! : hired, mismatch === "missing-project" ? undefined! : project))
.toThrow("hire_handoff_context_unverified");
},
);
it.each(["api_status_503", "api_request_failed"])("recovers a transient %s read without replaying the first user turn", async code => {
const f = await fixture({ readError: new ConnectionFailure(code) });
await expect(f.run()).rejects.toBe(f.stop);
expect(f.runReads()).toBe(2);
expect(f.evidence.checks).toContainEqual({ id: "one-attributed-native-hire", passed: true });
expect(vi.mocked(sendChatMessage).mock.calls.filter(([, text]) => text.startsWith("Hire exactly one"))).toHaveLength(1);
});
it.each([new JourneyError("failed", "api_status_503"), new ConnectionFailure("api_status_403"), new ConnectionFailure("api_status_500"), new Error("api_request_failed")])(
"never retries a candidate, identity, application or unknown error: %s", async readError => {
const f = await fixture({ readError }); await expect(f.run()).rejects.toBe(readError);
expect(f.runReads()).toBe(1); expect(sendChatMessage).toHaveBeenCalledTimes(1);
},
);
it("never retries ownership callbacks, whose mutations may already have committed", async () => {
const ownError = new ConnectionFailure("api_request_failed"), f = await fixture({ ownError });
await expect(f.run()).rejects.toBe(ownError); expect(f.runReads()).toBe(1); expect(sendChatMessage).toHaveBeenCalledTimes(1);
});
it.each(["manager", "project", "foreign-company"])("owns proven same-company children before rejecting a %s scope mismatch", async mismatch => {
const f = await fixture({ mutate(agents, tasks) {
if (mismatch === "manager") agents[1]!.reportsTo = "other";
else tasks.push({ id: "unexpected-task", companyId: mismatch === "foreign-company" ? "foreign" : "company",
title: "Unexpected task", projectId: "other-project" });
} });
await expect(f.run()).rejects.toMatchObject({ code: "hire_resource_scope_mismatch" });
expect(f.owned.some(row => row.id === "hired")).toBe(true);
expect(f.owned.some(row => row.id === "unexpected-task")).toBe(mismatch === "project");
expect(sendChatMessage).toHaveBeenCalledTimes(1);
});
it("persists an exact fresh-chat intent before a first send can commit and fail", async () => {
const firstSendError = new ConnectionFailure("api_request_failed"), f = await fixture({ firstSendError });
await expect(f.run()).rejects.toBe(firstSendError);
expect(f.owned).toContainEqual({ kind: "issue", name: "Chat with Lead journey-1-deadbeef", journeyId: "hire-delegate-reuse",
freshChat: { companyId: "company", userId: "user", leadAgentId: "lead", absent: true } });
expect(sendChatMessage).toHaveBeenCalledTimes(1);
});
it("rejects missing actual-user readiness before any child or chat creation intent and before sending", async () => {
const f = await fixture({ mutate(agents) {
agents[0]!.runtimeConfig.aiConnection = { provider: "openrouter", method: "api_key", mode: "delegated", connectionId: "approved", grantId: "grant" };
}, connectionList: { currentUserId: "operator", connections: [] } });
await expect(f.run()).rejects.toMatchObject({ code: "hire_connection_choice_mismatch" });
expect(f.owned).toEqual([]); expect(sendChatMessage).not.toHaveBeenCalled();
});
it("validates the real-user fallback without retaining raw credential-choice references in observation evidence", async () => {
const f = await fixture({ mutate(agents) {
agents[0]!.runtimeConfig.aiConnection = { provider: "openrouter", method: "api_key", mode: "delegated", connectionId: "approved", grantId: "grant" };
agents[1]!.runtimeConfig.aiConnection = { provider: "openrouter", method: "api_key", mode: "responsible_user" };
}, connectionList: { currentUserId: "user", connections: [{ companyId: "company", id: "approved", grantId: "grant", provider: "openrouter", method: "api_key",
ownership: "personal", ownerUserId: "user", isDefault: true, status: "connected" }] } });
await expect(f.run()).rejects.toBe(f.stop);
expect(f.evidence.checks).toContainEqual({ id: "one-attributed-native-hire", passed: true });
const observed = await readFile(path.join(f.directory, "hire-delegate-observation.json"), "utf8");
expect(observed).not.toMatch(/connectionId|grantId|responsibleUserId|approved/);
});
it.each(["api_status_503", "api_request_failed"])("retries a transient %s credential observation without replaying hire or ownership", async code => {
const f = await fixture({ connectionReadError: new ConnectionFailure(code), mutate(agents) {
agents[0]!.runtimeConfig.aiConnection = { provider: "openrouter", method: "api_key", mode: "delegated", connectionId: "approved", grantId: "grant" };
agents[1]!.runtimeConfig.aiConnection = { provider: "openrouter", method: "api_key", mode: "responsible_user" };
}, connectionList: { currentUserId: "user", connections: [{ companyId: "company", id: "approved", grantId: "grant", provider: "openrouter", method: "api_key",
ownership: "personal", ownerUserId: "user", isDefault: true, status: "connected" }] } });
await expect(f.run()).rejects.toBe(f.stop);
expect(f.runReads()).toBe(2); expect(f.owned.filter(row => row.id === "hired")).toHaveLength(1);
expect(vi.mocked(sendChatMessage).mock.calls.filter(([, text]) => text.startsWith("Hire exactly one"))).toHaveLength(1);
});
it.each([new ConnectionFailure("api_status_403"), new JourneyError("failed", "hire_connection_choice_mismatch")])(
"never retries authentication or approval failure in credential observation: %s", async connectionReadError => {
const f = await fixture({ connectionReadError, mutate(agents) {
agents[0]!.runtimeConfig.aiConnection = { provider: "openrouter", method: "api_key", mode: "delegated", connectionId: "approved", grantId: "grant" };
agents[1]!.runtimeConfig.aiConnection = { provider: "openrouter", method: "api_key", mode: "responsible_user" };
}, connectionList: { currentUserId: "user", connections: [{ companyId: "company", id: "approved", grantId: "grant", provider: "openrouter", method: "api_key",
ownership: "personal", ownerUserId: "user", isDefault: true, status: "connected" }] } });
await expect(f.run()).rejects.toBe(connectionReadError);
expect(f.runReads()).toBe(1); expect(sendChatMessage).toHaveBeenCalledTimes(1);
},
);
it("waits for reloaded replies and links, then clicks and reloads the exact saved child", async () => {
let visibleReads = 0, linkReads = 0, clicks = 0, reloads = 0;
const href = "/QA/issues/QA-2";
const reply = { async isVisible() { return ++visibleReads > 2; }, locator(selector: string) {
if (selector === "a[href]") return { async evaluateAll() { return ++linkReads > 1 ? ["https://foreign.test/QA/issues/QA-2", href] : []; } };
expect(selector).toBe(`a[href=${JSON.stringify(href)}]`);
return { first() { return { async click() { clicks++; } }; } };
} };
const page = { locator(selector: string) { expect(selector).toBe('[id="comment-saved"]'); return reply; },
async reload() { reloads++; }, getByTestId(id: string) { expect(id).toBe("issue-detail-header"); return { getByRole(role: string, options: Row) {
expect(role).toBe("heading"); expect(options).toEqual({ name: "Saved review", exact: true }); return {}; } }; } };
expect(await openSavedHireResult(page as any, "https://qa.example.test", "QA", [{ id: "saved", authorAgentId: "lead", companyId: "company", issueId: "chat", createdByRunId: "callback" }], "lead",
{ id: "review", identifier: "QA-2", title: "Saved review" }, Date.now() + 3000, { companyId: "company", chatId: "chat", runId: "callback" })).toBe(true);
expect(visibleReads).toBe(4); expect(linkReads).toBe(2); expect(clicks).toBe(1); expect(reloads).toBe(1);
});
it("ends a missing saved result at the original deadline without a click or replay", async () => {
const page = { locator() { return { async isVisible() { return false; } }; } };
expect(await openSavedHireResult(page as any, "https://qa.example.test", "QA", [{ id: "saved", authorAgentId: "lead", companyId: "company", issueId: "chat", createdByRunId: "callback" }], "lead",
{ id: "review", identifier: "QA-2", title: "Saved review" }, Date.now() + 10, { companyId: "company", chatId: "chat", runId: "callback" })).toBe(false);
});
it.each(["stale", "foreign", "truncated", "late-reply", "wrong-author", "activity-linked", "foreign-run", "contradictory-run", "extra-run", "fallback", "fallback-changed", "fallback-subscription", "fallback-subscription-wrong-method", "creation-backlog-reassigned"])("re-reads %s callback task state while preserving strict scope and seven-run accounting", async snapshot => {
const fallback = snapshot.startsWith("fallback"), subscription = snapshot.startsWith("fallback-subscription");
const provider = subscription ? "codex" : "opencode", accountProvider = subscription ? "openai" : "openrouter", method = subscription ? "subscription" : "api_key";
const campaignId = "journey-1-deadbeef", names = hireDelegateNames(campaignId), binding = fallback
? { provider: accountProvider, method, mode: "delegated", connectionId: "approved", grantId: "grant" } : { connectionId: "approved" };
const lead = { id: "lead", companyId: "company", name: `Lead ${campaignId}`, permissions: { canCreateAgents: true },
adapterConfig: { model: "fixed-model" }, runtimeConfig: { aiConnection: binding } };
const hired = { ...lead, id: "hired", name: names.hire, reportsTo: "lead", adapterType: "paperclip_runner" };
if (fallback) Object.assign(hired, { runtimeConfig: { aiConnection: { provider: accountProvider, method: "api_key", mode: "responsible_user" } } });
const chat = { id: "chat", companyId: "company", title: `Chat with ${lead.name}`, assigneeAgentId: "lead", conversationAgentId: "lead",
conversationUserId: "user", createdByUserId: "user", status: "in_review", conversationState: "waiting" };
const runs: Row[] = [], tasks: Row[] = [], comments: Row[] = [], activity: Row[] = [], stages: number[] = [];
let sent = 0, callbackPoll = 0;
const replyPolls = new Map<string, number>(), nativeReads = new Map<string, number>();
const run = (id: string, issueId: string, agentId: string, metadata: Row = {}) => {
const value = { id, companyId: "company", agentId, issueId, status: "succeeded", runtimeMode: "native",
contextSnapshot: { issueId, paperclipEnvironment: { id: "environment" }, aiConnection: fallback
? { ...binding, ...(snapshot === "fallback-subscription-wrong-method" && agentId !== "lead" ? { method: "api_key" } : {}),
mode: agentId === "lead" ? "delegated" : "responsible_user", responsibleUserId: "user" } : binding, ...metadata },
runnerProfileJson: { nativeExecutionInput: { provider: { kind: provider, model: "fixed-model" } } } };
runs.push(value); return value;
};
const audit = (action: string, entityId: string) => activity.push({ companyId: "company", actorType: "agent", actorId: "lead", agentId: "lead", runId: `lead-${sent}`, action, entityId, entityType: action.startsWith("agent.") ? "agent" : "issue",
details: { assigneeAgentId: snapshot === "creation-backlog-reassigned" && sent === 2 ? "lead" : "hired", assigneeUserId: null,
status: snapshot === "creation-backlog-reassigned" && sent === 2 ? "backlog" : "todo" } });
vi.mocked(sendChatMessage).mockImplementation(async (_page, text) => {
stages.push(runs.length); sent++; callbackPoll = 0;
comments.push({ id: `user-${sent}`, companyId: "company", issueId: "chat", authorUserId: "user", body: text });
run(`lead-${sent}`, "chat", "lead", { source: "issue.comment", wakeCommentId: `user-${sent}` });
if (sent === 1) audit("agent.hire_created", "hired");
else {
const task = { id: `task-${sent}`, identifier: `QA-${sent - 1}`, companyId: "company", title: sent === 2 ? names.checklist : names.review,
assigneeAgentId: "hired", projectId: "project", parentId: null, status: "done" };
tasks.push(task); audit("issue.created", task.id);
if (snapshot === "creation-backlog-reassigned" && sent === 2) activity.push({ companyId: "company", actorType: "agent", actorId: "lead", agentId: "lead",
runId: `lead-${sent}`, action: "issue.reassigned", entityId: task.id, entityType: "issue", details: { previousAssigneeAgentId: "lead", assigneeAgentId: "hired" } });
run(`child-${sent}`, task.id, "hired");
if (snapshot === "extra-run" && sent === 3) run("extra-native", "chat", "lead", { source: "issue.comment", wakeCommentId: `user-${sent}` });
}
});
const marker = `HIRE${campaignId.replace(/[^a-z0-9]/gi, "")}`;
const document = (taskId: string) => ({ id: `doc-${taskId}`, issueId: taskId, companyId: "company", key: "result", updatedAt: "2020-01-01T00:00:00Z", latestRevisionId: `revision-${taskId}`,
body: `Reference: ${taskId === "task-2" ? marker : `REVIEW${marker}`}` });
vi.mocked(readChatOutputDocument).mockImplementation(async (_api, taskId) => {
if (snapshot === "fallback-changed" && taskId === "task-3") Object.assign(hired, { runtimeConfig: { aiConnection: binding } });
return document(taskId);
});
const api = { origin: "https://qa.example.test", async get(route: string) {
if (route === "/api/instance/settings/experimental") return { enableAgentChat: true };
if (route === "/api/companies/company") return { requireBoardApprovalForNewAgents: false };
if (route === "/api/agents/lead") return lead;
if (route === "/api/companies/company/chats/lead") return sent ? chat : null;
if (route === "/api/companies/company/ai-connections") return { currentUserId: "user", connections: [{ companyId: "company", id: "approved", grantId: "grant",
provider: accountProvider, method, ownership: "personal", ownerUserId: "user", isDefault: true, status: "connected" }] };
if (route.endsWith("/installs")) return { connectionId: "approved", installs: [{ companyId: "company", targetType: "agent", targetId: "hired" }] };
if (route === "/api/agents/hired") return hired;
if (route === "/api/companies/company/agents") return sent ? [lead, hired] : [lead];
if (route === "/api/companies/company/issues" && snapshot === "truncated") return Array.from({ length: 500 }, (_, i) => ({ id: `old-${i}`, companyId: "company", title: "Higher priority existing work", projectId: "other", status: "backlog" }));
if (route === "/api/companies/company/issues") return tasks.map(task => ({ ...task,
status: runs.some(row => row.id === `completion-${sent}`) && task.id === `task-${sent}` ? "in_progress" : task.status }));
if (route === "/api/companies/company/activity?agentId=lead&limit=500") return activity;
if (route === "/api/issues/chat/comments?order=asc") {
if (["late-reply", "wrong-author"].includes(snapshot)) for (const callback of runs.filter(run => run.id.startsWith("completion-"))) {
if (comments.some(comment => comment.createdByRunId === callback.id)) continue;
const count = (replyPolls.get(callback.id) ?? 0) + 1; replyPolls.set(callback.id, count);
if (snapshot === "wrong-author" || count > 1) comments.push({ id: `reply-${callback.id}`, companyId: "company", issueId: "chat",
authorAgentId: snapshot === "wrong-author" ? "foreign" : "lead", createdByRunId: callback.id, body: `Saved [result](/QA/issues/QA-${Number(callback.id.split("-")[1]) - 1})` });
}
return structuredClone(comments);
}
if (route === "/api/issues/chat/runs" && sent > 1 && !runs.some(row => row.id === `completion-${sent}`) && ++callbackPoll > 1) {
const callback = run(`completion-${sent}`, "chat", "lead", { wakeReason: "chat_task_completed", wakeSource: "automation", wakeTriggerDetail: "system",
chatCompletionDeliveryIds: [`delivery-${sent}`], chatCompletionUpdates: [{ id: `task-${sent}`, status: "done" }] });
Object.assign(callback, { invocationSource: "automation", triggerDetail: "system" });
if (!["late-reply", "wrong-author"].includes(snapshot)) comments.push({ id: `reply-${sent}`, companyId: "company", issueId: "chat", authorAgentId: "lead", createdByRunId: callback.id, body: `Saved [result](/QA/issues/QA-${Number(callback.id.split("-")[1]) - 1})` });
}
const issueRuns = /^\/api\/issues\/([^/]+)\/runs$/.exec(route);
if (issueRuns) return runs.filter(row => row.issueId === issueRuns[1]
|| activity.some(event => event.entityType === "issue" && event.entityId === issueRuns[1] && event.runId === row.id)).map(row => ({ runId: row.id }));
if (route.startsWith("/api/heartbeat-runs/")) {
const row = runs.find(row => route.endsWith(`/${row.id}`))!;
const read = (nativeReads.get(row.id) ?? 0) + 1; nativeReads.set(row.id, read);
if (snapshot === "foreign-run" && row.id === "child-2") return { ...row, companyId: "foreign" };
if (snapshot === "contradictory-run" && row.id === "lead-2" && read === 2) return { ...row, agentId: "hired", issueId: "task-2",
contextSnapshot: { ...row.contextSnapshot, issueId: "task-2" } };
return row;
}
const output = /^\/api\/issues\/([^/]+)\/documents\/result(\/revisions)?$/.exec(route);
if (output) return output[2] ? [{ id: document(output[1]!).latestRevisionId, body: document(output[1]!).body,
createdByAgentId: "hired", createdByRunId: `child-${output[1]!.split("-")[1]}` }] : document(output[1]!);
if (route.startsWith("/api/issues/")) {
const task = tasks.find(row => route.endsWith(`/${row.id}`));
return snapshot === "foreign" && task ? { ...task, projectId: "foreign" } : task;
}
throw new Error(route);
}, async post() { return { id: "project", companyId: "company", name: names.project }; } };
const visited: string[] = [];
const page: Row = { currentUrl: "https://qa.example.test/QA/chats/lead", async goto(url: string) { page.currentUrl = url; }, async reload() {},
locator(selector: string) {
const comment = comments.find(row => selector === `[id=${JSON.stringify(`comment-${row.id}`)}]`);
return { async isVisible() { return Boolean(comment); }, locator(selector: string) {
const links = [...String(comment?.body ?? "").matchAll(/\]\(([^)]+)\)/g)].map(match => match[1]!);
if (selector === "a[href]") return { async evaluateAll() { return links; } };
const href = links.find(href => selector === `a[href=${JSON.stringify(href)}]`);
return { first() { return { async click() { expect(href).toBeDefined(); visited.push(href!); page.currentUrl = new URL(href!, api.origin).href; } }; } };
} };
}, getByTestId() { return { getByRole() { return {}; } }; } };
const context = { api, page, user: { id: "user" }, company: { id: "company", issuePrefix: "QA" },
agent: { id: "lead", model: "fixed-model", provider, runtimeMode: "native" }, environmentId: "environment" } as unknown as JourneyContext;
const evidence = { checks: [] } as unknown as JourneyEvidence, owned: Row[] = [];
const directory = await mkdtemp(path.join(os.tmpdir(), "hire-completion-")); directories.push(directory);
const execution = hireDelegateReuse(context, { async own(row: Row) { owned.push(row); } } as unknown as TargetSession, campaignId, evidence,
{ deadlineAt: Date.now() + 10_000, directory, capture: async () => {} });
if (["foreign", "wrong-author", "foreign-run", "contradictory-run", "extra-run", "fallback-changed", "fallback-subscription-wrong-method", "creation-backlog-reassigned"].includes(snapshot)) {
const code = snapshot === "creation-backlog-reassigned" ? "hire_initial_assignee_mismatch" : snapshot === "foreign" ? "hire_resource_scope_mismatch" : snapshot === "wrong-author" ? "hire_completion_reply_mismatch"
: snapshot === "extra-run" ? "hire_requested_turn_mismatch" : snapshot === "fallback-changed" ? "hire_native_binding_mismatch"
: snapshot === "fallback-subscription-wrong-method" ? "hire_run_credential_mismatch" : "hire_run_scope_mismatch";
await expect(execution).rejects.toMatchObject({ code });
expect(sendChatMessage).toHaveBeenCalledTimes(["extra-run", "fallback-changed"].includes(snapshot) ? 3 : 2);
if (snapshot === "creation-backlog-reassigned") {
expect(owned.some(row => row.id === "task-2")).toBe(true);
expect(runs.filter(run => run.issueId !== "chat").every(run => run.agentId === "hired")).toBe(true);
}
if (snapshot === "fallback-changed") { expect(stages).toEqual([0, 1, 4]); expect(runs).toHaveLength(7); }
return;
}
await execution;
if (snapshot === "stale") for (const [, text] of vi.mocked(sendChatMessage).mock.calls.slice(1)) {
expect(handoffTargets(text)).toEqual({ agents: ["hired"], projects: ["project"] });
}
expect(stages).toEqual([0, 1, 4]); expect(runs).toHaveLength(7); expect(sendChatMessage).toHaveBeenCalledTimes(3);
expect(visited).toEqual(["/QA/issues/QA-1", "/QA/issues/QA-2"]);
expect(evidence.checks).toContainEqual({ id: "delegated-result-visible-after-reload", passed: true });
expect(owned.find(row => row.id === "chat")?.freshChat).toEqual({ companyId: "company", userId: "user", leadAgentId: "lead", absent: true });
});
it.each(["earlier-handoff", "foreign-company", "foreign-chat", "wrong-run", "foreign-origin"])("does not use %s as the callback's reloaded result", async mismatch => {
const href = mismatch === "foreign-origin" ? "https://foreign.test/QA/issues/QA-2" : "/QA/issues/QA-2";
let clicked = false;
const comment = { id: "old", companyId: mismatch === "foreign-company" ? "foreign" : "company",
issueId: mismatch === "foreign-chat" ? "foreign" : "chat", authorAgentId: "lead",
createdByRunId: ["earlier-handoff", "wrong-run"].includes(mismatch) ? "handoff-run" : "callback" };
const reply = { async isVisible() { return true; }, locator(selector: string) {
return selector === "a[href]" ? { async evaluateAll() { return [href]; } }
: { first() { return { async click() { clicked = true; } }; } };
} };
const page = { locator() { return reply; }, async reload() {}, getByTestId() { return { getByRole() { return {}; } }; } };
expect(await openSavedHireResult(page as any, "https://qa.example.test", "QA", [comment], "lead",
{ id: "review", identifier: "QA-2", title: "Review" }, Date.now() + 10,
{ companyId: "company", chatId: "chat", runId: "callback" })).toBe(false);
expect(clicked).toBe(false);
});
@@ -0,0 +1,270 @@
import { mkdir, writeFile } from "node:fs/promises";
import { isDeepStrictEqual } from "node:util";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { buildAgentMentionHref, buildProjectMentionHref, parseAgentMentionHref, parseProjectMentionHref } from "../../../packages/shared/src/project-mentions.js";
import { sendChatMessage, readChatOutputDocument, type ChatIssue, type ChatRun } from "../chat-flow.js";
import { assertChatHire, mutableIssueSnapshot } from "../chat-hardening.js";
import { ConnectionFailure } from "../connection-config.js";
import { assertHireAuditCoverage, assertHireBinding, assertHireDocument, assertHireRuns, assertHireTaskCreation, hireDelegateNames, hireResultLinkMatches, hireRunIssueId, observedHireResources, readHireConnectionChoice, type HireScope } from "./hire-delegate-evidence.js";
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
import { sendHireHandoffMessage } from "./hire-delegate-composer.js";
type Row = Record<string, any>;
type Agent = Parameters<typeof assertChatHire>[0]["agents"][number] & Row;
type Task = ChatIssue & Row;
type Run = ChatRun & Row;
interface Options { deadlineAt: number; directory: string; capture(id: string, label: string, file: string): Promise<void> }
class HireReadTransportError extends Error {}
export function hireHandoffMentions(scope: Pick<HireScope, "companyId" | "leadId" | "projectId" | "campaignId">, hiredId: string, hired: Row, project: Row) {
if (![scope?.companyId, scope?.leadId, scope?.projectId, scope?.campaignId].every(value => typeof value === "string" && value.length > 0)) {
throw new JourneyError("failed", "hire_handoff_context_unverified");
}
const names = hireDelegateNames(scope.campaignId);
if (!hired || !project || typeof hiredId !== "string" || !hiredId || hired.id !== hiredId || hired.id === scope.leadId
|| hired.companyId !== scope.companyId || hired.reportsTo !== scope.leadId || hired.name !== names.hire
|| typeof project.id !== "string" || !project.id || project.id !== scope.projectId
|| project.companyId !== scope.companyId || project.name !== names.project) {
throw new JourneyError("failed", "hire_handoff_context_unverified");
}
const agentHref = buildAgentMentionHref(hiredId, hired.icon ?? null), projectHref = buildProjectMentionHref(project.id, project.color ?? null);
if (parseAgentMentionHref(agentHref)?.agentId !== hiredId || parseProjectMentionHref(projectHref)?.projectId !== project.id) {
throw new JourneyError("failed", "hire_handoff_context_unverified");
}
return Object.freeze({ teammate: `[@${hired.name}](${agentHref})`, project: `[@${project.name}](${projectHref})` });
}
export async function hireDelegateReuse(context: JourneyContext, session: TargetSession, campaignId: string, evidence: JourneyEvidence, options: Options) {
const { api, page } = context, agent = context.agent;
if (!agent || agent.runtimeMode !== "native" || !context.environmentId) throw new JourneyError("unsupported", "native_agent_and_environment_required");
const features = await api.get<Row>("/api/instance/settings/experimental");
if (features.enableAgentChat !== true) throw new JourneyError("unsupported", "agent_chat_required");
const company = await api.get<Row>(`/api/companies/${context.company.id}`);
if (company.requireBoardApprovalForNewAgents !== false) throw new JourneyError("unsupported", "automatic_hiring_policy_required");
const lead = await api.get<Row>(`/api/agents/${agent.id}`), names = hireDelegateNames(campaignId);
if (lead.id !== agent.id || lead.companyId !== context.company.id || lead.permissions?.canCreateAgents !== true || !lead.runtimeConfig?.aiConnection) {
throw new JourneyError("unsupported", "owned_hiring_agent_required");
}
const connectionChoice = await readHireConnectionChoice(api, { companyId: context.company.id,
responsibleUserId: context.user.id, binding: lead.runtimeConfig.aiConnection });
const check = (id: string, passed = true) => { evidence.checks.push({ id, passed }); if (!passed) throw new JourneyError("failed", id.replaceAll("-", "_")); };
await mkdir(options.directory, { recursive: true, mode: 0o700 });
const retain = (name: string, value: unknown) => writeFile(path.join(options.directory, name), JSON.stringify(value, null, 2), { mode: 0o600 });
await session.own({ kind: "project", name: names.project, journeyId: "hire-delegate-reuse" });
const project = await api.post<Row>(`/api/companies/${context.company.id}/projects`, { name: names.project, description: "Disposable native hiring and delegation verification." });
if (project.companyId !== context.company.id || !project.id || project.name !== names.project) throw new JourneyError("failed", "hire_project_binding_mismatch");
await session.own({ kind: "project", name: names.project, id: project.id, journeyId: "hire-delegate-reuse" });
const chatPath = `/api/companies/${context.company.id}/chats/${agent.id}`;
if (await api.get(chatPath)) throw new JourneyError("unsupported", "fresh_lead_chat_required");
const freshChat = { companyId: context.company.id, userId: context.user.id, leadAgentId: agent.id, absent: true as const };
await session.own({ kind: "issue", name: `Chat with ${lead.name}`, journeyId: "hire-delegate-reuse", freshChat });
const chatUrl = `${api.origin}/${context.company.issuePrefix}/chats/${agent.id}`;
await page.goto(chatUrl, { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
let scope: HireScope | undefined, hiredId: string | undefined;
let hiredConnectionBinding: unknown;
const known = new Set<string>();
const taskNames = [names.checklist, names.review];
const readObserved = async <T>(route: string): Promise<T> => {
try { return await api.get<T>(route); }
catch (error) {
if (error instanceof ConnectionFailure && (error.message === "api_request_failed"
|| /^api_status_(408|429|502|503|504)$/.test(error.message))) {
throw new HireReadTransportError("hire_observation_transport_unavailable");
}
throw error;
}
};
const readRuns = async (issueId: string) => {
const summaries = await readObserved<Row[]>(`/api/issues/${issueId}/runs`);
if (!Array.isArray(summaries) || summaries.length > 7) throw new JourneyError("failed", "hire_run_limit_mismatch");
return Promise.all(summaries.map(async row => {
if (typeof row.runId !== "string" || !row.runId) throw new JourneyError("failed", "hire_run_scope_mismatch");
const run = await readObserved<Run>(`/api/heartbeat-runs/${row.runId}`);
if (run.id !== row.runId) throw new JourneyError("failed", "hire_run_scope_mismatch");
return run;
}));
};
const observe = async () => {
const chat = await readObserved<Row | null>(chatPath);
if (!chat) return undefined;
if (chat.companyId !== context.company.id || chat.assigneeAgentId !== agent.id
|| chat.conversationAgentId !== agent.id || chat.conversationUserId !== context.user.id || chat.createdByUserId !== context.user.id) throw new JourneyError("failed", "hire_chat_scope_mismatch");
scope ??= { companyId: context.company.id, leadId: agent.id, chatId: chat.id, projectId: project.id, campaignId,
model: agent.model, provider: agent.provider, environmentId: context.environmentId!, connectionBinding: lead.runtimeConfig.aiConnection, connectionChoice,
origin: api.origin, issuePrefix: context.company.issuePrefix };
if (chat.id !== scope.chatId) throw new JourneyError("failed", "hire_chat_replaced");
if (!known.has(chat.id)) { await session.own({ kind: "issue", name: chat.title, id: chat.id, journeyId: "hire-delegate-reuse", freshChat }); known.add(chat.id); }
const leadProjection = await readRuns(chat.id);
const leadRuns = leadProjection.filter(run => run.companyId === scope!.companyId
&& run.agentId === scope!.leadId && hireRunIssueId(run) === chat.id);
const activity = await readObserved<Row[]>(`/api/companies/${context.company.id}/activity?agentId=${agent.id}&limit=500`);
assertHireAuditCoverage(activity, leadRuns);
const agents: Agent[] = [], tasks: Task[] = [], seen = new Set<string>();
let scopeMismatch = false;
for (const event of activity) {
if (event.companyId !== scope.companyId || event.actorType !== "agent" || event.actorId !== agent.id || event.agentId !== agent.id
|| !leadRuns.some(run => run.id === event.runId && run.companyId === scope!.companyId && run.agentId === agent.id
&& hireRunIssueId(run) === chat.id && run.runtimeMode === "native")) continue;
const kind = ["agent.hire_created", "agent.created"].includes(event.action) ? "agent"
: ["issue.created", "issue.child_created"].includes(event.action) ? "issue" : undefined;
if (!kind || event.entityType !== kind) continue;
if (typeof event.entityId !== "string" || !event.entityId) throw new JourneyError("failed", "hire_resource_scope_mismatch");
const key = `${kind}:${event.entityId}`;
if (seen.has(key)) continue;
if (seen.size >= 50) throw new JourneyError("failed", "hire_resource_coverage_exceeded");
seen.add(key);
const row = await readObserved<Row>(`/api/${kind === "agent" ? "agents" : "issues"}/${encodeURIComponent(event.entityId)}`);
if (row.id !== event.entityId) throw new JourneyError("failed", "hire_resource_scope_mismatch");
if (kind === "agent") agents.push(row as Agent); else tasks.push(row as Task);
const observed = observedHireResources(scope, { agents: kind === "agent" ? [row] : [], tasks: kind === "issue" ? [row] : [], activity, leadRuns });
scopeMismatch ||= observed.scopeMismatch;
for (const resource of [...observed.agents.map(row => ({ kind: "agent" as const, id: row.id, name: row.name })),
...observed.tasks.map(row => ({ kind: "issue" as const, id: row.id, name: row.title }))]) {
if (!known.has(resource.id)) { await session.own({ ...resource, journeyId: "hire-delegate-reuse" }); known.add(resource.id); }
}
}
const owned = observedHireResources(scope, { agents, tasks, activity, leadRuns });
if (scopeMismatch || owned.scopeMismatch) throw new JourneyError("failed", "hire_resource_scope_mismatch");
if (owned.agents.length) {
await readHireConnectionChoice({ get: readObserved }, { companyId: context.company.id, responsibleUserId: context.user.id,
binding: lead.runtimeConfig.aiConnection, hiredId: owned.agents[0]!.id });
const verified = assertHireBinding(scope, owned.agents, hiredId);
if (hiredConnectionBinding === undefined) hiredConnectionBinding = Object.freeze(structuredClone(verified.runtimeConfig.aiConnection));
else if (!isDeepStrictEqual(verified.runtimeConfig.aiConnection, hiredConnectionBinding)) throw new JourneyError("failed", "hire_native_binding_mismatch");
hiredId = verified.id;
}
assertHireTaskCreation(scope, hiredId, owned.tasks, activity, leadRuns);
const projectedRuns = [...leadProjection, ...(await Promise.all(owned.tasks.map(task => readRuns(task.id)))).flat()];
const canonical = new Map<string, Run>();
for (const run of projectedRuns) {
const previous = canonical.get(run.id);
if (previous && (previous.companyId !== run.companyId || previous.agentId !== run.agentId
|| hireRunIssueId(previous) !== hireRunIssueId(run))) throw new JourneyError("failed", "hire_run_scope_mismatch");
if (!previous) canonical.set(run.id, run);
}
const runs = [...canonical.values()];
const comments = await readObserved<Row[]>(`/api/issues/${chat.id}/comments?order=asc`);
const requestIds = comments.filter(row => row.authorUserId === context.user.id).map(row => row.id);
const completionRepliesReady = assertHireRuns(scope, hiredId, owned.tasks, runs, undefined, requestIds, comments);
for (const run of projectedRuns) assertHireRuns(scope, hiredId, owned.tasks, [run], undefined, requestIds, comments);
await retain("hire-delegate-observation.json", { scope: { ...scope, connectionBinding: undefined, connectionChoice: undefined }, chat, project,
agents: owned.agents.map(row => ({ id: row.id, name: row.name, reportsTo: row.reportsTo })), tasks: owned.tasks,
runs: runs.map(run => ({ id: run.id, issueId: hireRunIssueId(run), agentId: run.agentId, status: run.status })) });
return { chat, ...owned, runs, requestIds, comments, completionRepliesReady, allAgents: [lead as Agent, ...agents], activity };
};
const waitForIdle = async (count: number) => {
let awaitingReply = false;
while (Date.now() < options.deadlineAt) {
try {
const state = await observe();
awaitingReply = state?.completionRepliesReady === false;
if (state && state.completionRepliesReady && state.runs.length === count && state.runs.every(run => run.status === "succeeded")
&& state.chat.status === "in_review" && state.chat.conversationState === "waiting") {
assertHireRuns(scope!, hiredId, state.tasks, state.runs, count, state.requestIds, state.comments);
return state;
}
} catch (error) {
if (!(error instanceof HireReadTransportError)) throw error;
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
throw new JourneyError("failed", awaitingReply ? "hire_completion_reply_missing" : "hire_chat_not_completed");
};
const turn = async (text: string, count: number, handoff?: ReturnType<typeof hireHandoffMentions>) => {
await page.goto(chatUrl, { waitUntil: "domcontentloaded" });
const before = await api.get<Row | null>(chatPath);
const prior = before ? await api.get<Row[]>(`/api/issues/${before.id}/comments?order=asc`) : [];
if (handoff) await sendHireHandoffMessage(page, text, handoff, options.deadlineAt);
else await sendChatMessage(page, text);
const state = await waitForIdle(count);
const comments = await api.get<Row[]>(`/api/issues/${state.chat.id}/comments?order=asc`);
const added = comments.filter(row => row.authorUserId === context.user.id && !prior.some(old => old.id === row.id));
check("hire-user-messages-stored-once", added.length === 1 && added[0].body === text);
return state;
};
await session.own({ kind: "agent", name: names.hire, journeyId: "hire-delegate-reuse" });
let state = await turn(`Hire exactly one teammate named ${names.hire}, reporting to you, using your native runner, model, and available AI connection. Do not create or start any tasks yet. Tell me when this teammate is ready.`, 1);
const hired = assertHireBinding(scope!, state.agents);
check("one-attributed-native-hire", state.tasks.length === 0);
const handoff = hireHandoffMentions(scope!, hiredId!, hired, project);
const marker = `HIRE${campaignId.replace(/[^a-z0-9]/gi, "")}`;
await session.own({ kind: "issue", name: names.checklist, journeyId: "hire-delegate-reuse" });
state = await turn(`Have the existing ${handoff.teammate} write a concise launch checklist as a saved Paperclip document with the line "Reference: ${marker}". Create exactly one task titled ${names.checklist} in ${handoff.project}, already assigned to that teammate when it is created. Link the result here and let the teammate complete it. Do not hire another agent.`, 4, handoff);
const first = state.tasks.find(task => task.title === names.checklist);
check("first-delegated-task-complete", typeof first?.identifier === "string" && state.tasks.length === 1);
const readOutput = async (task: Row, token: string, runs: Row[]) => {
const document = await readChatOutputDocument(api, task.id, token);
const revisions = await api.get<Row[]>(`/api/issues/${task.id}/documents/${encodeURIComponent(document.key)}/revisions`);
assertHireDocument(scope!, task, document, revisions, runs, token, hired.id);
return document;
};
const firstOutput = await readOutput(first!, marker, state.runs);
const originalTask = mutableIssueSnapshot(await api.get<Task>(`/api/issues/${first!.id}`));
check("checklist-authored-by-hired-run");
await session.own({ kind: "issue", name: names.review, journeyId: "hire-delegate-reuse" });
state = await turn(`Have the existing ${handoff.teammate} review the checklist on ${first!.identifier} and write a separate saved review document with the line "Reference: REVIEW${marker}". Create exactly one task titled ${names.review} in ${handoff.project}, already assigned to that same teammate when it is created. Include the actual checklist in the handoff. Preserve the original checklist and task. Link the completed review here. Do not hire another agent.`, 7, handoff);
const second = state.tasks.find(task => task.title === names.review);
check("same-teammate-reused", typeof second?.identifier === "string" && state.tasks.length === 2 && state.tasks.every(task => taskNames.includes(task.title)));
const reviewOutput = await readOutput(second!, `REVIEW${marker}`, state.runs);
check("review-authored-by-same-hired-run");
check("original-checklist-preserved", isDeepStrictEqual(await api.get(`/api/issues/${first!.id}/documents/${encodeURIComponent(firstOutput.key)}`), firstOutput)
&& isDeepStrictEqual(mutableIssueSnapshot(await api.get<Task>(`/api/issues/${first!.id}`)), originalTask));
if (hiredConnectionBinding === undefined || !isDeepStrictEqual(
state.agents.find(row => row.id === hired.id)?.runtimeConfig?.aiConnection, hiredConnectionBinding)) {
throw new JourneyError("failed", "hire_native_binding_mismatch");
}
assertChatHire({ agents: state.allAgents, leadId: agent.id, hireName: names.hire, hiredId: hired.id,
connectionId: connectionChoice?.connectionId ?? lead.runtimeConfig.aiConnection.connectionId, binding: hiredConnectionBinding,
taskIds: [first!.id, second!.id], tasks: state.tasks, runs: state.runs });
assertHireRuns(scope!, hired.id, state.tasks, state.runs, 7, state.requestIds, state.comments);
const comments = await api.get<Row[]>(`/api/issues/${state.chat.id}/comments?order=asc`);
assertHireRuns(scope!, hired.id, state.tasks, state.runs, 7, state.requestIds, comments);
let resultsVisible = true;
for (const task of [first!, second!]) {
const callback = state.runs.find(run => {
const updates = run.contextSnapshot?.chatCompletionUpdates;
return run.contextSnapshot?.wakeReason === "chat_task_completed" && Array.isArray(updates) && updates[0]?.id === task.id;
});
if (!callback) throw new JourneyError("failed", "hire_completion_binding_mismatch");
await page.goto(chatUrl, { waitUntil: "domcontentloaded" }); await page.reload();
if (!await openSavedHireResult(page, api.origin, context.company.issuePrefix, comments, agent.id, task,
options.deadlineAt, { companyId: scope!.companyId, chatId: scope!.chatId, runId: callback.id })) {
resultsVisible = false; break;
}
}
check("delegated-result-visible-after-reload", resultsVisible);
check("configured-model-and-environment");
await retain("hire-delegate-completed.json", { hiredId: hired.id, firstTaskId: first!.id, secondTaskId: second!.id,
firstDocument: firstOutput, reviewDocument: reviewOutput, runIds: state.runs.map(run => run.id),
turns: state.runs.map(run => ({ id: run.id, issueId: hireRunIssueId(run), agentId: run.agentId,
wakeReason: run.contextSnapshot?.wakeReason, wakeCommentId: run.contextSnapshot?.wakeCommentId,
chatCompletionDeliveryIds: run.contextSnapshot?.chatCompletionDeliveryIds, chatCompletionUpdates: run.contextSnapshot?.chatCompletionUpdates })),
completionReplies: state.comments.filter(comment => state.runs.some(run => run.id === comment.createdByRunId
&& run.contextSnapshot?.wakeReason === "chat_task_completed")).map(comment => ({ id: comment.id, createdByRunId: comment.createdByRunId })) });
await options.capture("hire-delegate-reuse", "Hired teammate completed and reused", "hire-delegate-reuse.png");
}
export async function openSavedHireResult(page: Page, origin: string, issuePrefix: string, comments: Row[], leadId: string, task: Row, deadlineAt: number,
callback: { companyId: string; chatId: string; runId: string }) {
while (Date.now() < deadlineAt) {
for (const comment of comments.filter(row => row.authorAgentId === leadId && !row.authorUserId
&& row.companyId === callback.companyId && row.issueId === callback.chatId && row.createdByRunId === callback.runId)) {
const reply = page.locator(`[id=${JSON.stringify(`comment-${comment.id}`)}]`);
if (!await reply.isVisible()) continue;
const links = await reply.locator("a[href]").evaluateAll(elements => elements.map(element => element.getAttribute("href")!));
const href = links.find(href => hireResultLinkMatches({ origin, issuePrefix }, task, href));
if (!href) continue;
await reply.locator(`a[href=${JSON.stringify(href)}]`).first().click({ timeout: Math.max(1, deadlineAt - Date.now()) });
await page.reload();
await expect(page).toHaveURL(url => url.origin === origin
&& [task.id, task.identifier].some(id => url.pathname === `/${issuePrefix}/issues/${id}`), { timeout: Math.max(1, deadlineAt - Date.now()) });
await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: task.title, exact: true }))
.toBeVisible({ timeout: Math.max(1, deadlineAt - Date.now()) });
return true;
}
await new Promise(resolve => setTimeout(resolve, Math.min(200, Math.max(1, deadlineAt - Date.now()))));
}
return false;
}
+2 -2
View File
@@ -108,8 +108,8 @@ export async function launchProductJourneys(args: string[], repositoryRoot: stri
try { sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repositoryRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { /* Packaged harnesses may not include Git metadata. */ }
const source = resolveRunnerE2ESource({ sha, ref: null, workflowRunUrl: null });
const definitionHash = createHash("sha256").update(JSON.stringify(productJourneys));
for (const name of ["catalog.ts", "task-properties.ts", "task-search-filter.ts", "task-search-evidence.ts", "basic-flows.ts", "basic-evidence.ts", "task-document-flow.ts", "task-document-evidence.ts", "project-context-flow.ts", "project-context-readback.ts", "project-context-evidence.ts", "agent-flows.ts", "active-followup-flow.ts", "active-followup-evidence.ts", "stop-redirect-evidence.ts", "clarification-flow.ts", "clarification-evidence.ts", "../question-resume-ui.ts", "../question-resume-scoring.ts", "../runtime-question-readiness.ts", "../chat-flow.ts", "../continuation-readiness.ts", "../continuation-cases.ts", "success-contract.ts", "product-screenshot.ts", "cloud-onboarding.ts", "onboarding-connect.ts", "onboarding-first-task.ts", "workspace-deferral.ts", "onboarding-pre-acceptance.ts", "artifact-evidence.ts", "fixtures.ts", "launch.ts", "types.ts", "collection.ts", "local-target.ts"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name)));
for (const file of ["catalog.ts", "api.ts", "run-observations.ts", "native-task-reply.ts", "native-event-envelope.ts", "user-actions.ts", "plan-revision-flow.ts", "skill-studio-flow.ts", "protected-action-flow.ts", "story-artifact.ts", "stop-redirect-flow.ts", "everyday-cases.ts", "everyday-delivery.ts", "everyday-decisions.ts", "connection-guidance-evidence.ts", "everyday-artifact.py"]) definitionHash.update(await readFile(path.join(import.meta.dirname, "..", file)));
for (const name of ["catalog.ts", "task-properties.ts", "task-search-filter.ts", "task-search-evidence.ts", "basic-flows.ts", "basic-evidence.ts", "task-document-flow.ts", "task-document-evidence.ts", "project-context-flow.ts", "project-context-readback.ts", "project-context-evidence.ts", "agent-flows.ts", "hire-delegate-flow.ts", "hire-delegate-composer.ts", "hire-delegate-evidence.ts", "active-followup-flow.ts", "active-followup-evidence.ts", "stop-redirect-evidence.ts", "clarification-flow.ts", "clarification-evidence.ts", "../question-resume-ui.ts", "../question-resume-scoring.ts", "../runtime-question-readiness.ts", "../chat-flow.ts", "../continuation-readiness.ts", "../continuation-cases.ts", "success-contract.ts", "product-screenshot.ts", "cloud-onboarding.ts", "onboarding-connect.ts", "onboarding-first-task.ts", "workspace-deferral.ts", "onboarding-pre-acceptance.ts", "artifact-evidence.ts", "fixtures.ts", "launch.ts", "types.ts", "collection.ts", "local-target.ts", "../../../ui/src/lib/markdown-reply-content.ts", "../../../ui/src/lib/issue-reference.ts", "../../../ui/src/lib/company-routes.ts", "../../../ui/package.json"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name)));
for (const file of ["catalog.ts", "api.ts", "run-observations.ts", "native-task-reply.ts", "native-event-envelope.ts", "user-actions.ts", "plan-revision-flow.ts", "skill-studio-flow.ts", "protected-action-flow.ts", "story-artifact.ts", "stop-redirect-flow.ts", "everyday-cases.ts", "everyday-delivery.ts", "chat-hardening.ts", "everyday-decisions.ts", "connection-guidance-evidence.ts", "everyday-artifact.py"]) definitionHash.update(await readFile(path.join(import.meta.dirname, "..", file)));
definitionHash.update(runnerSuites.flatMap(suite => suite.tasks).find(task => task.id === "ask-question")!.buildPrompt("definition"));
const suiteDefinitionHash = definitionHash.digest("hex");
const expected = config.journeys.map(id => `product-journeys.${config.id}.${id === "cloud-onboarding" ? config.modelProfileId : isAgentJourney(id) ? "configured-agent" : "browser"}.${config.executionEnvironment}.${id}`);
@@ -1,6 +1,6 @@
/** Closed, target-independent success requirements used by dispatch and retained evidence. */
export const agentJourneyRuns = { "ask-question": 1, "plan-revise-accept": 3, "build-revise": 2,
"followup-while-running": 2, "protected-actions": 4, "create-skill-studio": 1, "stop-redirect": 3, "question-answer-resume": 3 } as const;
"followup-while-running": 2, "protected-actions": 4, "create-skill-studio": 1, "stop-redirect": 3, "question-answer-resume": 3, "hire-delegate-reuse": 7 } as const;
export function agentJourneyRunCountValid(id: string, count: unknown): boolean {
if (!Object.hasOwn(agentJourneyRuns, id) || !Number.isSafeInteger(count)) return false;
@@ -26,5 +26,6 @@ export const journeyRequiredChecks: Record<string, readonly string[]> = {
"stop-redirect": ["checkpoint-artifact-verified", "active-work-stopped", "stop-native-terminal-retained", "new-direction-completed", "stopped-run-remained-terminal",
"stopped-run-no-later-events", "stop-preserved-download", "redirect-consumed-once", "user-reply-stored-once", "configured-model-and-environment"],
"followup-while-running": ["followup-native-command-active", "followup-stored-once", "followup-consumed-once", "followup-final-answer-persisted", "followup-one-or-two-native-runs", "configured-model-and-environment"],
"hire-delegate-reuse": ["one-attributed-native-hire", "first-delegated-task-complete", "checklist-authored-by-hired-run", "same-teammate-reused", "review-authored-by-same-hired-run", "original-checklist-preserved", "hire-user-messages-stored-once", "delegated-result-visible-after-reload", "configured-model-and-environment"],
"cloud-onboarding": ["fresh-workspace", "ui-connection-and-native-agent", "first-task-complete", "downloadable-artifact-verified", "native-trace-verified"],
};
+3 -2
View File
@@ -3,7 +3,7 @@ import type { ConnectionApi } from "../connection-target.js";
export type JourneyOutcome = "passed" | "failed" | "blocked" | "unsupported";
export type JourneyId = "task-properties" | "task-create" | "project-task-context" | "task-document-persistence" | "ask-question" | "cloud-onboarding"
| "question-answer-resume" | "task-search-filter" | "followup-while-running" | "plan-revise-accept" | "build-revise" | "protected-actions" | "create-skill-studio" | "stop-redirect";
| "question-answer-resume" | "task-search-filter" | "followup-while-running" | "plan-revise-accept" | "build-revise" | "protected-actions" | "create-skill-studio" | "stop-redirect" | "hire-delegate-reuse";
export type TargetMode = "managed-self-hosted" | "existing-cloud" | "new-cloud";
export interface DeploymentIdentity {
kind: "self-hosted" | "cloud";
@@ -111,7 +111,8 @@ export interface TargetSession {
/** Publish the observed finalization failure without releasing resources or leases. */
checkpointEvidence?(input?: TargetLifecycleInput): Promise<void>;
/** Durable intent before creation; receipt immediately after creation. */
own(resource: { kind: "issue" | "project" | "agent" | "connection" | "tool-connection" | "skill"; name: string; id?: string; journeyId?: JourneyId }): Promise<void>;
own(resource: { kind: "issue" | "project" | "agent" | "connection" | "tool-connection" | "skill"; name: string; id?: string; journeyId?: JourneyId;
freshChat?: { companyId: string; userId: string; leadAgentId: string; absent: true } }): Promise<void>;
}
export interface TargetProvider {
version: 1;