Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 15 additions & 134 deletions packages/core/src/canvas/threadTimeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { describe, expect, it } from "vitest";
import {
buildThreadTimeline,
deriveThreadAgentStatus,
hasAgentMention,
normalizeAgentPromptText,
shouldSuspendThreadSession,
} from "./threadTimeline";
import { buildThreadTimeline, hasAgentMention } from "./threadTimeline";

describe("hasAgentMention", () => {
it.each([
Expand All @@ -19,151 +13,38 @@ describe("hasAgentMention", () => {
});
});

describe("normalizeAgentPromptText", () => {
it.each([
[
"forwarded thread comment",
"[Thread comment from Peter Kirkham] @agent which model are you?",
"which model are you?",
],
["direct prompt", "which model are you?", "which model are you?"],
[
"direct prompt with mention",
"@agent which model are you?",
"which model are you?",
],
])("normalizes a %s", (_name, content, expected) => {
expect(normalizeAgentPromptText(content)).toBe(expected);
});
});

describe("buildThreadTimeline", () => {
it("omits the session echo of a forwarded thread message", () => {
it("orders human messages chronologically", () => {
const timeline = buildThreadTimeline({
prompts: [
{
id: "prompt",
text: "[Thread comment from Peter Kirkham] @agent which model are you?",
timestamp: 200,
},
],
humanMessages: [
{
id: "human",
content: "@agent which model are you?",
createdAt: "1970-01-01T00:00:00.100Z",
forwardedToAgent: true,
id: "second",
content: "Second",
createdAt: "1970-01-01T00:00:00.200Z",
},
],
agentMessages: [],
});

expect(timeline.map((row) => row.kind)).toEqual(["human"]);
});

it("keeps a thread-comment prompt without a matching forwarded message", () => {
const timeline = buildThreadTimeline({
prompts: [
{
id: "prompt",
text: "[Thread comment from Peter Kirkham] @agent which model are you?",
timestamp: 200,
id: "first",
content: "First",
createdAt: "1970-01-01T00:00:00.100Z",
},
],
humanMessages: [],
agentMessages: [],
});

expect(timeline.map((row) => row.kind)).toEqual(["prompt"]);
expect(timeline.map((row) => row.message.id)).toEqual(["first", "second"]);
});

it("interleaves prompts, human replies, and agent turns chronologically", () => {
it("keeps malformed timestamps at the end", () => {
const timeline = buildThreadTimeline({
prompts: [{ id: "prompt", text: "Start", timestamp: 100 }],
humanMessages: [
{ id: "invalid", content: "Reply", createdAt: "invalid" },
{
id: "human",
content: "Reply",
createdAt: "1970-01-01T00:00:00.150Z",
id: "valid",
content: "First",
createdAt: "1970-01-01T00:00:00.100Z",
},
],
agentMessages: [{ id: "agent", text: "Done", timestamp: 200 }],
});

expect(timeline.map((row) => row.kind)).toEqual([
"prompt",
"human",
"agent",
]);
});

it("keeps malformed timestamps at the end", () => {
const timeline = buildThreadTimeline({
prompts: [{ id: "prompt", text: "Start", timestamp: 100 }],
humanMessages: [{ id: "human", content: "Reply", createdAt: "invalid" }],
agentMessages: [{ id: "agent", text: "Done", timestamp: 200 }],
});

expect(timeline.map((row) => row.kind)).toEqual([
"prompt",
"agent",
"human",
]);
});
});

describe("deriveThreadAgentStatus", () => {
it.each([
{
name: "returns no status before activity",
input: {},
expected: null,
},
{
name: "prioritizes failures",
input: { hasActivity: true, hasError: true, errorTitle: "Run failed" },
expected: { phase: "error", label: "Run failed" },
},
{
name: "prioritizes pending permissions over active work",
input: {
hasActivity: true,
pendingPermissionCount: 1,
isPromptPending: true,
},
expected: { phase: "needs_input", label: "Needs input" },
},
{
name: "reports active work",
input: { hasActivity: true, isPromptPending: true },
expected: { phase: "active", label: "Working…" },
},
{
name: "returns no status after work settles",
input: { hasActivity: true },
expected: null,
},
])("$name", ({ input, expected }) => {
expect(deriveThreadAgentStatus(input)).toEqual(expected);
});
});

describe("shouldSuspendThreadSession", () => {
it("suspends a local runless task so reading cannot start work", () => {
expect(
shouldSuspendThreadSession({
isCloud: false,
hasRun: false,
hasSession: false,
}),
).toBe(true);
});

it.each([
{ isCloud: true, hasRun: false, hasSession: false },
{ isCloud: false, hasRun: true, hasSession: false },
{ isCloud: false, hasRun: false, hasSession: true },
])("keeps an existing or cloud session attached", (input) => {
expect(shouldSuspendThreadSession(input)).toBe(false);
expect(timeline.map((row) => row.message.id)).toEqual(["valid", "invalid"]);
});
});
122 changes: 9 additions & 113 deletions packages/core/src/canvas/threadTimeline.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
export interface ThreadAgentMessage {
id: string;
text: string;
timestamp?: number;
}

export interface ThreadHumanMessage<T = unknown> {
id: string;
content: string;
Expand All @@ -12,133 +6,35 @@ export interface ThreadHumanMessage<T = unknown> {
value?: T;
}

export type ThreadTimelineRow<T = unknown> =
| { kind: "prompt"; timestamp: number; message: ThreadAgentMessage }
| { kind: "agent"; timestamp: number; message: ThreadAgentMessage }
| { kind: "human"; timestamp: number; message: ThreadHumanMessage<T> };

function validTimestamp(timestamp: number | undefined): number {
return timestamp !== undefined && Number.isFinite(timestamp)
? timestamp
: Number.MAX_SAFE_INTEGER;
}
export type ThreadTimelineRow<T = unknown> = {
kind: "human";
timestamp: number;
message: ThreadHumanMessage<T>;
};

function parsedTimestamp(timestamp: string): number {
const parsed = Date.parse(timestamp);
return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER;
}

export function buildThreadTimeline<T>({
prompts,
agentMessages,
humanMessages,
}: {
prompts: ThreadAgentMessage[];
agentMessages: ThreadAgentMessage[];
humanMessages: ThreadHumanMessage<T>[];
}): ThreadTimelineRow<T>[] {
const forwardedHumanContent = new Set(
humanMessages
.filter((message) => message.forwardedToAgent)
.map((message) => normalizeAgentPromptText(message.content)),
);
const visiblePrompts = prompts.filter(
(message) =>
!isThreadCommentPrompt(message.text) ||
!forwardedHumanContent.has(normalizeAgentPromptText(message.text)),
);

return [
...visiblePrompts.map(
(message): ThreadTimelineRow<T> => ({
kind: "prompt",
timestamp: validTimestamp(message.timestamp),
message,
}),
),
...humanMessages.map(
return humanMessages
.map(
(message): ThreadTimelineRow<T> => ({
kind: "human",
timestamp: parsedTimestamp(message.createdAt),
message,
}),
),
...agentMessages.map(
(message): ThreadTimelineRow<T> => ({
kind: "agent",
timestamp: validTimestamp(message.timestamp),
message,
}),
),
].sort((left, right) => left.timestamp - right.timestamp);
}

export type ThreadAgentPhase = "active" | "needs_input" | "error";

export interface ThreadAgentStatus {
phase: ThreadAgentPhase;
label: string;
)
.sort((left, right) => left.timestamp - right.timestamp);
}

const AGENT_MENTION_PATTERN = /(^|\s)@agent\b/i;
const THREAD_COMMENT_ATTRIBUTION_PATTERN =
/^\[Thread comment from [^\]\r\n]+\]\s*/i;
const LEADING_AGENT_MENTION_PATTERN = /^@agent\b[\s:]*/i;

export function hasAgentMention(content: string): boolean {
return AGENT_MENTION_PATTERN.test(content);
}

export function normalizeAgentPromptText(content: string): string {
return content
.trim()
.replace(THREAD_COMMENT_ATTRIBUTION_PATTERN, "")
.replace(LEADING_AGENT_MENTION_PATTERN, "")
.trim();
}

function isThreadCommentPrompt(content: string): boolean {
return THREAD_COMMENT_ATTRIBUTION_PATTERN.test(content.trim());
}

export function deriveThreadAgentStatus({
hasActivity = false,
hasError = false,
cloudStatus,
errorTitle,
pendingPermissionCount = 0,
isPromptPending = false,
isInitializing = false,
}: {
hasActivity?: boolean;
hasError?: boolean;
cloudStatus?: string | null;
errorTitle?: string | null;
pendingPermissionCount?: number;
isPromptPending?: boolean;
isInitializing?: boolean;
}): ThreadAgentStatus | null {
if (!hasActivity) return null;
if (hasError || cloudStatus === "failed") {
return { phase: "error", label: errorTitle ?? "Failed" };
}
if (pendingPermissionCount > 0) {
return { phase: "needs_input", label: "Needs input" };
}
if (isPromptPending || isInitializing) {
return { phase: "active", label: "Working…" };
}
return null;
}

export function shouldSuspendThreadSession({
isCloud,
hasRun,
hasSession,
}: {
isCloud: boolean;
hasRun: boolean;
hasSession: boolean;
}): boolean {
return !isCloud && !hasRun && !hasSession;
}
Loading
Loading