Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86c1307ed2 | |||
| 0968ea97d2 |
@@ -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;
|
||||
@@ -1,14 +1,7 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Spy on the markdown renderer so we can assert it is NOT called while the block
|
||||
// is collapsed (the #302 fix) and IS called once on expand. The count/fallback
|
||||
// tests don't depend on real markdown, so a light stub is safe.
|
||||
vi.mock("@/features/ai-chat/utils/markdown.ts", () => ({
|
||||
renderChatMarkdown: vi.fn((md: string) => `<p>${md}</p>`),
|
||||
}));
|
||||
|
||||
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
|
||||
// keeps the assertions on the component's OWN count logic (authoritative vs
|
||||
// estimate) rather than on translation, and mirrors the t-mock pattern used by
|
||||
@@ -24,7 +17,6 @@ vi.mock("react-i18next", () => ({
|
||||
|
||||
import ReasoningBlock from "./reasoning-block";
|
||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
@@ -70,18 +62,4 @@ describe("ReasoningBlock", () => {
|
||||
// either way the text is present in the document.
|
||||
expect(screen.getByText(/reasoning/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not parse the reasoning markdown while collapsed; parses on expand (#302)", () => {
|
||||
const renderSpy = vi.mocked(renderChatMarkdown);
|
||||
renderSpy.mockClear();
|
||||
renderBlock({ text: "**bold** reasoning", tokens: 5 });
|
||||
// Collapsed is the default. The expensive markdown parse (marked + DOMPurify)
|
||||
// must NOT run for the hidden body — that O(n^2) re-parse on every streamed
|
||||
// delta is exactly what froze the chat (#302). The collapsed body shows the
|
||||
// cheap raw-text fallback instead.
|
||||
expect(renderSpy).not.toHaveBeenCalled();
|
||||
// Expanding parses the current text exactly once (a user-initiated click).
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,19 +34,15 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
// Authoritative count wins; otherwise estimate live from the streamed text.
|
||||
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
||||
const trimmed = text.trim();
|
||||
// Parse the reasoning markdown ONLY while the block is expanded. Collapsed is the
|
||||
// default and the common case during a long "thinking" stream: reasoning text
|
||||
// streams in and grows with every throttled delta (~20Hz), so a `[trimmed]`-only
|
||||
// memo re-parses the whole, ever-growing text (marked + DOMPurify) on every delta
|
||||
// — an O(n²) storm that pins the main thread and freezes the chat, all for a block
|
||||
// the user isn't even looking at (the html is only shown inside <Collapse in={open}>
|
||||
// below). Gating on `open` skips that hidden parsing entirely; expanding parses the
|
||||
// current text once (an instant, user-initiated click), and further streaming while
|
||||
// open is the normal per-delta append render, like the answer.
|
||||
// Memoize the markdown render so toggling `open` (or a parent re-render caused
|
||||
// by an unrelated streamed delta) does not re-parse the reasoning text; it
|
||||
// recomputes only when the reasoning text itself changes (while it streams in).
|
||||
// collapseBlankLines collapses the blank-line gaps the model emits between every
|
||||
// list item / paragraph so the reasoning renders compactly (tight lists, joined
|
||||
// paragraphs) — ONLY here, not in the normal answer.
|
||||
const html = useMemo(
|
||||
() =>
|
||||
open && trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : "",
|
||||
[open, trimmed],
|
||||
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
|
||||
[trimmed],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/node_modules
|
||||
Reference in New Issue
Block a user