Skip to content

Commit 8d4d42e

Browse files
authored
feat(canvas): distinguish agent-directed thread messages
Generated-By: PostHog Code Task-Id: 87a79e23-20c4-440b-818b-28154924ffbc
1 parent 7ffce8a commit 8d4d42e

11 files changed

Lines changed: 337 additions & 33 deletions

File tree

packages/api-client/src/posthog-client.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { TaskThreadMessage } from "@posthog/shared/domain-types";
12
import { describe, expect, it, vi } from "vitest";
23
import { PostHogAPIClient } from "./posthog-client";
34

@@ -1696,3 +1697,66 @@ describe("PostHogAPIClient", () => {
16961697
});
16971698
});
16981699
});
1700+
1701+
describe("task thread messages", () => {
1702+
function message(
1703+
overrides: Partial<TaskThreadMessage> = {},
1704+
): TaskThreadMessage {
1705+
return {
1706+
id: "message-id",
1707+
task: "task-id",
1708+
content: "@agent investigate this",
1709+
created_at: "2026-07-16T00:00:00Z",
1710+
...overrides,
1711+
};
1712+
}
1713+
1714+
it("creates a message before forwarding it to the agent", async () => {
1715+
const client = new PostHogAPIClient(
1716+
"http://localhost:8000",
1717+
async () => "token",
1718+
async () => "token",
1719+
123,
1720+
);
1721+
const create = vi
1722+
.spyOn(client, "createTaskThreadMessage")
1723+
.mockResolvedValue(message());
1724+
const send = vi
1725+
.spyOn(client, "sendTaskThreadMessageToAgent")
1726+
.mockResolvedValue(
1727+
message({ forwarded_to_agent_at: "2026-07-16T00:00:01Z" }),
1728+
);
1729+
1730+
await client.createTaskThreadMessageForAgent(
1731+
"task-id",
1732+
"@agent investigate this",
1733+
);
1734+
1735+
expect(create).toHaveBeenCalledWith("task-id", "@agent investigate this");
1736+
expect(send).toHaveBeenCalledWith("task-id", "message-id");
1737+
expect(create.mock.invocationCallOrder[0]).toBeLessThan(
1738+
send.mock.invocationCallOrder[0],
1739+
);
1740+
});
1741+
1742+
it("returns the created message when forwarding fails", async () => {
1743+
const client = new PostHogAPIClient(
1744+
"http://localhost:8000",
1745+
async () => "token",
1746+
async () => "token",
1747+
123,
1748+
);
1749+
const sendError = new Error("No active run");
1750+
vi.spyOn(client, "createTaskThreadMessage").mockResolvedValue(message());
1751+
vi.spyOn(client, "sendTaskThreadMessageToAgent").mockRejectedValue(
1752+
sendError,
1753+
);
1754+
1755+
await expect(
1756+
client.createTaskThreadMessageForAgent(
1757+
"task-id",
1758+
"@agent investigate this",
1759+
),
1760+
).resolves.toEqual({ message: message(), sendError });
1761+
});
1762+
});

packages/api-client/src/posthog-client.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2630,6 +2630,21 @@ export class PostHogAPIClient {
26302630
return (await response.json()) as TaskThreadMessage;
26312631
}
26322632

