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>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user