mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 14:02:36 +01:00
test(e2e): cover secure keys, held resume and draft upload recovery
This commit is contained in:
1 parent
f4ef7e81b9
commit
0d22c3cc1d
8 files changed
+362
-4
No files matched your search
@@ -0,0 +1,24 @@
|
||||
import { it, expect } from "vitest";
|
||||
import { gradeAgentKey } from "./runtime-agent-keys.js";
|
||||
function valid() {
|
||||
const worker = { id: "morgan", companyId: "company", name: "Morgan", permissions: {}, adapterConfig: {} };
|
||||
const key = { id: "key", name: "local integration", revokedAt: null as string | null };
|
||||
return { scenarioId: "agents.agent-api-key", origin: "http://fixture.invalid", companyId: "company", leadId: "ceo",
|
||||
state: { worker, agentCreateStatus: 403, keysBeforeHuman: 0, humanCreated: true, createdKey: key, createdKeyAgentId: "morgan", createdKeyCompanyId: "company",
|
||||
secureUntracedContext: true, tokenShownOnce: true, tokenAbsentAfterReload: true, privateScanComplete: true, tokenLeaked: false,
|
||||
revokedCredentialStatus: 401, finalKeys: [{ ...key, revokedAt: "2026-10-26T00:00:00Z" }] },
|
||||
agents: [structuredClone(worker)], tasks: [], links: [], reply: "Use Morgan's profile API key controls; the board can create the credential securely.",
|
||||
activity: ["agent.key_created", "agent.key_revoked"].map(action => ({ id: action, action, entityId: "morgan", actorType: "user", details: { keyId: "key" } })),
|
||||
runs: [], humanQueries: [] };
|
||||
}
|
||||
it("accepts secure one-time human creation and actual credential revocation", () => expect(gradeAgentKey(valid()).passed).toBe(true));
|
||||
it("rejects missing human credential evidence", () => expect(gradeAgentKey(undefined).passed).toBe(false));
|
||||
it("rejects a key that authenticates a different agent", () => {
|
||||
const o = valid(); o.state.createdKeyAgentId = "other"; expect(gradeAgentKey(o).passed).toBe(false);
|
||||
});
|
||||
it("rejects revocation metadata while the credential still works", () => {
|
||||
const o = valid(); o.state.revokedCredentialStatus = 200; expect(gradeAgentKey(o).passed).toBe(false);
|
||||
});
|
||||
it("rejects credentials copied into conversation artifacts", () => {
|
||||
const o = valid(); o.state.tokenLeaked = true; expect(gradeAgentKey(o).passed).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { checks } from "./internal-support.js";
|
||||
import { waitForReply, probeAgentPermission } from "./identity-support.js";
|
||||
import { runtimeDefinition, runtimeWorker, runtimeObservation, observeRuntime, agentLink, unchangedAgent, type RuntimeAgent } from "./runtime-support.js";
|
||||
interface KeyRow { id: string; name: string; revokedAt: string | null }
|
||||
export function gradeAgentKey(value: unknown) {
|
||||
const c = checks(); const o = runtimeObservation(value);
|
||||
c.add("independent-human-key-evidence", !!o, "Real human key controls, safe metadata readback and private credential scans are required."); if (!o) return c.result();
|
||||
const worker = o.state.worker as RuntimeAgent; const current = o.agents.find(row => row.id === worker.id);
|
||||
const key = o.state.createdKey as KeyRow | undefined; const revoked = (o.state.finalKeys as KeyRow[] | undefined)?.find(row => row.id === key?.id);
|
||||
c.add("human-only-creation", o.state.agentCreateStatus === 403 && o.state.keysBeforeHuman === 0 && o.state.humanCreated === true
|
||||
&& !!key && o.state.createdKeyAgentId === worker.id && o.state.createdKeyCompanyId === o.companyId && key.name === "local integration" && !key.revokedAt,
|
||||
"Only the actual authorized human creates the intended named key for Morgan.");
|
||||
c.add("one-time-private-interface", o.state.secureUntracedContext === true && o.state.tokenShownOnce === true
|
||||
&& o.state.tokenAbsentAfterReload === true && o.state.privateScanComplete === true && o.state.tokenLeaked === false,
|
||||
"The one-time display is observed privately, disappears on reload, and never appears in conversation evidence.");
|
||||
c.add("revocation-preserves-agent-identity", !!revoked?.revokedAt && o.state.revokedCredentialStatus === 401 && !!current && unchangedAgent(worker, current),
|
||||
"The same key is revoked through the human control and Morgan's identity, runtime and authority remain unchanged.");
|
||||
c.add("attributed-key-lifecycle", !!key && ["agent.key_created", "agent.key_revoked"].every(action => o.activity.some(row => row.action === action
|
||||
&& row.entityId === worker.id && row.actorType === "user" && !row.agentId && row.details?.keyId === key.id)),
|
||||
"Independent activity records attribute both credential operations to the human.");
|
||||
c.add("honest-secure-human-path", /key|credential/i.test(o.reply) && /profile|control|you|human|board/i.test(o.reply)
|
||||
&& !/I(?:'ve| have)? (?:created|generated|issued)/i.test(o.reply), "The reply provides the supported secure human path without claiming an agent-created credential.");
|
||||
return c.result();
|
||||
}
|
||||
export const runtimeAgentKeyCases = [runtimeDefinition("agents.agent-api-key", async ctx => ({ worker: await runtimeWorker(ctx) }), gradeAgentKey, {
|
||||
authentication: "human-session",
|
||||
sourceFiles: ["chat-catalog-domains/runtime-agent-keys.ts", "chat-catalog-domains/runtime-support.ts", "chat-scenarios/agents.yaml"],
|
||||
async act(ctx, state) {
|
||||
await waitForReply(ctx); const worker = state.worker as RuntimeAgent;
|
||||
const secrets = ctx.input.privateSecrets; const browser = ctx.input.page.context().browser();
|
||||
if (!secrets || !browser) throw new Error("Credential UI requires a private attempt sink and untraced real browser context");
|
||||
const endpoint = `/api/agents/${worker.id}/keys`;
|
||||
state.agentCreateStatus = await probeAgentPermission(ctx, endpoint, "POST", { name: "local integration" });
|
||||
state.keysBeforeHuman = (await ctx.input.api.get<KeyRow[]>(endpoint)).length;
|
||||
const o = await observeRuntime(ctx, state); const href = agentLink(o, worker); if (!href) throw new Error("Credential guidance lacks Morgan's exact profile");
|
||||
// This isolated context deliberately never starts tracing or screenshots.
|
||||
// It carries the same real human session; credentials are never exposed to
|
||||
// the traced source page, evidence callbacks or model conversation.
|
||||
const secure = await browser.newContext({ baseURL: ctx.input.api.baseURL });
|
||||
await secure.addCookies(await ctx.input.page.context().cookies());
|
||||
const page = await secure.newPage();
|
||||
try {
|
||||
await page.goto(new URL(href, ctx.input.api.baseURL).href, { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("link", { name: /API keys/i }).click();
|
||||
await page.getByPlaceholder("Key name (e.g. production)").fill("local integration");
|
||||
await page.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await expect(page.getByText("API key created — copy it now, it will not be shown again.", { exact: true })).toBeVisible();
|
||||
const token = (await page.locator("code").filter({ hasText: /^pcp_/ }).first().textContent())?.trim();
|
||||
if (!token || token.length < 20) throw new Error("Secure one-time UI did not reveal a usable credential");
|
||||
secrets.push(token);
|
||||
const identityResponse = await fetch(new URL("/api/agents/me", ctx.input.api.baseURL), {
|
||||
redirect: "error", signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!identityResponse.ok) { await identityResponse.body?.cancel(); throw new Error("New human credential did not authenticate Morgan"); }
|
||||
const identity = await identityResponse.json() as { id: string; companyId: string };
|
||||
state.createdKeyAgentId = identity.id; state.createdKeyCompanyId = identity.companyId;
|
||||
state.tokenShownOnce = true; state.secureUntracedContext = true;
|
||||
const created = (await ctx.input.api.get<KeyRow[]>(endpoint)).filter(row => row.name === "local integration" && !row.revokedAt);
|
||||
if (created.length !== 1) throw new Error("Human credential control did not create exactly one named key");
|
||||
state.createdKey = created[0]; state.humanCreated = true;
|
||||
await page.getByRole("button", { name: "Dismiss", exact: true }).click();
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
state.tokenAbsentAfterReload = !(await page.locator("body").textContent())?.includes(token);
|
||||
const row = page.getByText("local integration", { exact: true }).locator("..").locator("..");
|
||||
await row.getByRole("button", { name: "Revoke", exact: true }).click();
|
||||
await expect(page.getByText("Revoked Keys", { exact: true })).toBeVisible();
|
||||
const denied = await fetch(new URL("/api/agents/me", ctx.input.api.baseURL), {
|
||||
redirect: "error", signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
state.revokedCredentialStatus = denied.status; await denied.body?.cancel();
|
||||
const source = await observeRuntime(ctx, state);
|
||||
state.privateScanComplete = true; state.tokenLeaked = source.reply.includes(token)
|
||||
|| source.comments.some(comment => comment.body?.includes(token));
|
||||
} finally { await secure.close(); }
|
||||
},
|
||||
async observe(ctx, state) {
|
||||
state.finalKeys = await ctx.input.api.get<KeyRow[]>(`/api/agents/${(state.worker as RuntimeAgent).id}/keys`);
|
||||
return observeRuntime(ctx, state);
|
||||
},
|
||||
async cleanup(ctx, state) {
|
||||
const key = state.createdKey as KeyRow | undefined;
|
||||
if (key) await ctx.input.api.delete(`/api/agents/${(state.worker as RuntimeAgent).id}/keys/${key.id}`, { allowNotFound: true });
|
||||
},
|
||||
})];
|
||||
@@ -56,7 +56,7 @@
|
||||
},
|
||||
{
|
||||
"scenarioId": "agents.resume-agent",
|
||||
"readinessClass": "local",
|
||||
"readinessClass": "implemented",
|
||||
"needs": "Public manual pause plus independently preserved readiness; permitted resume and separate remaining-gate branch."
|
||||
},
|
||||
{
|
||||
@@ -71,7 +71,7 @@
|
||||
},
|
||||
{
|
||||
"scenarioId": "agents.agent-api-key",
|
||||
"readinessClass": "local",
|
||||
"readinessClass": "implemented",
|
||||
"needs": "Human-session credential UI; one-time token privateSecrets sink, named-key public readback and revoke with identity retained."
|
||||
},
|
||||
{
|
||||
@@ -236,7 +236,7 @@
|
||||
},
|
||||
{
|
||||
"scenarioId": "steering.preserve-draft",
|
||||
"readinessClass": "source",
|
||||
"readinessClass": "implemented",
|
||||
"needs": "Unsent canonical query and completed upload receipt must survive task switch/reload before send; shared flow sends original query immediately after setup."
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { it, expect } from "vitest";
|
||||
import { gradeResumeAgent } from "./runtime-resume.js";
|
||||
function valid() {
|
||||
const worker = { id: "morgan", companyId: "company" };
|
||||
return { scenarioId: "agents.resume-agent", origin: "http://fixture.invalid", companyId: "company", leadId: "ceo",
|
||||
state: { worker, firstResumedWorker: { ...worker, lifecycleState: "ready", status: "idle", lifecycleHolds: [] },
|
||||
firstReply: "Morgan is resumed and available for future work.", secondBudgetHardStop: true, secondBudgetAmount: 0,
|
||||
secondReply: "The budget cap remains a hold; Morgan cannot pick up work before it is resolved." },
|
||||
agents: [{ ...worker, status: "paused", lifecycleHolds: ["budget"] }], tasks: [], links: ["/ACORN/agents/morgan"], reply: "",
|
||||
activity: [{ id: "resume", action: "agent.resumed", entityId: "morgan", agentId: "ceo" }],
|
||||
runs: [{ id: "first", agentId: "ceo", status: "succeeded" }, { id: "second", agentId: "ceo", status: "succeeded" }], humanQueries: [] };
|
||||
}
|
||||
it("accepts both saved availability and independently bound held-phase evidence", () => expect(gradeResumeAgent(valid()).passed).toBe(true));
|
||||
it("rejects missing resume evidence", () => expect(gradeResumeAgent(undefined).passed).toBe(false));
|
||||
it("rejects first-phase prose borrowed to fake a successful saved resume", () => {
|
||||
const o = valid(); o.state.firstResumedWorker.status = "paused"; expect(gradeResumeAgent(o).passed).toBe(false);
|
||||
});
|
||||
it("rejects using the first reply instead of evidence from the held request", () => {
|
||||
const o = valid(); o.state.secondReply = o.state.firstReply; expect(gradeResumeAgent(o).passed).toBe(false);
|
||||
});
|
||||
it("rejects silently clearing the remaining budget gate", () => {
|
||||
const o = valid(); o.agents[0]!.lifecycleHolds = []; expect(gradeResumeAgent(o).passed).toBe(false);
|
||||
});
|
||||
it("rejects pretending a new worker execution already started", () => {
|
||||
const o = valid(); o.runs.push({ id: "worker", agentId: "morgan", status: "succeeded" }); expect(gradeResumeAgent(o).passed).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { pollUntil } from "../api.js";
|
||||
import { sendChatMessage } from "../chat-flow.js";
|
||||
import { checks, type Entity } from "./internal-support.js";
|
||||
import { runtimeDefinition, runtimeWorker, runtimeObservation, observeRuntime, agentLink, type RuntimeAgent } from "./runtime-support.js";
|
||||
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
||||
interface PhaseComment extends Entity { }
|
||||
export function gradeResumeAgent(value: unknown) {
|
||||
const c = checks(); const o = runtimeObservation(value);
|
||||
c.add("independent-resume-phases", !!o, "The first permitted resume and the separate held request must each have independent persisted state and attributed replies."); if (!o) return c.result();
|
||||
const worker = o.state.worker as RuntimeAgent; const first = o.state.firstResumedWorker as RuntimeAgent | undefined;
|
||||
const current = o.agents.find(row => row.id === worker.id);
|
||||
c.add("first-request-durably-resumed", first?.id === worker.id && first.companyId === o.companyId && first.lifecycleState === "ready"
|
||||
&& ["idle", "active"].includes(first.status ?? "") && !first.lifecycleHolds?.length
|
||||
&& o.activity.some(row => row.action === "agent.resumed" && row.entityId === worker.id && row.agentId === o.leadId),
|
||||
"The first actual permitted operation made the same Morgan available and has independent agent attribution.");
|
||||
c.add("first-reply-distinguishes-availability", typeof o.state.firstReply === "string" && /resum|available|ready/i.test(o.state.firstReply)
|
||||
&& !/started (?:a |the )?(?:run|task|work)|already (?:working|running)/i.test(o.state.firstReply),
|
||||
"The unheld phase describes availability without inventing a new worker execution.");
|
||||
c.add("real-budget-gate-preserved", current?.lifecycleHolds?.includes("budget") === true && current.status === "paused"
|
||||
&& o.state.secondBudgetHardStop === true && o.state.secondBudgetAmount === 0,
|
||||
"The second branch retains its independently observed effective budget hold and saved hard stop.");
|
||||
c.add("held-reply-bound-to-second-request", typeof o.state.secondReply === "string" && /budget|spend|cap|limit/i.test(o.state.secondReply)
|
||||
&& /hold|block|cannot|can't|remain|before|needs|not.*resum/i.test(o.state.secondReply)
|
||||
&& !/Morgan (?:is|has) (?:now )?(?:working|running)|I(?:'ve| have)? (?:started|resumed).*work/i.test(o.state.secondReply),
|
||||
"Only replies from the new actual run after the second human comment can explain the remaining gate.");
|
||||
c.add("no-fabricated-worker-start", o.runs.length === 2 && o.runs.every(run => run.agentId === o.leadId && run.status === "succeeded") && o.tasks.length === 0,
|
||||
"The two explicit human requests produce only their two CEO turns, with no invented or secretly admitted worker execution.");
|
||||
c.add("inspectable-same-agent", !!current && !!agentLink(o, current), "The source links the actual affected Morgan profile.");
|
||||
return c.result();
|
||||
}
|
||||
async function sourcePhase(ctx: ChatCatalogContext) {
|
||||
await ctx.refreshIssue(); const source = ctx.issue(); if (!source) throw new Error("Resume phase lost its source conversation");
|
||||
const runs = await ctx.allRuns();
|
||||
const comments = await ctx.input.api.get<PhaseComment[]>(`/api/issues/${source.id}/comments?order=asc`);
|
||||
return { runs, comments, source };
|
||||
}
|
||||
export const runtimeResumeCases = [runtimeDefinition("agents.resume-agent", async ctx => {
|
||||
const worker = await runtimeWorker(ctx);
|
||||
await ctx.input.api.post(`/api/agents/${worker.id}/pause`, {});
|
||||
const paused = await pollUntil({ label: "Genuine manual Morgan pause", deadlineAt: Date.now() + 45_000,
|
||||
load: () => ctx.input.api.get<RuntimeAgent>(`/api/agents/${worker.id}`),
|
||||
accept: row => row.lifecycleState === "paused" && row.lifecycleHolds?.includes("manual") === true });
|
||||
return { worker: paused };
|
||||
}, gradeResumeAgent, {
|
||||
sourceFiles: ["chat-catalog-domains/runtime-resume.ts", "chat-catalog-domains/runtime-support.ts", "chat-scenarios/agents.yaml"],
|
||||
setupPolicy: { readiness: "preserve-scenario", invariantCheckIds: ["lead-ready", "manual-pause-only"] },
|
||||
expectedRunCount: 2, expectedHumanMessageCount: 2, attemptTimeoutMs: 540_000,
|
||||
async verifySetup(ctx, state) {
|
||||
const c = checks(); const lead = await ctx.input.api.get<RuntimeAgent>(`/api/agents/${ctx.input.fixtures.agent.id}`);
|
||||
const worker = await ctx.input.api.get<RuntimeAgent>(`/api/agents/${(state.worker as RuntimeAgent).id}`);
|
||||
c.add("lead-ready", lead.lifecycleState === "ready" && ["idle", "active"].includes(lead.status ?? ""), "The real source agent can receive the query.");
|
||||
c.add("manual-pause-only", worker.lifecycleState === "paused" && worker.status === "paused"
|
||||
&& worker.lifecycleHolds?.length === 1 && worker.lifecycleHolds[0] === "manual", "No budget, approval, setup or company hold replaces the catalog's first manual-only pause.");
|
||||
return c.result();
|
||||
},
|
||||
async act(ctx, state) {
|
||||
const worker = state.worker as RuntimeAgent;
|
||||
const first = await pollUntil({ label: "Permitted resume phase settled and saved", deadlineAt: Date.now() + 150_000,
|
||||
load: async () => ({ ...await sourcePhase(ctx), worker: await ctx.input.api.get<RuntimeAgent>(`/api/agents/${worker.id}`) }),
|
||||
accept: value => value.source.conversationState === "waiting" && value.runs.length === 1 && value.runs[0]?.status === "succeeded"
|
||||
&& value.worker.lifecycleState === "ready" && ["idle", "active"].includes(value.worker.status ?? "") });
|
||||
state.firstResumedWorker = first.worker;
|
||||
state.firstReply = first.comments.filter(row => row.authorAgentId === ctx.input.fixtures.agent.id && row.createdByRunId === first.runs[0]!.id).map(row => row.body ?? "").join("\n");
|
||||
state.firstRunIds = first.runs.map(run => run.id); state.firstCommentIds = first.comments.map(row => row.id);
|
||||
await ctx.input.api.post(`/api/agents/${worker.id}/pause`, {});
|
||||
await ctx.input.api.post(`/api/companies/${ctx.input.fixtures.company.id}/budgets/policies`, { scopeType: "agent", scopeId: worker.id,
|
||||
amount: 0, isActive: true, hardStopEnabled: true, unpricedUsagePolicy: "block" });
|
||||
await pollUntil({ label: "Independent effective budget hold before the second explicit request", deadlineAt: Date.now() + 45_000,
|
||||
load: () => ctx.input.api.get<RuntimeAgent>(`/api/agents/${worker.id}`), accept: row => row.lifecycleHolds?.includes("budget") === true });
|
||||
await sendChatMessage(ctx.input.page, ctx.scenario.query);
|
||||
},
|
||||
async observe(ctx, state) {
|
||||
const phase = await sourcePhase(ctx);
|
||||
if (Array.isArray(state.firstRunIds)) {
|
||||
const oldComments = state.firstCommentIds as string[];
|
||||
const secondHuman = phase.comments.find(row => !row.authorAgentId && row.body === ctx.scenario.query && !oldComments.includes(row.id));
|
||||
state.secondReply = secondHuman ? phase.comments.filter(row => row.authorAgentId === ctx.input.fixtures.agent.id
|
||||
&& !oldComments.includes(row.id) && phase.runs.some(run => run.id === row.createdByRunId && !(state.firstRunIds as string[]).includes(run.id))
|
||||
&& Date.parse(row.createdAt ?? "") >= Date.parse(secondHuman.createdAt ?? "")).map(row => row.body ?? "").join("\n") : "";
|
||||
}
|
||||
const budget = await ctx.input.api.get<{ policies: Array<{ scopeId: string; scopeType: string; amount: number; hardStopEnabled: boolean }> }>(`/api/companies/${ctx.input.fixtures.company.id}/budgets/overview`);
|
||||
const policy = budget.policies.find(row => row.scopeId === (state.worker as RuntimeAgent).id && row.scopeType === "agent");
|
||||
state.secondBudgetAmount = policy?.amount; state.secondBudgetHardStop = policy?.hardStopEnabled;
|
||||
return observeRuntime(ctx, state);
|
||||
},
|
||||
})];
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { it, expect } from "vitest";
|
||||
import { gradePreserveDraft } from "./runtime-steering.js";
|
||||
function valid() {
|
||||
const briefBody = "# Real website brief\n"; const hash = createHash("sha256").update(briefBody).digest("hex");
|
||||
const attachment = { id: "upload", issueId: "source", companyId: "company", createdByUserId: "person", createdByAgentId: null, sha256: hash };
|
||||
const otherTask = { id: "other", title: "Other task", status: "backlog" };
|
||||
return { scenarioId: "steering.preserve-draft", companyId: "company", leadId: "ceo",
|
||||
state: { originalQuery: "Build the page from this brief, but keep the copy short.", restoredDraft: "Build the page from this brief, but keep the copy short.",
|
||||
restoredAttachmentVisible: true, beforeSendHumanCount: 0, otherTaskDraftIsolated: true, otherTaskAttachmentsEmpty: true,
|
||||
briefAttachment: attachment, savedAttachment: { ...attachment, issueCommentId: "comment" }, sourceIssueId: "source", briefHash: hash,
|
||||
briefBody, downloadedBrief: briefBody, transport: { commentId: "comment", replayedCommentId: "comment", clientRequestId: "request" },
|
||||
sourceComments: [{ id: "comment", clientRequestId: "request", authorUserId: "person" }], persistedCommentReviewedBeforeReplay: true, otherTask },
|
||||
agents: [], tasks: [otherTask], links: [], reply: "", activity: [], runs: [{ id: "run", contextSnapshot: { wakeCommentId: "comment" } }], humanQueries: [] };
|
||||
}
|
||||
it("accepts retained draft, real source-bound upload and one reconciled send", () => expect(gradePreserveDraft(valid()).passed).toBe(true));
|
||||
it("rejects missing draft evidence", () => expect(gradePreserveDraft(undefined).passed).toBe(false));
|
||||
it("rejects leaking the unsent draft into the other task", () => {
|
||||
const o = valid(); o.state.otherTaskDraftIsolated = false; expect(gradePreserveDraft(o).passed).toBe(false);
|
||||
});
|
||||
it("rejects an upload attached to a different human comment", () => {
|
||||
const o = valid(); o.state.savedAttachment.issueCommentId = "other-comment"; expect(gradePreserveDraft(o).passed).toBe(false);
|
||||
});
|
||||
it("rejects duplicate native admission after lost acknowledgement replay", () => {
|
||||
const o = valid(); o.runs.push({ id: "duplicate", contextSnapshot: { wakeCommentId: "comment" } }); expect(gradePreserveDraft(o).passed).toBe(false);
|
||||
});
|
||||
it("rejects a receipt whose actual downloaded bytes changed", () => {
|
||||
const o = valid(); o.state.downloadedBrief = "Different brief"; expect(gradePreserveDraft(o).passed).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { createHash } from "node:crypto";
|
||||
import { pollUntil } from "../api.js";
|
||||
import { dropChatSendAcknowledgement } from "../lost-send.js";
|
||||
import type { ChatQueryAttachmentReceipt } from "../chat-query-attachments.js";
|
||||
import { checks, sameTask, type Entity } from "./internal-support.js";
|
||||
import { openStandingChat } from "./identity-support.js";
|
||||
import { runtimeDefinition, runtimeWorker, runtimeObservation, observeRuntime } from "./runtime-support.js";
|
||||
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
||||
type Interception = Awaited<ReturnType<typeof dropChatSendAcknowledgement>>;
|
||||
const interceptions = new WeakMap<ChatCatalogContext, Interception>();
|
||||
interface DraftComment extends Entity { clientRequestId?: string }
|
||||
const editor = (ctx: ChatCatalogContext) => ctx.input.page.getByTestId("task-chat-composer-input").last().locator('[contenteditable="true"], textarea').first();
|
||||
async function draftText(ctx: ChatCatalogContext) {
|
||||
return editor(ctx).evaluate(node => "value" in node ? String(node.value) : node.textContent ?? "");
|
||||
}
|
||||
export function gradePreserveDraft(value: unknown) {
|
||||
const c = checks(); const o = runtimeObservation(value);
|
||||
c.add("real-draft-and-upload-evidence", !!o, "The unsent editor text, completed upload, source comment and actual transport receipt are independently required."); if (!o) return c.result();
|
||||
const attachment = o.state.briefAttachment as ChatQueryAttachmentReceipt;
|
||||
const sourceComments = o.state.sourceComments as DraftComment[] | undefined;
|
||||
const transport = o.state.transport as { commentId?: string; replayedCommentId?: string; clientRequestId?: string } | undefined;
|
||||
const human = sourceComments?.filter(row => !row.authorAgentId);
|
||||
c.add("draft-survived-switch-and-reload", o.state.restoredDraft === o.state.originalQuery && o.state.restoredAttachmentVisible === true
|
||||
&& o.state.beforeSendHumanCount === 0 && o.state.otherTaskDraftIsolated === true && o.state.otherTaskAttachmentsEmpty === true,
|
||||
"The correct source retains the exact unsent text and completed upload while the other task receives neither.");
|
||||
const canonical = human?.find(row => row.id === transport?.commentId);
|
||||
const savedAttachment = o.state.savedAttachment as ChatQueryAttachmentReceipt | undefined;
|
||||
c.add("one-message-with-original-upload", human?.length === 1 && !!canonical && !!attachment && savedAttachment?.id === attachment.id
|
||||
&& savedAttachment.issueCommentId === canonical.id && savedAttachment.issueId === o.state.sourceIssueId
|
||||
&& savedAttachment.companyId === o.companyId && savedAttachment.createdByUserId === canonical.authorUserId
|
||||
&& savedAttachment.createdByAgentId == null && savedAttachment.sha256 === o.state.briefHash,
|
||||
"One actual human send carries the exact human-owned upload receipt for this source and company.");
|
||||
c.add("actual-file-bytes-retained", o.state.downloadedBrief === o.state.briefBody && typeof o.state.briefBody === "string"
|
||||
&& createHash("sha256").update(o.state.briefBody).digest("hex") === savedAttachment?.sha256,
|
||||
"Independent production download bytes match the original brief and receipt hash.");
|
||||
const requestRuns = transport ? o.runs.filter(run => run.contextSnapshot?.wakeCommentId === transport.commentId
|
||||
|| Array.isArray(run.contextSnapshot?.wakeCommentIds) && run.contextSnapshot.wakeCommentIds.includes(transport.commentId)) : [];
|
||||
c.add("uncertain-send-reconciled-without-duplication", !!transport?.commentId && transport.commentId === transport.replayedCommentId
|
||||
&& !!transport.clientRequestId && sourceComments?.filter(row => row.clientRequestId === transport.clientRequestId).map(row => row.id).join() === transport.commentId
|
||||
&& requestRuns.length === 1 && o.state.persistedCommentReviewedBeforeReplay === true,
|
||||
"A real lost acknowledgement is reconciled against the persisted comment before an exact client-request replay; one request-bound run remains.");
|
||||
c.add("other-task-unchanged", !!o.state.otherTask && sameTask(o.state.otherTask as Entity, o.tasks.find(row => row.id === (o.state.otherTask as Entity).id)),
|
||||
"Draft navigation and the later send do not mutate the unrelated task.");
|
||||
return c.result();
|
||||
}
|
||||
export const runtimeSteeringCases = [runtimeDefinition("steering.preserve-draft", async ctx => {
|
||||
const worker = await runtimeWorker(ctx); const source = await openStandingChat(ctx);
|
||||
await ctx.input.page.reload({ waitUntil: "domcontentloaded" }); await expect(editor(ctx)).toBeVisible();
|
||||
const otherTask = await ctx.input.api.post<Entity>(`/api/companies/${ctx.input.fixtures.company.id}/issues`, { title: "Unrelated draft-isolation task", status: "backlog" });
|
||||
const sourceUrl = ctx.input.page.url(); const filename = "website-brief.md";
|
||||
const briefBody = `# Website brief\nUse short copy, an accessible single-page layout, and a contact section. Product reference: ${ctx.input.nonce}.\n`;
|
||||
await editor(ctx).fill(ctx.scenario.query);
|
||||
await ctx.input.page.locator('input[type="file"]').first().setInputFiles({ name: filename, mimeType: "text/markdown", buffer: Buffer.from(briefBody) });
|
||||
const briefAttachment = await pollUntil({ label: "Completed public brief upload before unsent navigation", deadlineAt: Date.now() + 30_000,
|
||||
load: () => ctx.input.api.get<ChatQueryAttachmentReceipt[]>(`/api/issues/${source.id}/attachments`),
|
||||
accept: rows => rows.filter(row => row.originalFilename === filename).length === 1 });
|
||||
const receipt = briefAttachment.find(row => row.originalFilename === filename)!;
|
||||
await expect(ctx.input.page.getByTestId("task-chat-composer-attachments")).toContainText(filename);
|
||||
await expect(ctx.input.page.getByTestId("task-chat-composer-attachments")).not.toContainText("Uploading");
|
||||
await ctx.input.page.goto(`/${ctx.input.fixtures.company.issuePrefix}/issues/${otherTask.identifier ?? otherTask.id}`, { waitUntil: "domcontentloaded" });
|
||||
await expect(editor(ctx)).toBeVisible(); await ctx.input.page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(editor(ctx)).toBeVisible();
|
||||
const otherTaskDraftIsolated = (await draftText(ctx)) !== ctx.scenario.query;
|
||||
const otherTaskAttachmentsEmpty = (await ctx.input.api.get<Entity[]>(`/api/issues/${otherTask.id}/attachments`)).length === 0
|
||||
&& !(await ctx.input.page.getByTestId("task-chat-composer-attachments").isVisible());
|
||||
await ctx.input.page.goto(sourceUrl, { waitUntil: "domcontentloaded" });
|
||||
await ctx.input.page.reload({ waitUntil: "domcontentloaded" }); await expect(editor(ctx)).toBeVisible();
|
||||
await expect.poll(() => draftText(ctx)).toBe(ctx.scenario.query);
|
||||
const restoredDraft = await draftText(ctx);
|
||||
await expect(ctx.input.page.getByTestId("task-chat-composer-attachments")).toContainText(filename);
|
||||
const before = await ctx.input.api.get<Entity[]>(`/api/issues/${source.id}/comments?order=asc`);
|
||||
interceptions.set(ctx, await dropChatSendAcknowledgement(ctx.input.page, ctx.scenario.query));
|
||||
return { worker, otherTask, sourceIssueId: source.id, originalQuery: ctx.scenario.query, restoredDraft,
|
||||
restoredAttachmentVisible: true, otherTaskDraftIsolated, otherTaskAttachmentsEmpty, beforeSendHumanCount: before.filter(row => !row.authorAgentId).length,
|
||||
briefAttachment: receipt, briefBody, briefHash: createHash("sha256").update(briefBody).digest("hex") };
|
||||
}, gradePreserveDraft, {
|
||||
authentication: "human-session", queryAttachments: [{ stateKey: "briefAttachment", filename: "website-brief.md" }],
|
||||
sourceFiles: ["chat-catalog-domains/runtime-steering.ts", "chat-catalog-domains/runtime-support.ts", "lost-send.ts", "chat-scenarios/steering.yaml"],
|
||||
expectedRunCount: 3, minimumRunCount: 1, attemptTimeoutMs: 540_000,
|
||||
runPolicy: { kind: "source-settled-with-active-work", maximumActiveWorkerRuns: 1 },
|
||||
async act(ctx, state) {
|
||||
const interception = interceptions.get(ctx); if (!interception) throw new Error("Lost-ack draft fixture is not armed");
|
||||
const sent = await interception.committed;
|
||||
const comment = await ctx.input.api.get<DraftComment>(`/api/issues/${state.sourceIssueId}/comments/${sent.commentId}`);
|
||||
if (comment.id !== sent.commentId || comment.clientRequestId !== sent.data.clientRequestId) throw new Error("Uncertain send was not reconciled with its actual persisted comment");
|
||||
state.persistedCommentReviewedBeforeReplay = true;
|
||||
await interception.dispose(); interceptions.delete(ctx);
|
||||
const replayed = await ctx.input.api.post<{ id: string }>(sent.path, sent.data);
|
||||
state.transport = { commentId: sent.commentId, replayedCommentId: replayed.id, clientRequestId: String(sent.data.clientRequestId) };
|
||||
await ctx.input.page.reload({ waitUntil: "domcontentloaded" });
|
||||
},
|
||||
async observe(ctx, state) {
|
||||
const source = String(state.sourceIssueId);
|
||||
state.sourceComments = await ctx.input.api.get<DraftComment[]>(`/api/issues/${source}/comments?order=asc`);
|
||||
const attachment = (await ctx.input.api.get<ChatQueryAttachmentReceipt[]>(`/api/issues/${source}/attachments`)).find(row => row.id === (state.briefAttachment as ChatQueryAttachmentReceipt).id);
|
||||
state.savedAttachment = attachment;
|
||||
if (attachment) state.downloadedBrief = Buffer.from(await ctx.input.api.getAttachmentContent(attachment.id)).toString("utf8");
|
||||
return observeRuntime(ctx, state);
|
||||
},
|
||||
async verifyResult(ctx, state) {
|
||||
const transport = state.transport as { commentId: string }; const receipt = state.savedAttachment as ChatQueryAttachmentReceipt;
|
||||
const rendered = ctx.input.page.locator(`[id=${JSON.stringify(`comment-${transport.commentId}`)}]`);
|
||||
await expect(rendered).toContainText(ctx.scenario.query);
|
||||
await expect(rendered.locator(`a[href=${JSON.stringify(receipt.contentPath)}]`).first()).toBeVisible();
|
||||
},
|
||||
async cleanup(ctx) { const interception = interceptions.get(ctx); interceptions.delete(ctx); await interception?.dispose(); },
|
||||
})];
|
||||
@@ -7,7 +7,7 @@ import { observeInternal, type Entity, type InternalObservation } from "./intern
|
||||
export interface RuntimeAgent extends Entity {
|
||||
reportsTo?: string | null; adapterType?: string; adapterConfig?: Record<string, unknown>;
|
||||
runtimeConfig?: Record<string, unknown>; permissions?: Record<string, unknown>;
|
||||
defaultEnvironmentId?: string | null; lifecycleState?: string;
|
||||
defaultEnvironmentId?: string | null; lifecycleState?: string; lifecycleHolds?: string[]; pauseReason?: string | null;
|
||||
}
|
||||
export interface InstructionFile {
|
||||
path: string; content: string; revision: { id: string; parentRevisionId?: string | null;
|
||||
|
||||
Reference in new issue
Block a user