Skip to content

Commit 0039f32

Browse files
committed
feat(personas): support shared agent mentions
1 parent 4722014 commit 0039f32

28 files changed

Lines changed: 624 additions & 312 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { Agent } from '@service-storage/generated/schemas/agent';
2+
import type { Bot } from '@service-storage/generated/schemas/bot';
3+
import { describe, expect, it } from 'vitest';
4+
import { availableBotMentionUsers } from './use-channel-bot-mention-users';
5+
6+
const timestamp = '2026-08-27T12:00:00Z';
7+
8+
function bot(id: string, name: string, avatarUrl?: string): Bot {
9+
return {
10+
id,
11+
kind: 'owned',
12+
name,
13+
handle: name.toLowerCase().replaceAll(' ', '-'),
14+
has_agent: true,
15+
avatar_url: avatarUrl,
16+
created_at: timestamp,
17+
updated_at: timestamp,
18+
};
19+
}
20+
21+
function agent(
22+
id: string,
23+
name: string,
24+
channelScope: Agent['channel_scope'],
25+
harness = 'in-memory'
26+
): Agent {
27+
return {
28+
bot: bot(id, name),
29+
channel_ids: channelScope === 'all' ? [] : ['channel-1'],
30+
channel_scope: channelScope,
31+
default_model: 'model',
32+
harness,
33+
system_prompt: '',
34+
};
35+
}
36+
37+
describe('availableBotMentionUsers', () => {
38+
it('adds all-channel agents without adding selected agents from other channels', () => {
39+
expect(
40+
availableBotMentionUsers(
41+
[bot('installed', 'Installed')],
42+
[
43+
agent('global', 'Global', 'all'),
44+
agent('selected', 'Selected', 'selected'),
45+
],
46+
false
47+
).map((user) => user.id)
48+
).toEqual(['bot|installed', 'bot|global']);
49+
});
50+
51+
it('deduplicates an agent that is also an installed channel bot', () => {
52+
expect(
53+
availableBotMentionUsers(
54+
[bot('global', 'Global')],
55+
[agent('global', 'Global', 'all')],
56+
false
57+
)
58+
).toHaveLength(1);
59+
});
60+
61+
it('preserves the agent avatar for the mention menu', () => {
62+
const avatarUrl = 'https://example.com/global-agent.png';
63+
64+
expect(
65+
availableBotMentionUsers(
66+
[],
67+
[
68+
{
69+
...agent('global', 'Global', 'all'),
70+
bot: bot('global', 'Global', avatarUrl),
71+
},
72+
],
73+
false
74+
)
75+
).toEqual([
76+
{
77+
id: 'bot|global',
78+
name: 'Global',
79+
email: 'Global',
80+
photoUrl: avatarUrl,
81+
},
82+
]);
83+
});
84+
85+
it('only offers a global Cursor agent when Cursor is connected', () => {
86+
const cursorAgent = agent('cursor-agent', 'Cursor agent', 'all', 'cursor');
87+
88+
expect(availableBotMentionUsers([], [cursorAgent], false)).toEqual([]);
89+
expect(availableBotMentionUsers([], [cursorAgent], true)).toHaveLength(1);
90+
});
91+
});
Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,43 @@
11
import type { IUser } from '@core/user/types';
2+
import { useAgentsQuery } from '@queries/agents/agents';
3+
import { useCursorApiKeyStatusQuery } from '@queries/auth/cursor-api-key';
24
import { useChannelBotsQuery } from '@queries/channel/channel-bots';
5+
import type { Agent } from '@service-storage/generated/schemas/agent';
6+
import type { Bot } from '@service-storage/generated/schemas/bot';
37
import { type Accessor, createMemo } from 'solid-js';
48

