Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/web/src/features/activity/context/activity-context.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
39 changes: 36 additions & 3 deletions apps/web/src/features/activity/context/activity-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -39,10 +51,16 @@ export type ActivityContext = {
/** The signed-in user, so their own rows read "You". */
currentUserId: Accessor<string>;
/**
* 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<string>) => Accessor<string | undefined>;
/**
* 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<string>) => Accessor<string | undefined>;
/** Name, icon, and link target for a referenced entity. */
entityDisplay: (
entityId: Accessor<string>,
Expand All @@ -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<typeof useBotsQuery> | undefined;
const botsQuery = () => (bots ??= runWithOwner(owner, useBotsQuery));
return {
graphql: () => getGraphqlSoupClient(),
currentUserId: () => userId() ?? '',
Expand All @@ -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 ?? []);
},
Comment thread
cursor[bot] marked this conversation as resolved.
entityDisplay: (entityId, entityType) =>
usePropertyEntityDisplay(entityId, entityType),
propertyDefinition: (propertyId) => {
Expand Down
30 changes: 30 additions & 0 deletions apps/web/src/features/activity/core/actor.test.ts
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
24 changes: 24 additions & 0 deletions apps/web/src/features/activity/core/actor.ts
Original file line number Diff line number Diff line change
@@ -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|<email>` for users and `bot|<uuid>` 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 };
}
45 changes: 39 additions & 6 deletions apps/web/src/features/activity/primitives/actor-name.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,59 @@
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,
MOCK_VIEWER_ID,
} from '../tests/mock-context';
import { createActorName } from './actor-name';

function nameOf(
context: ReturnType<typeof createMockActivityContext>,
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');
});
});
37 changes: 27 additions & 10 deletions apps/web/src/features/activity/primitives/actor-name.ts
Original file line number Diff line number Diff line change
@@ -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<ActivityContext, 'currentUserId' | 'displayName'>,
context: Pick<ActivityContext, 'currentUserId' | 'displayName' | 'botName'>,
actorId: Accessor<string>
): Accessor<string> {
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<Accessor<string>>(() => {
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()();
}
8 changes: 7 additions & 1 deletion apps/web/src/features/activity/tests/mock-context.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,7 +12,8 @@ export type MockActivityContext = ActivityContext & {
/**
* In-memory implementations of every activity dependency. Entities resolve
* to `Entity <id>` 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 <first uuid group>`.
*/
export function createMockActivityContext(
overrides: Partial<ActivityContext> = {}
Expand All @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/lib/core/constant/macroSystem.ts
Original file line number Diff line number Diff line change
@@ -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|<uuid>` 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|<uuid>` 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;
}
Loading
Loading