diff --git a/apps/web/src/features/activity/context/activity-context.test.tsx b/apps/web/src/features/activity/context/activity-context.test.tsx new file mode 100644 index 00000000000..df50fb610ca --- /dev/null +++ b/apps/web/src/features/activity/context/activity-context.test.tsx @@ -0,0 +1,90 @@ +import { MACRO_SYSTEM_BOT_ID } from '@core/constant/macroSystem'; +import { createRoot } from 'solid-js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const useBotsQuery = vi.fn(); + +vi.mock('@queries/bots/bots', () => ({ + useBotsQuery: () => useBotsQuery(), +})); +vi.mock('@core/context/user', () => ({ useUserId: () => () => 'me' })); +vi.mock('@core/user', () => ({ + tryMacroId: () => undefined, + useDisplayName: () => [() => ''], +})); +vi.mock('@property/editor/hooks/useAllProperties', () => ({ + useAllProperties: () => () => [], +})); +vi.mock('@property/hooks', () => ({ usePropertyEntityDisplay: () => ({}) })); +vi.mock('@service-storage/graphql-soup', () => ({ + getGraphqlSoupClient: () => ({}), +})); + +const { useActivityContext } = await import('./activity-context'); + +const TEAM_BOT = '11111111-1111-4111-8111-111111111111'; +const OTHER_BOT = '22222222-2222-4222-8222-222222222222'; + +describe('appActivityContext.botName', () => { + beforeEach(() => { + useBotsQuery.mockReset(); + useBotsQuery.mockReturnValue({ + isPending: false, + isSuccess: true, + data: [ + { id: TEAM_BOT, name: 'Triage' }, + { id: OTHER_BOT, name: 'Digest' }, + ], + }); + }); + + it('subscribes to the bots list once per consumer, however many bot rows it names', () => { + createRoot((dispose) => { + const context = useActivityContext(); + const first = context.botName(() => TEAM_BOT); + const second = context.botName(() => OTHER_BOT); + + expect(first()).toBe('Triage'); + expect(second()).toBe('Digest'); + expect(first()).toBe('Triage'); + expect(useBotsQuery).toHaveBeenCalledTimes(1); + dispose(); + }); + }); + + it('is undefined while the list loads and `Bot` once it has failed', () => { + useBotsQuery.mockReturnValue({ + isPending: true, + isSuccess: false, + data: undefined, + }); + createRoot((dispose) => { + const context = useActivityContext(); + expect(context.botName(() => TEAM_BOT)()).toBeUndefined(); + dispose(); + }); + + useBotsQuery.mockReturnValue({ + isPending: false, + isSuccess: false, + isError: true, + data: undefined, + }); + createRoot((dispose) => { + const context = useActivityContext(); + expect(context.botName(() => TEAM_BOT)()).toBe('Bot'); + dispose(); + }); + }); + + it('never fetches the bots list for first-party bots', () => { + createRoot((dispose) => { + const context = useActivityContext(); + const name = context.botName(() => MACRO_SYSTEM_BOT_ID); + + expect(name()).toBe('System'); + expect(useBotsQuery).not.toHaveBeenCalled(); + dispose(); + }); + }); +}); diff --git a/apps/web/src/features/activity/context/activity-context.tsx b/apps/web/src/features/activity/context/activity-context.tsx index 2bbade0d5ab..9e71e643f56 100644 --- a/apps/web/src/features/activity/context/activity-context.tsx +++ b/apps/web/src/features/activity/context/activity-context.tsx @@ -3,10 +3,22 @@ import { tryMacroId, useDisplayName } from '@core/user'; import { useAllProperties } from '@property/editor/hooks/useAllProperties'; import { usePropertyEntityDisplay } from '@property/hooks'; import type { PropertyDefinitionDomain } from '@property/types'; +import { useBotsQuery } from '@queries/bots/bots'; +import { + firstPartyBotName, + getBotDisplayName, +} from '@queries/channel/message-sender'; import type { EntityType } from '@service-properties/generated/schemas/entityType'; import { getGraphqlSoupClient } from '@service-storage/graphql-soup'; import type { Client } from '@urql/core'; -import { type Accessor, createContext, type JSX, useContext } from 'solid-js'; +import { + type Accessor, + createContext, + getOwner, + type JSX, + runWithOwner, + useContext, +} from 'solid-js'; /** Resolved display for one referenced entity: name, icon, and link target. */ export type EntityDisplay = { @@ -39,10 +51,16 @@ export type ActivityContext = { /** The signed-in user, so their own rows read "You". */ currentUserId: Accessor; /** - * Display name for an actor id. Resolves to `undefined` when the id is - * not a user (automation rows), `''` while loading, else the name. + * Display name for a user actor id. Resolves to `undefined` when the id + * is not a user, `''` while loading, else the name. */ displayName: (actorId: Accessor) => Accessor; + /** + * Display name for a bot by bare UUID. First-party bots resolve at once + * from constants; team bots resolve from the bots list, `undefined` while + * it loads, `Bot` when the list does not know the id or failed to load. + */ + botName: (botId: Accessor) => Accessor; /** Name, icon, and link target for a referenced entity. */ entityDisplay: ( entityId: Accessor, @@ -65,6 +83,13 @@ export function useActivityContext(): ActivityContext { function appActivityContext(): ActivityContext { const userId = useUserId(); + // One bots subscription per consumer, made under the consumer's owner the + // first time a bot row asks for a name and reused after that, so it is not + // rebuilt each time a recycled row changes actor and user-only surfaces + // never fetch the list at all. + const owner = getOwner(); + let bots: ReturnType | undefined; + const botsQuery = () => (bots ??= runWithOwner(owner, useBotsQuery)); return { graphql: () => getGraphqlSoupClient(), currentUserId: () => userId() ?? '', @@ -74,6 +99,14 @@ function appActivityContext(): ActivityContext { const [name] = useDisplayName(id, { emailFallback: 'local-part' }); return name; }, + botName: (botId) => () => { + const id = botId(); + const firstParty = firstPartyBotName(id); + if (firstParty) return firstParty; + const list = botsQuery(); + if (!list || list.isPending) return undefined; + return getBotDisplayName(`bot|${id}`, undefined, list.data ?? []); + }, entityDisplay: (entityId, entityType) => usePropertyEntityDisplay(entityId, entityType), propertyDefinition: (propertyId) => { diff --git a/apps/web/src/features/activity/core/actor.test.ts b/apps/web/src/features/activity/core/actor.test.ts new file mode 100644 index 00000000000..de74b63112b --- /dev/null +++ b/apps/web/src/features/activity/core/actor.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { parseActor } from './actor'; + +describe('parseActor', () => { + it('parses users from macro ids', () => { + expect(parseActor('macro|sarah@example.com')).toEqual({ + kind: 'user', + id: 'macro|sarah@example.com', + }); + }); + + it('parses bots from bot principals', () => { + expect(parseActor('bot|00000000-0000-0000-0000-00000000a1a1')).toEqual({ + kind: 'bot', + botId: '00000000-0000-0000-0000-00000000a1a1', + }); + }); + + it('keeps anything else raw', () => { + expect(parseActor('system:nightly')).toEqual({ + kind: 'unknown', + raw: 'system:nightly', + }); + expect(parseActor('bot|')).toEqual({ kind: 'unknown', raw: 'bot|' }); + expect(parseActor('macro|no-at-sign')).toEqual({ + kind: 'unknown', + raw: 'macro|no-at-sign', + }); + }); +}); diff --git a/apps/web/src/features/activity/core/actor.ts b/apps/web/src/features/activity/core/actor.ts new file mode 100644 index 00000000000..a0e97cbaa84 --- /dev/null +++ b/apps/web/src/features/activity/core/actor.ts @@ -0,0 +1,24 @@ +import { type MacroId, tryMacroId } from '@core/user/macroId'; + +/** + * Who performed an activity event, parsed once from the wire `actorId`. + * The backend emits `macro|` for users and `bot|` for bots + * (first-party agents, team bots, and the system principal). Anything else + * is preserved raw so the row can still say something honest. + */ +export type Actor = + | { kind: 'user'; id: MacroId } + | { kind: 'bot'; botId: string } + | { kind: 'unknown'; raw: string }; + +const BOT_PREFIX = 'bot|'; + +export function parseActor(actorId: string): Actor { + const user = tryMacroId(actorId); + if (user) return { kind: 'user', id: user }; + if (actorId.startsWith(BOT_PREFIX)) { + const botId = actorId.slice(BOT_PREFIX.length); + if (botId.length > 0) return { kind: 'bot', botId }; + } + return { kind: 'unknown', raw: actorId }; +} diff --git a/apps/web/src/features/activity/primitives/actor-name.test.ts b/apps/web/src/features/activity/primitives/actor-name.test.ts index 9a38a7a9e51..40a8663eebd 100644 --- a/apps/web/src/features/activity/primitives/actor-name.test.ts +++ b/apps/web/src/features/activity/primitives/actor-name.test.ts @@ -1,3 +1,6 @@ +import { MACRO_AGENT_PRINCIPAL_ID } from '@core/constant/macroAgent'; +import { MACRO_SYSTEM_PRINCIPAL_ID } from '@core/constant/macroSystem'; +import { createRoot } from 'solid-js'; import { describe, expect, it } from 'vitest'; import { createMockActivityContext, @@ -5,22 +8,52 @@ import { } from '../tests/mock-context'; import { createActorName } from './actor-name'; +function nameOf( + context: ReturnType, + actorId: string +): string { + return createRoot((dispose) => { + const name = createActorName(context, () => actorId)(); + dispose(); + return name; + }); +} + describe('createActorName', () => { const context = createMockActivityContext(); it('names the viewer "You"', () => { - expect(createActorName(context, () => MOCK_VIEWER_ID)()).toBe('You'); + expect(nameOf(context, MOCK_VIEWER_ID)).toBe('You'); }); it('resolves other users through displayName', () => { - expect(createActorName(context, () => 'macro|sarah@example.com')()).toBe( - 'sarah' + expect(nameOf(context, 'macro|sarah@example.com')).toBe('sarah'); + }); + + it('names the system principal "System"', () => { + expect(nameOf(context, MACRO_SYSTEM_PRINCIPAL_ID)).toBe('System'); + }); + + it('names first-party bots from their constants', () => { + expect(nameOf(context, MACRO_AGENT_PRINCIPAL_ID)).toBe('Macro'); + }); + + it('resolves team bots through botName', () => { + expect(nameOf(context, 'bot|deadbeef-0000-0000-0000-000000000001')).toBe( + 'Bot deadbeef' ); }); - it('labels non-user actors as automation', () => { - expect(createActorName(context, () => 'system:nightly')()).toBe( - 'Automation' + it('reads empty while a team bot name is still loading', () => { + const loading = createMockActivityContext({ + botName: () => () => undefined, + }); + expect(nameOf(loading, 'bot|deadbeef-0000-0000-0000-000000000001')).toBe( + '' ); }); + + it('never says "Automation" for ids it cannot parse', () => { + expect(nameOf(context, 'system:nightly')).toBe('Unknown'); + }); }); diff --git a/apps/web/src/features/activity/primitives/actor-name.ts b/apps/web/src/features/activity/primitives/actor-name.ts index f7e0d92013f..2c5c2139e9e 100644 --- a/apps/web/src/features/activity/primitives/actor-name.ts +++ b/apps/web/src/features/activity/primitives/actor-name.ts @@ -1,16 +1,33 @@ -import type { Accessor } from 'solid-js'; +import { type Accessor, createMemo } from 'solid-js'; +import { match } from 'ts-pattern'; import type { ActivityContext } from '../context/activity-context'; +import { parseActor } from '../core/actor'; -/** "You" for the viewer, "Automation" for non-user actors, else the name. */ +/** + * The name an activity row shows for its actor. The viewer reads "You", + * other users read their display name, bots read their bot name (the + * system principal reads "System"), and ids the app cannot parse read + * "Unknown". Resolvers are created lazily per actor kind so a user row + * never subscribes to the bots list. + */ export function createActorName( - context: Pick, + context: Pick, actorId: Accessor ): Accessor { - const remote = context.displayName(actorId); - return () => { - const name = remote(); - if (name === undefined) return 'Automation'; - if (actorId() === context.currentUserId()) return 'You'; - return name; - }; + const resolver = createMemo>(() => { + const id = actorId(); + if (id === context.currentUserId()) return () => 'You'; + return match(parseActor(id)) + .with({ kind: 'user' }, (actor) => { + const remote = context.displayName(() => actor.id); + return () => remote() ?? 'Unknown'; + }) + .with({ kind: 'bot' }, (actor) => { + const remote = context.botName(() => actor.botId); + return () => remote() ?? ''; + }) + .with({ kind: 'unknown' }, () => () => 'Unknown') + .exhaustive(); + }); + return () => resolver()(); } diff --git a/apps/web/src/features/activity/tests/mock-context.ts b/apps/web/src/features/activity/tests/mock-context.ts index 03444b570cc..6f1b17b6e0c 100644 --- a/apps/web/src/features/activity/tests/mock-context.ts +++ b/apps/web/src/features/activity/tests/mock-context.ts @@ -1,3 +1,4 @@ +import { firstPartyBotName } from '@queries/channel/message-sender'; import type { Client } from '@urql/core'; import type { ActivityContext } from '../context/activity-context'; import { createMockGraphql, type MockGraphql } from './mock-graphql'; @@ -11,7 +12,8 @@ export type MockActivityContext = ActivityContext & { /** * In-memory implementations of every activity dependency. Entities resolve * to `Entity ` and link as markdown blocks; actor ids of the form - * `macro|name@…` resolve to `name`, anything else reads as automation. + * `macro|name@…` resolve to `name`; bot ids resolve to their first-party + * name or `Bot `. */ export function createMockActivityContext( overrides: Partial = {} @@ -26,6 +28,10 @@ export function createMockActivityContext( if (!id.startsWith('macro|')) return () => undefined; return () => id.slice('macro|'.length).split('@')[0] ?? ''; }, + botName: (botId) => () => { + const id = botId(); + return firstPartyBotName(id) ?? `Bot ${id.split('-')[0]}`; + }, entityDisplay: (entityId) => ({ name: () => `Entity ${entityId()}`, icon: () => null, diff --git a/apps/web/src/lib/core/constant/macroSystem.ts b/apps/web/src/lib/core/constant/macroSystem.ts new file mode 100644 index 00000000000..b8f525361be --- /dev/null +++ b/apps/web/src/lib/core/constant/macroSystem.ts @@ -0,0 +1,25 @@ +/** + * Identity for the autonomous Macro platform principal. Mirrors + * `bot_id::MACRO_SYSTEM_BOT_ID` on the backend, which attributes actions the + * platform takes on its own (onboarding seeds, scheduled jobs) to this bot. + */ +export const MACRO_SYSTEM_BOT_ID = '00000000-0000-0000-0000-000000005759'; + +/** + * Canonical principal id for the system bot, matching the `bot|` form + * used for bot senders, participants, and activity actors everywhere else. + */ +export const MACRO_SYSTEM_PRINCIPAL_ID = `bot|${MACRO_SYSTEM_BOT_ID}`; + +/** Display name for the system principal. */ +export const MACRO_SYSTEM_NAME = 'System'; + +/** + * Whether an id refers to the system bot. Accepts both the bare UUID and the + * `bot|` participant/sender form. + */ +export function isMacroSystemId(id: string | undefined): boolean { + if (!id) return false; + const bare = id.startsWith('bot|') ? id.slice('bot|'.length) : id; + return bare === MACRO_SYSTEM_BOT_ID; +} diff --git a/apps/web/src/lib/queries/channel/message-sender.ts b/apps/web/src/lib/queries/channel/message-sender.ts index 22440dcd59f..f449013b391 100644 --- a/apps/web/src/lib/queries/channel/message-sender.ts +++ b/apps/web/src/lib/queries/channel/message-sender.ts @@ -2,6 +2,7 @@ import { CURSOR_BOT_NAME, isCursorBotId } from '@core/constant/cursorAgent'; import { isMacroAgentId, MACRO_AGENT_NAME } from '@core/constant/macroAgent'; import { isMacroCoderId, MACRO_CODER_NAME } from '@core/constant/macroCoder'; import { isMacroNewId, MACRO_NEW_NAME } from '@core/constant/macroNew'; +import { isMacroSystemId, MACRO_SYSTEM_NAME } from '@core/constant/macroSystem'; import type { ApiChannelMessage, ApiThreadReply, @@ -35,11 +36,16 @@ export function senderFromStorageId(senderId: string): ApiMessageSender { return { type: 'user', id: senderId }; } -function systemBotDisplayName(id: string): string | undefined { +/** + * Display name for a first-party bot (bare UUID or `bot|`), resolved + * from constants so it never waits on the bots list. Undefined for team bots. + */ +export function firstPartyBotName(id: string): string | undefined { if (isMacroAgentId(id)) return MACRO_AGENT_NAME; if (isMacroCoderId(id)) return MACRO_CODER_NAME; if (isMacroNewId(id)) return MACRO_NEW_NAME; if (isCursorBotId(id)) return CURSOR_BOT_NAME; + if (isMacroSystemId(id)) return MACRO_SYSTEM_NAME; return undefined; } @@ -51,7 +57,7 @@ export function getBotDisplayName( ): string | undefined { const parsed = sender ?? senderFromStorageId(senderId); const systemName = - systemBotDisplayName(parsed.id) ?? systemBotDisplayName(senderId); + firstPartyBotName(parsed.id) ?? firstPartyBotName(senderId); if (parsed.type !== 'bot' && !systemName) return undefined; diff --git a/apps/web/src/lib/queries/channel/tests/message-sender.test.ts b/apps/web/src/lib/queries/channel/tests/message-sender.test.ts index 2d34ebdde64..e351360e3cf 100644 --- a/apps/web/src/lib/queries/channel/tests/message-sender.test.ts +++ b/apps/web/src/lib/queries/channel/tests/message-sender.test.ts @@ -2,9 +2,15 @@ import { MACRO_CODER_NAME, MACRO_CODER_PRINCIPAL_ID, } from '@core/constant/macroCoder'; +import { + MACRO_SYSTEM_BOT_ID, + MACRO_SYSTEM_NAME, + MACRO_SYSTEM_PRINCIPAL_ID, +} from '@core/constant/macroSystem'; import { describe, expect, it } from 'vitest'; import { type ChannelMessageWithMaybeSender, + firstPartyBotName, getBotDisplayName, normalizeChannelMessageSender, senderFromStorageId, @@ -102,6 +108,16 @@ describe('message sender normalization', () => { expect(getBotDisplayName(MACRO_CODER_PRINCIPAL_ID)).toBe(MACRO_CODER_NAME); }); + it('names the system principal without channel bot data', () => { + expect(getBotDisplayName(MACRO_SYSTEM_PRINCIPAL_ID)).toBe( + MACRO_SYSTEM_NAME + ); + expect(firstPartyBotName(MACRO_SYSTEM_BOT_ID)).toBe(MACRO_SYSTEM_NAME); + expect(firstPartyBotName('00000000-0000-0000-0000-000000000002')).toBe( + undefined + ); + }); + it('uses a generic label rather than exposing an unknown bot UUID', () => { expect(getBotDisplayName('bot|00000000-0000-0000-0000-000000000002')).toBe( 'Bot'