From 8b8f676e83a7fa43543160d66c2baa6828781f48 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 3 Nov 2026 09:09:40 -0600 Subject: [PATCH] fix(activity): guard timeline inference with scoped ownership history Use one ownership projection for window-independent history presence and recorded events. Preserve ordered narrative facts across punctuation. Co-Authored-By: Paperclip --- .../__tests__/work-timeline-service.test.ts | 139 +++++++++++++++++- server/src/services/work-timeline.ts | 90 ++++++------ .../internal-timeline-narrative.ts | 25 +++- ...internal-timeline-native-milestone.test.ts | 18 +++ 4 files changed, 217 insertions(+), 55 deletions(-) diff --git a/server/src/__tests__/work-timeline-service.test.ts b/server/src/__tests__/work-timeline-service.test.ts index 01bfee2df5e..050e7b64ab6 100644 --- a/server/src/__tests__/work-timeline-service.test.ts +++ b/server/src/__tests__/work-timeline-service.test.ts @@ -1,8 +1,8 @@ import { randomUUID } from "node:crypto"; import express from "express"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import request from "supertest"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { activityLog, agentWakeupRequests, @@ -136,7 +136,7 @@ describeEmbeddedPostgres("work timeline aggregation", () => { expect(result.from.toISOString()).toBe("2026-02-12T00:00:00.000Z"); }); - it("aggregates runs, human events, approvals, and delegation edges", async () => { + it("aggregates runs, human events, approvals, and recorded assignments", async () => { const { companyId, userId, agentAId, agentBId } = await seedBase(); const parentIssueId = randomUUID(); const childIssueId = randomUUID(); @@ -256,15 +256,33 @@ describeEmbeddedPostgres("work timeline aggregation", () => { "created", "commented", "approved", - "delegated", "assigned", ])); expect(result.edges).toEqual(expect.arrayContaining([ - expect.objectContaining({ kind: "delegation", issueId: childIssueId }), expect.objectContaining({ kind: "assignment", issueId: childIssueId }), ])); }); + it("does not retain an inferred delegation when assignment history was recorded", async () => { + const { companyId, agentAId, agentBId } = await seedBase(); + const parentId = randomUUID(), childId = randomUUID(); + await db.insert(issues).values([ + { id: parentId, companyId, title: "Parent", status: "done", assigneeAgentId: agentAId, + createdAt: new Date("2026-03-01T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") }, + { id: childId, companyId, parentId, title: "Child", status: "done", assigneeAgentId: agentBId, + createdAt: new Date("2026-03-03T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") }, + ]); + await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, + action: "issue.assigned", entityType: "issue", entityId: childId, + details: { assigneeAgentId: agentBId }, createdAt: new Date("2026-03-05T12:00:00Z") }); + const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" }); + expect(response.status).toBe(200); + expect(response.body.events).toContainEqual(expect.objectContaining({ issueId: childId, kind: "assigned" })); + expect(response.body.events.filter((event: { kind: string }) => event.kind === "delegated")).toEqual([]); + expect(response.body.edges.filter((edge: { kind: string }) => edge.kind === "delegation")).toEqual([]); + }); + it("preserves delegation edges when parent and child cross a page boundary", async () => { const { companyId, userId, agentAId, agentBId } = await seedBase(); const parentIssueId = randomUUID(); @@ -513,6 +531,113 @@ describeEmbeddedPostgres("work timeline aggregation", () => { expect(maxActiveChecks).toBeLessThanOrEqual(16); }); + it.each(["before", "after"] as const)("does not invent creation ownership from a reassignment %s the query window", async when => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const issueId = randomUUID(); + await db.insert(issues).values({ id: issueId, companyId, title: "Historical launch owner", + status: "done", priority: "medium", assigneeAgentId: agentBId, createdByUserId: userId, + createdAt: new Date("2026-03-01T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") }); + await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId, + entityType: "issue", entityId: issueId, action: "issue.updated", + createdAt: new Date(when === "before" ? "2026-03-02T10:00:00Z" : "2026-03-06T10:00:00Z"), + details: { assigneeAgentId: agentBId, changes: { assigneeAgentId: { from: agentAId, to: agentBId } } } }); + const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ issueId, from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.edges.filter((edge: { kind: string }) => edge.kind === "assignment")).toEqual([]); + expect(response.body.events.filter((event: { kind: string }) => event.kind === "assigned")).toEqual([]); + expect(response.body.events).toContainEqual(expect.objectContaining({ issueId, kind: "created" })); + }); + + it.each([ + ["child", "2026-03-02T10:00:00Z"], ["child", "2026-03-05T12:00:00Z"], + ["parent", "2026-03-02T10:00:00Z"], ["parent", "2026-03-06T10:00:00Z"], + ] as const)("does not infer a creation delegation after %s ownership changed at %s", async (changedIssue, at) => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const parentId = randomUUID(), childId = randomUUID(); + await db.insert(issues).values([ + { id: parentId, companyId, title: "Launch parent", status: "done", priority: "medium", + createdByUserId: userId, assigneeAgentId: agentAId, + createdAt: new Date("2026-03-01T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") }, + { id: childId, companyId, parentId, title: "Launch child", status: "done", priority: "medium", + createdByAgentId: agentAId, assigneeAgentId: agentBId, + createdAt: new Date("2026-03-03T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") }, + ]); + const target = changedIssue === "parent" ? agentAId : agentBId; + const previous = changedIssue === "parent" ? agentBId : agentAId; + await db.insert(activityLog).values({ companyId, actorType: "user", actorId: userId, + entityType: "issue", entityId: changedIssue === "parent" ? parentId : childId, + action: "issue.updated", createdAt: new Date(at), + details: { assigneeAgentId: target, changes: { assigneeAgentId: { from: previous, to: target } } } }); + const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ limit: 1, from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" }); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.pagination).toMatchObject({ totalIssues: 2, hasMore: true }); + expect(response.body.edges.filter((edge: { kind: string }) => edge.kind === "delegation")).toEqual([]); + expect(response.body.events.filter((event: { kind: string }) => event.kind === "delegated")).toEqual([]); + if (changedIssue === "child" && at.startsWith("2026-03-05")) { + expect(response.body.events).toContainEqual({ actorId: `user:${userId}`, issueId: childId, kind: "assigned", at: new Date(at).toISOString() }); + expect(response.body.edges).toContainEqual({ fromActorId: `user:${userId}`, toActorId: `agent:${agentBId}`, issueId: childId, kind: "assignment", at: new Date(at).toISOString() }); + } + }); + + it("checks ownership history once for the readable page and its readable parents only", async () => { + const { companyId, userId, agentAId, agentBId } = await seedBase(); + const parentId = randomUUID(), childId = randomUUID(), hiddenId = randomUUID(), unpagedId = randomUUID(); + const foreignCompanyId = randomUUID(); + await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign", issuePrefix: "TF" }); + await db.insert(issues).values([parentId, unpagedId, childId, hiddenId].map((id, index) => ({ + id, companyId, title: `Scoped ${index}`, status: "done", assigneeAgentId: id === parentId ? agentAId : agentBId, + parentId: id === childId ? parentId : null, createdByUserId: userId, + createdAt: new Date(Date.parse("2026-03-01T10:00:00Z") + index * 1000), updatedAt: new Date("2026-03-05T10:00:00Z"), + }))); + const ownershipRow = (entityId: string, scopedCompanyId = companyId) => ({ companyId: scopedCompanyId, + actorType: "user", actorId: userId, entityType: "issue", entityId, action: "issue.updated", + createdAt: new Date("2026-03-02T10:00:00Z"), details: { changes: { assigneeAgentId: { from: agentBId, to: agentAId } } } }); + await db.insert(activityLog).values([ownershipRow(parentId), ownershipRow(hiddenId), ownershipRow(childId, foreignCompanyId), + ...Array.from({ length: 1000 }, () => ownershipRow(unpagedId))]); + const queries: Array<{ query: string; parameters: unknown[] }> = []; + const previousDebug = db.$client.options.debug; + let result; + db.$client.options.debug = (_connection, query, parameters) => queries.push({ query, parameters }); + try { + result = await workTimelineService(db).getTimeline({ companyId, limit: 1, + from: new Date("2026-03-05T00:00:00Z"), to: new Date("2026-03-06T00:00:00Z"), + canReadIssue: async issue => issue.id !== hiddenId }); + } finally { db.$client.options.debug = previousDebug; } + const history = queries.filter(row => /^select distinct /i.test(row.query) && row.query.includes('"activity_log"')); + expect(history).toHaveLength(1); + expect(history[0]!.query).not.toContain("created_at"); + const scopedIds = history[0]!.parameters.filter(value => typeof value === "string" && /^[a-f0-9-]{36}$/.test(value)); + expect(new Set(scopedIds)).toEqual(new Set([companyId, parentId, childId])); + const rows = await db.$client.unsafe(history[0]!.query, history[0]!.parameters as any[]); + expect(rows.map(row => row.entity_id)).toEqual([parentId]); + const explain = await db.$client.unsafe(`explain (analyze, format json) ${history[0]!.query}`, history[0]!.parameters as any[]); + const plan = explain[0]!["QUERY PLAN"][0].Plan; + expect(plan["Actual Rows"]).toBe(1); + expect(result.edges).toContainEqual(expect.objectContaining({ issueId: childId, kind: "assignment", toActorId: `agent:${agentBId}` })); + expect(result.edges.some(edge => edge.kind === "delegation")).toBe(false); + expect(result.events.every(event => event.issueId === childId)).toBe(true); + console.info("Timeline ownership lookup", JSON.stringify({ queryCount: history.length, scopedIssueCount: 2, + returnedIssueCount: rows.length, actualPlanRows: plan["Actual Rows"], planNode: plan["Node Type"] })); + }); + + it("fails the route when ownership history cannot be read instead of inventing creation ownership", async () => { + const { companyId, agentAId } = await seedBase(); + const issueId = randomUUID(); + await db.insert(issues).values({ id: issueId, companyId, title: "History failure", status: "done", assigneeAgentId: agentAId, + createdAt: new Date("2026-03-05T10:00:00Z"), updatedAt: new Date("2026-03-05T10:00:00Z") }); + const selectDistinct = db.selectDistinct.bind(db); + const fault = vi.spyOn(db, "selectDistinct").mockImplementationOnce(() => selectDistinct({ issueId: sql`1 / 0` }) as any); + try { + const response = await request(createApp()).get(`/api/companies/${companyId}/timeline`) + .query({ issueId, from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" }); + expect(fault).toHaveBeenCalledOnce(); + expect(response.status).toBe(500); + expect(response.body).not.toHaveProperty("edges"); + } finally { fault.mockRestore(); } + }); + it("serves the recorded human reassignment instead of inferring the current owner at creation", async () => { const { companyId, userId, agentAId, agentBId } = await seedBase(); const issueId = randomUUID(); @@ -550,12 +675,14 @@ describeEmbeddedPostgres("work timeline aggregation", () => { const { companyId, userId, agentAId, agentBId } = await seedBase(); const createdAt = new Date("2026-03-05T10:00:00Z"); const changedAt = new Date("2026-03-05T11:00:00Z"); - for (const kind of ["absent", "null", "unchanged", "self-assignment"] as const) { + for (const kind of ["absent", "null", "unchanged", "canonical-empty", "canonical-unchanged", "self-assignment"] as const) { const issueId = randomUUID(); const assigneeAgentId = kind === "null" ? null : kind === "self-assignment" ? agentBId : agentAId; await db.insert(issues).values({ id: issueId, companyId, title: kind, status: "done", priority: "medium", assigneeAgentId, createdByUserId: userId, createdAt, updatedAt: changedAt }); const details = kind === "absent" ? { status: "done" } + : kind === "canonical-empty" ? { assigneeAgentId, changes: {} } + : kind === "canonical-unchanged" ? { assigneeAgentId, changes: { assigneeAgentId: { from: agentAId, to: agentAId } } } : { assigneeAgentId, _previous: { assigneeAgentId: agentAId } }; await db.insert(activityLog).values({ companyId, actorType: "agent", actorId: agentAId, action: "issue.updated", entityType: "issue", entityId: issueId, details, createdAt: changedAt }); diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts index 816933a9050..90fe95b3674 100644 --- a/server/src/services/work-timeline.ts +++ b/server/src/services/work-timeline.ts @@ -1,5 +1,5 @@ import { withAgentAppearance } from "@paperclipai/shared"; -import { and, asc, desc, eq, gte, inArray, isNull, lte, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gte, inArray, isNull, lte, or, sql, type SQL } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, @@ -119,8 +119,30 @@ function dateIso(value: Date | null | undefined) { return value ? value.toISOString() : null; } -function readString(value: unknown) { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +/** One ownership contract for history detection and recorded event projection. */ +function timelineOwnershipProjection() { + const record = (value: SQL) => sql`case when jsonb_typeof(${value}) = 'object' then ${value} else '{}'::jsonb end`; + const details = record(sql`${activityLog.details}`); + const previous = record(sql`${details} -> '_previous'`); + const changes = record(sql`${details} -> 'changes'`); + const canonical = sql`${activityLog.action} = 'issue.updated' and jsonb_typeof(${details} -> 'changes') = 'object'`; + const changed = ["assigneeAgentId", "assigneeUserId"].map(key => { + const change = record(sql`${changes} -> ${key}`); + return sql`case when ${canonical} then + (${change} ? 'from' and ${change} ? 'to' and (${change} -> 'from') is distinct from (${change} -> 'to')) + else (${details} ? ${key} and (not (${previous} ? ${key}) or (${details} -> ${key}) is distinct from (${previous} -> ${key}))) end`; + }); + const stringValue = (value: SQL) => sql`case when jsonb_typeof(${value}) = 'string' + then nullif(btrim(${value} #>> '{}'), '') else null end`; + const target = (key: string, legacyKey: string) => sql`case when ${canonical} + then ${stringValue(sql`${changes} -> ${key} -> 'to'`)} + else coalesce(${stringValue(sql`${details} -> ${key}`)}, ${stringValue(sql`${details} -> ${legacyKey}`)}) end`; + return { + recorded: sql`coalesce(position('assign' in ${activityLog.action}) > 0 or + (${activityLog.action} = 'issue.updated' and (${sql.join(changed, sql` or `)})), false)`, + agentId: target("assigneeAgentId", "toAgentId"), + userId: target("assigneeUserId", "toUserId"), + }; } function readNumber(value: unknown) { @@ -486,6 +508,19 @@ export function workTimelineService(db: Db) { }; } + const ownership = timelineOwnershipProjection(); + // Read only presence, for the page and its already-readable parents. This + // returns at most 2 * MAX_LIMIT IDs, regardless of history length or window. + const ownershipIssueIds = maybeUuidList(pagedIssues.flatMap(issue => [ + issue.id, ...(issue.parentId && issueById.has(issue.parentId) ? [issue.parentId] : []), + ])); + const ownershipRows = await db.selectDistinct({ issueId: activityLog.entityId }) + .from(activityLog) + .where(and(eq(activityLog.companyId, input.companyId), eq(activityLog.entityType, "issue"), + inArray(activityLog.entityId, ownershipIssueIds), ownership.recorded)) + .limit(ownershipIssueIds.length); + const ownershipHistory = new Set(ownershipRows.map(row => row.issueId)); + const actorIds = new Set(); const events: WorkTimelineEvent[] = []; const edges: WorkTimelineEdge[] = []; @@ -501,7 +536,7 @@ export function workTimelineService(db: Db) { }); const assigneeActorId = actorForIssueAssignee(issue); - if (assigneeActorId) { + if (assigneeActorId && !ownershipHistory.has(issue.id)) { actorIds.add(assigneeActorId); edges.push({ fromActorId: creatorActorId, @@ -514,7 +549,8 @@ export function workTimelineService(db: Db) { const parent = issue.parentId ? issueById.get(issue.parentId) : null; const parentActorId = parent ? actorForIssueAssignee(parent) ?? actorForIssueCreator(parent) : null; - if (parentActorId && assigneeActorId && parentActorId !== assigneeActorId) { + if (parentActorId && assigneeActorId && parentActorId !== assigneeActorId + && !ownershipHistory.has(issue.id) && parent && !ownershipHistory.has(parent.id)) { actorIds.add(parentActorId); edges.push({ fromActorId: parentActorId, @@ -645,7 +681,9 @@ export function workTimelineService(db: Db) { actorType: activityLog.actorType, actorId: activityLog.actorId, action: activityLog.action, - details: activityLog.details, + assignmentRecorded: ownership.recorded, + assignmentAgentId: ownership.agentId, + assignmentUserId: ownership.userId, createdAt: activityLog.createdAt, }) .from(activityLog) @@ -728,42 +766,10 @@ export function workTimelineService(db: Db) { }); } - // Canonical task edits use issue.updated, not an assignment-specific action. - // The presence of a field is distinct from a null (unassignment) value. - const assignments = logRows.flatMap((row) => { - const details = row.details && typeof row.details === "object" && !Array.isArray(row.details) - ? row.details as Record : {}; - const previous = details._previous && typeof details._previous === "object" && !Array.isArray(details._previous) - ? details._previous as Record : {}; - const changes = row.action === "issue.updated" && details.changes && typeof details.changes === "object" && !Array.isArray(details.changes) - ? details.changes as Record : null; - const changeFor = (key: string) => { - const value = changes?.[key]; - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; - }; - // PATCH payload fields include repeated values; its saved change receipt - // alone identifies actual changes. Older records retain their prior shape. - const changed = ["assigneeAgentId", "assigneeUserId"].some((key) => { - if (changes) { - const change = changeFor(key); - return Object.hasOwn(change, "from") && Object.hasOwn(change, "to") && change.from !== change.to; - } - return Object.hasOwn(details, key) && (!Object.hasOwn(previous, key) || details[key] !== previous[key]); - }); - if (!row.action.includes("assign") && !(row.action === "issue.updated" && changed)) return []; - const targetAgentId = changes ? readString(changeFor("assigneeAgentId").to) - : readString(details.assigneeAgentId) ?? readString(details.toAgentId); - const targetUserId = changes ? readString(changeFor("assigneeUserId").to) - : readString(details.assigneeUserId) ?? readString(details.toUserId); - return [{ row, toActorId: targetAgentId ? actorId("agent", targetAgentId) - : targetUserId ? actorId("user", targetUserId) : null }]; - }); - const recordedAssignmentIssues = new Set(assignments.map(({ row }) => row.issueId)); - // A current owner does not prove that they owned the task when it was created. - for (let index = edges.length - 1; index >= 0; index--) { - if (edges[index]!.kind === "assignment" && recordedAssignmentIssues.has(edges[index]!.issueId)) edges.splice(index, 1); - } - for (const { row, toActorId } of assignments) { + for (const row of logRows) { + if (!row.assignmentRecorded) continue; + const toActorId = row.assignmentAgentId ? actorId("agent", row.assignmentAgentId) + : row.assignmentUserId ? actorId("user", row.assignmentUserId) : null; if (!toActorId) continue; const logActorType = row.actorType === "agent" || row.actorType === "user" || row.actorType === "plugin" ? row.actorType : "system"; 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 c03b7279aa8..5ba49cf3fc2 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-timeline-narrative.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-timeline-narrative.ts @@ -10,9 +10,10 @@ export function truthfulTimelineAccount(input: { // Search milestone clauses, not the first occurrence of a worker or outcome. // A summary or heading may mention the repair before the ordered account. const clauses = [...body.matchAll(/[^.!?\n]+(?:[.!?]|$)/g)].map(match => ({ text: match[0], at: match.index })); - const failures = clauses.filter(({ text }) => mentions(text, firstWorker) + const failureOutcome = /\b(?:fail(?:ed|ure|ing)?|exit(?:ed)?(?:\s+with)?(?:\s+(?:code|status))?\s*[:=]?\s*1)\b/gi; + const failures = clauses.flatMap(({ text, at }) => mentions(text, firstWorker) && /\b(?:verification|verify_launch_html|check|test|command)\b/i.test(text) - && /\b(?:fail(?:ed|ure|ing)?|exit(?:ed)?(?:\s+with)?(?:\s+(?:code|status))?\s*[:=]?\s*1)\b/i.test(text)); + ? [...text.matchAll(failureOutcome)].map(match => ({ at: at + match.index })) : []); const humanActors = ["you", "human operator", "board member", "human", "person", "user", "operator", "board", input.personName].filter(Boolean).map(name => escape(name!)).join("|"); const humanActor = `\\b(?:${humanActors})\\b`; const assignment = "\\b(?:reassign(?:ed|ment)?|assign(?:ed|ment)?|hand(?:ed)?(?:\\s+\\w+){0,4}\\s+(?:off|over)|handed|handoff)\\b"; @@ -25,11 +26,21 @@ export function truthfulTimelineAccount(input: { return !!namedRole && !["The", "A", "An", input.personName].some(name => name?.toLowerCase() === namedRole[1]!.toLowerCase()) || /\b(?:another|different|other)\s+(?:human|person|user|operator)\b/i.test(text); }; - const handoffs = clauses.filter(({ text }) => handoffToRepairWorker.test(text) && !differentNamedPerson(text) - && !/\b(?:not|never|no)\b[^.!?]{0,50}\b(?:reassign|assign|hand|handoff)/i.test(text)); - const repairs = clauses.filter(({ text }) => mentions(text, repairWorker) - && /\b(?:repair(?:ed)?|fix(?:ed)?|label)\b/i.test(text) - && /\b(?:pass(?:ed)?|succeed(?:ed)?|exit(?:ed)?(?:\s+with)?(?:\s+(?:code|status))?\s*[:=]?\s*0)\b/i.test(text)); + const handoffs = clauses.flatMap(({ text, at }) => { + if (differentNamedPerson(text) || /\b(?:not|never|no)\b[^.!?]{0,50}\b(?:reassign|assign|hand|handoff)/i.test(text)) return []; + return [...text.matchAll(new RegExp(handoffToRepairWorker.source, "gi"))].filter(match => { + // An explicit other person's role must not become the current human just + // because earlier milestones share this sentence. + if (!/^(?:human|person|user|operator|board)\b/i.test(match[0])) return true; + const prefix = /([A-Z][\p{L}'-]+(?:\s+[A-Z][\p{L}'-]+)*)\s*,?\s*(?:the\s+)?$/u.exec(text.slice(0, match.index)); + return !prefix || ["The", "A", "An", "Then", input.personName].some(name => name?.toLowerCase() === prefix[1]!.toLowerCase()); + }).map(match => ({ at: at + match.index })); + }); + const repairAction = new RegExp(`\\b${escape(repairWorker)}\\b(?:\\s+(?:then|later|now|personally|explicitly|actually|had|has|have|also|who)){0,3}\\s+\\b(?:repair(?:ed)?|fix(?:ed)?)\\b|\\b(?:was|got|had been|has been)\\s+(?:repaired|fixed)\\b[^.!?\\n]{0,40}\\bby\\s+${escape(repairWorker)}\\b`, "gi"); + const repairSuccess = /\b(?:pass(?:ed)?|succeed(?:ed)?|exit(?:ed)?(?:\s+with)?(?:\s+(?:code|status))?\s*[:=]?\s*0)\b/i; + const repairs = clauses.flatMap(({ text, at }) => [...text.matchAll(repairAction)] + .filter(match => repairSuccess.test(text.slice(match.index + match[0].length))) + .map(match => ({ at: at + match.index }))); const ordered = failures.some(failure => handoffs.some(handoff => handoff.at > failure.at && /\b(?:paused|blocked|stopped)\b/i.test(body.slice(failure.at, handoff.at)) && repairs.some(repair => repair.at > handoff.at))); 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 5cae7c27fc1..4d9db5cb2c6 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 @@ -113,6 +113,24 @@ it.each(["missing link", "hidden work", "source mutation", "wrong command", "wro expect(gradeWorkTimeline(o).passed).toBe(false); }); +it.each([ + "Today UTC: Morgan ran verification with exit code 1 and paused, you reassigned the task to Riley, and Riley fixed the label and the check passed with exit code 0.", + "Today UTC: Morgan ran verification with exit code 1 and paused; you reassigned the task to Riley; Riley fixed the label and the check passed with exit code 0.", + "Today UTC: Morgan ran verification with exit code 1 and paused then you reassigned the task to Riley and Riley fixed the label and the check passed with exit code 0.", +])("orders matched facts independently of milestone punctuation: %s", reply => { + const o = fixture(); o.reply = reply; expect(gradeWorkTimeline(o).passed).toBe(true); +}); +it.each([ + "Today UTC: you reassigned the task to Riley, Morgan ran verification with exit code 1 and paused, and Riley fixed the label and the check passed with exit code 0.", + "Today UTC: Morgan ran verification with exit code 1 and paused, Morgan reassigned the task to Riley while you watched, and Riley fixed the label and the check passed with exit code 0.", + "Today UTC: Morgan ran verification with exit code 1 and paused, you reassigned the task to Riley, and Morgan fixed the label and the check passed with exit code 0.", + "Today UTC: Morgan ran verification with exit code 1 and paused, Riley fixed the label and the check passed with exit code 0, then you reassigned the task to Riley.", + "Today UTC: Morgan ran verification with exit code 1 and paused, Jordan the human reassigned the task to Riley, and Riley fixed the label and the check passed with exit code 0.", + "Today UTC: Morgan ran verification with exit code 1 and paused, and Riley fixed the label and the check passed with exit code 0.", +])("retains ordering and actor requirements inside one sentence: %s", reply => { + const o = fixture(); o.reply = reply; expect(gradeWorkTimeline(o).passed).toBe(false); +}); + it("admits the selected live timeline metadata without invoking fixture callbacks", async () => { const descriptor = findChatCatalogCase("activity.work-timeline")!; const implementation = workTimelineCases[0]!;