feat(ai-chat): agent avatar stack — agent front, launcher behind (#300) #304

Open
agent_coder wants to merge 2 commits from feat/300-agent-avatar-stack into develop
20 changed files with 1177 additions and 229 deletions
@@ -1222,8 +1222,8 @@
"Commented": "Commented",
"Resolved comment": "Resolved comment",
"Ran tool {{name}}": "Ran tool {{name}}",
"AI-agent": "AI-agent",
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
"AI agent {{name}}": "AI agent {{name}}",
"Endpoints": "Endpoints",
"where we fetch models": "where we fetch models",
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
@@ -724,7 +724,8 @@
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
"AI agent {{name}}": "AI-агент {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
"Failed": "Ошибка",
@@ -0,0 +1,101 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AgentAvatarStack } from "./agent-avatar-stack";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
type Props = React.ComponentProps<typeof AgentAvatarStack>;
function renderStack(props: Props) {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const utils = render(
<Provider store={store}>
<MantineProvider>
<AgentAvatarStack {...props} />
</MantineProvider>
</Provider>,
);
return { store, ...utils };
}
describe("AgentAvatarStack", () => {
it("internal chat WITH role: emoji glyph in front + human launcher behind", () => {
const { container } = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
// Emoji is used as the glyph (priority 2), NOT the sparkles fallback.
expect(screen.getByText("🔬")).toBeDefined();
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
// Label: bold role name + dimmed "· launcher".
expect(screen.getByText("Researcher")).toBeDefined();
expect(screen.getByText(/·/)).toBeDefined();
expect(screen.getByText("Alice")).toBeDefined();
});
it("internal chat WITHOUT role: sparkles fallback + 'AI agent' + launcher", () => {
const { container } = renderStack({
agent: { name: "AI agent", avatarUrl: null },
launcher: { name: "Bob", avatarUrl: null },
aiChatId: "chat-2",
});
// No avatarUrl and no emoji => sparkles glyph (priority 3).
expect(container.querySelector(".tabler-icon-sparkles")).not.toBeNull();
expect(screen.getByText("AI agent")).toBeDefined();
expect(screen.getByText("Bob")).toBeDefined();
});
it("external MCP: agent avatar in front, NO launcher behind", () => {
const { container } = renderStack({
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
launcher: null,
aiChatId: null,
});
// avatarUrl provided (priority 1) => not the sparkles fallback.
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
expect(screen.getByText("MCP Bot")).toBeDefined();
// No human behind => no "·" separator is rendered.
expect(screen.queryByText(/·/)).toBeNull();
// No internal chat => the stack is not an interactive deep-link button.
expect(screen.queryByRole("button")).toBeNull();
});
it("click deep-links into the chat when aiChatId is present", () => {
const { store } = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
const button = screen.getByRole("button");
fireEvent.click(button);
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared on switch
});
it("click is a no-op / not interactive without a chat target", () => {
const onActivate = vi.fn();
renderStack({
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
launcher: null,
aiChatId: null,
onActivate,
});
expect(screen.queryByRole("button")).toBeNull();
expect(onActivate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,183 @@
import { Avatar, Box, Group, Text, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// The FRONT identity (the acting agent) and the BEHIND identity (the human who
// launched it). Both are computed server-side (#300) so the client never branches
// on the internal-vs-MCP provenance — it just renders whatever it is handed.
export interface AgentInfo {
name: string;
emoji?: string | null;
avatarUrl?: string | null;
}
export interface LauncherInfo {
name: string;
avatarUrl?: string | null;
}
// Same violet token as the former AiAgentBadge (which used color="violet").
const AGENT_COLOR = "violet";
const GLYPH_SIZE = 38;
const LAUNCHER_SIZE = 22;
/**
* The front avatar. Image-source priority (#300):
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
* 2. agent.emoji -> the role emoji on a violet circle.
* 3. otherwise -> the IconSparkles glyph on a violet circle (fallback).
*/
function AgentGlyph({ agent }: { agent: AgentInfo }) {
if (agent.avatarUrl) {
return (
<CustomAvatar
size={GLYPH_SIZE}
avatarUrl={agent.avatarUrl}
name={agent.name}
/>
);
}
if (agent.emoji) {
return (
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
{agent.emoji}
</span>
</Avatar>
);
}
return (
<Avatar size={GLYPH_SIZE} radius="xl" color={AGENT_COLOR} variant="filled">
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
</Avatar>
);
}
export interface AgentAvatarStackProps {
agent: AgentInfo;
// null/absent => external MCP (front agent avatar only, no human behind).
launcher?: LauncherInfo | null;
// Deep-links into the internal AI chat when present (null for external MCP).
aiChatId?: string | null;
// Fired after the stack deep-links into its chat, so the caller can react
// (e.g. the page-history row closes the history modal). Keeps this ui/ primitive
// free of cross-feature coupling (inherited from the old AiAgentBadge, #143).
onActivate?: () => void;
}
/**
* The "agent avatar stack" (#300): the AGENT glyph in front, and — for an
* internal AI chat — the HUMAN who launched it as a smaller avatar offset behind.
* Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the
* whole stack is a deep-link into that chat (the click the old badge owned moved
* here); the click is contained (stopPropagation) so it does not also trigger an
* enclosing row handler.
*/
export function AgentAvatarStack({
agent,
launcher,
aiChatId,
onActivate,
}: AgentAvatarStackProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const clickable = !!aiChatId;
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching chats must start with a clean composer — clear any unsent draft
// so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
// Internal chat => "role on behalf of person"; external MCP => just the agent.
const tooltip = launcher
? t("AI agent «{{role}}» on behalf of {{person}}", {
role: agent.name,
person: launcher.name,
})
: t("AI agent {{name}}", { name: agent.name });
const stack = (
<Box
pos="relative"
style={{
width: GLYPH_SIZE,
height: GLYPH_SIZE,
flexShrink: 0,
cursor: clickable ? "pointer" : undefined,
}}
{...(clickable
? {
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{launcher && (
<Box pos="absolute" bottom={0} right={0} style={{ zIndex: 0 }}>
<CustomAvatar
size={LAUNCHER_SIZE}
avatarUrl={launcher.avatarUrl}
name={launcher.name}
style={{ border: "2px solid var(--mantine-color-body)" }}
/>
</Box>
)}
<Box pos="relative" style={{ zIndex: 1 }}>
<AgentGlyph agent={agent} />
</Box>
</Box>
);
return (
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Tooltip label={tooltip} withArrow>
{stack}
</Tooltip>
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{agent.name}
</Text>
{launcher && (
<>
<Text size="xs" c="dimmed" fw={400} aria-hidden>
·
</Text>
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
{launcher.name}
</Text>
</>
)}
</Group>
</Group>
);
}
export default AgentAvatarStack;
@@ -1,96 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Provider, createStore } from "jotai";
import { AiAgentBadge } from "./ai-agent-badge";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
return render(
<MantineProvider>
<AiAgentBadge {...props} />
</MantineProvider>,
);
}
// Render a clickable badge inside an explicit jotai store, with a leftover draft
// and an onActivate + parent-click spy, so the deep-link side effects are
// assertable. Returns the store and spies.
function setupClickable() {
const store = createStore();
store.set(aiChatDraftAtom, "leftover draft from another chat");
const onActivate = vi.fn();
const onParentClick = vi.fn();
render(
<Provider store={store}>
<MantineProvider>
<div onClick={onParentClick}>
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
</div>
</MantineProvider>
</Provider>,
);
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
}
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
}
describe("AiAgentBadge", () => {
it("renders the AI-agent label", () => {
renderBadge({ authorName: "Bot" });
expect(screen.getByText("AI-agent")).toBeDefined();
});
it("is clickable (accessible button) when aiChatId is present", () => {
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
const badge = screen.getByRole("button");
expect(badge).toBeDefined();
expect(badge.textContent).toContain("AI-agent");
});
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
const { store, onActivate, onParentClick, badge } = setupClickable();
fireEvent.click(badge);
expectDeepLinked(store, onActivate);
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
});
it.each(["Enter", " "])(
"keyboard %j activates the deep-link (same side effects as click)",
(key) => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key });
expectDeepLinked(store, onActivate);
},
);
it("an unrelated key does NOT activate the badge", () => {
const { store, onActivate, badge } = setupClickable();
fireEvent.keyDown(badge, { key: "Tab" });
expect(store.get(activeAiChatIdAtom)).toBeNull();
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
expect(onActivate).not.toHaveBeenCalled();
});
it.each([{ aiChatId: null }, {}])(
"is a plain non-clickable label without a chat target (%o)",
(props) => {
renderBadge({ authorName: "Bot", ...props });
expect(screen.getByText("AI-agent")).toBeDefined();
// No interactive role is exposed when there is no chat to deep-link into.
expect(screen.queryByRole("button")).toBeNull();
},
);
});
@@ -1,99 +0,0 @@
import { Badge, Tooltip } from "@mantine/core";
import { IconSparkles } from "@tabler/icons-react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSetAtom } from "jotai";
import {
activeAiChatIdAtom,
aiChatWindowOpenAtom,
aiChatDraftAtom,
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
interface AiAgentBadgeProps {
authorName?: string;
aiChatId?: string | null;
// Fired after the badge deep-links into its chat. The caller handles its own
// context (e.g. the page-history row closes the history modal) so this generic
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
onActivate?: () => void;
}
/**
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
* ADDITIVE — shown next to the human author, never replacing them. Reused by the
* page-history list and the comments sidebar.
*
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
* badge deep-links into that chat: it sets the active-chat atom and opens the
* floating AI-chat window, then invokes `onActivate` so the caller can react
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
* external MCP write with no internal ai_chats row), the badge is a plain
* non-clickable label. The click is contained (stopPropagation) so it does not
* also trigger an enclosing row's click handler.
*/
export function AiAgentBadge({
authorName,
aiChatId,
onActivate,
}: AiAgentBadgeProps) {
const { t } = useTranslation();
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
const setDraft = useSetAtom(aiChatDraftAtom);
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
name: authorName ?? "",
});
const openChat = useCallback(
(event: React.SyntheticEvent) => {
event.stopPropagation();
if (!aiChatId) return;
setActiveChatId(aiChatId);
// Switching to another chat must start with a clean composer — clear any
// unsent draft so it does not leak from the previously open chat.
setDraft("");
setAiChatWindowOpen(true);
onActivate?.();
},
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
);
const badge = (
<Badge
size="sm"
variant="light"
color="violet"
radius="sm"
leftSection={<IconSparkles size={12} stroke={2} />}
style={aiChatId ? { cursor: "pointer" } : undefined}
{...(aiChatId
? {
// Keep the default Badge root element (not a <button>) to avoid an
// invalid <button>-in-<button> nesting inside a row's
// UnstyledButton; expose it as an accessible button via
// role/keyboard.
role: "button",
tabIndex: 0,
onClick: openChat,
onKeyDown: (event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openChat(event);
}
},
}
: {})}
>
{t("AI-agent")}
</Badge>
);
return (
<Tooltip label={tooltip} withArrow>
{badge}
</Tooltip>
);
}
export default AiAgentBadge;
@@ -40,20 +40,30 @@ function renderItem(comment: IComment) {
);
}
describe("CommentListItem — AI badge", () => {
it('renders the AI-agent badge when createdSource === "agent"', () => {
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
expect(screen.getByText("AI-agent")).toBeDefined();
describe("CommentListItem — agent avatar stack", () => {
it('renders the agent avatar stack when createdSource === "agent"', () => {
// External-MCP shape: agent is the account itself, no launcher behind.
renderItem(
baseComment({
createdSource: "agent",
aiChatId: null,
agent: { name: "Service Bot", avatarUrl: null },
launcher: null,
}),
);
// The stack renders the agent name label (the creator name is also shown in
// the row header, so it appears more than once).
expect(screen.getAllByText("Service Bot").length).toBeGreaterThan(0);
});
it('does NOT render the stack for a normal user comment (createdSource "user")', () => {
const { container } = renderItem(baseComment({ createdSource: "user" }));
// No agent glyph (sparkles) is present for a plain human comment.
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
expect(screen.getByText("Service Bot")).toBeDefined();
});
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
renderItem(baseComment({ createdSource: "user" }));
expect(screen.queryByText("AI-agent")).toBeNull();
expect(screen.getByText("Service Bot")).toBeDefined();
});
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
// the insertion gate (agent → badge, user → no badge) above (#143 review).
// The stack's own behaviors (glyph priority, launcher-behind, deep-link click)
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
// only guards the insertion gate (agent → stack, user → no stack).
});
@@ -1,5 +1,5 @@
import { Group, Text, Box } from "@mantine/core";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import React, { useEffect, useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
@@ -132,9 +132,10 @@ function CommentListItem({
{comment.creator.name}
</Text>
{comment.createdSource === "agent" && (
<AiAgentBadge
authorName={comment.creator?.name}
{comment.createdSource === "agent" && comment.agent && (
<AgentAvatarStack
agent={comment.agent}
launcher={comment.launcher}
aiChatId={comment.aiChatId}
/>
)}
@@ -1,5 +1,9 @@
import { IUser } from "@/features/user/types/user.types";
import { QueryParams } from "@/lib/types.ts";
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
export interface IComment {
id: string;
@@ -24,6 +28,11 @@ export interface IComment {
createdSource?: string;
aiChatId?: string | null;
resolvedSource?: string | null;
// Server-normalized "agent avatar stack" provenance (#300), present only when
// createdSource === "agent": `agent` is the front identity, `launcher` the
// human behind it (null for an external MCP agent).
agent?: AgentInfo | null;
launcher?: LauncherInfo | null;
yjsSelection?: {
anchor: any;
head: any;
@@ -1,6 +1,6 @@
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import { formattedDate } from "@/lib/time";
import classes from "./css/history.module.css";
import clsx from "clsx";
@@ -99,12 +99,13 @@ const HistoryItem = memo(function HistoryItem({
</>
)}
{isAgentEdit && (
<AiAgentBadge
authorName={historyItem.lastUpdatedBy?.name}
{isAgentEdit && historyItem.agent && (
<AgentAvatarStack
agent={historyItem.agent}
launcher={historyItem.launcher}
aiChatId={historyItem.lastUpdatedAiChatId}
// The history row owns the modal: close it when the badge deep-links
// into the chat (the badge no longer reaches into page-history).
// The history row owns the modal: close it when the stack deep-links
// into the chat (the stack no longer reaches into page-history).
onActivate={() => setHistoryModalOpen(false)}
/>
)}
@@ -1,3 +1,8 @@
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
interface IPageHistoryUser {
id: string;
name: string;
@@ -24,4 +29,9 @@ export interface IPageHistory {
// (when present) deep-links to the chat that produced the edit.
lastUpdatedSource?: string;
lastUpdatedAiChatId?: string | null;
// Server-normalized "agent avatar stack" provenance (#300), present only when
// lastUpdatedSource === "agent": `agent` is the front identity, `launcher` the
// human behind it (null for an external MCP agent).
agent?: AgentInfo | null;
launcher?: LauncherInfo | null;
}
@@ -0,0 +1,237 @@
import { CommentService } from './comment.service';
/**
* Caller-contract coverage for the three live comment broadcasts (#300/#304):
* - commentCreated (create @153)
* - commentUpdated (update @214) ← the fragile path this suite spotlights
* - commentResolved (resolveComment @283)
*
* All three must emit a payload carrying the {agent,launcher} avatar stack for an
* AGENT comment, and NEITHER field for a non-agent comment. The enrichment lives
* in CommentRepo.findById(..., {includeCreator:true}); the service contract these
* tests pin is that every broadcast reads its payload from that enriched
* single-row load rather than from an un-enriched object.
*
* NON-VACUITY for the update path: the service is handed an UN-enriched input
* comment (no agent/launcher), while findById returns the ENRICHED shape. The
* pre-#304 update() re-emitted the caller's object in place, so it would emit the
* un-enriched input and the `agent`/`launcher` assertions would FAIL. The fix
* re-fetches via findById, so the broadcast carries the stack regardless of how
* the caller pre-loaded the comment.
*/
describe('CommentService — broadcast carries the agent avatar stack', () => {
// An enriched agent comment as CommentRepo.findById(..., includeCreator:true)
// returns it: the {agent,launcher} pair is attached and agentRole is stripped.
const enrichedAgentComment = (over?: Record<string, unknown>) => ({
id: 'comment-new',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
createdSource: 'agent',
agent: { name: 'Researcher', emoji: '🔬', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
...over,
});
// A plain human comment: findById attaches neither agent nor launcher.
const plainHumanComment = (over?: Record<string, unknown>) => ({
id: 'comment-new',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
createdSource: 'user',
...over,
});
function makeService(findByIdReturn: unknown) {
const commentRepo: any = {
// In these flows findById is only the post-write enriched re-read
// (no parentCommentId is set, so no parent lookup path is taken).
findById: jest.fn(async () => findByIdReturn),
insertComment: jest.fn(async () => ({ id: 'comment-new' })),
updateComment: jest.fn(async () => undefined),
};
const pageRepo: any = {};
const wsService: any = { emitCommentEvent: jest.fn() };
const collaborationGateway: any = {
handleYjsEvent: jest.fn(async () => undefined),
};
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
const notificationQueue: any = { add: jest.fn(async () => undefined) };
const service = new CommentService(
commentRepo,
pageRepo,
wsService,
collaborationGateway,
generalQueue,
notificationQueue,
);
return { service, commentRepo, wsService };
}
// Pull the emitted event object (3rd arg of emitCommentEvent) for an operation.
const emittedEvent = (wsService: any, operation: string) =>
wsService.emitCommentEvent.mock.calls
.map((c: any[]) => c[2])
.find((e: any) => e.operation === operation);
const page = { id: 'page-1', spaceId: 'space-1' } as any;
const user = (id = 'user-1') => ({ id }) as any;
const emptyDoc = JSON.stringify({ type: 'doc', content: [] });
describe('commentCreated', () => {
it('emits agent + launcher for an agent comment', async () => {
const { service, wsService } = makeService(enrichedAgentComment());
await service.create(
{ page, workspaceId: 'ws-1', user: user() },
{ content: emptyDoc } as any,
{ actor: 'agent', aiChatId: 'chat-1' },
);
const event = emittedEvent(wsService, 'commentCreated');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
await service.create(
{ page, workspaceId: 'ws-1', user: user() },
{ content: emptyDoc } as any,
);
const event = emittedEvent(wsService, 'commentCreated');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
describe('commentUpdated — the fragile path (spotlight)', () => {
it('emits agent + launcher even when the caller pre-loaded an UN-enriched comment', async () => {
// findById (the re-fetch) returns the enriched shape...
const { service, wsService, commentRepo } = makeService(
enrichedAgentComment(),
);
// ...but the caller hands in an object with NO agent/launcher. The pre-#304
// update() re-emitted THIS object in place, so this test fails against it;
// the re-fetch fix makes the broadcast independent of the pre-load.
const inputComment: any = {
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
// deliberately no `agent` / `launcher`
};
await service.update(
inputComment,
{ content: emptyDoc } as any,
user('user-1'),
);
// The broadcast must re-read the enriched row (persisted update, then load).
expect(commentRepo.updateComment).toHaveBeenCalled();
expect(commentRepo.findById).toHaveBeenCalledWith('comment-new', {
includeCreator: true,
includeResolvedBy: true,
});
const event = emittedEvent(wsService, 'commentUpdated');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
const inputComment: any = {
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
content: { type: 'doc', content: [] },
};
await service.update(
inputComment,
{ content: emptyDoc } as any,
user('user-1'),
);
const event = emittedEvent(wsService, 'commentUpdated');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
describe('commentResolved', () => {
it('emits agent + launcher for an agent comment', async () => {
const { service, wsService } = makeService(enrichedAgentComment());
await service.resolveComment(
{
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
} as any,
true,
user('user-1'),
{ actor: 'agent', aiChatId: 'chat-1' },
);
const event = emittedEvent(wsService, 'commentResolved');
expect(event).toBeDefined();
expect(event.comment.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(event.comment.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
});
it('emits neither field for a non-agent comment', async () => {
const { service, wsService } = makeService(plainHumanComment());
await service.resolveComment(
{
id: 'comment-new',
creatorId: 'user-1',
pageId: 'page-1',
spaceId: 'space-1',
workspaceId: 'ws-1',
} as any,
true,
user('user-1'),
);
const event = emittedEvent(wsService, 'commentResolved');
expect(event).toBeDefined();
expect(event.comment).not.toHaveProperty('agent');
expect(event.comment).not.toHaveProperty('launcher');
});
});
});
@@ -207,17 +207,27 @@ export class CommentService {
false,
);
comment.content = commentContent;
comment.editedAt = editedAt;
comment.updatedAt = editedAt;
// Re-fetch the enriched comment before broadcasting, symmetric with
// create()/resolveComment(). updateComment() above has already persisted the
// new content/timestamps, so this single-row read reflects the edit AND
// carries the same {agent,launcher} avatar stack (via includeCreator) as the
// other two broadcasts. This deliberately does NOT reuse the caller's
// pre-loaded `comment`: relying on the controller happening to load it with
// includeCreator:true is exactly the fragile coupling that let the agent
// stack silently vanish on edit once already (#300/#304) — a future caller
// dropping that flag must not regress the broadcast.
const updatedComment = await this.commentRepo.findById(comment.id, {
includeCreator: true,
includeResolvedBy: true,
});
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
operation: 'commentUpdated',
pageId: comment.pageId,
comment,
comment: updatedComment,
});
return comment;
return updatedComment;
}
async resolveComment(
@@ -0,0 +1,129 @@
import { resolveAgentProvenance } from './agent-provenance';
import { commentAgentRoleQuery } from './comment/comment.repo';
import { pageHistoryAgentRoleQuery } from './page/page-history.repo';
/**
* The server-authoritative "agent avatar stack" resolver (#300) normalizes the
* two provenance shapes into { agent (front), launcher (behind) } so the client
* never branches. These tests pin the exact resolved shape for the three agent
* cases plus the non-agent pass-through.
*/
describe('resolveAgentProvenance', () => {
const human = { name: 'Alice', avatarUrl: 'a.png' };
it('internal chat WITH role: agent = role (emoji, no avatar), launcher = human', () => {
const result = resolveAgentProvenance({
isAgent: true,
aiChatId: 'chat-1',
creator: human,
agentRole: { name: 'Researcher', emoji: '🔬' },
});
expect(result).toEqual({
agent: { name: 'Researcher', emoji: '🔬', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
});
});
it('internal chat WITHOUT role: agent = "AI agent" fallback, launcher = human', () => {
const result = resolveAgentProvenance({
isAgent: true,
aiChatId: 'chat-1',
creator: human,
agentRole: null,
});
expect(result).toEqual({
agent: { name: 'AI agent', avatarUrl: null },
launcher: { name: 'Alice', avatarUrl: 'a.png' },
});
// The fallback agent carries no emoji (only sparkles glyph on the client).
expect(result?.agent).not.toHaveProperty('emoji');
});
it('external MCP (aiChatId null): agent = the account itself, launcher = null', () => {
const result = resolveAgentProvenance({
isAgent: true,
aiChatId: null,
creator: { name: 'MCP Bot', avatarUrl: 'bot.png' },
agentRole: null,
});
expect(result).toEqual({
agent: { name: 'MCP Bot', avatarUrl: 'bot.png' },
launcher: null,
});
});
it('non-agent content: returns null so the caller omits both fields', () => {
expect(
resolveAgentProvenance({
isAgent: false,
aiChatId: null,
creator: human,
agentRole: null,
}),
).toBeNull();
});
});
/**
* The role-resolution subquery must NOT filter on enabled/deletedAt: historical
* agent content keeps its signature even after the role is disabled or
* soft-deleted (same rule as AiAgentRoleRepo.findById, NOT findLiveEnabled). We
* record the query-builder calls and assert the join binds only id<->roleId and
* that `where` is never called with an enabled/deletedAt filter.
*/
describe('agent role subquery — no live/enabled filter', () => {
function makeRecorder() {
const calls: { method: string; args: unknown[] }[] = [];
const builder = new Proxy(
{},
{
get(_t, prop: string) {
return (...args: unknown[]) => {
calls.push({ method: prop, args });
return builder;
};
},
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eb = { selectFrom: (...args: unknown[]) => (calls.push({ method: 'selectFrom', args }), builder) } as any;
return { eb, calls };
}
function assertNoLiveFilter(
query: (eb: any) => unknown, // eslint-disable-line @typescript-eslint/no-explicit-any
chatIdColumn: string,
) {
const { eb, calls } = makeRecorder();
query(eb);
const innerJoin = calls.find((c) => c.method === 'innerJoin');
expect(innerJoin?.args).toEqual([
'aiAgentRoles',
'aiAgentRoles.id',
'aiChats.roleId',
]);
const whereRef = calls.find((c) => c.method === 'whereRef');
expect(whereRef?.args).toEqual(['aiChats.id', '=', chatIdColumn]);
// The security-narrowing filters used by findLiveEnabled must be ABSENT.
const filtered = calls
.flatMap((c) => c.args)
.filter((a) => a === 'enabled' || a === 'deletedAt');
expect(filtered).toEqual([]);
// No `where(...)` at all (only the join + whereRef).
expect(calls.some((c) => c.method === 'where')).toBe(false);
}
it('comment subquery joins by id only, keyed on comments.aiChatId', () => {
assertNoLiveFilter(commentAgentRoleQuery, 'comments.aiChatId');
});
it('page-history subquery joins by id only, keyed on lastUpdatedAiChatId', () => {
assertNoLiveFilter(
pageHistoryAgentRoleQuery,
'pageHistory.lastUpdatedAiChatId',
);
});
});
@@ -0,0 +1,93 @@
/**
* Server-authoritative "agent avatar stack" provenance (#300).
*
* Agent-authored content (comments / page-history snapshots) is displayed as a
* two-avatar stack: the AGENT in front, and the HUMAN who launched it behind.
* This module normalizes the two provenance shapes the client can encounter into
* the SAME pair of sub-objects so the client never has to branch:
*
* agent — FRONT (the acting agent identity)
* launcher — BEHIND (the human on whose behalf it acted; null when there is none)
*
* The discriminator is purely SERVER-SIDE data (createdSource / lastUpdatedSource
* plus aiChatId) that only the server can set — none of it is read from request
* input, so an external caller cannot spoof an `agent` badge.
*/
/** Front avatar identity. `avatarUrl`/`emoji` feed the glyph source priority. */
export interface AgentInfo {
name: string;
emoji?: string | null;
avatarUrl?: string | null;
}
/** Behind avatar identity — the human who launched the agent (internal chat). */
export interface LauncherInfo {
name: string;
avatarUrl?: string | null;
}
/**
* Inputs to the resolver, drawn entirely from server-side columns:
* - `isAgent` — createdSource/lastUpdatedSource === 'agent'.
* - `aiChatId` — internal-AI-chat discriminator: non-null => internal chat (the
* provenance token was minted for the human, so `creator` is the human and the
* agent identity comes from the chat's role); null => external MCP (the login
* IS a dedicated agent account, so `creator` is the agent, no separate human).
* - `creator` — the row's human author (internal) OR agent account (MCP).
* - `agentRole`— the chat's bound role (name + optional emoji), resolved WITHOUT
* any enabled/deleted filter so historical content keeps its signature even
* after the role is disabled or soft-deleted; null when the chat has no role.
*/
export interface AgentProvenanceInput {
isAgent: boolean;
aiChatId: string | null | undefined;
creator: { name: string; avatarUrl?: string | null } | null | undefined;
agentRole: { name: string; emoji?: string | null } | null | undefined;
}
export interface AgentProvenance {
agent: AgentInfo;
launcher: LauncherInfo | null;
}
/** Fallback display name for an internal agent edit whose chat has no role. */
export const AGENT_FALLBACK_NAME = 'AI agent';
/**
* Resolve the front/behind identities from server-side provenance. Returns
* `null` for non-agent content so the caller can OMIT both fields (the client
* then keeps its plain single-human avatar).
*/
export function resolveAgentProvenance(
input: AgentProvenanceInput,
): AgentProvenance | null {
if (!input.isAgent) return null;
// External MCP: no internal chat row; the login itself is the agent account.
if (input.aiChatId == null) {
return {
agent: {
name: input.creator?.name ?? AGENT_FALLBACK_NAME,
avatarUrl: input.creator?.avatarUrl ?? null,
},
launcher: null,
};
}
// Internal AI chat: the agent identity is the chat's role (or the fallback
// when the chat has no role), and the launcher is the human chat owner.
const agent: AgentInfo = input.agentRole
? {
name: input.agentRole.name,
emoji: input.agentRole.emoji ?? null,
avatarUrl: null,
}
: { name: AGENT_FALLBACK_NAME, avatarUrl: null };
const launcher: LauncherInfo | null = input.creator
? { name: input.creator.name, avatarUrl: input.creator.avatarUrl ?? null }
: null;
return { agent, launcher };
}
@@ -0,0 +1,124 @@
import { CommentRepo } from './comment.repo';
/**
* Enrichment coverage for CommentRepo.findById (#300).
*
* The {agent,launcher} avatar stack must be attached on the SINGLE-ROW read
* path, not only on findPageComments — the live websocket broadcasts
* (commentCreated/commentUpdated/commentResolved) return a comment loaded via
* findById. These tests would FAIL against the previous un-enriched findById
* (which returned the raw row without calling attachCommentAgent and without
* selecting the agent-role subquery).
*
* The Kysely db is replaced by a chainable recorder so the query never touches a
* real database: it records the `.select(...)` args (to prove the agent-role
* subquery is selected on the includeCreator path) and returns a preset row from
* executeTakeFirst (to prove attachCommentAgent maps it into {agent,launcher}).
*/
describe('CommentRepo.findById — agent avatar stack enrichment', () => {
function makeRepo(row: unknown) {
const selectArgs: unknown[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builder: any = {
selectFrom: () => builder,
selectAll: () => builder,
select: (arg: unknown) => {
selectArgs.push(arg);
return builder;
},
// Kysely's $if(condition, cb) invokes cb(qb) only when the condition is
// truthy; mirror that so gating (includeCreator) is exercised faithfully.
$if: (cond: unknown, cb: (qb: unknown) => unknown) => {
if (cond) cb(builder);
return builder;
},
where: () => builder,
executeTakeFirst: async () => row,
};
const db = { selectFrom: () => builder };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const repo = new CommentRepo(db as any);
return { repo, selectArgs };
}
const enrichOpts = { includeCreator: true, includeResolvedBy: true };
it('internal agent chat WITH role: returns agent = role, launcher = creator, and strips agentRole', async () => {
const { repo, selectArgs } = makeRepo({
id: 'c-1',
createdSource: 'agent',
aiChatId: 'chat-1',
creator: { name: 'Alice', avatarUrl: 'a.png' },
agentRole: { name: 'Researcher', emoji: '🔬' },
});
const result: any = await repo.findById('c-1', enrichOpts);
expect(result.agent).toEqual({
name: 'Researcher',
emoji: '🔬',
avatarUrl: null,
});
expect(result.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
// The internal join column must never leak to the client.
expect(result).not.toHaveProperty('agentRole');
// The enrichment SELECTs the agent-role subquery on the includeCreator path
// (mirrors the list-query proof; absent in the pre-fix findById).
expect(selectArgs).toContain(repo.withAgentRole);
});
it('external MCP agent (aiChatId null): agent = the account, launcher = null', async () => {
const { repo } = makeRepo({
id: 'c-2',
createdSource: 'agent',
aiChatId: null,
creator: { name: 'MCP Bot', avatarUrl: 'bot.png' },
agentRole: null,
});
const result: any = await repo.findById('c-2', enrichOpts);
expect(result.agent).toEqual({ name: 'MCP Bot', avatarUrl: 'bot.png' });
expect(result.launcher).toBeNull();
expect(result).not.toHaveProperty('agentRole');
});
it('non-agent comment: neither agent nor launcher is attached', async () => {
const { repo } = makeRepo({
id: 'c-3',
createdSource: 'user',
aiChatId: null,
creator: { name: 'Bob', avatarUrl: null },
agentRole: null,
});
const result: any = await repo.findById('c-3', enrichOpts);
expect(result).not.toHaveProperty('agent');
expect(result).not.toHaveProperty('launcher');
// A plain human comment still strips the internal join column.
expect(result).not.toHaveProperty('agentRole');
});
it('missing row: returns undefined without crashing the enrichment', async () => {
const { repo } = makeRepo(undefined);
await expect(repo.findById('nope', enrichOpts)).resolves.toBeUndefined();
});
it('non-includeCreator callers keep the plain shape (no enrichment, no agent-role select)', async () => {
const { repo, selectArgs } = makeRepo({
id: 'c-4',
createdSource: 'agent',
aiChatId: 'chat-1',
});
// No opts => the enrichment (and its subquery select) must be skipped, so
// callers doing a bare lookup (parent-comment check, controller findOne)
// are unaffected by the additive fields.
const result: any = await repo.findById('c-4');
expect(result).not.toHaveProperty('agent');
expect(result).not.toHaveProperty('launcher');
expect(selectArgs).not.toContain(repo.withAgentRole);
});
});
@@ -12,6 +12,24 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
import { ExpressionBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { resolveAgentProvenance } from '../agent-provenance';
/**
* Role-resolution subquery for a comment's bound AI chat (#300). Joins
* comments.aiChatId -> ai_chats.role_id -> ai_agent_roles and selects the role's
* name + emoji. NO enabled/deletedAt filter: historical agent content must keep
* its signature even after the role is later disabled or soft-deleted — the same
* "resolve by id, ignore live/enabled" rule as AiAgentRoleRepo.findById (NOT
* findLiveEnabled). Exported so a unit test can assert the join binds only
* id<->roleId and never filters on enabled/deletedAt.
*/
export function commentAgentRoleQuery(eb: ExpressionBuilder<DB, 'comments'>) {
return eb
.selectFrom('aiChats')
.innerJoin('aiAgentRoles', 'aiAgentRoles.id', 'aiChats.roleId')
.select(['aiAgentRoles.name', 'aiAgentRoles.emoji'])
.whereRef('aiChats.id', '=', 'comments.aiChatId');
}
@Injectable()
export class CommentRepo {
@@ -22,13 +40,30 @@ export class CommentRepo {
commentId: string,
opts?: { includeCreator: boolean; includeResolvedBy: boolean },
): Promise<Comment> {
return await this.db
const comment = await this.db
.selectFrom('comments')
.selectAll('comments')
.$if(opts?.includeCreator, (qb) => qb.select(this.withCreator))
.$if(opts?.includeResolvedBy, (qb) => qb.select(this.withResolvedBy))
// #300: enrich the single-row read with the agent-role subquery so the
// {agent,launcher} avatar stack is attached here too — the live websocket
// broadcasts (commentCreated/Updated/Resolved) return a comment loaded via
// findById, and must carry the SAME provenance as the list query
// findPageComments. Without this a freshly created / edited / resolved
// agent comment arrives un-enriched and the client's
// `createdSource === 'agent' && agent` gate drops the stack until a full
// refetch. Gated on includeCreator (mirroring findPageComments, which
// always selects the creator): the internal-chat launcher IS the creator,
// so the resolver needs it, and every broadcast caller passes
// includeCreator: true. Non-includeCreator callers keep the plain shape.
.$if(opts?.includeCreator, (qb) => qb.select(this.withAgentRole))
.where('id', '=', commentId)
.executeTakeFirst();
// Guard a missing row (don't destructure undefined in attachCommentAgent)
// and leave non-enriched callers' shape untouched.
if (!comment || !opts?.includeCreator) return comment;
return attachCommentAgent(comment) as Comment;
}
async findPageComments(pageId: string, pagination: PaginationOptions) {
@@ -37,15 +72,18 @@ export class CommentRepo {
.selectAll('comments')
.select((eb) => this.withCreator(eb))
.select((eb) => this.withResolvedBy(eb))
.select((eb) => this.withAgentRole(eb))
.where('pageId', '=', pageId);
return executeWithCursorPagination(query, {
const result = await executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'asc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return { ...result, items: result.items.map(attachCommentAgent) };
}
async updateComment(
@@ -82,6 +120,12 @@ export class CommentRepo {
).as('creator');
}
/** Select the comment's resolved chat role (name + emoji) as `agentRole`, or
* null when the comment has no internal chat / the chat has no role (#300). */
withAgentRole(eb: ExpressionBuilder<DB, 'comments'>) {
return jsonObjectFrom(commentAgentRoleQuery(eb)).as('agentRole');
}
withResolvedBy(eb: ExpressionBuilder<DB, 'comments'>) {
return jsonObjectFrom(
eb
@@ -116,3 +160,30 @@ export class CommentRepo {
return Number(result?.count) > 0;
}
}
/**
* Attach the normalized agent/launcher provenance (#300) to a comment row and
* strip the internal `agentRole` join column. Non-agent rows pass through
* unchanged (neither field added — the client keeps the plain human avatar). The
* human author (`creator`) is the launcher for an internal chat, or the agent
* itself for external MCP; the resolver encodes both cases.
*/
function attachCommentAgent<
R extends {
createdSource?: string | null;
aiChatId?: string | null;
creator?: { name: string; avatarUrl?: string | null } | null;
agentRole?: { name: string; emoji?: string | null } | null;
},
>(row: R) {
const { agentRole, ...rest } = row;
const provenance = resolveAgentProvenance({
isAgent: row.createdSource === 'agent',
aiChatId: row.aiChatId,
creator: row.creator,
agentRole,
});
return provenance
? { ...rest, agent: provenance.agent, launcher: provenance.launcher }
: rest;
}
@@ -0,0 +1,107 @@
import { PageHistoryRepo } from './page-history.repo';
/**
* Enrichment coverage for the page-history agent avatar stack (#300/#304).
*
* attachPageHistoryAgent maps a DIFFERENT column set than comments —
* `lastUpdatedSource` / `lastUpdatedAiChatId` / `lastUpdatedBy` instead of
* `createdSource` / `aiChatId` / `creator` — so it needs its own direct proof
* that the {agent,launcher} pair resolves for each provenance shape and that the
* internal `agentRole` join column is stripped.
*
* The mapping is exercised through findPageHistoryByPageId (the only page-history
* path that enriches). The Kysely db is a chainable recorder: query-builder
* methods return the builder and `.execute()` (called by
* executeWithCursorPagination) yields preset rows, so no real database is
* touched. The `.select((eb) => ...)` callbacks are recorded but never invoked,
* so the preset row stands in for what the DB would have returned.
*
* NON-VACUITY: against an identity mapping (raw row pass-through) the agent-case
* assertions fail — `agent`/`launcher` would be undefined and the internal
* `agentRole` column would leak.
*/
describe('PageHistoryRepo.findPageHistoryByPageId — agent avatar stack enrichment', () => {
function makeRepo(rows: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const builder: any = {
selectFrom: () => builder,
select: () => builder,
where: () => builder,
orderBy: () => builder,
limit: () => builder,
execute: async () => rows,
};
const db = { selectFrom: () => builder };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new PageHistoryRepo(db as any);
}
// perPage high enough that a single preset row never triggers the extra-row
// "has next page" branch (which would call generateCursor).
const pagination = { limit: 50 } as any;
const firstItem = async (row: Record<string, unknown>) => {
const repo = makeRepo([row]);
const result = await repo.findPageHistoryByPageId('page-1', pagination);
return result.items[0] as any;
};
it('internal chat WITH role: agent = role (emoji, no avatar), launcher = human, agentRole stripped', async () => {
const item = await firstItem({
id: 'ph-1',
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: 'chat-1',
lastUpdatedBy: { name: 'Alice', avatarUrl: 'a.png' },
agentRole: { name: 'Editor', emoji: '✏️' },
});
expect(item.agent).toEqual({ name: 'Editor', emoji: '✏️', avatarUrl: null });
expect(item.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
// The internal join column must never leak to the client.
expect(item).not.toHaveProperty('agentRole');
});
it('internal chat WITHOUT role: agent = "AI agent" fallback, launcher = human', async () => {
const item = await firstItem({
id: 'ph-2',
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: 'chat-1',
lastUpdatedBy: { name: 'Alice', avatarUrl: 'a.png' },
agentRole: null,
});
expect(item.agent).toEqual({ name: 'AI agent', avatarUrl: null });
expect(item.agent).not.toHaveProperty('emoji');
expect(item.launcher).toEqual({ name: 'Alice', avatarUrl: 'a.png' });
expect(item).not.toHaveProperty('agentRole');
});
it('external MCP (lastUpdatedAiChatId null): agent = the account itself, launcher = null', async () => {
const item = await firstItem({
id: 'ph-3',
lastUpdatedSource: 'agent',
lastUpdatedAiChatId: null,
lastUpdatedBy: { name: 'MCP Bot', avatarUrl: 'bot.png' },
agentRole: null,
});
expect(item.agent).toEqual({ name: 'MCP Bot', avatarUrl: 'bot.png' });
expect(item.launcher).toBeNull();
expect(item).not.toHaveProperty('agentRole');
});
it('non-agent (lastUpdatedSource !== "agent"): neither agent nor launcher, agentRole stripped', async () => {
const item = await firstItem({
id: 'ph-4',
lastUpdatedSource: 'user',
lastUpdatedAiChatId: null,
lastUpdatedBy: { name: 'Bob', avatarUrl: null },
agentRole: null,
});
expect(item).not.toHaveProperty('agent');
expect(item).not.toHaveProperty('launcher');
// A plain human row still strips the internal join column.
expect(item).not.toHaveProperty('agentRole');
});
});
@@ -12,6 +12,25 @@ import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagin
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { ExpressionBuilder, sql } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { resolveAgentProvenance } from '../agent-provenance';
/**
* Role-resolution subquery for a page-history row's bound AI chat (#300). Joins
* pageHistory.lastUpdatedAiChatId -> ai_chats.role_id -> ai_agent_roles and
* selects the role's name + emoji. NO enabled/deletedAt filter: historical agent
* content must keep its signature even after the role is disabled or soft-deleted
* (same rule as AiAgentRoleRepo.findById, NOT findLiveEnabled). Exported so a
* unit test can assert the join never filters on enabled/deletedAt.
*/
export function pageHistoryAgentRoleQuery(
eb: ExpressionBuilder<DB, 'pageHistory'>,
) {
return eb
.selectFrom('aiChats')
.innerJoin('aiAgentRoles', 'aiAgentRoles.id', 'aiChats.roleId')
.select(['aiAgentRoles.name', 'aiAgentRoles.emoji'])
.whereRef('aiChats.id', '=', 'pageHistory.lastUpdatedAiChatId');
}
@Injectable()
export class PageHistoryRepo {
@@ -94,15 +113,18 @@ export class PageHistoryRepo {
.select(this.baseFields)
.select((eb) => this.withLastUpdatedBy(eb))
.select((eb) => this.withContributors(eb))
.select((eb) => this.withAgentRole(eb))
.where('pageId', '=', pageId);
return executeWithCursorPagination(query, {
const result = await executeWithCursorPagination(query, {
perPage: pagination.limit,
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [{ expression: 'id', direction: 'desc' }],
parseCursor: (cursor) => ({ id: cursor.id }),
});
return { ...result, items: result.items.map(attachPageHistoryAgent) };
}
async findPageLastHistory(
@@ -138,6 +160,12 @@ export class PageHistoryRepo {
).as('lastUpdatedBy');
}
/** Select the row's resolved chat role (name + emoji) as `agentRole`, or null
* when there is no internal chat / the chat has no role (#300). */
withAgentRole(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonObjectFrom(pageHistoryAgentRoleQuery(eb)).as('agentRole');
}
withContributors(eb: ExpressionBuilder<DB, 'pageHistory'>) {
return jsonArrayFrom(
eb
@@ -151,3 +179,30 @@ export class PageHistoryRepo {
).as('contributors');
}
}
/**
* Attach the normalized agent/launcher provenance (#300) to a page-history row
* and strip the internal `agentRole` join column. The trigger is
* `lastUpdatedSource === 'agent'`, the internal-chat discriminator is
* `lastUpdatedAiChatId`, and the human is `lastUpdatedBy`. Non-agent rows pass
* through unchanged (neither field added).
*/
function attachPageHistoryAgent<
R extends {
lastUpdatedSource?: string | null;
lastUpdatedAiChatId?: string | null;
lastUpdatedBy?: { name: string; avatarUrl?: string | null } | null;
agentRole?: { name: string; emoji?: string | null } | null;
},
>(row: R) {
const { agentRole, ...rest } = row;
const provenance = resolveAgentProvenance({
isAgent: row.lastUpdatedSource === 'agent',
aiChatId: row.lastUpdatedAiChatId,
creator: row.lastUpdatedBy,
agentRole,
});
return provenance
? { ...rest, agent: provenance.agent, launcher: provenance.launcher }
: rest;
}
+1
View File
@@ -0,0 +1 @@
node_modules/node_modules