diff --git a/tests/runner-e2e/chat-catalog-descriptors.json b/tests/runner-e2e/chat-catalog-descriptors.json index 8dc35a7581a..fbb91b3584b 100644 --- a/tests/runner-e2e/chat-catalog-descriptors.json +++ b/tests/runner-e2e/chat-catalog-descriptors.json @@ -258,7 +258,7 @@ "../../server/src/services/native-runtime/status-decision-committer.ts", "../../server/src/services/work-timeline.ts" ], - "oracleVersion": "paperclip.chat-catalog.work-timeline.v8", + "oracleVersion": "paperclip.chat-catalog.work-timeline.v9", "coverageGaps": [], "expectedRunCount": 3, "minimumRunCount": 3, diff --git a/tests/runner-e2e/chat-catalog-domains/internal-timeline-narrative.ts b/tests/runner-e2e/chat-catalog-domains/internal-timeline-narrative.ts index 49a75bfd5c8..3dc017b2073 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-timeline-narrative.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-timeline-narrative.ts @@ -7,7 +7,7 @@ const calendarWords = new Set((["long", "short"] as const).flatMap(style => { })); export function truthfulTimelineAccount(input: { - reply: string; personName?: string; firstWorker: string; repairWorker: string; + reply: string; personName?: string; taskIdentifier?: string; firstWorker: string; repairWorker: string; }) { const { reply, firstWorker, repairWorker } = input; if (!firstWorker || !repairWorker || firstWorker === repairWorker) return false; @@ -29,7 +29,8 @@ export function truthfulTimelineAccount(input: { const assignment = "\\b(?:reassign(?:ed|ment)?|assign(?:ed|ment)?|hand(?:ed)?(?:\\s+\\w+){0,4}\\s+(?:off|over)|handed|handoff)\\b"; const target = `\\bto\\s+${escape(repairWorker)}\\b`; const actorAction = `${humanActor}(?:\\s+(?:then|later|now|personally|explicitly|actually|had|has|have|also)){0,3}\\s+${assignment}`; - const passiveAction = `(?:\\b(?:was|got|had been|has been)\\s+${assignment}|\\b(?:the|same|this)\\s+(?:task|work)\\s+(?:reassigned|assigned|handed\\s+(?:off|over))\\b)`; + const taskSubject = `(?:(?:the|same|this)\\s+(?:task|work)${input.taskIdentifier ? `|${escape(input.taskIdentifier)}` : ""})`; + const passiveAction = `(?:\\b(?:was|got|had been|has been)\\s+${assignment}|\\b${taskSubject}\\s+(?:reassigned|assigned|handed\\s+(?:off|over))\\b)`; const handoffToRepairWorker = new RegExp(`${actorAction}${withinClause}{0,60}${target}|${passiveAction}${withinClause}{0,60}${target}${withinClause}{0,40}\\bby\\s+(?:(?:the|a|an)\\s+)?${humanActor}`, "i"); const passiveHandoffToRepairWorker = new RegExp(`${passiveAction}${withinClause}{0,60}${target}`, "gi"); const successorOwnership = new RegExp(`\\b${escape(repairWorker)}\\s+took\\s+(?:over\\s+(?:the\\s+)?(?:same\\s+)?(?:task|work)|over\\s+from\\s+${escape(firstWorker)}|over(?=\\s*(?:[,;.!?\\n]|$))|the\\s+same\\s+(?:task|work))\\b`, "gi"); diff --git a/tests/runner-e2e/chat-catalog-domains/internal-timeline-native-milestone.test.ts b/tests/runner-e2e/chat-catalog-domains/internal-timeline-native-milestone.test.ts index 412b2b2144c..40956a0b87d 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-timeline-native-milestone.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-timeline-native-milestone.test.ts @@ -24,7 +24,7 @@ function fixture() { const first = { id: "first", companyId: "company", agentId: "morgan", status: "succeeded", contextSnapshot: { issueId: "task" }, resultJson: { decisionId: "block-decision", assessmentId: "assessment-first", issueStatusBefore: "in_progress", issueStatusAfter: "blocked" } }; const second = { ...first, id: "second", agentId: "riley", resultJson: { decisionId: "done-decision", assessmentId: "assessment-second", issueStatusBefore: "in_progress", issueStatusAfter: "done" } }; const blocked = { id: "task", companyId: "company", status: "blocked", assigneeAgentId: "morgan", lastStatusDecisionId: "block-decision" }; - const done = { ...blocked, status: "done", assigneeAgentId: "riley", lastStatusDecisionId: "done-decision" }; + const done = { ...blocked, identifier: "RUN-3", status: "done", assigneeAgentId: "riley", lastStatusDecisionId: "done-decision" }; const blockedEvent = { ...producer("blocked", "first", "block-decision"), id: "blocked", createdAt: "2026-10-26T01:00:00Z" }; const doneEvent = { ...producer("done", "second", "done-decision"), id: "done", createdAt: "2026-10-26T03:00:00Z" }; return { scenarioId: "activity.work-timeline", origin: "https://fixture.test", companyId: "company", leadId: "ceo", @@ -57,6 +57,24 @@ it("does not move the repair before a reduced-passive handoff", () => { o.reply = "Morgan ran verification which failed and work paused. Riley repaired the label and the check passed. Same task reassigned to Riley."; expect(gradeWorkTimeline(o).passed).toBe(false); }); +it.each([ + { subject: "RUN-3", successor: "Riley", expected: true }, + { subject: "RUN-9", successor: "Riley", expected: false }, + { subject: "RUN-3", successor: "Morgan", expected: false }, +])("binds an identified task handoff to saved identity and successor: $subject / $successor", ({ subject, successor, expected }) => { + const o = fixture(); + o.reply = `Morgan ran verification which failed and work paused.\n\n## Repair — ${subject} reassigned to ${successor}.\nRiley repaired the label and the check passed.`; + expect(gradeWorkTimeline(o).passed).toBe(expected); +}); +it("requires saved task identity and chronological repair for an identified handoff", () => { + const o = fixture(); + o.reply = "Morgan ran verification which failed and work paused. RUN-3 reassigned to Riley. Riley repaired the label and the check passed."; + Reflect.deleteProperty(o.state.task, "identifier"); + expect(gradeWorkTimeline(o).passed).toBe(false); + o.state.task.identifier = "RUN-3"; + o.reply = "Morgan ran verification which failed and work paused. Riley repaired the label and the check passed. RUN-3 reassigned to Riley."; + expect(gradeWorkTimeline(o).passed).toBe(false); +}); it("accepts the successor's natural ownership transition in the ordered account", () => { const o = fixture(); o.reply = o.reply.replace("Then a human reassigned the task to Riley.", "Then Riley took over the task."); @@ -251,7 +269,7 @@ it("admits the selected live timeline metadata without invoking fixture callback const spies = callbacks.map(key => vi.spyOn(implementation, key)); try { expect(await validateChatCatalogConsistency([descriptor])).toEqual({ descriptorCount: 1, fixtureCallbacksInvoked: 0 }); - expect(descriptor.oracleVersion).toBe("paperclip.chat-catalog.work-timeline.v8"); + expect(descriptor.oracleVersion).toBe("paperclip.chat-catalog.work-timeline.v9"); expect(descriptor.sourceFiles).toEqual(implementation.sourceFiles); expect(descriptor).toMatchObject({ expectedRunCount: 3, minimumRunCount: 3, attemptTimeoutMs: 720000 }); for (const spy of spies) expect(spy).not.toHaveBeenCalled(); diff --git a/tests/runner-e2e/chat-catalog-domains/internal-work-timeline.ts b/tests/runner-e2e/chat-catalog-domains/internal-work-timeline.ts index 17e2b344cd1..e7accfac299 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-work-timeline.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-work-timeline.ts @@ -56,7 +56,7 @@ export function gradeWorkTimeline(value: unknown) { && day >= monday.toISOString().slice(0, 10) && day <= today; }); c.add("ordered-and-truthful-account", worker.id === first.agentId && repairWorker.id === second.agentId && dateClaimsInWindow && truthfulTimelineAccount({ reply: o.reply, - firstWorker: typeof worker.name === "string" ? worker.name : "", repairWorker: typeof repairWorker.name === "string" ? repairWorker.name : "", + taskIdentifier: typeof t.identifier === "string" ? t.identifier : undefined, firstWorker: typeof worker.name === "string" ? worker.name : "", repairWorker: typeof repairWorker.name === "string" ? repairWorker.name : "", personName: typeof o.state.personName === "string" ? o.state.personName : undefined }), "The reply attributes and orders the actual command failure/pause, saved reassignment to the different repair worker and successful repair, keeps explicit ISO dates within the current-week observation window, and does not invent a failed native run."); c.add("inspectable-major-milestones", hasLink(o, "issues", t) && hasLink(o, "issues", prep), "Both actual launch work items are linked for inspection."); const hidden = entity(o.state, "hiddenTask"); @@ -92,7 +92,7 @@ function timeline(): ChatCatalogCase { const state = { worker, repairWorker, project: p, task: second.t, prepTask, hiddenTask, firstRun, secondRun, blockedTask: first.t, blockedEventId: blockedEvent.id, handoffEventId: handoff.id, doneEventId: done.id, timezone, today, zoneLabel, personId: session.user.id, personName: session.user.name }; await ctx.input.evidence("chat-catalog-real-launch-timeline.json", { ...state, verification: verifier.receipts, events }); return state; }, gradeWorkTimeline); - return { ...base, oracleVersion: "paperclip.chat-catalog.work-timeline.v8", authentication: "human-session", expectedRunCount: 3, minimumRunCount: 3, attemptTimeoutMs: 720000, + return { ...base, oracleVersion: "paperclip.chat-catalog.work-timeline.v9", authentication: "human-session", expectedRunCount: 3, minimumRunCount: 3, attemptTimeoutMs: 720000, sourceFiles: [...base.sourceFiles, "chat-catalog-domains/internal-work-timeline.ts", "chat-catalog-domains/internal-timeline-narrative.ts", "chat-catalog-domains/internal-timeline-verifier.ts", "chat-catalog-domains/internal-privacy.ts", "chat-catalog-domains/identity-auth.ts", "chat-catalog-domains/internal-routine-audit.ts", "../e2e/fetch-allowed-port.ts", "../../server/src/services/native-runtime/status-decision-committer.ts", "../../server/src/services/work-timeline.ts"], async observe(ctx, state): Promise { return { ...await observeInternal(ctx, state), verification: structuredClone(verifier?.receipts ?? []), timelineEvents: await ctx.input.api.get(`/api/issues/${entity(state, "task").id}/activity`), hiddenStatus: await agentStatus(ctx, ctx.input.fixtures.agent.id, `/api/issues/${entity(state, "hiddenTask").id}`) }; }, async verifyResult(ctx, state, value) { await base.verifyResult(ctx, state, value); const source = ctx.input.page.url(); try { await ctx.input.page.goto(new URL(`/${ctx.input.fixtures.company.issuePrefix}/issues/${entity(state, "prepTask").identifier}`, ctx.input.api.baseURL).href); await expect(ctx.input.page.getByRole("heading", { name: entity(state, "prepTask").title!, exact: true })).toBeVisible(); } finally { await ctx.input.page.goto(source); await expect(ctx.input.page.getByTestId("task-chat-composer-input")).toBeVisible(); } },