9+
function mentionUser(bot: Bot): IUser {
10+
return {
11+
id: `bot|${bot.id}`,
12+
name: bot.name,
13+
email: bot.name,
14+
photoUrl: bot.avatar_url ?? undefined,
15+
};
16+
}
17+
18+
/** Build mention entries from installed channel bots and virtual global agents. */
19+
export function availableBotMentionUsers(
20+
channelBots: readonly Bot[],
21+
agents: readonly Agent[],
22+
cursorConnected: boolean
23+
): IUser[] {
24+
const globalAgents = agents.filter(
25+
(agent) =>
26+
agent.channel_scope === 'all' &&
27+
agent.bot.has_agent &&
28+
(agent.harness !== 'cursor' || cursorConnected)
29+
);
30+
const seen = new Set<string>();
31+
32+
return [...channelBots, ...globalAgents.map((agent) => agent.bot)]
33+
.map(mentionUser)
34+
.filter((user) => {
35+
if (seen.has(user.id)) return false;
36+
seen.add(user.id);
37+
return true;
38+
});
39+
}
40+
541
/**
642
* The channel's bots as synthetic [`IUser`] entries for the `@`-mention
743
* typeahead. Like `macroAiMentionUser()`, `email` is set to the bot's name so
@@ -12,13 +48,15 @@ import { type Accessor, createMemo } from 'solid-js';
1248
export function useChannelBotMentionUsers(
1349
channelId: Accessor<string>
1450
): Accessor<IUser[]> {
15-
const query = useChannelBotsQuery(channelId);
51+
const channelBots = useChannelBotsQuery(channelId);
52+
const agents = useAgentsQuery();
53+
const cursorStatus = useCursorApiKeyStatusQuery();
1654

17-
return createMemo(() => {
18-
return (query.data ?? []).map((bot) => ({
19-
id: `bot|${bot.id}`,
20-
name: bot.name,
21-
email: bot.name,
22-
}));
23-
});
55+
return createMemo(() =>
56+
availableBotMentionUsers(
57+
channelBots.data ?? [],
58+
agents.data ?? [],
59+
cursorStatus.data?.registered ?? false
60+
)
61+
);
2462
}

apps/web/src/features/settings/Agents.test.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const agentMocks = vi.hoisted(() => ({
4141
toastSuccess: vi.fn(),
4242
toastFailure: vi.fn(),
4343
currentUserId: 'macro|user@example.com',
44+
currentTeam: { team: { id: 'team-1' } } as { team: { id: string } } | null,
4445
isTeamOwner: false,
4546
}));
4647

@@ -66,7 +67,7 @@ vi.mock('@queries/agents/agents', () => ({
6667
}));
6768

6869
vi.mock('@queries/team/teams', () => ({
69-
useCurrentTeamQuery: () => ({ data: { team: { id: 'team-1' } } }),
70+
useCurrentTeamQuery: () => ({ data: agentMocks.currentTeam }),
7071
useIsTeamOwner: () => () => agentMocks.isTeamOwner,
7172
}));
7273

@@ -109,6 +110,7 @@ beforeEach(() => {
109110
agentMocks.delete.mockResolvedValue(undefined);
110111
agentMocks.update.mockResolvedValue(undefined);
111112
agentMocks.currentUserId = 'macro|user@example.com';
113+
agentMocks.currentTeam = { team: { id: 'team-1' } };
112114
agentMocks.isTeamOwner = false;
113115
});
114116

@@ -437,6 +439,25 @@ describe('Agents', () => {
437439
expect(screen.getByRole('option', { name: 'general' })).toBeTruthy();
438440
});
439441

442+
it('explains why team sharing is disabled without a team', () => {
443+
agentMocks.currentTeam = null;
444+
445+
render(() => <Agents />);
446+
fireEvent.click(screen.getByRole('button', { name: 'Create agent' }));
447+
448+
const dialog = screen.getByRole('dialog');
449+
const teamOption = within(dialog).getByLabelText('Team');
450+
expect(teamOption).toHaveProperty('disabled', true);
451+
const teamCardClasses = teamOption.closest('label')?.classList;
452+
expect(teamCardClasses?.contains('cursor-not-allowed')).toBe(true);
453+
expect(teamCardClasses?.contains('opacity-50')).toBe(true);
454+
expect(
455+
within(dialog).getByText(
456+
'Team agents need a team owner. Create or join a team in Team settings to enable this option.'
457+
)
458+
).toBeTruthy();
459+
});
460+
440461
it('persists creation through the agents API', async () => {
441462
render(() => <Agents />);
442463
fireEvent.click(screen.getByRole('button', { name: 'Create agent' }));

apps/web/src/features/settings/Agents.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -805,12 +805,18 @@ function AgentDialog(props: {
805805
description={
806806
props.canShareWithTeam
807807
? 'Your team can use this agent in shared channels.'
808-
: 'Join a team to create a team agent.'
808+
: 'Create or join a team before sharing agents.'
809809
}
810810
disabled={!props.canShareWithTeam}
811811
onChange={() => setShare('Team')}
812812
/>
813813
</fieldset>
814+
<Show when={!props.canShareWithTeam}>
815+
<p class="mt-3 border-t border-edge-muted pt-3 text-xs text-ink-extra-muted">
816+
Team agents need a team owner. Create or join a team in Team
817+
settings to enable this option.
818+
</p>
819+
</Show>
814820
</AgentFormSection>
815821
</form>
816822
</Panel.Body>
@@ -868,7 +874,13 @@ function ChoiceRow(props: {
868874
onChange: () => void;
869875
}) {
870876
return (
871-
<label class="flex min-w-0 items-start gap-3 rounded-lg border border-edge-muted p-3 has-checked:border-accent has-checked:bg-accent-bg">
877+
<label
878+
class="flex min-w-0 items-start gap-3 rounded-lg border border-edge-muted p-3 has-checked:border-accent has-checked:bg-accent-bg"
879+
classList={{
880+
'cursor-not-allowed opacity-50': props.disabled,
881+
'cursor-pointer': !props.disabled,
882+
}}
883+
>
872884
<input
873885
type="radio"
874886
name={props.name}

apps/web/src/lib/core/component/LexicalMarkdown/component/menu/MentionsMenu/components/MentionsMenuItem.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,14 @@ export function MentionsMenuItem(props: {
4545
const icon = () => {
4646
switch (props.item.kind) {
4747
case 'user':
48-
return <UserIcon id={props.item.id} size="sm" isDeleted={false} />;
48+
return (
49+
<UserIcon
50+
id={props.item.id}
51+
size="sm"
52+
isDeleted={false}
53+
photoUrl={props.item.data.photoUrl}
54+
/>
55+
);
4956

5057
case 'group':
5158
return <UsersIcon class="size-4 text-ink-muted" />;

apps/web/src/lib/core/component/UserIcon.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,9 +196,16 @@ export function UserIcon(props: UserIconProps) {
196196
size={size()}
197197
class={cn('bg-surface text-accent ring ring-edge-muted', props.class)}
198198
>
199-
<Avatar.Fallback>
200-
<RobotIcon class="size-[62%]" />
201-
</Avatar.Fallback>
199+
<Show
200+
when={props.photoUrl}
201+
fallback={
202+
<Avatar.Fallback>
203+
<RobotIcon class="size-[62%]" />
204+
</Avatar.Fallback>
205+
}
206+
>
207+
{(photoUrl) => <Avatar.Image src={photoUrl()} alt="" />}
208+
</Show>
202209
</Avatar>
203210
</Match>
204211

apps/web/src/lib/core/user/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type IUser = {
88
id: string;
99
email: string;
1010
name: string;
11+
photoUrl?: string;
1112
lastInteraction?: DateValue;
1213
};
1314

crates/agent_harness/src/domain/model.rs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,10 @@ pub struct AgentRuntimeConfig {
125125
pub model: String,
126126
/// Harness slug stamped onto the new session.
127127
pub harness: String,
128-
/// Agent-authored instructions prepended to its first prompt.
128+
/// Configured agent instructions, reserved for a dedicated runtime transport.
129129
pub system_prompt: String,
130130
}
131131

132-
/// Whether a user belongs to the Macro staff domain - the egress crate's
133-
/// predicate, reused so the harness's staff gates and the proxy's can never
134-
/// disagree about who staff is.
135-
pub(crate) use agent_egress::domain::model::is_macro_staff;
136-
137132
/// Where a prompt came from, when it came from somewhere the session should
138133
/// answer back into.
139134
#[derive(Debug, Clone)]

crates/agent_harness/src/domain/service.rs

Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,14 @@ use tracing::instrument::WithSubscriber as _;
2222
use crate::domain::error::{HarnessError, Result};
2323
use crate::domain::model::{
2424
AgentKind, AnnounceOrigin, AnnouncePrompt, DeliverAction, HarnessCommand, HarnessDefaults,
25-
OpenSession, SessionAnnouncement, SpawnContainer, is_macro_staff,
25+
OpenSession, SessionAnnouncement, SpawnContainer,
2626
};
2727
use crate::domain::ports::{
2828
AgentPromptComposer, ChannelPromptContext, ContainerManager, RuntimeConnections,
2929
SandboxEgressProvisioner, SessionAnnouncer,
3030
};
3131
use crate::domain::sandbox::SandboxResizeEffect;
3232

33-
fn apply_agent_instructions(system_prompt: &str, prompt: String) -> String {
34-
let instructions = system_prompt.trim();
35-
if instructions.is_empty() {
36-
return prompt;
37-
}
38-
format!("<agent_instructions>\n{instructions}\n</agent_instructions>\n\n{prompt}")
39-
}
40-
4133
type SessionWorkers = DashMap<AgentSessionId, mpsc::UnboundedSender<QueuedCommand>>;
4234

4335
struct QueuedCommand {
@@ -550,26 +542,6 @@ where
550542
Egress: SandboxEgressProvisioner,
551543
{
552544
async fn execute(&self, session_id: AgentSessionId, command: HarnessCommand) -> Result<()> {
553-
match &command {
554-
HarnessCommand::Open(open)
555-
if open.runtime.kind == AgentKind::Cursor
556-
&& !is_macro_staff(&open.origin.sender) =>
557-
{
558-
return Err(AgentSessionError::Forbidden.into());
559-
}
560-
HarnessCommand::Deliver(deliver) => {
561-
let session = self.sessions.get_session(session_id).await?;
562-
if AgentKind::for_session(session.bot_id, &session.harness) == AgentKind::Cursor
563-
&& !deliver.actor.as_ref().is_some_and(is_macro_staff)
564-
{
565-
return Err(AgentSessionError::Forbidden.into());
566-
}
567-
}
568-
HarnessCommand::Open(_)
569-
| HarnessCommand::SetSandboxSize(_)
570-
| HarnessCommand::Delete => {}
571-
}
572-
573545
match command {
574546
HarnessCommand::Open(command) => self.open(session_id, command).await,
575547
HarnessCommand::Deliver(command) => self.deliver(session_id, command).await,
@@ -682,12 +654,10 @@ where
682654
let prior_messages = self
683655
.load_prompt_context(origin.channel_id, origin.message_id, Some(&origin.sender))
684656
.await;
685-
let composed_prompt = apply_agent_instructions(
686-
&runtime.system_prompt,
687-
self.prompt_composer
688-
.compose(&origin.content, Some(&prior_messages))
689-
.await?,
690-
);
657+
let composed_prompt = self
658+
.prompt_composer
659+
.compose(&origin.content, Some(&prior_messages))
660+
.await?;
691661

692662
// Provisioned before the session exists, because the row is what makes
693663
// the token mean anything: it carries the hash the proxy recognises.

0 commit comments

Comments
 (0)