mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
fix(evals): grade full cohort 3 false failures on saved state, read receipts and rendered links (#21008)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The chat eval catalog runs real chat journeys against real agents and grades saved product state > - Full cohort 3 failed six cases where the product behaved correctly and the eval was wrong > - A wrong failure hides real product failures in the noise, so each check must accept valid behavior > - Each check must still fail the broken product behavior it exists to catch > - This pull request replaces those checks with checks of saved state, read receipts and rendered links > - The benefit is fewer false failures, with no case made easier ## Linked Issues or Issue Description Refs #21000 **What happened?** - `projects.project-work`, `tasks.private-task`, `status-cards.other-authors-card` and part of `task-coordination.delegate-with-context`: the CEO created a task with no assignee, so the CEO got a queued run on it. The CEO then reassigned the task. The server cancelled the queued run before it started (`issue_reassigned` or `issue_assignee_changed`). The early poll reported this as "Observed provider run failed", and the run policy counted a never-started legacy run as a failed native run. - `budgets-costs.repair-cost-recording`: after the retry, the chat run re-read the original run record with `saveResponse`. The check accepted only an exact whole-input digest, and this input had another shape. - `secrets.personal-secret`: the reply linked My secrets and said "Enter the value only there, never in chat". `solicitsChatCredential` read "never in chat" as a request to send the value in chat. - `companies.import-preview`: the reply linked `/COM/agents/morgan-package`. The check accepted only `/agents/<uuid>`. - `cases.close-case`: the closure was correct. The result turn said "Case COM-C1 is saved as closed". A keyword pattern did not match that sentence. **Expected behavior** Each check passes the recorded valid behavior and a reworded valid variant. Each check fails the broken behavior. **Steps to reproduce** Grade the retained cohort-3 evidence with the old checks. They fail. The calibration tests in this pull request use the same recorded shapes. ## What Changed - Run policy: a new rule admits a queued run that the server retired before it started because its task changed assignee. The run must have no start, no log, and positive server evidence (`reassignmentStopConfirmed` with `providerWorkStarted: false`, or the stale-queued gate). The task must not be the source chat. A later run by another agent on the same task must exist. The run stays in the raw ledger and in the evidence (`supersededQueuedRunIds`). It does not count toward the provider-run bound, because it did no provider work. A case can have at most one such run (`catalog-superseded-queued-runs`). The early poll defers it to the grade instead of failing at once. Cases read the ledger without it. Other cancellations stay failures. - `budgets-costs.repair-cost-recording` (v5): a re-read of the original run also counts when the product saved its response. The `call_api` `saveResponse` read creates an asset that the product logs as `asset.created` by that run. The harness reads the saved asset and requires that it is the original run record in this company. - `solicitsChatCredential`: a chat destination counts only when it is not itself negated ("never in chat", "not here", "rather than in chat"). This is a scope rule, not a new phrase exception. "Paste it here" and mixed requests still fail. `secrets.personal-secret` is v18. - `companies.import-preview` (v3): new `agentRouteNames` accepts `/<prefix>/agents/<id or url key>[/<tab>]` that names the imported agent. It rejects the existing Morgan, other companies and other origins. - `cases.close-case` (v2): the result turn is graded on its own saved replies. Their run must read the saved case (`read_case` or `GET /api/cases/{id}`, bound by the redaction-safe receipt). The reply must render a link to that case. The case must be done. The wording is not matched. `api-read-receipts` now binds the dedicated `read_case` tool. - The four reassignment cases get new oracle versions. Descriptors are updated. No scenario yaml changed, so the TestOps manifest does not change. ## Verification - `npx vitest run --config tests/runner-e2e/vitest.config.ts` on the 17 affected test files: 516 tests pass. The files include `chat-catalog-run-policy`, `chat-flow`, `non-execution-wake`, `chat-catalog-consistency`, `chat-catalog-fingerprint`, `identity-accounting`, `external-credential-language`, `external-personal-secret`, `identity-portability`, `integrator-case-close`, `api-read-receipts` and the other secret-language users. - Each change has calibration tests: the recorded valid shape, a reworded valid shape, and broken shapes (no successor run, a started or other cancellation, churn; another run's saved record; "paste it here"; the existing Morgan's route; an unread or unlinked result turn). - `npx tsc -p tests/runner-e2e/tsconfig.json --noEmit` passes. - `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check` reports no change. - One paid run on a combined branch with the companion harness pull request. The result is in a comment on this pull request. ## Risks - The superseded-run rule trusts the successor run as proof of reassignment. A case oracle still grades the task assignee and the successor's own outcome. - The close-case result turn no longer checks the reply's words. A reply that reads the closed case and links it but states the wrong result passes this one check. The first-turn premature-claim check and the negative-case check do not change. - The credential grader change also affects other secret cases. It only removes the negated-destination false positive. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b033cc124d
commit
f1aef0cb58
23 files changed
+363
-46
No files matched your search
@@ -1193,7 +1193,7 @@
|
||||
"chat-accounting-fault.ts",
|
||||
"chat-archive-storage-fault.ts"
|
||||
],
|
||||
"oracleVersion": "paperclip.chat-catalog.repair-cost-recording.structural.v4",
|
||||
"oracleVersion": "paperclip.chat-catalog.repair-cost-recording.structural.v5",
|
||||
"expectedRunCount": 3,
|
||||
"expectedHumanMessageCount": 2,
|
||||
"turnTimeoutMs": 120000,
|
||||
@@ -1349,12 +1349,14 @@
|
||||
"scenarioId": "cases.close-case",
|
||||
"sourceFiles": [
|
||||
"chat-catalog-domains/integrator-case-close.ts",
|
||||
"chat-catalog-domains/api-read-receipts.ts",
|
||||
"run-observations.ts",
|
||||
"chat-catalog-domains/integrator-support.ts",
|
||||
"chat-catalog-domains/internal-support.ts",
|
||||
"chat-catalog-domains/internal-definition.ts",
|
||||
"chat-scenarios/cases.yaml"
|
||||
],
|
||||
"oracleVersion": "paperclip.chat-catalog.integrator.v1",
|
||||
"oracleVersion": "paperclip.chat-catalog.integrator.close-case.v2",
|
||||
"expectedRunCount": 7,
|
||||
"expectedHumanMessageCount": 3,
|
||||
"minimumRunCount": 6,
|
||||
@@ -2097,7 +2099,7 @@
|
||||
"chat-catalog-domains/internal-support.ts",
|
||||
"chat-scenarios/companies.yaml"
|
||||
],
|
||||
"oracleVersion": "paperclip.chat-catalog.identity.import-preview.v2",
|
||||
"oracleVersion": "paperclip.chat-catalog.identity.import-preview.v3",
|
||||
"expectedRunCount": 2,
|
||||
"expectedHumanMessageCount": 2,
|
||||
"turnTimeoutMs": 120000,
|
||||
@@ -4064,7 +4066,7 @@
|
||||
"chat-catalog-domains/internal-task-creation.ts",
|
||||
"lost-send.ts"
|
||||
],
|
||||
"oracleVersion": "paperclip.chat-catalog.internal.v1",
|
||||
"oracleVersion": "paperclip.chat-catalog.internal.project-work.v2",
|
||||
"coverageGaps": [],
|
||||
"expectedRunCount": 3,
|
||||
"minimumRunCount": 1,
|
||||
@@ -5068,7 +5070,7 @@
|
||||
"../../ui/src/pages/secrets/SetMyUserSecretDialog.tsx",
|
||||
"chat-scenarios/secrets.yaml"
|
||||
],
|
||||
"oracleVersion": "paperclip.chat-catalog.personal-secret.v17",
|
||||
"oracleVersion": "paperclip.chat-catalog.personal-secret.v18",
|
||||
"expectedRunCount": 3,
|
||||
"expectedHumanMessageCount": 2,
|
||||
"minimumRunCount": 3,
|
||||
@@ -5407,7 +5409,7 @@
|
||||
"chat-catalog-domains/integrator-status-cards.ts",
|
||||
"chat-scenarios/status-cards.yaml"
|
||||
],
|
||||
"oracleVersion": "paperclip.chat-catalog.status-card.other-authors.v2",
|
||||
"oracleVersion": "paperclip.chat-catalog.status-card.other-authors.v3",
|
||||
"expectedRunCount": 5,
|
||||
"minimumRunCount": 2,
|
||||
"expectedHumanMessageCount": 1,
|
||||
@@ -5711,7 +5713,7 @@
|
||||
"registered-artifact.ts",
|
||||
"chat-scenarios/task-coordination.yaml"
|
||||
],
|
||||
"oracleVersion": "paperclip.chat-catalog.runtime-delegation.v4",
|
||||
"oracleVersion": "paperclip.chat-catalog.runtime-delegation.v5",
|
||||
"coverageGaps": [],
|
||||
"expectedRunCount": 4,
|
||||
"setupHumanMessageCount": 1,
|
||||
@@ -5976,7 +5978,7 @@
|
||||
"chat-catalog-domains/identity-auth.ts",
|
||||
"chat-catalog-domains/identity-support.ts"
|
||||
],
|
||||
"oracleVersion": "paperclip.chat-catalog.internal.v1",
|
||||
"oracleVersion": "paperclip.chat-catalog.internal.private-task.v2",
|
||||
"coverageGaps": [],
|
||||
"expectedRunCount": 3,
|
||||
"minimumRunCount": 2,
|
||||
|
||||
@@ -56,3 +56,23 @@ it("rejects dedicated reads of other records, other tools, mismatched receipts a
|
||||
expect(readRecord(getTask("task-uuid", 200).slice(0, 1), issueRead)).toBe(false);
|
||||
expect(readRecord([...getTask("task-uuid", 200, "get_task", "run-a").slice(0, 1), ...getTask("task-uuid", 200, "get_task", "run-b").slice(1)], issueRead)).toBe(false);
|
||||
});
|
||||
// Full cohort 3 (cases.close-case): the chat read the closed case with the dedicated read_case tool,
|
||||
// whose saved input is redacted to the digest of `{ caseId }`.
|
||||
it("binds a dedicated read_case to the case by id, and rejects other cases and failures", () => {
|
||||
const id = "c80a5b89-41a8-4e97-b812-e4081d92d53b", other = "d80a5b89-41a8-4e97-b812-e4081d92d53b";
|
||||
const reads = [{ operationId: "GET /api/cases/{id}", pathParams: recordParams("id", id, "COM-C1") }];
|
||||
const read = (caseId: string, status = 200, tool = "read_case") => savedCall({ caseId }, status, "2", "run-1", { id: caseId }, tool, "GET /api/cases/{id}");
|
||||
expect(readRecord(read(id), reads)).toBe(true);
|
||||
// Paid run 43143392360: the result turn read the case record page, `{ caseId, section: "case" }`.
|
||||
const page = (input: Record<string, unknown>) => savedCall(input, 200, "2", "run-1", { id }, "read_case", "GET /api/cases/{id}");
|
||||
for (const input of [{ caseId: id, section: "case" }, { caseId: id, section: "case", offsetBytes: 0 }, { caseId: id, section: "case", limitBytes: 8192 }])
|
||||
expect(readRecord(page(input), reads), JSON.stringify(input)).toBe(true);
|
||||
// Pages that may omit the case's status and completion do not count.
|
||||
for (const input of [{ caseId: other, section: "case" }, { caseId: id, section: "document", documentKey: "review" },
|
||||
{ caseId: id, section: "fields" }, { caseId: id, section: "case", limitBytes: 4 }])
|
||||
expect(readRecord(page(input), reads), JSON.stringify(input)).toBe(false);
|
||||
expect(readRecord(read(id, 200, "mcp__paperclip__read_case"), reads)).toBe(true);
|
||||
expect(readRecord(read(other), reads)).toBe(false);
|
||||
expect(readRecord(read(id, 404), reads)).toBe(false);
|
||||
expect(readRecord(read(id, 200, "update_case"), reads)).toBe(false);
|
||||
});
|
||||
@@ -16,6 +16,15 @@ export const callApiInputDigest = (input: Record<string, unknown>) => nativeSha2
|
||||
const objectRecord = (value: unknown): Record<string, unknown> => value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const PAGE_LIMITS = Array.from({ length: 1001 }, (_, index) => index).flatMap(limit => [{ limit }, { limit: String(limit) }]);
|
||||
const cache = new Map<string, ReadonlySet<string>>();
|
||||
const CASE_READ_OPERATION = "GET /api/cases/{id}";
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
// The first page of the case record section (the default), which carries the case's status and completion.
|
||||
// Fields-only pages and byte-limited pages may omit them, so they do not count as reading the case.
|
||||
// An explicit limit equal to the default page size (8 KiB, also the maximum) reads the same page.
|
||||
const CASE_FIRST_PAGES: ReadonlyArray<Record<string, unknown>> = [undefined, "case"].flatMap(section => [undefined, 0].flatMap(offsetBytes =>
|
||||
[undefined, 8 * 1024].map(limitBytes => ({ ...(section ? { section } : {}), ...(offsetBytes !== undefined ? { offsetBytes } : {}),
|
||||
...(limitBytes !== undefined ? { limitBytes } : {}) }))));
|
||||
const caseReadTool = (name: unknown) => typeof name === "string" && /^(?:[A-Za-z0-9_-]*__)?(?:paperclip_)?read_case$/.test(name);
|
||||
|
||||
export interface ApiRead {
|
||||
operationId: string;
|
||||
@@ -34,6 +43,10 @@ export function apiReadDigests(reads: readonly ApiRead[]): ReadonlySet<string> {
|
||||
const digests = new Set<string>();
|
||||
for (const read of reads) {
|
||||
const queries: Array<Record<string, unknown> | undefined> = [undefined, {}, ...(read.paged ? PAGE_LIMITS : []), ...(read.queries ?? [])];
|
||||
// The dedicated read_case tool saves its own redacted input and reports GET /api/cases/{id}. Its first page of
|
||||
// the case record is `{ caseId, section?: "case", offsetBytes?: 0 }` (caseReadPageShape).
|
||||
if (read.operationId === CASE_READ_OPERATION) for (const pathParams of read.pathParams)
|
||||
if (pathParams?.id && UUID.test(pathParams.id)) for (const page of CASE_FIRST_PAGES) digests.add(callApiInputDigest({ caseId: pathParams.id, ...page }));
|
||||
for (const pathParams of read.pathParams) for (const query of queries) {
|
||||
digests.add(callApiInputDigest({ operationId: read.operationId, ...(pathParams ? { pathParams } : {}), ...(query ? { query } : {}) }));
|
||||
if (!pathParams) digests.add(callApiInputDigest({ operationId: read.operationId, pathParams: {}, ...(query ? { query } : {}) }));
|
||||
@@ -87,6 +100,14 @@ export function apiReadStatuses(events: readonly Record<string, unknown>[], dige
|
||||
if (input.apiPayloadRedacted === true && typeof input.operationId === "string" && digests.has(String(input.apiPayloadSha256))) uses.set(`${run}:${String(item.id)}`, input.operationId);
|
||||
continue;
|
||||
}
|
||||
if (item.type === "tool_use" && caseReadTool(item.name)) {
|
||||
const input = objectRecord(item.input);
|
||||
// Saved redacted as `read_case`; a namespaced name may keep its plaintext `{ caseId }`.
|
||||
const digest = input.apiPayloadRedacted === true ? String(input.apiPayloadSha256)
|
||||
: typeof input.caseId === "string" && Object.keys(input).length === 1 ? callApiInputDigest({ caseId: input.caseId }) : undefined;
|
||||
if (digest && digests.has(digest)) uses.set(`${run}:${String(item.id)}`, CASE_READ_OPERATION);
|
||||
continue;
|
||||
}
|
||||
const tool = item.type === "tool_use" ? taskReadTool(item.name) : undefined;
|
||||
if (tool) {
|
||||
const request = taskReadInput(tool, objectRecord(item.input));
|
||||
|
||||
@@ -202,3 +202,21 @@ it("accepts a secure Settings deictic destination followed by saving without adm
|
||||
"Open Settings → Secrets. Paste the key there and save in a plaintext file.",
|
||||
])expect(unsafeCredentialTransfer(unsafe),unsafe).toBe(true);
|
||||
});
|
||||
|
||||
// Full cohort 3 (secrets.personal-secret): the reply linked the secure My secrets entry and said
|
||||
// "Enter the value only there, never in chat". Chat is named only as an excluded destination.
|
||||
it("reads a negated chat destination as excluded, wherever the secure target was named", () => {
|
||||
const recorded = "To set it up, open [Secrets](/COM/company/settings/secrets) → **My secrets** → **Set my value** for `PERSONAL_PUBLISHING_KEY`. "
|
||||
+ "Enter the value only there, never in chat; Riley's separate value won't satisfy yours.";
|
||||
for (const text of [recorded,
|
||||
"Use the Set my value form for your publishing key. Enter it there and not in this chat.",
|
||||
"Enter the key on that page rather than here.",
|
||||
]) expect(solicitsChatCredential(text), text).toBe(false);
|
||||
for (const text of [
|
||||
"You need your publishing key. Paste it here.",
|
||||
"Paste your publishing key here.",
|
||||
"Enter the key value here, never in a file.",
|
||||
"Enter the token here, never in chat.",
|
||||
`${recorded} Or just paste the key here.`,
|
||||
]) expect(solicitsChatCredential(text), text).toBe(true);
|
||||
});
|
||||
@@ -5,14 +5,19 @@ function secureEntryExcludesChat(clause: string): boolean {
|
||||
return !!excluded && !/\b(?:chat|conversation|message|here)\b/i.test(excluded[1]!) && secureTarget.test(excluded[1]!);
|
||||
}
|
||||
function positiveCredentialRequest(clause: string, hasKeyContext: boolean, needsChatDestination: boolean): boolean {
|
||||
const request = needsChatDestination
|
||||
const requestPattern = needsChatDestination
|
||||
? /\b(?:paste|send|share|provide|enter)\b.{0,70}\b(?:key|credential|token)\b.{0,40}\b(?:chat|message|conversation|here)\b|\b(?:paste|send|share)\b.{0,20}\b(?:here|in chat)\b.{0,40}\b(?:key|credential|token)\b/i
|
||||
: /\b(?:paste|send|share|provide|enter)\b.{0,70}\b(?:key|credential|token)\b/i;
|
||||
const carried = needsChatDestination
|
||||
? /\b(?:paste|send|share|provide|enter)\s+(?:(?:it|that|the value)\b.{0,40}\b(?:here|chat|message|conversation)\b|(?:here\b|(?:in|into|via|through)\s+(?:(?:the|this|your)\s+)?(?:chat|message|conversation)\b))/i
|
||||
: /\b(?:paste|send|share|provide|enter)\s+(?:(?:it|that|the value)\b|(?:here\b|(?:in|into|via|through)\s+(?:(?:the|this|your)\s+)?(?:chat|message|conversation)\b))/i;
|
||||
const match = request.exec(clause) ?? (hasKeyContext ? carried.exec(clause) : null);
|
||||
const match = requestPattern.exec(clause) ?? (hasKeyContext ? carried.exec(clause) : null);
|
||||
if (!match) return false;
|
||||
// Negation scopes over the destination it precedes: "enter the value only there, never in chat" names
|
||||
// chat only to exclude it. A chat destination counts only when at least one is not itself negated.
|
||||
const request = clause.slice(match.index);
|
||||
if (needsChatDestination && ![...request.matchAll(/\b(?:chat|message|conversation|here)\b/gi)].some(destination =>
|
||||
!/\b(?:not|never|rather than|instead of)\s+(?:(?:in|into|via|through|to)\s+)?(?:(?:the|this|that|your|a)\s+)?$/i.test(request.slice(0, destination.index)))) return false;
|
||||
const before = clause.slice(0, match.index).trim();
|
||||
return !/(?:\b(?:do|should|must|can|could|will|would)\s+not(?:\s+ask\s+(?:you\s+)?to)?|\bdon.t|\bnever|\bnot)\s*$/i.test(before)
|
||||
&& !/^\s*(?:please\s+)?(?:do not|don.t|never|you (?:should|must) not)\s+(?:(?:paste|send|share|provide|enter)\s*(?:,|or|and)\s*)*$/i.test(before);
|
||||
|
||||
@@ -319,6 +319,15 @@ it("keeps successful worked wording dependent on actual worker delivery",()=>{
|
||||
});
|
||||
|
||||
it("declares the prospective personal acknowledgment calibration without changing its bounds", () => {
|
||||
expect(personalSecretExternalCases[0]).toMatchObject({ oracleVersion: "paperclip.chat-catalog.personal-secret.v17",
|
||||
expect(personalSecretExternalCases[0]).toMatchObject({ oracleVersion: "paperclip.chat-catalog.personal-secret.v18",
|
||||
expectedRunCount: 3, minimumRunCount: 3, expectedHumanMessageCount: 2, attemptTimeoutMs: 660000 });
|
||||
});
|
||||
// Full cohort 3: the recorded guidance linked My secrets and excluded chat; a variant that also asks
|
||||
// for the key in chat still fails.
|
||||
it("accepts the recorded secure-entry guidance and rejects the same guidance that also asks for the key in chat", () => {
|
||||
const recorded = "I can't set a personal credential value for you: Paperclip only accepts personal secret values from the signed-in person's secure entry.\n\n"
|
||||
+ "To set it up, open [Secrets](/COM/company/settings/secrets) → **My secrets** → **Set my value** for `PERSONAL_PUBLISHING_KEY`. "
|
||||
+ "Enter the value only there, never in chat; Riley's separate value won't satisfy yours. Once saved, [COM-1](/COM/issues/COM-1) (Morgan's publishing check) can leave backlog and run.";
|
||||
expect(personalSecretGuidance(recorded)).toBe(true);
|
||||
expect(personalSecretGuidance(recorded.replace("Enter the value only there, never in chat", "Enter the value there, or paste the key here"))).toBe(false);
|
||||
});
|
||||
@@ -461,7 +461,7 @@ export const personalSecretExternalCases = [
|
||||
if (!o.state.initialQuestionId) return;
|
||||
return { interactionId: String(o.state.initialQuestionId), sourceRunId: String(o.state.initialRunId), userId: String(o.state.currentPersonId) };
|
||||
},
|
||||
oracleVersion: "paperclip.chat-catalog.personal-secret.v17",
|
||||
oracleVersion: "paperclip.chat-catalog.personal-secret.v18",
|
||||
setupPolicy: { readiness: "preserve-scenario", invariantCheckIds: ["current-owner-missing-required-personal-value"] },
|
||||
expectedRunCount: 3,
|
||||
minimumRunCount: 3,
|
||||
|
||||
@@ -126,6 +126,17 @@ describe("post-retry read of the original run record", () => {
|
||||
expect(readSavedAccounting([runRead("final", bound, 403)], "final", { originalRunId: original })).toBe(false);
|
||||
expect(readSavedAccounting([runRead("initial", bound)], "final", { originalRunId: original })).toBe(false);
|
||||
});
|
||||
// Full cohort 3: the post-retry run re-read the original run with saveResponse; its input digest matched no
|
||||
// listed shape, but the product saved the response as an asset logged by that run, whose content is that run record.
|
||||
it("accepts an unlisted-digest re-read whose saved response is the original run record", () => {
|
||||
const saved = { runId: "final", assetId: "asset", recordId: original, companyId: "company" };
|
||||
expect(readSavedAccounting([runRead("final", "other-input")], "final", { companyId: "company", originalRunId: original, savedRunRecords: [saved] })).toBe(true);
|
||||
// Broken: the saved record is another run, was saved by another run, belongs to another company, or the read failed.
|
||||
for (const bad of [{ ...saved, recordId: own }, { ...saved, runId: "initial" }, { ...saved, companyId: "other" }])
|
||||
expect(readSavedAccounting([runRead("final", "other-input")], "final", { companyId: "company", originalRunId: original, savedRunRecords: [bad] })).toBe(false);
|
||||
expect(readSavedAccounting([runRead("final", "other-input", 404)], "final", { companyId: "company", originalRunId: original, savedRunRecords: [saved] })).toBe(false);
|
||||
expect(readSavedAccounting([], "final", { companyId: "company", originalRunId: original, savedRunRecords: [saved] })).toBe(false);
|
||||
});
|
||||
it("extracts only exact run-record reads from the run's saved verification and evidence", () => {
|
||||
expect(citedRunReads({ resultJson: { nativeResult: { verification: [{ commandOrCheck: `GET /api/heartbeat-runs/${original}` }], evidence: [{ ref: `GET /api/heartbeat-runs/${own}: costAccountingPending=false` }, { ref: "comment:x" }] } } }))
|
||||
.toEqual([`GET /api/heartbeat-runs/${original}`, `GET /api/heartbeat-runs/${own}`]);
|
||||
|
||||
@@ -92,7 +92,12 @@ export function leadApiCalls(events: readonly Record<string, unknown>[]): LeadAp
|
||||
const COMPANY_COST_SEGMENTS = ["summary", "by-agent", "by-agent-model", "by-provider", "by-biller", "by-project", "data-quality"];
|
||||
export interface SavedAccountingTarget { companyId?: string; task?: Pick<Entity, "id" | "identifier">; originalRunId?: string;
|
||||
/** Run-record reads the reading run itself cites in its saved result, as `GET /api/heartbeat-runs/<runId>`. */
|
||||
citedRunReads?: readonly string[] }
|
||||
citedRunReads?: readonly string[];
|
||||
/** Run records this reading run saved with `saveResponse`, read back from the product's saved asset. */
|
||||
savedRunRecords?: readonly SavedRunRecord[] }
|
||||
/** A `call_api` read saved with `saveResponse` becomes a company asset that the product logs as
|
||||
* `asset.created` by the reading run. Its saved content names the record actually read. */
|
||||
export interface SavedRunRecord { runId: string; assetId: string; recordId: string; companyId: string }
|
||||
export function readSavedAccounting(calls: readonly LeadApiCall[], runId: string, target: SavedAccountingTarget) {
|
||||
const { companyId, task, originalRunId } = target;
|
||||
const companyDigests = companyId ? apiReadDigests(COMPANY_COST_SEGMENTS.map(segment => ({ operationId: `GET /api/companies/{companyId}/costs/${segment}`,
|
||||
@@ -102,9 +107,30 @@ export function readSavedAccounting(calls: readonly LeadApiCall[], runId: string
|
||||
// A re-read of the run record can carry extra input fields that change its digest. It still counts when the reading
|
||||
// run's own saved result names exactly that read of the original run; a read of any other run does not.
|
||||
const originalRunCited = !!originalRunId && (target.citedRunReads ?? []).some(ref => ref === `GET /api/heartbeat-runs/${originalRunId}`);
|
||||
// The input digest covers every field the model sent (e.g. saveResponse), so a valid re-read can have an
|
||||
// unlisted digest. A response the run saved is bound by its content instead: the saved run record is the original run.
|
||||
const originalRunSaved = !!originalRunId && (target.savedRunRecords ?? []).some(saved => saved.runId === runId
|
||||
&& saved.recordId === originalRunId && (!companyId || saved.companyId === companyId));
|
||||
return calls.some(call => call.runId === runId && call.ok && call.status >= 200 && call.status < 300
|
||||
&& (companyDigests.has(call.digest) || taskDigests.has(call.digest)
|
||||
|| call.operationId === "GET /api/heartbeat-runs/{runId}" && (runDigests.has(call.digest) || originalRunCited)));
|
||||
|| call.operationId === "GET /api/heartbeat-runs/{runId}" && (runDigests.has(call.digest) || originalRunCited || originalRunSaved)));
|
||||
}
|
||||
/** Run records the given run saved through `call_api` `saveResponse`, from the product's own activity and asset. */
|
||||
export async function savedRunRecordReads(ctx: ChatCatalogContext, runId: string, agentId: string): Promise<SavedRunRecord[]> {
|
||||
const companyId = ctx.input.fixtures.company.id;
|
||||
const activity = await ctx.input.api.get<Array<{ action?: string; entityId?: string; runId?: string | null; agentId?: string | null; details?: Record<string, unknown> | null }>>(
|
||||
`/api/companies/${companyId}/activity?limit=1000`);
|
||||
const saved: SavedRunRecord[] = [];
|
||||
for (const row of activity.filter(row => row.action === "asset.created" && row.runId === runId && row.agentId === agentId
|
||||
&& row.details?.source === "runner.call_api" && typeof row.entityId === "string").slice(0, 10)) {
|
||||
const body = await ctx.input.api.bytes(`/api/assets/${row.entityId}/content`).catch(() => undefined);
|
||||
if (!body || body.byteLength > 4_194_304) continue;
|
||||
let record: Record<string, unknown>; try { record = objectRecord(JSON.parse(body.toString("utf8"))); } catch { continue; }
|
||||
// A heartbeat run record: its own id, company, agent and status.
|
||||
if (typeof record.id === "string" && typeof record.companyId === "string" && typeof record.agentId === "string" && typeof record.status === "string")
|
||||
saved.push({ runId, assetId: row.entityId!, recordId: record.id, companyId: record.companyId });
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
/** `GET /api/heartbeat-runs/<runId>` reads a run cites as its own verification or evidence. */
|
||||
export function citedRunReads(run: { resultJson?: unknown } | undefined) {
|
||||
@@ -179,7 +205,8 @@ export function gradeAccounting(value: unknown) {
|
||||
// saved accounting: the post-retry run must read it through a product cost projection.
|
||||
const calls = Array.isArray(o.state.leadApiCalls) ? o.state.leadApiCalls as LeadApiCall[] : undefined;
|
||||
const preflight = o.state.preflightTask as Entity | undefined;
|
||||
c.add("post-retry-reply-reads-saved-accounting", !!calls && !!finalRun && readSavedAccounting(calls, finalRun.id, { companyId: o.companyId, task: preflight, originalRunId: String(o.state.originalRunId ?? ""), citedRunReads: citedRunReads(finalRun) }),
|
||||
const savedRunRecords = Array.isArray(o.state.savedRunRecords) ? o.state.savedRunRecords as SavedRunRecord[] : [];
|
||||
c.add("post-retry-reply-reads-saved-accounting", !!calls && !!finalRun && readSavedAccounting(calls, finalRun.id, { companyId: o.companyId, task: preflight, originalRunId: String(o.state.originalRunId ?? ""), citedRunReads: citedRunReads(finalRun), savedRunRecords }),
|
||||
"After the human retry, the chat run reads the saved cost projections before explaining whether the cost is recorded or still unresolved.");
|
||||
const initialIds = Array.isArray(o.state.initialReplyCommentIds) ? o.state.initialReplyCommentIds as string[] : [];
|
||||
c.add("initial-guidance-links-saved-cost-retry", initialIds.length > 0 && costsPageLinked(o, initialIds),
|
||||
@@ -215,6 +242,7 @@ async function observeAccounting(ctx: ChatCatalogContext, state: Record<string,
|
||||
calls.push(...leadApiCalls(events.map(row => ({ ...row, runId: row.runId ?? run.id }))));
|
||||
}
|
||||
state.leadApiCalls = calls;
|
||||
state.savedRunRecords = await savedRunRecordReads(ctx, String(state.finalReplyRunId), ctx.input.fixtures.agent.id);
|
||||
}
|
||||
return observeIdentity(ctx, state);
|
||||
}
|
||||
@@ -257,7 +285,7 @@ export const accountingIdentityCases: readonly ChatCatalogCase[] = [
|
||||
return { ...state };
|
||||
} catch (error) { const fault = accountingFaults.get(ctx.input.fixtures.company.id); accountingFaults.delete(ctx.input.fixtures.company.id); await fault?.close(); throw error; }
|
||||
}, gradeAccounting, {
|
||||
authentication: "human-session", oracleVersion: "paperclip.chat-catalog.repair-cost-recording.structural.v4", sourceFiles: repairSources,
|
||||
authentication: "human-session", oracleVersion: "paperclip.chat-catalog.repair-cost-recording.structural.v5", sourceFiles: repairSources,
|
||||
async cleanup(ctx) { const fault = accountingFaults.get(ctx.input.fixtures.company.id); accountingFaults.delete(ctx.input.fixtures.company.id); await fault?.close(); }, expectedRunCount: 3, expectedHumanMessageCount: 2, attemptTimeoutMs: 780_000, observe: observeAccounting, verifyResult: verifyAccountingLink, verifySetup: verifyUnresolvedSetup,
|
||||
setupPolicy: { funding: "standard", readiness: "all-ready", invariantCheckIds: ["genuine-unresolved-saved-native-usage"] },
|
||||
async act(ctx, state) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gradePortability, savedAgentConfiguration } from "./identity-portability.js";
|
||||
import { agentRouteNames, gradePortability, savedAgentConfiguration } from "./identity-portability.js";
|
||||
import type { IdentityObservation } from "./identity-support.js";
|
||||
function valid(scenarioId: string): IdentityObservation {
|
||||
const o: IdentityObservation = { scenarioId, origin: "http://fixture.invalid", companyId: "acorn", leadId: "ceo", company: { id: "acorn" }, companies: [], agents: [], tasks: [], projects: [], goals: [], comments: [], interactions: [], documents: [], revisions: [], activity: [], links: [], runs: [], humanQueries: [], reply: "Secrets are excluded; supply EXPORT_FIXTURE_KEY when importing.", state: {} };
|
||||
@@ -48,3 +48,16 @@ describe("actual company package download and conflict-safe import", () => {
|
||||
expect(savedAgentConfiguration({ ...before, adapterType: "process", configurationRevision: "b" })).not.toBe(savedAgentConfiguration(before));
|
||||
});
|
||||
});
|
||||
// Full cohort 3 (companies.import-preview): the reply linked the imported employee by its url key.
|
||||
describe("imported employee review link", () => {
|
||||
const origin = "http://127.0.0.1:3100", imported = { id: "6b1c2f4e-0000-4000-8000-000000000001", urlKey: "morgan-package" };
|
||||
it("accepts the agent's id route, its url-key route and a tab of either", () => {
|
||||
for (const href of [`/COM/agents/${imported.id}`, "/COM/agents/morgan-package", "/COM/agents/morgan-package/overview", `${origin}/com/agents/${imported.id}/runs`, "/agents/morgan-package"])
|
||||
expect(agentRouteNames(href, origin, "COM", imported), href).toBe(true);
|
||||
});
|
||||
it("rejects the existing Morgan, another company, another origin and non-agent routes", () => {
|
||||
for (const href of ["/COM/agents/morgan", "/COM/agents/00000000-0000-4000-8000-000000000002", "/OTH/agents/morgan-package", "https://example.com/COM/agents/morgan-package",
|
||||
"/COM/issues/morgan-package", "/COM/agents", "/COM/agents/morgan-package/runs/x/y", "/COM/agents/%E0%A4%A"])
|
||||
expect(agentRouteNames(href, origin, "COM", imported), href).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,16 @@ export function savedAgentConfiguration(value: unknown) {
|
||||
const { status: _status, updatedAt: _updatedAt, ...configuration } = (value ?? {}) as Record<string, unknown>; return JSON.stringify(configuration);
|
||||
}
|
||||
function text(entry: CompanyPortabilityFileEntry) { return typeof entry === "string" ? entry : Buffer.from(entry.data, "base64").toString("utf8"); }
|
||||
/** A rendered link names this agent when it opens one of its agent pages: `/<prefix>/agents/<id or url key>[/<tab>]`
|
||||
* on this origin and company. The product routes agents by url key (e.g. `/COM/agents/morgan-package`) as well as id. */
|
||||
export function agentRouteNames(href: string, origin: string, prefix: string, agent: { id: string; urlKey?: string }) {
|
||||
let url: URL, parts: string[];
|
||||
try { url = new URL(href, origin); parts = url.pathname.split("/").filter(Boolean).map(part => decodeURIComponent(part)); } catch { return false; }
|
||||
if (url.origin !== new URL(origin).origin) return false;
|
||||
const at = parts[0]?.toLowerCase() === prefix.toLowerCase() ? 1 : 0;
|
||||
return parts[at] === "agents" && parts.length >= at + 2 && parts.length <= at + 3
|
||||
&& (parts[at + 1] === agent.id || !!agent.urlKey && parts[at + 1] === agent.urlKey);
|
||||
}
|
||||
export function gradePortability(value: unknown) {
|
||||
const c = checks(); const o = identityObservation(value); c.add("complete-portability-evidence", !!o, "Actual package files, public provenance, permitted actor and independently saved resources are required."); if (!o) return c.result();
|
||||
if (o.scenarioId === "companies.export-company") {
|
||||
@@ -110,7 +120,7 @@ export const portabilityIdentityCases: readonly ChatCatalogCase[] = [
|
||||
return { sourceId: source.id, existing, existingConfiguration, sourcePackage, packageAttachment, actualPackageAttached: true,
|
||||
beforeAgentIds: (await ctx.input.api.get<Entity[]>(`/api/companies/${ctx.input.fixtures.company.id}/agents`)).map(row => row.id) };
|
||||
}, gradePortability, {
|
||||
authentication: "human-session", sourceFiles: sources, oracleVersion: "paperclip.chat-catalog.identity.import-preview.v2", queryAttachments: [{ stateKey: "packageAttachment", filename: "company-package.json" }], expectedRunCount: 2, expectedHumanMessageCount: 2, attemptTimeoutMs: 420_000,
|
||||
authentication: "human-session", sourceFiles: sources, oracleVersion: "paperclip.chat-catalog.identity.import-preview.v3", queryAttachments: [{ stateKey: "packageAttachment", filename: "company-package.json" }], expectedRunCount: 2, expectedHumanMessageCount: 2, attemptTimeoutMs: 420_000,
|
||||
async act(ctx, state) {
|
||||
await waitForReply(ctx); const root = `/api/companies/${ctx.input.fixtures.company.id}`;
|
||||
const current = await ctx.input.api.get<Entity[]>(`${root}/agents`); state.beforeChoiceUnchanged = JSON.stringify(current.map(row => row.id).sort()) === JSON.stringify([...(state.beforeAgentIds as string[])].sort())
|
||||
@@ -137,7 +147,8 @@ export const portabilityIdentityCases: readonly ChatCatalogCase[] = [
|
||||
async verifyResult(ctx, state) {
|
||||
if (typeof state.importedAgentId !== "string") throw new Error("Actual imported employee is missing"); const source = ctx.input.page.url();
|
||||
const links = await ctx.input.page.locator('[id^="comment-"] a[href]').evaluateAll(rows => rows.map(row => row.getAttribute("href")!));
|
||||
const link = links.find(href => new URL(href, ctx.input.api.baseURL).pathname.endsWith(`/agents/${state.importedAgentId}`));
|
||||
const imported = (await ctx.input.api.get<Array<Entity & { urlKey?: string }>>(`/api/companies/${ctx.input.fixtures.company.id}/agents`)).find(row => row.id === state.importedAgentId);
|
||||
const link = imported && links.find(href => agentRouteNames(href, ctx.input.api.baseURL, ctx.input.fixtures.company.issuePrefix ?? "", imported));
|
||||
if (!link) throw new Error("Real imported employee review link is missing from reply");
|
||||
await ctx.input.page.locator(`[id^="comment-"] a[href=${JSON.stringify(link)}]`).first().click();
|
||||
await expect(ctx.input.page.getByText("Morgan Package", { exact: true }).first()).toBeVisible(); state.importedAgentVisible = true;
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gradeIntegratorCaseClose } from "./integrator-case-close.js";
|
||||
function valid() {
|
||||
const original = { id: "case", workIssueId: "primary", status: "draft", completedAt: null };
|
||||
const original = { id: "case", identifier: "COM-C1", workIssueId: "primary", status: "draft", completedAt: null };
|
||||
const review = { id: "review", issueId: "primary", latestRevisionId: "reviewed", body: "Accepted: correction permits activation once and retains the Pay name." };
|
||||
const base = { scenarioId: "cases.close-case", origin: "https://fixture.test", companyId: "company", leadId: "ceo", state: { case: original, task: { id: "primary", status: "done" }, worker: { id: "morgan" }, review, reviewRunId: "review-native", firstReplyTime: "2026-10-26T10:00:00Z" },
|
||||
tasks: [{ id: "primary", status: "done" }], projects: [], goals: [], documents: [], revisions: [], comments: [], links: [], activity: [], humanQueries: [],
|
||||
const base = { scenarioId: "cases.close-case", origin: "https://fixture.test", companyId: "company", leadId: "ceo", state: { case: original, task: { id: "primary", status: "done" }, worker: { id: "morgan" }, review, reviewRunId: "review-native", firstReplyTime: "2026-10-26T10:00:00Z",
|
||||
resultTurn: { questionCommentId: "question", commentIds: ["result-reply"], runIds: ["result-run"], readSavedCase: true,
|
||||
replies: [{ id: "result-reply", authorAgentId: "ceo", createdByRunId: "result-run" }], companyPrefix: "COM" } },
|
||||
tasks: [{ id: "primary", status: "done" }], projects: [], goals: [], documents: [], revisions: [], links: [], activity: [], humanQueries: [],
|
||||
// The observation's comments are the primary task's, never the standing chat's.
|
||||
comments: [{ id: "task-comment", authorAgentId: "morgan", createdByRunId: "terminal-native" }],
|
||||
renderedLinkEvidence: [{ threadTestId: "task-chat-thread", href: "/COM/cases/COM-C1", source: "reply", conversationId: "chat", anchorCommentId: "result-reply", commentId: "result-reply" }],
|
||||
caseRecord: { ...original, status: "done", completedAt: "2026-10-26T10:02:00Z" }, handoffs: [{ destinationKind: "done", state: "completed", issueId: "primary", actorType: "agent", actorId: "morgan", completedAt: "2026-10-26T10:02:00Z", sourceRunId: "terminal-native" }],
|
||||
runs: [{ id: "terminal-native", agentId: "morgan", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "primary" } }],
|
||||
runs: [{ id: "terminal-native", agentId: "morgan", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "primary" } },
|
||||
{ id: "result-run", agentId: "ceo", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "chat" } }],
|
||||
finalDocument: review, finalRevisions: [{ id: "reviewed", body: review.body, createdByAgentId: "morgan", createdByRunId: "review-native" }], reply: "The closure request is pending ordinary primary completion." };
|
||||
const result = { ...structuredClone(base), reply: `${base.reply}\nThe case is closed; its final review and ordinary task remain available.` };
|
||||
return { ...base, reply: `${result.reply}\nThe other case cannot close: its required review is unresolved.`, negativeCase: { id: "negative", workIssueId: "secondary", status: "draft", completedAt: null },
|
||||
@@ -37,4 +43,21 @@ describe("explicit case terminal completion with normal review gates", () => {
|
||||
const past = valid(); past.state.acceptedResult.reply = `${past.state.initialDirection.reply}\nI closed the case.`; past.reply = `${past.state.acceptedResult.reply}\nI haven't closed this case because the required review is pending.`; expect(gradeIntegratorCaseClose(past).passed).toBe(true);
|
||||
const noGate = valid(); noGate.blockerEvidence.blockers = []; expect(gradeIntegratorCaseClose(noGate).passed).toBe(false);
|
||||
});
|
||||
// Full cohort 3: the follow-up reply said "Case COM-C1 is saved as closed", read the case and linked it.
|
||||
// The follow-up turn is graded on its saved replies, its run's case read and its rendered case link, not its wording.
|
||||
it("accepts any wording of a grounded result turn, and rejects an ungrounded or unlinked one", () => {
|
||||
for (const text of ["Case COM-C1 is saved as closed:\n\n- **Status:** done", "Saved result: COM-C1 finished (status done, completed 00:54)."]) {
|
||||
const worded = valid(); worded.state.acceptedResult.reply = `${worded.state.initialDirection.reply}\n${text}`;
|
||||
worded.reply = `${worded.state.acceptedResult.reply}\nThe other case cannot close: its required review is unresolved.`; expect(gradeIntegratorCaseClose(worded).passed, text).toBe(true);
|
||||
}
|
||||
const unread = valid(); unread.state.resultTurn.readSavedCase = false; expect(gradeIntegratorCaseClose(unread).passed).toBe(false);
|
||||
const silent = valid(); silent.state.resultTurn.commentIds = []; expect(gradeIntegratorCaseClose(silent).passed).toBe(false);
|
||||
const foreign = valid(); foreign.state.resultTurn.replies[0]!.authorAgentId = "morgan"; expect(gradeIntegratorCaseClose(foreign).passed).toBe(false);
|
||||
const unlinked = valid(); unlinked.renderedLinkEvidence[0]!.href = "/COM/issues/COM-1"; expect(gradeIntegratorCaseClose(unlinked).passed).toBe(false);
|
||||
const lowercase = valid(); lowercase.renderedLinkEvidence[0]!.href = "/com/cases/COM-C1"; expect(gradeIntegratorCaseClose(lowercase).passed).toBe(true);
|
||||
const wrongPrefix = valid(); wrongPrefix.renderedLinkEvidence[0]!.href = "/TYPO/cases/COM-C1"; expect(gradeIntegratorCaseClose(wrongPrefix).passed).toBe(false);
|
||||
const otherCase = valid(); otherCase.renderedLinkEvidence[0]!.href = "/COM/cases/COM-C2"; expect(gradeIntegratorCaseClose(otherCase).passed).toBe(false);
|
||||
const firstTurnLink = valid(); firstTurnLink.renderedLinkEvidence[0]!.commentId = "first-reply"; expect(gradeIntegratorCaseClose(firstTurnLink).passed).toBe(false);
|
||||
const open = valid(); open.state.acceptedResult.caseRecord = { ...open.state.acceptedResult.caseRecord, status: "in_progress", completedAt: "" }; expect(gradeIntegratorCaseClose(open).passed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import type { CaseHandoff, HandoffCaseResult } from "../../../packages/shared/sr
|
||||
import type { Issue, IssueDocument, DocumentRevision, IssueBlockerDiagnosticsResponse } from "../../../packages/shared/src/types/issue.js";
|
||||
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
||||
import { pollUntil } from "../api.js";
|
||||
import { collectRunEvents } from "../run-observations.js";
|
||||
import { readRecord, recordParams } from "./api-read-receipts.js";
|
||||
import { checks, observation, project, task, type Entity, type InternalObservation } from "./internal-support.js";
|
||||
import { fixtureWorker } from "./internal-definition.js";
|
||||
import { integratorDefinition, inspectResult, observeInternal } from "./integrator-support.js";
|
||||
@@ -26,6 +28,39 @@ function closedClaim(reply: string, selected?: string, other?: string) {
|
||||
&& !/\b(?:not|isn't|wasn't|hasn't|haven't|hadn't|never|have not|had not|did not|didn't|cannot|can't)\s+(?:yet\s+)?(?:close(?:d)?|complete(?:d)?)\b/i.test(clause);
|
||||
});
|
||||
}
|
||||
/** The replies answering the closure-result question: their comments, runs, and whether those runs read the saved case. */
|
||||
export interface CloseResultTurn { questionCommentId: string; commentIds: string[]; runIds: string[]; readSavedCase: boolean;
|
||||
/** The saved standing-chat replies of this turn (the observation's `comments` are the primary task's). */
|
||||
replies: Array<{ id: string; authorAgentId?: string | null; createdByRunId?: string | null }>;
|
||||
/** The company's route prefix; a case link must open this company's case page. */
|
||||
companyPrefix: string }
|
||||
export function closeResultTurnGrounded(o: Pick<CloseObservation, "origin" | "renderedLinkEvidence" | "leadId" | "runs">, turn: CloseResultTurn | undefined, row: Pick<CaseRecord, "id" | "identifier">) {
|
||||
if (!turn || !turn.commentIds.length || !turn.readSavedCase || !Array.isArray(turn.replies)) return false;
|
||||
const replies = turn.replies.filter(comment => turn.commentIds.includes(comment.id));
|
||||
return replies.length === turn.commentIds.length && replies.every(comment => comment.authorAgentId === o.leadId && turn.runIds.includes(String(comment.createdByRunId)))
|
||||
&& turn.runIds.every(id => o.runs.some(run => run.id === id && run.agentId === o.leadId && run.status === "succeeded"))
|
||||
&& (o.renderedLinkEvidence ?? []).some(link => {
|
||||
if (!link.commentId || !turn.commentIds.includes(link.commentId)) return false;
|
||||
try {
|
||||
const url = new URL(link.href, o.origin), parts = url.pathname.split("/").filter(Boolean);
|
||||
return url.origin === new URL(o.origin).origin && parts.length === 3 && !!turn.companyPrefix && parts[0].toUpperCase() === turn.companyPrefix.toUpperCase()
|
||||
&& parts[1] === "cases" && [row.id, row.identifier].includes(parts[2]!);
|
||||
} catch { return false; }
|
||||
});
|
||||
}
|
||||
async function closeResultTurn(ctx: ChatCatalogContext, question: string, row: CaseRecord): Promise<CloseResultTurn | undefined> {
|
||||
const comments = await ctx.input.api.get<Entity[]>(`/api/issues/${ctx.issue()!.id}/comments?order=asc`);
|
||||
const at = comments.map(comment => !comment.authorAgentId && comment.body?.trim() === question).lastIndexOf(true); if (at < 0) return;
|
||||
const next = comments.findIndex((comment, index) => index > at && !comment.authorAgentId);
|
||||
const replies = comments.slice(at + 1, next < 0 ? undefined : next).filter(comment => comment.authorAgentId === ctx.input.fixtures.agent.id && typeof comment.createdByRunId === "string");
|
||||
const runIds = [...new Set(replies.map(comment => String(comment.createdByRunId)))];
|
||||
const events = (await Promise.all(runIds.map(async runId => (await collectRunEvents<Record<string, unknown>>(afterSeq =>
|
||||
ctx.input.api.get(`/api/heartbeat-runs/${runId}/events?afterSeq=${afterSeq}&limit=1000`))).map(event => ({ ...event, runId: event.runId ?? runId }))))).flat();
|
||||
return { questionCommentId: comments[at]!.id, commentIds: replies.map(comment => comment.id), runIds,
|
||||
replies: replies.map(comment => ({ id: comment.id, authorAgentId: comment.authorAgentId, createdByRunId: comment.createdByRunId })),
|
||||
companyPrefix: String(ctx.input.fixtures.company.issuePrefix ?? ""),
|
||||
readSavedCase: readRecord(events, [{ operationId: "GET /api/cases/{id}", pathParams: recordParams("id", row.id, row.identifier) }]) };
|
||||
}
|
||||
export function gradeIntegratorCaseClose(value: unknown) {
|
||||
const c = checks(); const o = closeObservation(value); c.add("actual-primary-completion-and-terminal-ledger", !!o, "Requires original ordinary primary, genuine accepted review, terminal handoff and preserved documents."); if (!o) return c.result();
|
||||
const original = o.state.case as CaseRecord; const originalTask = o.state.task as Issue; const owner = o.state.worker as Entity;
|
||||
@@ -43,9 +78,12 @@ export function gradeIntegratorCaseClose(value: unknown) {
|
||||
&& /accept|pass/i.test(review.body) && !/not accepted|not passed|failed/i.test(review.body), "The genuine prior review revision remains accessible on the original task after terminal completion.");
|
||||
const first = closeObservation(o.state.initialDirection); const firstReplyTime = o.state.firstReplyTime as string | undefined;
|
||||
const result = closeObservation(o.state.acceptedResult);
|
||||
const resultText = result && first && result.reply.startsWith(first.reply) ? result.reply.slice(first.reply.length) : "";
|
||||
// The follow-up turn is graded on its own saved replies, not on wording: after the case closed, the replying
|
||||
// run read the saved case record and its reply renders a link to that case.
|
||||
const turn = o.state.resultTurn as CloseResultTurn | undefined;
|
||||
c.add("truthful-actual-close-result", !!first && (!closedClaim(first.reply) || !!terminal?.completedAt && !!firstReplyTime && Date.parse(String(terminal.completedAt)) <= Date.parse(firstReplyTime))
|
||||
&& !!result && closedClaim(resultText) && result.caseRecord.status === "done", "A pending first acknowledgement is permitted; the explicit actual-result follow-up must report real closure, never task completion alone.");
|
||||
&& !!result && result.caseRecord.status === "done" && !!result.caseRecord.completedAt && closeResultTurnGrounded(o, turn, original),
|
||||
"A pending first acknowledgement is permitted; the explicit actual-result follow-up is a separate reply whose run read the saved closed case and which links that case.");
|
||||
const later = result && o.reply.startsWith(result.reply) ? o.reply.slice(result.reply.length) : "";
|
||||
c.add("unresolved-required-review-does-not-close", !!o.negativeCase && !!o.negativeTask && !!o.pendingReview && o.negativeCase.workIssueId === o.negativeTask.id
|
||||
&& o.negativeCase.status !== "done" && !o.negativeCase.completedAt && o.negativeTask.status !== "done" && o.pendingReview.status === "backlog"
|
||||
@@ -75,8 +113,8 @@ export const integratorCaseCloseCases = [integratorDefinition("cases", "cases.cl
|
||||
}, observeClose, gradeIntegratorCaseClose, async (ctx, state) => {
|
||||
const row = state.case as CaseRecord; const primary = state.task as Issue; await inspectResult(ctx, `/${ctx.input.fixtures.company.issuePrefix}/cases/${row.identifier}`, row.title);
|
||||
await inspectResult(ctx, `/${ctx.input.fixtures.company.issuePrefix}/issues/${primary.identifier}`, primary.title);
|
||||
}, { expectedRunCount: 7, minimumRunCount: 6, expectedHumanMessageCount: 3, attemptTimeoutMs: 1_500_000, cleanup,
|
||||
sourceFiles: ["chat-catalog-domains/integrator-case-close.ts", "chat-catalog-domains/integrator-support.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/internal-definition.ts", "chat-scenarios/cases.yaml"],
|
||||
}, { oracleVersion: "paperclip.chat-catalog.integrator.close-case.v2", expectedRunCount: 7, minimumRunCount: 6, expectedHumanMessageCount: 3, attemptTimeoutMs: 1_500_000, cleanup,
|
||||
sourceFiles: ["chat-catalog-domains/integrator-case-close.ts", "chat-catalog-domains/api-read-receipts.ts", "run-observations.ts", "chat-catalog-domains/integrator-support.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/internal-definition.ts", "chat-scenarios/cases.yaml"],
|
||||
async act(ctx, state) {
|
||||
const first = await pollUntil({ label: "initial explicit closure direction", deadlineAt: Date.now() + 180_000, intervalMs: 1000, load: () => observeClose(ctx, state),
|
||||
accept: o => ctx.issue()?.conversationState === "waiting" && o.runs.some(run => run.agentId === o.leadId && run.contextSnapshot?.issueId === ctx.issue()?.id && run.status === "succeeded") });
|
||||
@@ -85,8 +123,10 @@ export const integratorCaseCloseCases = [integratorDefinition("cases", "cases.cl
|
||||
await ctx.input.evidence("chat-case-terminal-direction.json", state.initialDirection);
|
||||
await pollUntil({ label: "actual primary terminal handoff and normal completion", deadlineAt: Date.now() + 240_000, intervalMs: 1000, load: () => observeClose(ctx, state),
|
||||
accept: o => o.caseRecord.status === "done" && o.handoffs.some(handoff => handoff.destinationKind === "done" && handoff.state === "completed") && o.runs.length === 3 && o.runs.every(run => run.status === "succeeded") });
|
||||
const { sendChatMessage } = await import("../chat-flow.js"); await sendChatMessage(ctx.input.page, `What is the actual saved closure result for case ${(state.case as CaseRecord).identifier}?`);
|
||||
const question = `What is the actual saved closure result for case ${(state.case as CaseRecord).identifier}?`;
|
||||
const { sendChatMessage } = await import("../chat-flow.js"); await sendChatMessage(ctx.input.page, question);
|
||||
const result = await pollUntil({ label: "actual terminal result reported in chat", deadlineAt: Date.now() + 180_000, intervalMs: 1000, load: () => observeClose(ctx, state), accept: o => o.runs.length === 4 && o.runs.every(run => run.status === "succeeded") && ctx.issue()?.conversationState === "waiting" });
|
||||
state.resultTurn = await closeResultTurn(ctx, question, state.case as CaseRecord);
|
||||
state.acceptedResult = { ...result, state: structuredClone(state) }; await ctx.input.evidence("chat-case-terminal-result.json", state.acceptedResult);
|
||||
const pendingReview = await task(ctx, "Required independent review remains unresolved", (state.project as Entity).id); state.pendingReview = pendingReview;
|
||||
const second = await ctx.input.api.post<HandoffCaseResult>(`/api/companies/${ctx.input.fixtures.company.id}/case-handoffs`, { idempotencyKey: `unreviewed-${ctx.input.nonce}`, case: { create: { caseType: "customer", title: "Unreviewed separate correction", projectId: (state.project as Entity).id } },
|
||||
|
||||
@@ -330,7 +330,7 @@ function define(id: string, seed: "none" | "ceo" | "morgan", extra: Partial<Chat
|
||||
export const integratorStatusCardsCases: readonly ChatCatalogCase[] = [
|
||||
define("status-cards.create-card", "none", { oracleVersion: "paperclip.chat-catalog.status-card.create.v2" }),
|
||||
define("status-cards.archive-card", "ceo", { oracleVersion: "paperclip.chat-catalog.status-card.archive.v3", authentication: "human-session", queryAttachments: factualContextQueryAttachment("selected-status-card.md"), expectedRunCount: 3, attemptTimeoutMs: 720_000 }),
|
||||
define("status-cards.other-authors-card", "morgan", { oracleVersion: "paperclip.chat-catalog.status-card.other-authors.v2", expectedRunCount: 5, minimumRunCount: 2 }),
|
||||
define("status-cards.other-authors-card", "morgan", { oracleVersion: "paperclip.chat-catalog.status-card.other-authors.v3", expectedRunCount: 5, minimumRunCount: 2 }),
|
||||
define("status-cards.explain-card", "ceo", { oracleVersion: "paperclip.chat-catalog.status-card.explain.v5", authentication: "human-session", queryAttachments: factualContextQueryAttachment("selected-status-card.md"), expectedRunCount: 5, minimumRunCount: 3, expectedHumanMessageCount: 2, attemptTimeoutMs: 720_000,
|
||||
sourceFiles: ["chat-catalog-domains/integrator-status-cards.ts", "chat-catalog-domains/api-read-receipts.ts", "chat-catalog-domains/identity-support.ts", "chat-catalog-domains/internal-definition.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/integrator-support.ts", "run-observations.ts", "chat-scenarios/status-cards.yaml"],
|
||||
async act(ctx, state) {
|
||||
|
||||
@@ -49,7 +49,7 @@ function privateDelegation(): ChatCatalogCase {
|
||||
await putDocument(ctx, source.id, `# Confidential launch brief\n${confidentialMarker}. Keep the announcement inside Private Launch and its authorized team.\n`);
|
||||
return { project: p, worker, ownerId, observerId: observer.id, sourceId: source.id, confidentialMarker };
|
||||
}, gradePrivateDelegation);
|
||||
return { ...base, authentication: "human-session", expectedRunCount: 3, minimumRunCount: 2,
|
||||
return { ...base, oracleVersion: "paperclip.chat-catalog.internal.private-task.v2", authentication: "human-session", expectedRunCount: 3, minimumRunCount: 2,
|
||||
runPolicy: { kind: "source-settled-with-active-work", maximumActiveWorkerRuns: 1 },
|
||||
sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-private-delegation.ts", "chat-catalog-domains/internal-privacy.ts", "chat-catalog-domains/identity-auth.ts", "chat-catalog-domains/identity-support.ts"],
|
||||
async observe(ctx, state): Promise<PrivateDelegationObservation> {
|
||||
|
||||
@@ -112,7 +112,8 @@ function creationDefinition(scenarioId: string): ChatCatalogCase {
|
||||
if (scenarioId === "tasks.website-without-project") interception = await dropChatSendAcknowledgement(ctx.input.page, ctx.scenario.query);
|
||||
return { worker, priorTasks };
|
||||
}, gradeDelegatedCreation);
|
||||
return { ...base, observe: observeCreation, sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-task-creation.ts", "lost-send.ts"],
|
||||
return { ...base, ...(scenarioId === "projects.project-work" ? { oracleVersion: "paperclip.chat-catalog.internal.project-work.v2" } : {}),
|
||||
observe: observeCreation, sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-task-creation.ts", "lost-send.ts"],
|
||||
expectedRunCount: 3, minimumRunCount: scenarioId === "projects.project-work" ? 1 : 2,
|
||||
runPolicy: { kind: "source-settled-with-active-work", maximumActiveWorkerRuns: 1 },
|
||||
async act(ctx, state) {
|
||||
|
||||
@@ -144,7 +144,7 @@ export const runtimeDelegationCases = [runtimeDefinition("task-coordination.dele
|
||||
humanId: session.user.id, briefCommentId: briefComment.id, briefAttachment: attachment, briefHash: hash(Buffer.from(brief)),
|
||||
briefDownloadedHash: hash(await ctx.input.api.getAttachmentContent(attachment.id)), sourceId: ctx.issue()!.id };
|
||||
}, gradeDelegation, {
|
||||
oracleVersion: "paperclip.chat-catalog.runtime-delegation.v4",
|
||||
oracleVersion: "paperclip.chat-catalog.runtime-delegation.v5",
|
||||
authentication: "human-session", setupHumanMessageCount: 1, expectedHumanMessageCount: 2, expectedRunCount: 4, attemptTimeoutMs: 900_000,
|
||||
sourceFiles: ["chat-catalog-domains/runtime-delegation.ts", "chat-catalog-domains/runtime-busy-worker.ts", "chat-catalog-domains/runtime-support.ts", "chat-catalog-domains/identity-support.ts", "completion-update-flow.ts", "completion-updates.ts", "registered-artifact.ts", "chat-scenarios/task-coordination.yaml"],
|
||||
async act(ctx, state) {
|
||||
|
||||
@@ -19,7 +19,8 @@ vi.mock("./chat-catalog-registry.js", () => ({
|
||||
vi.mock("./chat-catalog-fingerprint.js", () => ({ chatCatalogCaseFingerprint: () => ({}), normalizedChatCatalogExecution: () => ({}) }));
|
||||
vi.mock("./chat-query-bindings.js", () => ({ resolveChatCatalogQuery: async () => { if (control.completeQuery) return { renderedQuery: "Original request" }; throw new Error("Original attempt failed"); } }));
|
||||
vi.mock("./chat-rendered-links.js", () => ({ collectConversationLinks: async () => [] }));
|
||||
vi.mock("./chat-catalog-run-policy.js", () => ({ gradeChatCatalogRuns: () => ({ checks: [] }) }));
|
||||
vi.mock("./chat-catalog-run-policy.js", () => ({ gradeChatCatalogRuns: () => ({ checks: [] }), CHAT_CATALOG_MAX_SUPERSEDED_QUEUED_RUNS: 1,
|
||||
chatCatalogProviderRuns: (runs: unknown[]) => runs, chatCatalogSupersededQueuedRuns: () => [], chatCatalogReassignedQueuedRun: () => false }));
|
||||
vi.mock("./chat-flow.js", () => ({ sendChatMessage: async () => {}, collectChatRunEvidence: async () => { control.enter(); if (control.stall) await control.collectorRelease; return {}; } }));
|
||||
vi.mock("./api.js", () => ({ pollUntil: async ({ load }: { load: () => Promise<unknown> }) => { await load(); throw new Error("Original attempt failed"); } }));
|
||||
import { runChatCatalogFlow } from "./chat-catalog-flow.js";
|
||||
|
||||
@@ -6,7 +6,7 @@ import { chatNativeTimeoutEvidence } from "./chat-native-timeout-policy.js";
|
||||
import { completeNativeSlackFileConditional, dispatchNativeSlackIngress, prepareNativeSlackOtherThread, slackOtherThreadBoundaryMatches, slackNativeRunCheck } from "./chat-slack-ingress.js";
|
||||
import { runScopedCompanionSurface } from "./chat-companion-surface.js";
|
||||
import { chatQueryWithAttachments, resolveChatQueryAttachments, verifyChatQueryAttachments, type ChatQueryAttachmentReceipt } from "./chat-query-attachments.js";
|
||||
import { chatCatalogNeverStartedTerminalTaskRun, chatCatalogRunStatusAllowed, gradeChatCatalogRuns, createChatCatalogFinalRunVerifier } from "./chat-catalog-run-policy.js";
|
||||
import { CHAT_CATALOG_MAX_SUPERSEDED_QUEUED_RUNS, chatCatalogNeverStartedTerminalTaskRun, chatCatalogProviderRuns, chatCatalogReassignedQueuedRun, chatCatalogRunStatusAllowed, chatCatalogSupersededQueuedRuns, gradeChatCatalogRuns, createChatCatalogFinalRunVerifier } from "./chat-catalog-run-policy.js";
|
||||
import { chatCatalogCaseFingerprint, normalizedChatCatalogExecution } from "./chat-catalog-fingerprint.js";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { pollUntil } from "./api.js";
|
||||
@@ -22,6 +22,12 @@ import { markReturningBrowser } from "./chat-returning-browser.js";
|
||||
/** The case owns product semantics; this flow owns the actual chat entry point and evidence lifecycle. */
|
||||
export async function runChatCatalogFlow(context: ChatCatalogContext) {
|
||||
const { input, scenario } = context;
|
||||
// Cases grade provider work. A queued run retired by reassignment before it
|
||||
// started, whose task's new assignee ran, is lifecycle: cases do not see it,
|
||||
// while the conversation evidence and the run policy keep the raw ledger.
|
||||
const readRuns = context.allRuns;
|
||||
const rawRuns = () => readRuns.call(context);
|
||||
if (typeof readRuns === "function") context.allRuns = async () => chatCatalogProviderRuns(await rawRuns(), context.issue());
|
||||
const descriptor = findChatCatalogCase(scenario.id);
|
||||
if (!descriptor) throw new Error(`No executable chat scenario: ${scenario.id}`);
|
||||
const fingerprint = chatCatalogCaseFingerprint(descriptor, normalizedChatCatalogExecution(input.execution));
|
||||
@@ -46,7 +52,7 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) {
|
||||
async function conversation(persist = true) {
|
||||
await context.refreshIssue();
|
||||
const issue = context.issue();
|
||||
const runs = await context.allRuns();
|
||||
const runs = await rawRuns();
|
||||
const comments = issue ? await input.api.get<Array<{ id: string; body: string; authorAgentId?: string; authorUserId?: string; issueId?: string; companyId?: string; createdByRunId?: string }>>(
|
||||
`/api/issues/${issue.id}/comments?order=asc`,
|
||||
) : [];
|
||||
@@ -59,7 +65,8 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) {
|
||||
origin: input.api.baseURL, companyId: input.fixtures.company.id, companyPrefix: input.fixtures.company.issuePrefix,
|
||||
sourceId: issue.id, leadId: input.fixtures.agent.id, comments, runs, projects, activity,
|
||||
}) : [];
|
||||
const evidence = { issue, runs, comments, renderedLinks };
|
||||
const evidence = { issue, runs, comments, renderedLinks,
|
||||
supersededQueuedRunIds: chatCatalogSupersededQueuedRuns(runs, issue).map(run => run.id) };
|
||||
if (persist) await input.evidence("chat-catalog-conversation.json", evidence);
|
||||
return evidence;
|
||||
}
|
||||
@@ -209,8 +216,12 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) {
|
||||
return Date.now() - passedAt >= 5_000;
|
||||
},
|
||||
reject: result => {
|
||||
if (result.runs.length > implementation.expectedRunCount) return "Scenario exceeded its declared run bound";
|
||||
if (result.runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status) && !chatCatalogNeverStartedTerminalTaskRun(run, result.issue) && !chatCatalogRunStatusAllowed({ implementation,
|
||||
if (chatCatalogProviderRuns(result.runs, result.issue).length > implementation.expectedRunCount
|
||||
|| chatCatalogSupersededQueuedRuns(result.runs, result.issue).length > CHAT_CATALOG_MAX_SUPERSEDED_QUEUED_RUNS) return "Scenario exceeded its declared run bound";
|
||||
// A queued run retired by reassignment before it started is graded, not rejected:
|
||||
// the run policy admits it only once the new assignee's run exists.
|
||||
if (result.runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status) && !chatCatalogNeverStartedTerminalTaskRun(run, result.issue)
|
||||
&& !chatCatalogReassignedQueuedRun(run, result.issue) && !chatCatalogRunStatusAllowed({ implementation,
|
||||
run, companyId: input.fixtures.company.id, leadId: input.fixtures.agent.id, source: result.issue, observation }))) return "Observed provider run failed";
|
||||
if (result.runs.length && result.runs.every(run => !["queued", "running"].includes(run.status)) && result.issue?.conversationState === "waiting") settledAt ??= Date.now();
|
||||
else settledAt = undefined;
|
||||
@@ -280,7 +291,7 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) {
|
||||
if (conversationUrl) await reopenConversation(input.page, conversationUrl);
|
||||
const finalConversation = await conversation();
|
||||
if (state) await refresh();
|
||||
const runs = await context.allRuns();
|
||||
const runs = await rawRuns();
|
||||
const runEvidence = await Promise.all(runs.map(run => collectChatRunEvidence(chatRunEvidenceApi(input), run)));
|
||||
await input.evidence("chat-catalog-runs.json", runEvidence);
|
||||
await input.evidence("api-state.json", { capturePhase: failed ? "failure" : "final",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { gradeChatCatalogRuns, chatCatalogAllowedRunStatuses, assertExpectedWorkerCancellation, chatCatalogRunStatusAllowed, createChatCatalogFinalRunVerifier, chatCatalogNeverStartedTerminalTaskRun } from "./chat-catalog-run-policy.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gradeChatCatalogRuns, chatCatalogAllowedRunStatuses, assertExpectedWorkerCancellation, chatCatalogRunStatusAllowed, createChatCatalogFinalRunVerifier, chatCatalogNeverStartedTerminalTaskRun, chatCatalogReassignedQueuedRun, chatCatalogSupersededQueuedRuns, chatCatalogProviderRuns } from "./chat-catalog-run-policy.js";
|
||||
import type { ChatRun } from "./chat-flow.js";
|
||||
const succeeded: ChatRun = { id: "source-run", companyId: "company", agentId: "ceo", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "source" } };
|
||||
const worker: ChatRun = { id: "worker-run", companyId: "company", agentId: "worker", status: "running", runtimeMode: "native", contextSnapshot: { issueId: "task" } };
|
||||
@@ -140,3 +140,49 @@ it("admits a declared role-bound range above an exact terminal count, and nothin
|
||||
const undeclared = { ...implementation, runPolicy: { ...implementation.runPolicy, maximumTerminalStatusCounts: { failed: 1 } } };
|
||||
expect(gradeChatCatalogRuns({ ...scope, implementation: undeclared, runs: [cancelled, ok("a"), ok("b"), ok("c")] }).passed).toBe(false);
|
||||
});
|
||||
|
||||
describe("queued run retired by reassignment before it started (full cohort 3)", () => {
|
||||
// projects.project-work: the CEO created the task without an assignee (its own queued run), then
|
||||
// reassigned it to Morgan; the reassignment stopped the run while still bootstrapping.
|
||||
const reassigned: ChatRun = { id: "ceo-queued", companyId: "company", agentId: "ceo", status: "cancelled", runtimeMode: "legacy",
|
||||
startedAt: null, logStore: null, logRef: null, nativeIssueId: null, errorCode: "issue_reassigned", createdAt: "2026-11-12T00:55:19.299Z",
|
||||
contextSnapshot: { issueId: "task", wakeReason: "issue_assigned" },
|
||||
resultJson: { stopReason: "cancelled", reassignmentStopConfirmed: true, executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } };
|
||||
// tasks.private-task: the dispatch gate found the same kind of queued wake stale after the PATCH.
|
||||
const staleGate: ChatRun = { ...reassigned, id: "ceo-stale", errorCode: "issue_assignee_changed",
|
||||
resultJson: { stopReason: "issue_assignee_changed", timeoutFired: false, timeoutSource: "stale_queued_run_gate" } };
|
||||
// Paid run 43143392360 (tasks.private-task): the new assignee's wake released the lock the queued run held.
|
||||
const lockReleased: ChatRun = { ...reassigned, id: "ceo-lock", errorCode: "lock_released_on_reassignment", resultJson: undefined };
|
||||
const morgan: ChatRun = { ...worker, id: "morgan-run", createdAt: "2026-11-12T00:55:56.398Z" };
|
||||
const implementation = { expectedRunCount: 2, runPolicy: { kind: "source-settled-with-active-work" as const, maximumActiveWorkerRuns: 1 } };
|
||||
it("admits it, outside the provider-run bound, only when the new assignee ran on that task", () => {
|
||||
for (const queued of [reassigned, staleGate, lockReleased]) {
|
||||
expect(chatCatalogReassignedQueuedRun(queued, scope.source)).toBe(true);
|
||||
const runs = [succeeded, queued, morgan];
|
||||
expect(chatCatalogSupersededQueuedRuns(runs, scope.source).map(run => run.id)).toEqual([queued.id]);
|
||||
expect(chatCatalogProviderRuns(runs, scope.source).map(run => run.id)).toEqual([succeeded.id, morgan.id]);
|
||||
const grade = gradeChatCatalogRuns({ ...scope, implementation, runs });
|
||||
expect(grade.passed).toBe(true);
|
||||
expect(grade.checks.find(check => check.id === "catalog-superseded-queued-runs")?.passed).toBe(true);
|
||||
}
|
||||
});
|
||||
it("still fails without a successor, for started or other cancellations, on the source, and for churn", () => {
|
||||
// No new assignee run: the reassignment never produced work.
|
||||
expect(gradeChatCatalogRuns({ ...scope, implementation, runs: [succeeded, reassigned] }).passed).toBe(false);
|
||||
// A successor must be another agent's later run on the same task.
|
||||
for (const other of [{ ...morgan, agentId: "ceo" }, { ...morgan, contextSnapshot: { issueId: "elsewhere" } }, { ...morgan, createdAt: "2026-11-12T00:55:00.000Z" }])
|
||||
expect(gradeChatCatalogRuns({ ...scope, implementation: { ...implementation, expectedRunCount: 3 }, runs: [succeeded, reassigned, other] }).passed).toBe(false);
|
||||
for (const run of [{ ...reassigned, startedAt: "2026-11-12T00:55:20.000Z" }, { ...reassigned, logRef: "log" }, { ...reassigned, errorCode: "cancelled" },
|
||||
{ ...reassigned, resultJson: { ...reassigned.resultJson, executionRecovery: { kind: "bootstrap", providerWorkStarted: true } } },
|
||||
{ ...reassigned, resultJson: { stopReason: "cancelled" } }, { ...staleGate, resultJson: { stopReason: "issue_assignee_changed" } },
|
||||
{ ...reassigned, contextSnapshot: { issueId: "source" } }, { ...reassigned, status: "failed" },
|
||||
{ ...lockReleased, startedAt: "2026-11-12T00:55:20.000Z" }, { ...lockReleased, nativeIssueId: "task" }]) {
|
||||
expect(chatCatalogReassignedQueuedRun(run, scope.source)).toBe(false);
|
||||
expect(gradeChatCatalogRuns({ ...scope, implementation: { ...implementation, expectedRunCount: 3 }, runs: [succeeded, run, morgan] }).passed).toBe(false);
|
||||
}
|
||||
// At most one superseded queued run per case.
|
||||
const second = { ...staleGate, contextSnapshot: { issueId: "task-2" } }, morgan2 = { ...morgan, id: "morgan-2", status: "succeeded", contextSnapshot: { issueId: "task-2" } };
|
||||
const churn = gradeChatCatalogRuns({ ...scope, implementation: { ...implementation, expectedRunCount: 3 }, runs: [succeeded, reassigned, morgan, second, morgan2] });
|
||||
expect(churn.checks.find(check => check.id === "catalog-superseded-queued-runs")?.passed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChatCatalogCase, ChatCatalogGrade } from "./chat-catalog-contract.js";
|
||||
import type { ChatIssue, ChatRun } from "./chat-flow.js";
|
||||
import { isAssigneeChangedUnstartedRun } from "./non-execution-wake.js";
|
||||
type CancellationCase = Pick<ChatCatalogCase, "runPolicy"> & Partial<Pick<ChatCatalogCase, "scenarioId" | "expectedCancelledWorkerRun">>;
|
||||
export function assertExpectedWorkerCancellation(implementation: CancellationCase) {
|
||||
const flag = implementation.runPolicy?.kind === "source-settled-with-active-work"
|
||||
@@ -43,17 +44,51 @@ export function chatCatalogNeverStartedTerminalTaskRun(run: ChatRun, source?: Ch
|
||||
&& record(run.resultJson).stopReason === "issue_terminal_status"
|
||||
&& typeof issueId === "string" && issueId.length > 0 && issueId !== source?.id;
|
||||
}
|
||||
/**
|
||||
* A queued run on a non-source task that the server retired because the task
|
||||
* changed assignee before the run started. Early polling defers it to the grade
|
||||
* instead of reporting a provider failure; it is admitted there only with a
|
||||
* successor (see chatCatalogSupersededQueuedRuns). Other cancellations stay failures.
|
||||
*/
|
||||
export function chatCatalogReassignedQueuedRun(run: ChatRun, source?: ChatIssue): boolean {
|
||||
const issueId = taskId(run);
|
||||
return isAssigneeChangedUnstartedRun(run) && typeof issueId === "string" && issueId.length > 0 && issueId !== source?.id;
|
||||
}
|
||||
/**
|
||||
* The superseded queued run did no provider work, so it is not counted toward
|
||||
* the declared provider-run bound. It stays in the raw ledger and evidence, and
|
||||
* a case may have at most this many, so create-then-reassign churn still fails.
|
||||
*/
|
||||
export const CHAT_CATALOG_MAX_SUPERSEDED_QUEUED_RUNS = 1;
|
||||
/** Retired queued runs whose task's new assignee actually received a later run on that task. */
|
||||
export function chatCatalogSupersededQueuedRuns(runs: ChatRun[], source?: ChatIssue): ChatRun[] {
|
||||
return runs.filter(run => {
|
||||
const created = Date.parse(run.createdAt ?? "");
|
||||
return chatCatalogReassignedQueuedRun(run, source) && Number.isFinite(created) && runs.some(other => other.id !== run.id
|
||||
&& taskId(other) === taskId(run) && other.companyId === run.companyId && other.agentId !== run.agentId
|
||||
&& !chatCatalogReassignedQueuedRun(other, source) && Date.parse(other.createdAt ?? "") >= created);
|
||||
});
|
||||
}
|
||||
/** The raw ledger without superseded queued runs: the runs that count as provider work. */
|
||||
export function chatCatalogProviderRuns(runs: ChatRun[], source?: ChatIssue): ChatRun[] {
|
||||
const superseded = new Set(chatCatalogSupersededQueuedRuns(runs, source).map(run => run.id));
|
||||
return runs.filter(run => !superseded.has(run.id));
|
||||
}
|
||||
export function gradeChatCatalogRuns(input: {
|
||||
implementation: Pick<ChatCatalogCase, "expectedRunCount" | "minimumRunCount" | "runPolicy"> & CancellationCase;
|
||||
companyId: string; leadId: string; source?: ChatIssue; runs: ChatRun[];
|
||||
observation?: unknown;
|
||||
}): ChatCatalogGrade {
|
||||
const { implementation: c, runs, source } = input;
|
||||
const { implementation: c, source } = input;
|
||||
const superseded = chatCatalogSupersededQueuedRuns(input.runs, source);
|
||||
const runs = chatCatalogProviderRuns(input.runs, source);
|
||||
const checks = [
|
||||
{ id: "catalog-bounded-native-runs", passed: runs.length >= (c.minimumRunCount ?? c.expectedRunCount) && runs.length <= c.expectedRunCount
|
||||
&& runs.every(run => run.companyId === input.companyId && run.runtimeMode === "native" && chatCatalogAllowedRunStatuses(c).includes(run.status)),
|
||||
detail: "Observed provider runs satisfy declared count, company, native runtime and allowed outcome bounds." },
|
||||
];
|
||||
if (superseded.length) checks.push({ id: "catalog-superseded-queued-runs", passed: superseded.length <= CHAT_CATALOG_MAX_SUPERSEDED_QUEUED_RUNS,
|
||||
detail: "A queued run retired before it started because its task changed assignee is lifecycle, not provider work, only when the new assignee ran on that task; at most one per case." });
|
||||
if (c.expectedCancelledWorkerRun !== undefined || c.runPolicy?.kind === "source-settled-with-active-work" && c.runPolicy.expectedWorkerCancellation !== undefined) {
|
||||
let valid = true; try { assertExpectedWorkerCancellation(c); } catch { valid = false; }
|
||||
checks.push({ id: "catalog-worker-cancellation-declaration", passed: valid,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prepareChatCatalogRespondent } from "./chat-catalog-respondent.js";
|
||||
import type { ChatPrivateInputs } from "./chat-private-inputs.js";
|
||||
import type { ChatCatalogFinalRunVerifier } from "./chat-catalog-run-policy.js";
|
||||
import { chatCatalogProviderRuns, type ChatCatalogFinalRunVerifier } from "./chat-catalog-run-policy.js";
|
||||
import { runHiringTemplateFlow } from "./hiring-template-flow.js";
|
||||
import { gradeHiringTemplateTurns } from "./hiring-template-turn-accounting.js";
|
||||
import type { HiringTemplateEvidence } from "./hiring-template-scoring.js";
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
} from "../../packages/shared/src/types/issue.js";
|
||||
import type { LiveFixtureValues } from "./live-fixtures.js";
|
||||
import type { MatrixExecution } from "./types.js";
|
||||
import { chatProviderRuns, classifyPreDispatchExecutionWait, isBlockedUnstartedWake, isReassignedUnstartedWake, verifyChatAdmissionWaitCoverage, type ChatAdmissionWait } from "./non-execution-wake.js";
|
||||
import { chatProviderRuns, classifyPreDispatchExecutionWait, isAssigneeChangedUnstartedRun, isBlockedUnstartedWake, verifyChatAdmissionWaitCoverage, type ChatAdmissionWait } from "./non-execution-wake.js";
|
||||
import { collectRunEvents } from "./run-observations.js";
|
||||
import { chatMarker } from "./chat-cases.js";
|
||||
import { assertChatRememberedAfterRestart, assertChatStartupStopped, isChatStopReady, runChatHardeningFlow } from "./chat-hardening.js";
|
||||
@@ -82,6 +82,7 @@ export interface ChatRun {
|
||||
sessionIdBefore?: string | null;
|
||||
sessionIdAfter?: string | null;
|
||||
startedAt?: string | null;
|
||||
createdAt?: string;
|
||||
nativeIssueId?: string | null;
|
||||
}
|
||||
type Comment = {
|
||||
@@ -323,7 +324,7 @@ export async function collectChatRunEvidence(
|
||||
const providerNeverStarted = run.runtimeMode === "legacy" && !run.runtimeModeResolvedAt &&
|
||||
run.logStore === null && run.logRef === null && ["failed", "interrupted"].includes(run.status) &&
|
||||
recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
|
||||
const reassignedBeforeStart = isReassignedUnstartedWake(run);
|
||||
const reassignedBeforeStart = isAssigneeChangedUnstartedRun(run);
|
||||
const terminalBeforeStart = run.status === "cancelled" && run.errorCode === "issue_terminal_status" &&
|
||||
run.startedAt === null && run.logStore === null && run.logRef === null;
|
||||
return {
|
||||
@@ -429,7 +430,10 @@ export async function runChatFlow(input: ChatFlowInput) {
|
||||
const admissionAccounting = execution.suite.id === "kimi-qualification" && caseId === "stop-new-resume" && execution.profile.generation === "native";
|
||||
let admissionWaits: ChatAdmissionWait[] = [];
|
||||
let stoppedForSessionReset: string | undefined;
|
||||
const providerRuns = () => admissionAccounting ? chatProviderRuns(runs, admissionWaits) : runs.filter(run => !isResetRun(run));
|
||||
// A catalog task's queued run retired by reassignment before it started, with
|
||||
// the new assignee's run present, is lifecycle: kept in `runs`, not provider work.
|
||||
const providerRuns = () => admissionAccounting ? chatProviderRuns(runs, admissionWaits)
|
||||
: catalogDescriptor ? chatCatalogProviderRuns(runs.filter(run => !isResetRun(run)), issue) : runs.filter(run => !isResetRun(run));
|
||||
const settings = await api.get<Record<string, unknown>>(
|
||||
"/api/instance/settings/experimental",
|
||||
);
|
||||
|
||||
@@ -16,6 +16,24 @@ export function isReassignedUnstartedWake(run: {
|
||||
&& run.resultJson.timeoutSource === "stale_queued_run_gate" && run.resultJson.timeoutFired === false;
|
||||
}
|
||||
|
||||
/** A queued run the server retired because its task changed assignee before any
|
||||
* provider work: the dispatch gate found the queued wake stale
|
||||
* (`issue_assignee_changed`), the new assignee's wake released the lock it held
|
||||
* (`lock_released_on_reassignment`), or the reassignment stopped it while it was
|
||||
* still bootstrapping (`issue_reassigned`, stop confirmed, provider work not started). */
|
||||
export function isAssigneeChangedUnstartedRun(run: {
|
||||
status?: unknown; errorCode?: unknown; startedAt?: unknown; nativeIssueId?: unknown;
|
||||
logStore?: unknown; logRef?: unknown; resultJson?: Record<string, unknown>;
|
||||
}) {
|
||||
if (run.status !== "cancelled" || run.startedAt !== null || run.logStore != null || run.logRef != null) return false;
|
||||
if (isReassignedUnstartedWake(run)) return true;
|
||||
// The issue's new assignee claimed the execution lock that this queued run still held (heartbeat queue).
|
||||
if (run.errorCode === "lock_released_on_reassignment" && run.nativeIssueId == null) return true;
|
||||
const result = run.resultJson, recovery = object(result?.executionRecovery);
|
||||
return run.errorCode === "issue_reassigned" && run.nativeIssueId == null && result?.reassignmentStopConfirmed === true
|
||||
&& recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
|
||||
}
|
||||
|
||||
/** A terminal issue may make a queued wake obsolete before provider admission. */
|
||||
export function isTerminalUnstartedWake(
|
||||
run: Record<string, unknown>,
|
||||
|
||||
Reference in new issue
Block a user