mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
80318e4567
commit
8b8f676e83
4 files changed
+217
-55
No files matched your search
@@ -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<string>`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 });
|
||||
|
||||
@@ -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<string | null>`case when jsonb_typeof(${value}) = 'string'
|
||||
then nullif(btrim(${value} #>> '{}'), '') else null end`;
|
||||
const target = (key: string, legacyKey: string) => sql<string | null>`case when ${canonical}
|
||||
then ${stringValue(sql`${changes} -> ${key} -> 'to'`)}
|
||||
else coalesce(${stringValue(sql`${details} -> ${key}`)}, ${stringValue(sql`${details} -> ${legacyKey}`)}) end`;
|
||||
return {
|
||||
recorded: sql<boolean>`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<string>();
|
||||
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<string, unknown> : {};
|
||||
const previous = details._previous && typeof details._previous === "object" && !Array.isArray(details._previous)
|
||||
? details._previous as Record<string, unknown> : {};
|
||||
const changes = row.action === "issue.updated" && details.changes && typeof details.changes === "object" && !Array.isArray(details.changes)
|
||||
? details.changes as Record<string, unknown> : null;
|
||||
const changeFor = (key: string) => {
|
||||
const value = changes?.[key];
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
};
|
||||
// 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";
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
Reference in new issue
Block a user