Files
gitmost/apps/server/src/database/repos/agent-provenance.ts
T
agent_coder 0968ea97d2 feat(ai-chat): agent avatar stack — agent in front, launcher behind (#300)
For AI-agent-authored content (comments + page history), replace the text AI-AGENT
badge with an avatar stack: the agent in front, the human who launched it smaller and
behind. This fixes the inverted hierarchy (the action was the agent's; the human just
launched it). closes #300.

Backend: a single server-authoritative resolver resolveAgentProvenance normalizes to
{ agent, launcher } from server columns only (createdSource/lastUpdatedSource, aiChatId,
creator, chat role) — nothing from request input, so agent identity can't be spoofed.
Internal chat -> agent = chat role (name/emoji), launcher = human; external MCP
(aiChatId null) -> agent = the agent account, launcher = null; non-agent -> neither.
The role join (aiChatId -> ai_chats.role_id -> ai_agent_roles) deliberately does NOT
filter enabled/deleted_at, so a later-disabled role still labels historical content
(mirrors findById, not findLiveEnabled). Enrichment is applied on BOTH findPageComments
(list) AND findById (the create/resolve/update broadcast path), so the stack shows on
live comment events and doesn't vanish on resolve/edit.

Frontend: new AgentAvatarStack + AgentGlyph (avatarUrl -> role emoji on violet ->
IconSparkles on violet), integrated into comment-list-item and history-item where the
badge was; the deep-link-to-chat click moved onto the stack. ai-agent-badge removed.

Tests: AgentAvatarStack (role/no-role/MCP/click/non-clickable), the provenance resolver
+ recorder tests proving the role join never filters enabled/deleted, and findById
enrichment (guards the live-broadcast regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 05:28:53 +03:00

94 lines
3.5 KiB
TypeScript

/**
* 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 };
}