Compare commits

...

1 Commits

Author SHA1 Message Date
Innei 64a1ce737b feat(agent): route single first-line mentions directly 2026-04-27 18:19:24 +08:00
5 changed files with 379 additions and 36 deletions
@@ -903,7 +903,7 @@ describe('ConversationLifecycle actions', () => {
});
describe('@agent mention delegation', () => {
it('should NOT set isSupervisor on assistant message when @agent is mentioned in non-group chat', async () => {
it('should route directly to the single first-line @agent in non-group chat', async () => {
const { result } = renderHook(() => useChatStore());
const sendMessageInServerSpy = vi
@@ -944,9 +944,9 @@ describe('ConversationLifecycle actions', () => {
});
});
// Assistant message metadata should NOT contain isSupervisor
expect(sendMessageInServerSpy).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'agent-a',
newAssistantMessage: expect.objectContaining({
metadata: undefined,
}),
@@ -954,16 +954,130 @@ describe('ConversationLifecycle actions', () => {
expect.any(AbortController),
);
// But runtime should receive mentionedAgents in initialContext
expect(result.current.internal_execAgentRuntime).toHaveBeenCalledWith(
const execCall = (result.current.internal_execAgentRuntime as any).mock.calls[0]?.[0];
expect(execCall?.context.agentId).toBe('agent-a');
expect(execCall?.initialContext?.initialContext?.mentionedAgents).toBeUndefined();
expect(execCall?.initialContext?.initialContext?.injectedManifests).toBeUndefined();
});
it('should keep supervisor callAgent delegation for multiple @agent mentions in non-group chat', async () => {
const { result } = renderHook(() => useChatStore());
const sendMessageInServerSpy = vi
.spyOn(aiChatService, 'sendMessageInServer')
.mockResolvedValue({
messages: [
createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
createMockMessage({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, role: 'assistant' }),
],
topics: [],
assistantMessageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
userMessageId: TEST_IDS.USER_MESSAGE_ID,
} as any);
await act(async () => {
await result.current.sendMessage({
message: '@Agent A @Agent B compare options',
editorData: {
root: {
children: [
{
children: [
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
{ text: ' ', type: 'text' },
{
label: 'Agent B',
metadata: { id: 'agent-b', type: 'agent' },
type: 'mention',
},
{ text: ' compare options', type: 'text' },
],
type: 'paragraph',
},
],
type: 'root',
},
} as any,
context: createTestContext(),
});
});
expect(sendMessageInServerSpy).toHaveBeenCalledWith(
expect.objectContaining({
initialContext: expect.objectContaining({
initialContext: expect.objectContaining({
mentionedAgents: [{ id: 'agent-a', name: 'Agent A' }],
}),
}),
agentId: TEST_IDS.SESSION_ID,
}),
expect.any(AbortController),
);
const execCall = (result.current.internal_execAgentRuntime as any).mock.calls[0]?.[0];
expect(execCall?.context.agentId).toBe(TEST_IDS.SESSION_ID);
expect(execCall?.initialContext?.initialContext?.mentionedAgents).toEqual([
{ id: 'agent-a', name: 'Agent A' },
{ id: 'agent-b', name: 'Agent B' },
]);
expect(execCall?.initialContext?.initialContext?.injectedManifests).toHaveLength(1);
});
it('should keep supervisor callAgent delegation when @agent is not on the first line', async () => {
const { result } = renderHook(() => useChatStore());
const sendMessageInServerSpy = vi
.spyOn(aiChatService, 'sendMessageInServer')
.mockResolvedValue({
messages: [
createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
createMockMessage({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, role: 'assistant' }),
],
topics: [],
assistantMessageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
userMessageId: TEST_IDS.USER_MESSAGE_ID,
} as any);
await act(async () => {
await result.current.sendMessage({
message: 'Please route this\n@Agent A',
editorData: {
root: {
children: [
{
children: [{ text: 'Please route this', type: 'text' }],
type: 'paragraph',
},
{
children: [
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
],
type: 'paragraph',
},
],
type: 'root',
},
} as any,
context: createTestContext(),
});
});
expect(sendMessageInServerSpy).toHaveBeenCalledWith(
expect.objectContaining({
agentId: TEST_IDS.SESSION_ID,
}),
expect.any(AbortController),
);
const execCall = (result.current.internal_execAgentRuntime as any).mock.calls[0]?.[0];
expect(execCall?.context.agentId).toBe(TEST_IDS.SESSION_ID);
expect(execCall?.initialContext?.initialContext?.mentionedAgents).toEqual([
{ id: 'agent-a', name: 'Agent A' },
]);
expect(execCall?.initialContext?.initialContext?.injectedManifests).toHaveLength(1);
});
it('should NOT inject mentionedAgents into initialContext when in group chat', async () => {
@@ -979,15 +1093,17 @@ describe('ConversationLifecycle actions', () => {
},
} as any);
vi.spyOn(aiChatService, 'sendMessageInServer').mockResolvedValue({
messages: [
createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
createMockMessage({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, role: 'assistant' }),
],
topics: [],
assistantMessageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
userMessageId: TEST_IDS.USER_MESSAGE_ID,
} as any);
const sendMessageInServerSpy = vi
.spyOn(aiChatService, 'sendMessageInServer')
.mockResolvedValue({
messages: [
createMockMessage({ id: TEST_IDS.USER_MESSAGE_ID, role: 'user' }),
createMockMessage({ id: TEST_IDS.ASSISTANT_MESSAGE_ID, role: 'assistant' }),
],
topics: [],
assistantMessageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
userMessageId: TEST_IDS.USER_MESSAGE_ID,
} as any);
await act(async () => {
await result.current.sendMessage({
@@ -1020,8 +1136,16 @@ describe('ConversationLifecycle actions', () => {
});
});
expect(sendMessageInServerSpy).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'sub-agent-id',
}),
expect.any(AbortController),
);
// Runtime should NOT receive mentionedAgents in group context
const execCall = (result.current.internal_execAgentRuntime as any).mock.calls[0]?.[0];
expect(execCall?.context.agentId).toBe('sub-agent-id');
const initialCtx = execCall?.initialContext?.initialContext;
expect(initialCtx?.mentionedAgents).toBeUndefined();
});
@@ -12,6 +12,7 @@ export {
parseMentionedAgentsFromEditorData,
parseSelectedSkillsFromEditorData,
parseSelectedToolsFromEditorData,
parseSingleFirstLineAgentMentionDirectRoute,
} from './parseCommands';
export type { CommandSendOverrides } from './types';
@@ -5,6 +5,7 @@ import {
parseMentionedAgentsFromEditorData,
parseSelectedSkillsFromEditorData,
parseSelectedToolsFromEditorData,
parseSingleFirstLineAgentMentionDirectRoute,
} from './parseCommands';
describe('parseCommandsFromEditorData', () => {
@@ -500,3 +501,139 @@ describe('parseMentionedAgentsFromEditorData', () => {
]);
});
});
describe('parseSingleFirstLineAgentMentionDirectRoute', () => {
it('should return target agent when the only agent mention starts the first non-empty paragraph', () => {
const editorData = {
root: {
children: [
{
children: [{ text: ' ', type: 'text' }],
type: 'paragraph',
},
{
children: [
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
{ text: ' please handle this', type: 'text' },
],
type: 'paragraph',
},
],
type: 'root',
},
};
expect(parseSingleFirstLineAgentMentionDirectRoute(editorData)).toEqual({
targetAgent: { id: 'agent-a', name: 'Agent A' },
});
});
it('should return undefined when there are multiple agent mention occurrences', () => {
const editorData = {
root: {
children: [
{
children: [
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
{ text: ' and ', type: 'text' },
{
label: 'Agent B',
metadata: { id: 'agent-b', type: 'agent' },
type: 'mention',
},
],
type: 'paragraph',
},
],
type: 'root',
},
};
expect(parseSingleFirstLineAgentMentionDirectRoute(editorData)).toBeUndefined();
});
it('should return undefined when another non-agent mention appears later', () => {
const editorData = {
root: {
children: [
{
children: [
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
{ text: ' compare with ', type: 'text' },
{
label: 'Topic X',
metadata: { id: 'topic-x', topicId: 'topic-x', type: 'topic' },
type: 'mention',
},
],
type: 'paragraph',
},
],
type: 'root',
},
};
expect(parseSingleFirstLineAgentMentionDirectRoute(editorData)).toBeUndefined();
});
it('should return undefined when the first non-empty paragraph does not start with the mention', () => {
const editorData = {
root: {
children: [
{
children: [
{ text: 'Please ask ', type: 'text' },
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
],
type: 'paragraph',
},
],
type: 'root',
},
};
expect(parseSingleFirstLineAgentMentionDirectRoute(editorData)).toBeUndefined();
});
it('should return undefined when the mention is not in the first non-empty paragraph', () => {
const editorData = {
root: {
children: [
{
children: [{ text: 'First line', type: 'text' }],
type: 'paragraph',
},
{
children: [
{
label: 'Agent A',
metadata: { id: 'agent-a', type: 'agent' },
type: 'mention',
},
],
type: 'paragraph',
},
],
type: 'root',
},
};
expect(parseSingleFirstLineAgentMentionDirectRoute(editorData)).toBeUndefined();
});
});
@@ -17,6 +17,21 @@ export interface ParsedActionTag {
export interface ParsedCommand extends ParsedActionTag {}
interface MentionNodeMatch {
agent: RuntimeMentionedAgent;
node: any;
}
interface MentionNodeOccurrence {
label: string;
metadata: Record<string, unknown>;
node: any;
}
export interface SingleFirstLineAgentMentionDirectRoute {
targetAgent: RuntimeMentionedAgent;
}
/**
* Walk the Lexical JSON tree to find all action-tag nodes.
* Returns the extracted action tags in document order.
@@ -92,20 +107,34 @@ export const parseMentionedAgentsFromEditorData = (
): RuntimeMentionedAgent[] => {
if (!editorData) return [];
const agents: RuntimeMentionedAgent[] = [];
const seen = new Set<string>();
const mentions = collectAgentMentionOccurrences(editorData.root);
walkMentionNode(editorData.root, (label, metadata) => {
// Only accept explicit agent mentions — skip topics, ALL_MEMBERS, and other types
if (metadata?.type !== 'agent') return;
const id = metadata?.id as string | undefined;
if (!id || seen.has(id)) return;
return mentions.reduce<RuntimeMentionedAgent[]>((agents, mention) => {
if (seen.has(mention.agent.id)) return agents;
seen.add(id);
agents.push({ id, name: label || id });
});
seen.add(mention.agent.id);
agents.push(mention.agent);
return agents;
return agents;
}, []);
};
export const parseSingleFirstLineAgentMentionDirectRoute = (
editorData: Record<string, any> | undefined,
): SingleFirstLineAgentMentionDirectRoute | undefined => {
if (!editorData) return;
const allMentions = collectMentionOccurrences(editorData.root);
if (allMentions.length !== 1) return;
const mentions = collectAgentMentionOccurrences(editorData.root);
if (mentions.length !== 1) return;
const firstMeaningfulNode = findFirstMeaningfulNode(editorData.root);
if (firstMeaningfulNode !== mentions[0].node) return;
return { targetAgent: mentions[0].agent };
};
/**
@@ -132,13 +161,54 @@ function collectText(node: any, out: string[]): void {
}
}
function collectAgentMentionOccurrences(node: any): MentionNodeMatch[] {
const mentions: MentionNodeMatch[] = [];
for (const mention of collectMentionOccurrences(node)) {
// Only accept explicit agent mentions — skip topics, ALL_MEMBERS, and other types
if (mention.metadata?.type !== 'agent') continue;
const id = mention.metadata?.id as string | undefined;
if (!id) continue;
mentions.push({
agent: { id, name: mention.label || id },
node: mention.node,
});
}
return mentions;
}
function collectMentionOccurrences(node: any): MentionNodeOccurrence[] {
const mentions: MentionNodeOccurrence[] = [];
walkMentionNode(node, (mentionNode, label, metadata) => {
mentions.push({ label, metadata, node: mentionNode });
});
return mentions;
}
function findFirstMeaningfulNode(node: any): any | undefined {
if (!node) return;
if (node.type === 'text') {
return typeof node.text === 'string' && node.text.trim().length > 0 ? node : undefined;
}
if (node.type === 'mention' || node.type === 'action-tag') return node;
if (Array.isArray(node.children)) {
for (const child of node.children) {
const meaningfulNode = findFirstMeaningfulNode(child);
if (meaningfulNode) return meaningfulNode;
}
}
}
function walkMentionNode(
node: any,
cb: (label: string, metadata: Record<string, unknown>) => void,
cb: (node: any, label: string, metadata: Record<string, unknown>) => void,
): void {
if (!node) return;
if (node.type === 'mention' && node.metadata) {
cb(node.label ?? '', node.metadata);
cb(node, node.label ?? '', node.metadata);
}
if (Array.isArray(node.children)) {
for (const child of node.children) {
@@ -54,6 +54,7 @@ import {
parseMentionedAgentsFromEditorData,
parseSelectedSkillsFromEditorData,
parseSelectedToolsFromEditorData,
parseSingleFirstLineAgentMentionDirectRoute,
processCommands,
} from './commandBus';
/**
@@ -134,9 +135,12 @@ export class ConversationLifecycleActionImpl {
const selectedSkills = parseSelectedSkillsFromEditorData(editorData);
const selectedTools = parseSelectedToolsFromEditorData(editorData);
const mentionedAgents = parseMentionedAgentsFromEditorData(editorData);
const directMentionRoute = !context.groupId
? parseSingleFirstLineAgentMentionDirectRoute(editorData)
: undefined;
// Use context from params (required)
const { agentId } = context;
const requestedAgentId = context.agentId;
// If creating new thread (isNew + scope='thread'), threadId will be created by server
const isCreatingNewThread = context.isNew && context.scope === 'thread';
// Build newThread params for server from new context format
@@ -149,7 +153,7 @@ export class ConversationLifecycleActionImpl {
}
: undefined;
if (!agentId) return;
if (!requestedAgentId) return;
// ── Command Bus: extract and process built-in commands from editorData ──
const commandOverrides: CommandSendOverrides = processCommands({
@@ -198,18 +202,25 @@ export class ConversationLifecycleActionImpl {
context = { ...context, topicId: undefined };
}
// When creating new thread, override threadId to undefined (server will create it)
// Direct first-line @agent routes this turn to the target agent instead of
// asking the current agent to delegate through callAgent.
const agentId = directMentionRoute?.targetAgent.id ?? context.agentId;
if (!agentId) return;
// Check if current agentId is the supervisor agent of the group
let isGroupSupervisor = false;
if (context.groupId) {
const group = agentGroupByIdSelectors.groupById(context.groupId)(getChatGroupStoreState());
isGroupSupervisor = group?.supervisorAgentId === agentId;
}
// In non-group context, @agent mentions make the current agent act as supervisor
const hasMentionedAgents = !context.groupId && mentionedAgents.length > 0;
// In non-group context, non-direct @agent mentions make the current agent act as supervisor
const hasMentionedAgents =
!context.groupId && !directMentionRoute && mentionedAgents.length > 0;
const operationContext = {
...context,
agentId,
// When creating new thread, override threadId to undefined (server will create it)
...(isCreatingNewThread && { threadId: undefined }),
// Only set isSupervisor for actual group supervisors — NOT for @agent mentions.
// isSupervisor triggers group-specific UI rendering (SupervisorMessage with group avatars).
@@ -270,7 +281,7 @@ export class ConversationLifecycleActionImpl {
// Use provided messages or query from store
// For /newTopic from existing topic, start with empty message list (fresh topic)
const contextKey = messageMapKey(context);
const contextKey = messageMapKey(operationContext);
const messages = forceNewTopicFromExisting
? []
: (inputMessages ?? displayMessageSelectors.getDisplayMessagesByKey(contextKey)(this.#get()));