2633+
async createTaskThreadMessageForAgent(
2634+
taskId: string,
2635+
content: string,
2636+
): Promise<{ message: TaskThreadMessage; sendError: unknown | null }> {
2637+
const message = await this.createTaskThreadMessage(taskId, content);
2638+
try {
2639+
return {
2640+
message: await this.sendTaskThreadMessageToAgent(taskId, message.id),
2641+
sendError: null,
2642+
};
2643+
} catch (sendError) {
2644+
return { message, sendError };
2645+
}
2646+
}
2647+
26332648
async deleteTaskThreadMessage(
26342649
taskId: string,
26352650
messageId: string,

packages/core/src/canvas/threadTimeline.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,22 @@ import { describe, expect, it } from "vitest";
22
import {
33
buildThreadTimeline,
44
deriveThreadAgentStatus,
5+
hasAgentMention,
56
shouldSuspendThreadSession,
67
} from "./threadTimeline";
78

9+
describe("hasAgentMention", () => {
10+
it.each([
11+
["at the start", "@agent investigate this", true],
12+
["after other text", "Could you @Agent check this?", true],
13+
["inside an email-like token", "person@agent.com", false],
14+
["as part of a longer handle", "@agents", false],
15+
["without a mention", "human-only note", false],
16+
])("detects an agent mention %s", (_name, content, expected) => {
17+
expect(hasAgentMention(content)).toBe(expected);
18+
});
19+
});
20+
821
describe("buildThreadTimeline", () => {
922
it("interleaves prompts, human replies, and agent turns chronologically", () => {
1023
const timeline = buildThreadTimeline({

packages/core/src/canvas/threadTimeline.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ export interface ThreadAgentStatus {
6868
label: string;
6969
}
7070

71+
const AGENT_MENTION_PATTERN = /(^|\s)@agent\b/i;
72+
73+
export function hasAgentMention(content: string): boolean {
74+
return AGENT_MENTION_PATTERN.test(content);
75+
}
76+
7177
export function deriveThreadAgentStatus({
7278
hasActivity = false,
7379
hasError = false,

packages/ui/src/features/canvas/components/MentionComposer.tsx

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { RobotIcon } from "@phosphor-icons/react";
12
import { Avatar, AvatarFallback, InputGroup } from "@posthog/quill";
23
import type { UserBasic } from "@posthog/shared/domain-types";
34
import { getUserInitials } from "@posthog/ui/features/auth/userInitials";
45
import {
6+
type ComposerMentionCandidate,
57
contentToDoc,
68
docToContent,
7-
filterMentionCandidates,
9+
filterComposerMentionCandidates,
810
} from "@posthog/ui/features/canvas/utils/mentionComposer";
911
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
1012
import { Text } from "@radix-ui/themes";
@@ -23,6 +25,7 @@ interface MentionComposerProps {
2325
onSubmit: () => void;
2426
/** The taggable pool; typically the org's members. */
2527
members: UserBasic[];
28+
allowAgentMention?: boolean;
2629
onMentionInsert?: (member: UserBasic) => void;
2730
placeholder?: string;
2831
rows?: number;
@@ -40,8 +43,8 @@ const EDITOR_CLASS =
4043
"w-full px-2.5 py-2 outline-none break-words [overflow-wrap:break-word] [white-space:pre-wrap] [word-break:break-word]";
4144

4245
interface SuggestionSession {
43-
items: UserBasic[];
44-
command: (member: UserBasic) => void;
46+
items: ComposerMentionCandidate[];
47+
command: (candidate: ComposerMentionCandidate) => void;
4548
}
4649

4750
/**
@@ -55,6 +58,7 @@ export function MentionComposer({
5558
onValueChange,
5659
onSubmit,
5760
members,
61+
allowAgentMention = false,
5862
onMentionInsert,
5963
placeholder,
6064
rows,
@@ -78,6 +82,8 @@ export function MentionComposer({
7882
// latest props and popup state.
7983
const membersRef = useRef(members);
8084
membersRef.current = members;
85+
const allowAgentMentionRef = useRef(allowAgentMention);
86+
allowAgentMentionRef.current = allowAgentMention;
8187
const onValueChangeRef = useRef(onValueChange);
8288
onValueChangeRef.current = onValueChange;
8389
const onSubmitRef = useRef(onSubmit);
@@ -121,11 +127,21 @@ export function MentionComposer({
121127
char: "@",
122128
allowSpaces: true,
123129
items: ({ query }) =>
124-
filterMentionCandidates(membersRef.current, query),
125-
// The suggestion pipeline carries whole members, not node attrs,
126-
// so member data survives into `command`.
130+
filterComposerMentionCandidates(
131+
membersRef.current,
132+
query,
133+
allowAgentMentionRef.current,
134+
),
127135
command: ({ editor: e, range, props }) => {
128-
const member = props as unknown as UserBasic;
136+
const candidate = props as unknown as ComposerMentionCandidate;
137+
if (candidate.kind === "agent") {
138+
e.chain()
139+
.focus()
140+
.insertContentAt(range, { type: "text", text: "@agent " })
141+
.run();
142+
return;
143+
}
144+
const { member } = candidate;
129145
e.chain()
130146
.focus()
131147
.insertContentAt(range, [
@@ -143,17 +159,17 @@ export function MentionComposer({
143159
setDismissed(false);
144160
setSelectedIndex(0);
145161
setSession({
146-
items: props.items as unknown as UserBasic[],
147-
command: (member) =>
148-
props.command(member as unknown as MentionNodeAttrs),
162+
items: props.items as unknown as ComposerMentionCandidate[],
163+
command: (candidate) =>
164+
props.command(candidate as unknown as MentionNodeAttrs),
149165
});
150166
},
151167
onUpdate: (props) => {
152168
setSelectedIndex(0);
153169
setSession({
154-
items: props.items as unknown as UserBasic[],
155-
command: (member) =>
156-
props.command(member as unknown as MentionNodeAttrs),
170+
items: props.items as unknown as ComposerMentionCandidate[],
171+
command: (candidate) =>
172+
props.command(candidate as unknown as MentionNodeAttrs),
157173
});
158174
},
159175
onKeyDown: ({ event }) => {
@@ -172,8 +188,8 @@ export function MentionComposer({
172188
return true;
173189
}
174190
if (event.key === "Enter" || event.key === "Tab") {
175-
const member = items[highlightedRef.current];
176-
if (member) sessionRef.current?.command(member);
191+
const candidate = items[highlightedRef.current];
192+
if (candidate) sessionRef.current?.command(candidate);
177193
return true;
178194
}
179195
return false;
@@ -227,37 +243,49 @@ export function MentionComposer({
227243
<div className="absolute inset-x-0 bottom-full z-50 mb-1 flex flex-col overflow-hidden rounded-md border border-[var(--gray-a6)] bg-[var(--color-panel-solid)] text-[13px] shadow-lg">
228244
<div
229245
role="listbox"
230-
aria-label="Mention a teammate"
246+
aria-label="Mention a teammate or agent"
231247
className="max-h-56 overflow-y-auto py-1"
232248
>
233-
{session.items.map((member, index) => (
249+
{session.items.map((candidate, index) => (
234250
<button
235251
type="button"
236252
role="option"
237253
aria-selected={index === highlightedIndex}
238-
key={member.uuid}
254+
key={
255+
candidate.kind === "agent" ? "agent" : candidate.member.uuid
256+
}
239257
ref={(el) => {
240258
itemRefs.current[index] = el;
241259
}}
242260
// Keep focus in the editor so insertion lands at the caret.
243261
onMouseDown={(event) => event.preventDefault()}
244-
onClick={() => session.command(member)}
262+
onClick={() => session.command(candidate)}
245263
onMouseEnter={() => setSelectedIndex(index)}
246264
className={`flex w-full items-center gap-2 border-none px-2 py-1 text-left ${
247265
index === highlightedIndex ? "bg-[var(--accent-a4)]" : ""
248266
}`}
249267
>
250268
<Avatar size="xs" className="shrink-0">
251-
<AvatarFallback>{getUserInitials(member)}</AvatarFallback>
269+
<AvatarFallback>
270+
{candidate.kind === "agent" ? (
271+
<RobotIcon size={12} />
272+
) : (
273+
getUserInitials(candidate.member)
274+
)}
275+
</AvatarFallback>
252276
</Avatar>
253277
<Text size="1" weight="medium" className="truncate">
254-
{userDisplayName(member)}
278+
{candidate.kind === "agent"
279+
? "Agent"
280+
: userDisplayName(candidate.member)}
255281
</Text>
256282
<Text
257283
size="1"
258284
className="ml-auto shrink-0 truncate text-muted-foreground"
259285
>
260-
{member.email}
286+
{candidate.kind === "agent"
287+
? "Send to agent"
288+
: candidate.member.email}
261289
</Text>
262290
</button>
263291
))}

packages/ui/src/features/canvas/components/ThreadPanel.test.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { render, screen } from "@testing-library/react";
22
import { describe, expect, it } from "vitest";
3-
import { AgentTurnRow } from "./ThreadPanel";
3+
import { AgentTurnRow, UserPromptRow } from "./ThreadPanel";
44

55
describe("AgentTurnRow", () => {
66
it.each([
@@ -17,3 +17,17 @@ describe("AgentTurnRow", () => {
1717
expect(status.closest('[data-slot="thread-item-body"]')).not.toBeNull();
1818
});
1919
});
20+
21+
describe("UserPromptRow", () => {
22+
it("prefixes direct task prompts with @agent", () => {
23+
render(
24+
<UserPromptRow
25+
message={{ id: "prompt", text: "Investigate this", timestamp: 1 }}
26+
author={{ id: 1, uuid: "user", email: "user@example.com" }}
27+
/>,
28+
);
29+
30+
expect(screen.getByText("@agent")).toBeInTheDocument();
31+
expect(screen.getByText("Investigate this")).toBeInTheDocument();
32+
});
33+
});

0 commit comments

Comments
 (0)