diff --git a/tests/runner-e2e/chat-catalog-descriptors.json b/tests/runner-e2e/chat-catalog-descriptors.json
index 7cd366f88f1..f70a4350051 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.v3",
+ "oracleVersion": "paperclip.chat-catalog.work-timeline.v4",
"coverageGaps": [],
"expectedRunCount": 3,
"minimumRunCount": 3,
diff --git a/tests/runner-e2e/chat-catalog-domains/internal-timeline-narrative.test.ts b/tests/runner-e2e/chat-catalog-domains/internal-timeline-narrative.test.ts
new file mode 100644
index 00000000000..f316c85820f
--- /dev/null
+++ b/tests/runner-e2e/chat-catalog-domains/internal-timeline-narrative.test.ts
@@ -0,0 +1,25 @@
+import { expect, it } from "vitest";
+import { truthfulTimelineAccount } from "./internal-timeline-narrative.js";
+
+const account = `**First attempt (Morgan)**
+Morgan built the page. The verification command failed with exit 1. Morgan reported the task as blocked.
+
+**Repair (Riley)**
+You reassigned the task to Riley. He added \`\`, preserved the heading, and verification exited 0.`;
+const grade = (reply: string) => truthfulTimelineAccount({ reply, firstWorker: "Morgan", repairWorker: "Riley" });
+it("resolves a named section's command and pronoun without requiring repeated names", () => {
+ expect(grade(account)).toBe(true);
+});
+it.each([
+ account.replace("You reassigned the task to Riley. ", ""),
+ account.replace("You reassigned", "Morgan reassigned"),
+ account.replace("He added", "Morgan added"),
+ account.replace("First attempt (Morgan)", "First attempt (Riley)"),
+ account + " A native run failed.",
+ account.replace("verification exited 0", "verification exited 1"),
+])("retains consequential actor and outcome requirements: %s", reply => {
+ expect(grade(reply)).toBe(false);
+});
+it("does not carry a section owner through an unrelated paragraph", () => {
+ expect(grade(account.replace("The verification", "\n\nThe verification"))).toBe(false);
+});
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 a8ebf3ca6b3..06a7091d9a3 100644
--- a/tests/runner-e2e/chat-catalog-domains/internal-timeline-narrative.ts
+++ b/tests/runner-e2e/chat-catalog-domains/internal-timeline-narrative.ts
@@ -3,15 +3,31 @@ export function truthfulTimelineAccount(input: {
reply: string; personName?: string; firstWorker: string; repairWorker: string;
}) {
const { reply, firstWorker, repairWorker } = input;
- const body = reply.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/[*`]/g, "");
if (!firstWorker || !repairWorker || firstWorker === repairWorker) return false;
const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const mentions = (text: string, name: string) => new RegExp(`\\b${escape(name)}\\b`, "i").test(text);
+ // A named Markdown section can own its paragraph's command result and
+ // subject pronoun. Never carry that ownership across a blank line, another
+ // worker, or an unlabelled paragraph. Other paragraphs keep explicit actors.
+ let offset = 0;
+ const paragraphs = reply.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").split(/\n\s*\n/).map(raw => {
+ const heading = /^\s*(?:#{1,6} +([^\n]+)|\*\*([^\n]+)\*\*)[ \t]*\n/.exec(raw);
+ const owners = [firstWorker, repairWorker].filter(name => mentions(heading?.[1] ?? heading?.[2] ?? "", name));
+ const owner = owners.length === 1 && ![firstWorker, repairWorker].some(name => name !== owners[0] && mentions(raw, name)) ? owners[0] : undefined;
+ const text = raw.replace(/[*`]/g, "")
+ // Inline source markup still names its semantic repair object.
+ .replace(/<([a-z][a-z0-9]*)\b[^>]*>/gi, "$1 ").replace(/<\/[a-z][a-z0-9]*\s*>/gi, "")
+ .replace(/(^|[.!?]\s+)(he|she|they)\b/gi, (match, lead) => owner ? lead + owner : match);
+ const paragraph = { text, owner, start: offset, end: offset + text.length };
+ offset = paragraph.end + 2;
+ return paragraph;
+ });
+ const body = paragraphs.map(row => row.text).join("\n\n");
// 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 withinClause = "(?:[^.!?\\n]|\\.(?=\\S))";
const clauses = [...body.matchAll(new RegExp(`${withinClause}+(?:[!?]|\\.(?=\\s|$)|$)`, "g"))]
- .map(match => ({ text: match[0], at: match.index }));
+ .map(match => ({ text: match[0], at: match.index, owner: paragraphs.find(row => row.start <= match.index && row.end > match.index)?.owner }));
// A command noun can have ordinary modifiers, but never absorb a new event,
// actor, negation or clause boundary while connecting this pause to failure.
const commandBoundaries = [
@@ -25,7 +41,7 @@ export function truthfulTimelineAccount(input: {
const commandSubject = `(?:${commandModifier}[ \\t]+){0,3}(?:verification|verify_launch_html|check|test|command)`;
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)
+ const failures = clauses.flatMap(({ text, at, owner }) => (mentions(text, firstWorker) || owner === firstWorker)
&& new RegExp(`\\b${commandSubject}\\b`, "i").test(text)
? [...text.matchAll(failureOutcome)].map(match => ({ at: at + match.index, end: at + match.index + match[0].length, clauseAt: at })) : []);
const humanActors = ["you", "human operator", "board member", "human", "person", "user", "operator", "board", input.personName].filter(Boolean).map(name => escape(name!)).join("|");
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 b38a41dbba9..75de56792ff 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
@@ -205,7 +205,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.v3");
+ expect(descriptor.oracleVersion).toBe("paperclip.chat-catalog.work-timeline.v4");
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 5c270a2c45b..bf4ca672a7d 100644
--- a/tests/runner-e2e/chat-catalog-domains/internal-work-timeline.ts
+++ b/tests/runner-e2e/chat-catalog-domains/internal-work-timeline.ts
@@ -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.v3", authentication: "human-session", expectedRunCount: 3, minimumRunCount: 3, attemptTimeoutMs: 720000,
+ return { ...base, oracleVersion: "paperclip.chat-catalog.work-timeline.v4", 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(); } },