🐛 fix: fix supervisor id issue (#11584)

* fix group supervisor issue

* fix identity memory id

* fix supervisor message

* fix bug

* fix loading issue

* add debug mode
This commit is contained in:
Arvin Xu
2026-01-18 21:52:07 +08:00
committed by GitHub
parent a9e320b893
commit c09758409f
16 changed files with 795 additions and 133 deletions

View File

@@ -7,9 +7,9 @@ const log = debug('context-engine:processor:GroupMessageFlattenProcessor');
/**
* Group Message Flatten Processor
* Responsible for flattening role=assistantGroup messages into standard assistant + tool message sequences
* Responsible for flattening role=assistantGroup and role=supervisor messages into standard assistant + tool message sequences
*
* AssistantGroup messages are created when assistant messages with tools are merged with their tool results.
* AssistantGroup/Supervisor messages are created when assistant messages with tools are merged with their tool results.
* This processor converts them back to a flat structure that AI models can understand.
*/
export class GroupMessageFlattenProcessor extends BaseProcessor {
@@ -31,8 +31,11 @@ export class GroupMessageFlattenProcessor extends BaseProcessor {
// Process each message
for (const message of clonedContext.messages) {
// Check if this is an assistantGroup message with children field
if (message.role === 'assistantGroup' && message.children) {
// Check if this is an assistantGroup or supervisor message with children field
if (
(message.role === 'assistantGroup' || message.role === 'supervisor') &&
message.children
) {
// If children array is empty, skip this message entirely (no content to flatten)
if (message.children.length === 0) {
continue;
@@ -42,7 +45,7 @@ export class GroupMessageFlattenProcessor extends BaseProcessor {
groupMessagesFlattened++;
log(
`Flattening assistantGroup message ${message.id} with ${message.children.length} children`,
`Flattening ${message.role} message ${message.id} with ${message.children.length} children`,
);
// Flatten each child
@@ -148,7 +151,7 @@ export class GroupMessageFlattenProcessor extends BaseProcessor {
clonedContext.metadata.toolMessagesCreated = toolMessagesCreated;
log(
`AssistantGroup message flatten processing completed: ${groupMessagesFlattened} groups flattened, ${assistantMessagesCreated} assistant messages created, ${toolMessagesCreated} tool messages created`,
`AssistantGroup/Supervisor message flatten processing completed: ${groupMessagesFlattened} groups flattened, ${assistantMessagesCreated} assistant messages created, ${toolMessagesCreated} tool messages created`,
);
return this.markAsExecuted(clonedContext);

View File

@@ -489,6 +489,109 @@ describe('GroupMessageFlattenProcessor', () => {
});
});
describe('Supervisor Messages', () => {
it('should flatten supervisor message with children', async () => {
const processor = new GroupMessageFlattenProcessor();
const input: any[] = [
{
id: 'msg-supervisor-1',
role: 'supervisor',
content: '',
createdAt: '2025-10-27T10:00:00.000Z',
updatedAt: '2025-10-27T10:00:10.000Z',
meta: { title: 'Supervisor Agent' },
children: [
{
id: 'msg-1',
content: 'Let me coordinate the agents',
tools: [
{
id: 'tool-1',
type: 'builtin',
apiName: 'broadcast',
arguments: '{"message":"Hello agents"}',
identifier: 'lobe-group-management',
result: {
id: 'msg-tool-1',
content: 'Broadcast sent',
error: null,
state: {},
},
},
],
usage: { totalTokens: 100 },
},
],
},
];
const context = createContext(input);
const result = await processor.process(context);
// Should create 2 messages: 1 assistant + 1 tool
expect(result.messages).toHaveLength(2);
// Check assistant message (supervisor gets flattened to assistant)
const assistantMsg = result.messages[0];
expect(assistantMsg.role).toBe('assistant');
expect(assistantMsg.id).toBe('msg-1');
expect(assistantMsg.content).toBe('Let me coordinate the agents');
expect(assistantMsg.tools).toHaveLength(1);
// Check tool message
const toolMsg = result.messages[1];
expect(toolMsg.role).toBe('tool');
expect(toolMsg.id).toBe('msg-tool-1');
expect(toolMsg.content).toBe('Broadcast sent');
});
it('should flatten supervisor message with content only (no tools)', async () => {
const processor = new GroupMessageFlattenProcessor();
const input: any[] = [
{
id: 'msg-supervisor-1',
role: 'supervisor',
content: '',
children: [
{
id: 'msg-1',
content: 'Anthropic cowork',
},
],
},
];
const context = createContext(input);
const result = await processor.process(context);
// Should create 1 assistant message
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('assistant');
expect(result.messages[0].content).toBe('Anthropic cowork');
});
it('should handle supervisor message with empty children', async () => {
const processor = new GroupMessageFlattenProcessor();
const input: any[] = [
{
id: 'msg-supervisor-1',
role: 'supervisor',
content: '',
children: [],
},
];
const context = createContext(input);
const result = await processor.process(context);
// Empty children means no messages created
expect(result.messages).toHaveLength(0);
});
});
describe('Real-world Test Case', () => {
it('should flatten the provided real-world group message', async () => {
const processor = new GroupMessageFlattenProcessor();

View File

@@ -1,6 +1,6 @@
import debug from 'debug';
import { BaseProvider } from '../base/BaseProvider';
import { BaseFirstUserContentProvider } from '../base/BaseFirstUserContentProvider';
import type { PipelineContext, ProcessorOptions } from '../types';
const log = debug('context-engine:provider:GroupAgentBuilderContextInjector');
@@ -262,8 +262,10 @@ ${parts.join('\n')}
/**
* Group Agent Builder Context Injector
* Responsible for injecting current group context when Group Agent Builder tool is enabled
*
* Extends BaseFirstUserContentProvider to consolidate with other first-user-message injectors
*/
export class GroupAgentBuilderContextInjector extends BaseProvider {
export class GroupAgentBuilderContextInjector extends BaseFirstUserContentProvider {
readonly name = 'GroupAgentBuilderContextInjector';
constructor(
@@ -273,19 +275,17 @@ export class GroupAgentBuilderContextInjector extends BaseProvider {
super(options);
}
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const clonedContext = this.cloneContext(context);
protected buildContent(): string | null {
// Skip if Group Agent Builder is not enabled
if (!this.config.enabled) {
log('Group Agent Builder not enabled, skipping injection');
return this.markAsExecuted(clonedContext);
return null;
}
// Skip if no group context
if (!this.config.groupContext) {
log('No group context provided, skipping injection');
return this.markAsExecuted(clonedContext);
return null;
}
// Format group context
@@ -295,34 +295,21 @@ export class GroupAgentBuilderContextInjector extends BaseProvider {
// Skip if no content to inject
if (!formattedContent) {
log('No content to inject after formatting');
return this.markAsExecuted(clonedContext);
return null;
}
// Find the first user message index
const firstUserIndex = clonedContext.messages.findIndex((msg) => msg.role === 'user');
log('Group Agent Builder context prepared for injection');
return formattedContent;
}
if (firstUserIndex === -1) {
log('No user messages found, skipping injection');
return this.markAsExecuted(clonedContext);
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
const result = await super.doProcess(context);
// Update metadata if content was injected
if (this.config.enabled && this.config.groupContext) {
result.metadata.groupAgentBuilderContextInjected = true;
}
// Insert a new user message with group context before the first user message
const groupContextMessage = {
content: formattedContent,
createdAt: Date.now(),
id: `group-agent-builder-context-${Date.now()}`,
meta: { injectType: 'group-agent-builder-context', systemInjection: true },
role: 'user' as const,
updatedAt: Date.now(),
};
clonedContext.messages.splice(firstUserIndex, 0, groupContextMessage);
// Update metadata
clonedContext.metadata.groupAgentBuilderContextInjected = true;
log('Group Agent Builder context injected as new user message');
return this.markAsExecuted(clonedContext);
return result;
}
}

View File

@@ -0,0 +1,307 @@
import { describe, expect, it } from 'vitest';
import type { PipelineContext } from '../../types';
import { GroupAgentBuilderContextInjector } from '../GroupAgentBuilderContextInjector';
import { UserMemoryInjector } from '../UserMemoryInjector';
describe('GroupAgentBuilderContextInjector', () => {
const createContext = (messages: any[]): PipelineContext => ({
initialState: { messages: [] },
isAborted: false,
messages,
metadata: {},
});
describe('Basic Injection', () => {
it('should inject group context before first user message', async () => {
const injector = new GroupAgentBuilderContextInjector({
enabled: true,
groupContext: {
groupId: 'grp_123',
groupTitle: 'Test Group',
members: [
{ id: 'agt_1', title: 'Agent 1', isSupervisor: true },
{ id: 'agt_2', title: 'Agent 2', isSupervisor: false },
],
},
});
const context = createContext([
{ role: 'system', content: 'You are a helpful assistant' },
{ role: 'user', content: 'Hello' },
]);
const result = await injector.process(context);
// Should have 3 messages now (system + injected user + original user)
expect(result.messages).toHaveLength(3);
expect(result.messages[0].role).toBe('system');
expect(result.messages[1].role).toBe('user');
expect(result.messages[1].content).toContain('<current_group_context>');
expect(result.messages[1].content).toContain('grp_123');
expect(result.messages[1].content).toContain('Test Group');
expect(result.messages[2].role).toBe('user');
expect(result.messages[2].content).toBe('Hello');
});
it('should skip injection when not enabled', async () => {
const injector = new GroupAgentBuilderContextInjector({
enabled: false,
groupContext: {
groupId: 'grp_123',
},
});
const context = createContext([
{ role: 'system', content: 'You are a helpful assistant' },
{ role: 'user', content: 'Hello' },
]);
const result = await injector.process(context);
expect(result.messages).toHaveLength(2);
});
it('should skip injection when no group context provided', async () => {
const injector = new GroupAgentBuilderContextInjector({
enabled: true,
});
const context = createContext([
{ role: 'system', content: 'You are a helpful assistant' },
{ role: 'user', content: 'Hello' },
]);
const result = await injector.process(context);
expect(result.messages).toHaveLength(2);
});
});
describe('Content Consolidation with UserMemoryInjector', () => {
it('should consolidate content into single user message when both injectors are used', async () => {
// First injector: UserMemoryInjector
const memoryInjector = new UserMemoryInjector({
memories: {
identities: [{ description: 'User is a developer', id: 'id_1' }],
},
});
// Second injector: GroupAgentBuilderContextInjector
const groupInjector = new GroupAgentBuilderContextInjector({
enabled: true,
groupContext: {
groupId: 'grp_123',
groupTitle: 'Dev Team',
},
});
const context = createContext([
{ role: 'system', content: 'You are a helpful assistant' },
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there!' },
]);
// Process through both injectors in order
const afterMemory = await memoryInjector.process(context);
const afterGroup = await groupInjector.process(afterMemory);
// Should have 4 messages: system + SINGLE injected user + original user + assistant
expect(afterGroup.messages).toHaveLength(4);
// Check message order
expect(afterGroup.messages[0].role).toBe('system');
expect(afterGroup.messages[1].role).toBe('user'); // Consolidated injection
expect(afterGroup.messages[2].role).toBe('user'); // Original user message
expect(afterGroup.messages[3].role).toBe('assistant');
// The consolidated message should contain BOTH user memory AND group context
const injectedMessage = afterGroup.messages[1];
expect(injectedMessage.content).toContain('<user_memory>'); // From UserMemoryInjector
expect(injectedMessage.content).toContain('<current_group_context>'); // From GroupAgentBuilderContextInjector
expect(injectedMessage.content).toContain('User is a developer');
expect(injectedMessage.content).toContain('grp_123');
expect(injectedMessage.content).toContain('Dev Team');
});
it('should work correctly when only GroupAgentBuilderContextInjector is used', async () => {
const groupInjector = new GroupAgentBuilderContextInjector({
enabled: true,
groupContext: {
groupId: 'grp_123',
groupTitle: 'Test Group',
},
});
const context = createContext([
{ role: 'system', content: 'You are a helpful assistant' },
{ role: 'user', content: 'Hello' },
]);
const result = await groupInjector.process(context);
expect(result.messages).toHaveLength(3);
expect(result.messages[1].content).toContain('<current_group_context>');
});
it('should work correctly when only UserMemoryInjector is used', async () => {
const memoryInjector = new UserMemoryInjector({
memories: {
identities: [{ description: 'User is a developer', id: 'id_1' }],
},
});
const context = createContext([
{ role: 'system', content: 'You are a helpful assistant' },
{ role: 'user', content: 'Hello' },
]);
const result = await memoryInjector.process(context);
expect(result.messages).toHaveLength(3);
expect(result.messages[1].content).toContain('<user_memory>');
});
it('should NOT create duplicate user messages when injectors run in sequence', async () => {
const memoryInjector = new UserMemoryInjector({
memories: {
identities: [{ description: 'Identity 1', id: 'id_1' }],
contexts: [{ title: 'Context 1', id: 'ctx_1' }],
},
});
const groupInjector = new GroupAgentBuilderContextInjector({
enabled: true,
groupContext: {
groupId: 'grp_123',
groupTitle: 'Team Alpha',
members: [
{ id: 'agt_1', title: 'Alice', isSupervisor: true },
{ id: 'agt_2', title: 'Bob', isSupervisor: false },
],
config: {
systemPrompt: 'Collaborate effectively',
},
},
});
const context = createContext([
{ role: 'system', content: 'System prompt' },
{ role: 'user', content: 'First user message' },
{ role: 'assistant', content: 'First response' },
{ role: 'user', content: 'Second user message' },
]);
// Process through both injectors
const afterMemory = await memoryInjector.process(context);
const afterGroup = await groupInjector.process(afterMemory);
// Count user messages
const userMessages = afterGroup.messages.filter((m) => m.role === 'user');
// Should have 3 user messages: 1 consolidated injection + 2 original
expect(userMessages).toHaveLength(3);
// The first user message should be the consolidated injection
expect(userMessages[0].content).toContain('<user_memory>');
expect(userMessages[0].content).toContain('<current_group_context>');
// Original messages should remain unchanged
expect(userMessages[1].content).toBe('First user message');
expect(userMessages[2].content).toBe('Second user message');
});
it('should preserve order: first injector content comes first in the consolidated message', async () => {
// UserMemoryInjector runs first
const memoryInjector = new UserMemoryInjector({
memories: {
identities: [{ description: 'Dev identity', id: 'id_1' }],
},
});
// GroupAgentBuilderContextInjector runs second
const groupInjector = new GroupAgentBuilderContextInjector({
enabled: true,
groupContext: {
groupId: 'grp_order_test',
groupTitle: 'Order Test Group',
},
});
const context = createContext([{ role: 'user', content: 'Hello' }]);
// Process in order: memory first, then group
const afterMemory = await memoryInjector.process(context);
const afterGroup = await groupInjector.process(afterMemory);
const injectedContent = afterGroup.messages[0].content as string;
// user_memory should appear BEFORE current_group_context
const memoryIndex = injectedContent.indexOf('<user_memory>');
const groupIndex = injectedContent.indexOf('<current_group_context>');
expect(memoryIndex).toBeLessThan(groupIndex);
});
});
describe('Group Context Formatting', () => {
it('should format members correctly', async () => {
const injector = new GroupAgentBuilderContextInjector({
enabled: true,
groupContext: {
members: [
{
id: 'agt_1',
title: 'Supervisor Agent',
description: 'Manages the team',
isSupervisor: true,
},
{
id: 'agt_2',
title: 'Worker Agent',
description: 'Does the work',
isSupervisor: false,
},
],
},
});
const context = createContext([{ role: 'user', content: 'Hello' }]);
const result = await injector.process(context);
const injectedContent = result.messages[0].content;
expect(injectedContent).toContain('<group_members count="2">');
expect(injectedContent).toContain('role="supervisor"');
expect(injectedContent).toContain('role="participant"');
expect(injectedContent).toContain('Supervisor Agent');
expect(injectedContent).toContain('Worker Agent');
});
it('should format config correctly', async () => {
const injector = new GroupAgentBuilderContextInjector({
enabled: true,
groupContext: {
config: {
scene: 'collaborative',
enableSupervisor: true,
systemPrompt: 'Work together as a team',
openingMessage: 'Welcome to the team!',
openingQuestions: ['How can I help?', 'What would you like to do?'],
},
},
});
const context = createContext([{ role: 'user', content: 'Hello' }]);
const result = await injector.process(context);
const injectedContent = result.messages[0].content;
expect(injectedContent).toContain('<group_config>');
expect(injectedContent).toContain('<scene>collaborative</scene>');
expect(injectedContent).toContain('<enableSupervisor>true</enableSupervisor>');
expect(injectedContent).toContain('<openingMessage>Welcome to the team!</openingMessage>');
expect(injectedContent).toContain('<openingQuestions count="2">');
});
});
});

View File

@@ -120,15 +120,9 @@ exports[`promptUserMemory > identities only > should format identities grouped b
"<user_memory>
<instruction>The following are memories about this user retrieved from previous conversations. Use this information to personalize your responses and maintain continuity.</instruction>
<identities count="3">
<personal count="1">
<identity role="Father">User is a father of two children</identity>
</personal>
<professional count="1">
<identity role="Software Engineer">User is a senior software engineer</identity>
</professional>
<demographic count="1">
<identity>User is based in Shanghai</identity>
</demographic>
<identity type="personal" role="Father" id="id-1">User is a father of two children</identity>
<identity type="professional" role="Software Engineer" id="id-2">User is a senior software engineer</identity>
<identity type="demographic" id="id-3">User is based in Shanghai</identity>
</identities>
</user_memory>"
`;
@@ -137,10 +131,8 @@ exports[`promptUserMemory > identities only > should format single type identiti
"<user_memory>
<instruction>The following are memories about this user retrieved from previous conversations. Use this information to personalize your responses and maintain continuity.</instruction>
<identities count="2">
<demographic count="2">
<identity>User is 35 years old</identity>
<identity>User speaks Mandarin and English</identity>
</demographic>
<identity type="demographic" id="id-1">User is 35 years old</identity>
<identity type="demographic" id="id-2">User speaks Mandarin and English</identity>
</identities>
</user_memory>"
`;
@@ -149,10 +141,8 @@ exports[`promptUserMemory > identities only > should format single type identiti
"<user_memory>
<instruction>The following are memories about this user retrieved from previous conversations. Use this information to personalize your responses and maintain continuity.</instruction>
<identities count="2">
<personal count="2">
<identity>User is married</identity>
<identity>User has a pet dog</identity>
</personal>
<identity type="personal" id="id-1">User is married</identity>
<identity type="personal" id="id-2">User has a pet dog</identity>
</identities>
</user_memory>"
`;
@@ -161,9 +151,7 @@ exports[`promptUserMemory > identities only > should format single type identiti
"<user_memory>
<instruction>The following are memories about this user retrieved from previous conversations. Use this information to personalize your responses and maintain continuity.</instruction>
<identities count="1">
<professional count="1">
<identity role="CTO">User works at a tech startup</identity>
</professional>
<identity type="professional" role="CTO" id="id-1">User works at a tech startup</identity>
</identities>
</user_memory>"
`;
@@ -172,9 +160,7 @@ exports[`promptUserMemory > identities only > should handle identity with null v
"<user_memory>
<instruction>The following are memories about this user retrieved from previous conversations. Use this information to personalize your responses and maintain continuity.</instruction>
<identities count="1">
<personal count="1">
<identity></identity>
</personal>
<identity type="personal" id="id-1"></identity>
</identities>
</user_memory>"
`;
@@ -183,9 +169,7 @@ exports[`promptUserMemory > identities only > should handle identity without rol
"<user_memory>
<instruction>The following are memories about this user retrieved from previous conversations. Use this information to personalize your responses and maintain continuity.</instruction>
<identities count="1">
<personal count="1">
<identity>User enjoys hiking</identity>
</personal>
<identity type="personal" id="id-1">User enjoys hiking</identity>
</identities>
</user_memory>"
`;
@@ -194,9 +178,7 @@ exports[`promptUserMemory > mixed memory types > should format all memory types
"<user_memory>
<instruction>The following are memories about this user retrieved from previous conversations. Use this information to personalize your responses and maintain continuity.</instruction>
<identities count="1">
<professional count="1">
<identity role="Tech Lead">User is a tech lead at a startup</identity>
</professional>
<identity type="professional" role="Tech Lead" id="id-1">User is a tech lead at a startup</identity>
</identities>
<contexts count="1">
<context id="ctx-1" title="Experience Level">Senior developer with 10 years experience</context>
@@ -217,15 +199,9 @@ exports[`promptUserMemory > mixed memory types > should format all memory types
"<user_memory>
<instruction>The following are memories about this user retrieved from previous conversations. Use this information to personalize your responses and maintain continuity.</instruction>
<identities count="3">
<personal count="1">
<identity role="Father">User is a father</identity>
</personal>
<professional count="1">
<identity role="Senior Engineer">User is a senior engineer</identity>
</professional>
<demographic count="1">
<identity>User lives in Beijing</identity>
</demographic>
<identity type="personal" role="Father" id="id-1">User is a father</identity>
<identity type="professional" role="Senior Engineer" id="id-2">User is a senior engineer</identity>
<identity type="demographic" id="id-3">User lives in Beijing</identity>
</identities>
<contexts count="1">
<context id="ctx-1" title="Current Work">Working on AI products</context>

View File

@@ -98,29 +98,10 @@ const isValidIdentityItem = (item: UserMemoryIdentityItem): boolean => {
* Formats a single identity memory item
*/
const formatIdentityItem = (item: UserMemoryIdentityItem): string => {
const typeAttr = item.type ? ` type="${item.type}"` : '';
const roleAttr = item.role ? ` role="${item.role}"` : '';
return ` <identity${roleAttr}>${item.description || ''}</identity>`;
};
/**
* Format identities grouped by type as XML
* Types: personal (角色), professional (职业), demographic (属性)
*/
const formatIdentitiesSection = (identities: UserMemoryIdentityItem[]): string => {
const personal = identities.filter((i) => i.type === 'personal');
const professional = identities.filter((i) => i.type === 'professional');
const demographic = identities.filter((i) => i.type === 'demographic');
return [
personal.length > 0 &&
` <personal count="${personal.length}">\n${personal.map(formatIdentityItem).join('\n')}\n </personal>`,
professional.length > 0 &&
` <professional count="${professional.length}">\n${professional.map(formatIdentityItem).join('\n')}\n </professional>`,
demographic.length > 0 &&
` <demographic count="${demographic.length}">\n${demographic.map(formatIdentityItem).join('\n')}\n </demographic>`,
]
.filter(Boolean)
.join('\n');
const idAttr = item.id ? ` id="${item.id}"` : '';
return ` <identity${typeAttr}${roleAttr}${idAttr}>${item.description || ''}</identity>`;
};
/**
@@ -156,9 +137,9 @@ export const promptUserMemory = ({ memories }: PromptUserMemoryOptions): string
'<instruction>The following are memories about this user retrieved from previous conversations. Use this information to personalize your responses and maintain continuity.</instruction>',
);
// Add identities section (user's identity information, grouped by type)
// Add identities section (user's identity information)
if (hasIdentities) {
const identitiesXml = formatIdentitiesSection(identities);
const identitiesXml = identities.map((item) => formatIdentityItem(item)).join('\n');
contentParts.push(`<identities count="${identities.length}">
${identitiesXml}
</identities>`);

View File

@@ -11,10 +11,10 @@ import { useSendMenuItems } from './useSendMenuItems';
const leftActions: ActionKeys[] = [
'model',
'search',
'typo',
'fileUpload',
'tools',
'---',
['tools', 'params', 'clear'],
['typo', 'params', 'clear'],
'mainToken',
];

View File

@@ -37,8 +37,8 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
s.agentUpdatingId === id,
]);
// Separate loading state from chat store - only subscribe if this session is active
const isLoading = useChatStore(operationSelectors.isAgentRuntimeRunning);
// Separate loading state from chat store - only show loading for this specific agent
const isLoading = useChatStore(operationSelectors.isAgentRunning(id));
// Get display title with fallback
const displayTitle = title || t('untitledAgent');

View File

@@ -55,7 +55,8 @@ const GroupMessage = memo<GroupMessageProps>(({ id, index, disableEditing, isLat
// Get editing state from ConversationStore
const creating = useConversationStore(messageStateSelectors.isMessageCreating(id));
const newScreen = useNewScreen({ creating, isLatestItem });
const generating = useConversationStore(messageStateSelectors.isMessageGenerating(id));
const newScreen = useNewScreen({ creating: creating || generating, isLatestItem });
const setMessageItemActionElementPortialContext = useSetMessageItemActionElementPortialContext();
const setMessageItemActionTypeContext = useSetMessageItemActionTypeContext();

View File

@@ -18,10 +18,11 @@ interface ContentLoadingProps {
const ContentLoading = memo<ContentLoadingProps>(({ id }) => {
const { t } = useTranslation('chat');
const runningOp = useChatStore(operationSelectors.getDeepestRunningOperationByMessage(id));
console.log('runningOp', runningOp);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [startTime, setStartTime] = useState(runningOp?.metadata?.startTime);
const operationType = runningOp?.type as OperationType | undefined;
const startTime = runningOp?.metadata?.startTime;
// Track elapsed time, reset when operation type changes
useEffect(() => {
@@ -39,7 +40,12 @@ const ContentLoading = memo<ContentLoadingProps>(({ id }) => {
const interval = setInterval(updateElapsed, 1000);
return () => clearInterval(interval);
}, [startTime, operationType]);
}, [startTime]);
useEffect(() => {
setElapsedSeconds(0);
setStartTime(Date.now());
}, [operationType, id]);
// Get localized label based on operation type
const operationLabel = operationType

View File

@@ -5,6 +5,7 @@ import {
type LobeAgentConfig,
type MessageMapScope,
} from '@lobechat/types';
import debug from 'debug';
import { produce } from 'immer';
import { getAgentStoreState } from '@/store/agent';
@@ -12,6 +13,8 @@ import { agentSelectors, chatConfigByIdSelectors } from '@/store/agent/selectors
import { getChatGroupStoreState } from '@/store/agentGroup';
import { agentGroupByIdSelectors, agentGroupSelectors } from '@/store/agentGroup/selectors';
const log = debug('mecha:agentConfigResolver');
/**
* Applies params adjustments based on chatConfig settings.
*
@@ -99,6 +102,8 @@ export interface ResolvedAgentConfig {
export const resolveAgentConfig = (ctx: AgentConfigResolverContext): ResolvedAgentConfig => {
const { agentId, model, documentContent, plugins, targetAgentConfig } = ctx;
log('resolveAgentConfig called with agentId: %s, scope: %s', agentId, ctx.scope);
const agentStoreState = getAgentStoreState();
// Get base config from store
@@ -111,19 +116,46 @@ export const resolveAgentConfig = (ctx: AgentConfigResolverContext): ResolvedAge
// Check if this is a builtin agent
// First check agent store, then check if this is a supervisor agent in agentGroup store
let slug = agentSelectors.getAgentSlugById(agentId)(agentStoreState);
log('slug from agentStore: %s (agentId: %s)', slug, agentId);
// If not found in agent store, check if this is a supervisor agent in any group
// Supervisor agents have their slug stored in agentGroup store, not agent store
if (!slug) {
const groupStoreState = getChatGroupStoreState();
const groupMap = groupStoreState.groupMap;
const groupMapKeys = Object.keys(groupMap);
log(
'checking groupStore for supervisor - groupMap has %d groups: %o',
groupMapKeys.length,
groupMapKeys.map((key) => ({
groupId: key,
supervisorAgentId: groupMap[key]?.supervisorAgentId,
title: groupMap[key]?.title,
})),
);
const group = agentGroupByIdSelectors.groupBySupervisorAgentId(agentId)(groupStoreState);
log(
'groupBySupervisorAgentId result for agentId %s: %o',
agentId,
group
? {
groupId: group.id,
supervisorAgentId: group.supervisorAgentId,
title: group.title,
}
: null,
);
if (group) {
// This is a supervisor agent - use the builtin slug
slug = BUILTIN_AGENT_SLUGS.groupSupervisor;
log('agentId %s identified as group supervisor, assigned slug: %s', agentId, slug);
}
}
if (!slug) {
log('agentId %s is not a builtin agent (no slug found)', agentId);
// Regular agent - use provided plugins if available, fallback to agent's plugins
const finalPlugins = plugins && plugins.length > 0 ? plugins : basePlugins;
@@ -183,18 +215,45 @@ export const resolveAgentConfig = (ctx: AgentConfigResolverContext): ResolvedAge
// Build groupSupervisorContext if this is a group-supervisor agent
let groupSupervisorContext;
if (slug === BUILTIN_AGENT_SLUGS.groupSupervisor) {
log('building groupSupervisorContext for agentId: %s', agentId);
const groupStoreState = getChatGroupStoreState();
// Find the group by supervisor agent ID
const group = agentGroupSelectors.getGroupBySupervisorAgentId(agentId)(groupStoreState);
log(
'getGroupBySupervisorAgentId result: %o',
group
? {
agentsCount: group.agents?.length,
groupId: group.id,
supervisorAgentId: group.supervisorAgentId,
title: group.title,
}
: null,
);
if (group) {
const groupMembers = agentGroupSelectors.getGroupMembers(group.id)(groupStoreState);
log(
'groupMembers for groupId %s: %o',
group.id,
groupMembers.map((m) => ({ id: m.id, isSupervisor: m.isSupervisor, title: m.title })),
);
groupSupervisorContext = {
availableAgents: groupMembers.map((agent) => ({ id: agent.id, title: agent.title })),
groupId: group.id,
groupTitle: group.title || 'Group Chat',
systemPrompt: agentConfig.systemRole,
};
log('groupSupervisorContext built: %o', {
availableAgentsCount: groupSupervisorContext.availableAgents.length,
groupId: groupSupervisorContext.groupId,
groupTitle: groupSupervisorContext.groupTitle,
hasSystemPrompt: !!groupSupervisorContext.systemPrompt,
});
} else {
log('WARNING: group not found for supervisor agentId: %s', agentId);
}
}
@@ -258,6 +317,12 @@ export const resolveAgentConfig = (ctx: AgentConfigResolverContext): ResolvedAge
// Apply params adjustments based on chatConfig
const finalAgentConfig = applyParamsFromChatConfig(resolvedAgentConfig, resolvedChatConfig);
log('resolveAgentConfig completed for agentId: %s, result: %o', agentId, {
isBuiltinAgent: true,
pluginsCount: finalPlugins.length,
slug,
});
return {
agentConfig: finalAgentConfig,
chatConfig: resolvedChatConfig,

View File

@@ -48,6 +48,12 @@ export interface ChatGroupInternalAction {
type: string;
},
) => void;
/**
* Fetch group detail directly and update store.
* Unlike refreshGroupDetail which uses SWR mutate, this method fetches immediately
* and is useful when SWR hook is not yet mounted (e.g., after createGroup).
*/
internal_fetchGroupDetail: (groupId: string) => Promise<void>;
internal_updateGroupMaps: (groups: ChatGroupItem[]) => void;
loadGroups: () => Promise<void>;
refreshGroupDetail: (groupId: string) => Promise<void>;
@@ -91,6 +97,30 @@ const chatGroupInternalSlice: StateCreator<
return {
internal_dispatchChatGroup: dispatch,
internal_fetchGroupDetail: async (groupId: string) => {
const groupDetail = await chatGroupService.getGroupDetail(groupId);
if (!groupDetail) return;
// Update groupMap with full group detail including supervisorAgentId and agents
dispatch({ payload: { id: groupDetail.id, value: groupDetail }, type: 'updateGroup' });
// Sync group agents to agentStore for builtin agent resolution
const agentStore = getAgentStoreState();
for (const agent of groupDetail.agents) {
agentStore.internal_dispatchAgentMap(agent.id, agent as any);
}
// Set activeAgentId to supervisor for correct model resolution
if (groupDetail.supervisorAgentId) {
agentStore.setActiveAgentId(groupDetail.supervisorAgentId);
useChatStore.setState(
{ activeAgentId: groupDetail.supervisorAgentId },
false,
'syncActiveAgentIdFromAgentGroup',
);
}
},
internal_updateGroupMaps: (groups) => {
// Build a candidate map from incoming groups
const incomingMap = groups.reduce(

View File

@@ -10,19 +10,31 @@ vi.mock('@/services/chatGroup', () => ({
chatGroupService: {
addAgentsToGroup: vi.fn(),
createGroup: vi.fn(),
getGroupDetail: vi.fn(),
getGroups: vi.fn(),
},
}));
vi.mock('@/store/session', () => ({
getSessionStoreState: vi.fn(() => ({
activeId: 'some-session-id',
refreshSessions: vi.fn().mockResolvedValue(undefined),
sessions: [],
switchSession: vi.fn(),
vi.mock('@/store/home', () => ({
getHomeStoreState: vi.fn(() => ({
refreshAgentList: vi.fn(),
switchToGroup: vi.fn(),
})),
}));
vi.mock('@/store/agent', () => ({
getAgentStoreState: vi.fn(() => ({
internal_dispatchAgentMap: vi.fn(),
setActiveAgentId: vi.fn(),
})),
}));
vi.mock('@/store/chat', () => ({
useChatStore: {
setState: vi.fn(),
},
}));
describe('ChatGroupLifecycleSlice', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -47,12 +59,17 @@ describe('ChatGroupLifecycleSlice', () => {
title: 'Test Group',
userId: 'user-1',
};
const mockGroupDetail = {
...mockGroup,
agents: [],
supervisorAgentId: 'supervisor-1',
};
vi.mocked(chatGroupService.createGroup).mockResolvedValue({
group: mockGroup as any,
supervisorAgentId: 'supervisor-1',
});
vi.mocked(chatGroupService.getGroups).mockResolvedValue([mockGroup as any]);
vi.mocked(chatGroupService.getGroupDetail).mockResolvedValue(mockGroupDetail as any);
const { result } = renderHook(() => useAgentGroupStore());
@@ -71,13 +88,18 @@ describe('ChatGroupLifecycleSlice', () => {
title: 'Test Group',
userId: 'user-1',
};
const mockGroupDetail = {
...mockGroup,
agents: [],
supervisorAgentId: 'supervisor-1',
};
vi.mocked(chatGroupService.createGroup).mockResolvedValue({
group: mockGroup as any,
supervisorAgentId: 'supervisor-1',
});
vi.mocked(chatGroupService.addAgentsToGroup).mockResolvedValue({ added: [], existing: [] });
vi.mocked(chatGroupService.getGroups).mockResolvedValue([mockGroup as any]);
vi.mocked(chatGroupService.getGroupDetail).mockResolvedValue(mockGroupDetail as any);
const { result } = renderHook(() => useAgentGroupStore());
@@ -91,14 +113,46 @@ describe('ChatGroupLifecycleSlice', () => {
]);
});
it('should not switch session when silent is true', async () => {
const mockSwitchSession = vi.fn();
const { getSessionStoreState } = await import('@/store/session');
vi.mocked(getSessionStoreState).mockReturnValue({
activeId: 'some-session-id',
refreshSessions: vi.fn().mockResolvedValue(undefined),
sessions: [],
switchSession: mockSwitchSession,
it('should fetch group detail and store supervisorAgentId for tools injection', async () => {
const mockGroup = {
id: 'new-group-id',
title: 'Test Group',
userId: 'user-1',
};
const mockSupervisorAgentId = 'supervisor-agent-123';
const mockGroupDetail = {
...mockGroup,
agents: [],
supervisorAgentId: mockSupervisorAgentId,
};
vi.mocked(chatGroupService.createGroup).mockResolvedValue({
group: mockGroup as any,
supervisorAgentId: mockSupervisorAgentId,
});
vi.mocked(chatGroupService.getGroupDetail).mockResolvedValue(mockGroupDetail as any);
const { result } = renderHook(() => useAgentGroupStore());
await act(async () => {
await result.current.createGroup({ title: 'Test Group' });
});
// Verify getGroupDetail was called to fetch full group info
expect(chatGroupService.getGroupDetail).toHaveBeenCalledWith('new-group-id');
// Verify supervisorAgentId is stored in groupMap for tools injection
const groupDetail = result.current.groupMap['new-group-id'];
expect(groupDetail).toBeDefined();
expect(groupDetail.supervisorAgentId).toBe(mockSupervisorAgentId);
});
it('should not switch to group when silent is true', async () => {
const mockSwitchToGroup = vi.fn();
const { getHomeStoreState } = await import('@/store/home');
vi.mocked(getHomeStoreState).mockReturnValue({
refreshAgentList: vi.fn(),
switchToGroup: mockSwitchToGroup,
} as any);
const mockGroup = {
@@ -106,12 +160,17 @@ describe('ChatGroupLifecycleSlice', () => {
title: 'Test Group',
userId: 'user-1',
};
const mockGroupDetail = {
...mockGroup,
agents: [],
supervisorAgentId: 'supervisor-1',
};
vi.mocked(chatGroupService.createGroup).mockResolvedValue({
group: mockGroup as any,
supervisorAgentId: 'supervisor-1',
});
vi.mocked(chatGroupService.getGroups).mockResolvedValue([mockGroup as any]);
vi.mocked(chatGroupService.getGroupDetail).mockResolvedValue(mockGroupDetail as any);
const { result } = renderHook(() => useAgentGroupStore());
@@ -119,7 +178,7 @@ describe('ChatGroupLifecycleSlice', () => {
await result.current.createGroup({ title: 'Test Group' }, [], true);
});
expect(mockSwitchSession).not.toHaveBeenCalled();
expect(mockSwitchToGroup).not.toHaveBeenCalled();
});
});
});

View File

@@ -5,7 +5,7 @@ import { type StateCreator } from 'zustand/vanilla';
import { chatGroupService } from '@/services/chatGroup';
import { type ChatGroupStore } from '@/store/agentGroup/store';
import { useChatStore } from '@/store/chat';
import { getSessionStoreState } from '@/store/session';
import { getHomeStoreState } from '@/store/home';
export interface ChatGroupLifecycleAction {
createGroup: (
@@ -14,7 +14,6 @@ export interface ChatGroupLifecycleAction {
silent?: boolean,
) => Promise<string>;
/**
* @deprecated Use switchTopic(undefined) instead
* Switch to a new topic in the group
* Clears activeTopicId and navigates to group root
*/
@@ -32,11 +31,8 @@ export const chatGroupLifecycleSlice: StateCreator<
[],
ChatGroupLifecycleAction
> = (_, get) => ({
/**
* @param silent - if true, do not switch to the new group session
*/
createGroup: async (newGroup, agentIds, silent = false) => {
const { switchSession } = getSessionStoreState();
const { switchToGroup, refreshAgentList } = getHomeStoreState();
const { group } = await chatGroupService.createGroup(newGroup);
@@ -52,11 +48,13 @@ export const chatGroupLifecycleSlice: StateCreator<
get().internal_dispatchChatGroup({ payload: group, type: 'addGroup' });
await get().loadGroups();
await getSessionStoreState().refreshSessions();
// Fetch full group detail to get supervisorAgentId and agents for tools injection
await get().internal_fetchGroupDetail(group.id);
refreshAgentList();
if (!silent) {
switchSession(group.id);
switchToGroup(group.id);
}
return group.id;

View File

@@ -509,6 +509,130 @@ describe('Operation Selectors', () => {
});
});
describe('isAgentRunning', () => {
it('should return false when no operations exist', () => {
const { result } = renderHook(() => useChatStore());
expect(operationSelectors.isAgentRunning('agent1')(result.current)).toBe(false);
});
it('should return true only for the agent with running operations', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.startOperation({
type: 'execAgentRuntime',
context: { agentId: 'agent1' },
});
});
expect(operationSelectors.isAgentRunning('agent1')(result.current)).toBe(true);
expect(operationSelectors.isAgentRunning('agent2')(result.current)).toBe(false);
});
it('should return false when operation completes', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'execAgentRuntime',
context: { agentId: 'agent1' },
}).operationId;
});
expect(operationSelectors.isAgentRunning('agent1')(result.current)).toBe(true);
act(() => {
result.current.completeOperation(opId!);
});
expect(operationSelectors.isAgentRunning('agent1')(result.current)).toBe(false);
});
it('should exclude aborting operations', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'execAgentRuntime',
context: { agentId: 'agent1' },
}).operationId;
});
expect(operationSelectors.isAgentRunning('agent1')(result.current)).toBe(true);
act(() => {
result.current.updateOperationMetadata(opId!, { isAborting: true });
});
expect(operationSelectors.isAgentRunning('agent1')(result.current)).toBe(false);
});
it('should detect any topic with running operations for the agent', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
// Agent 1, topic 1
result.current.startOperation({
type: 'execAgentRuntime',
context: { agentId: 'agent1', topicId: 'topic1' },
});
// Agent 1, topic 2
result.current.startOperation({
type: 'execAgentRuntime',
context: { agentId: 'agent1', topicId: 'topic2' },
});
// Agent 2, topic 3
result.current.startOperation({
type: 'execAgentRuntime',
context: { agentId: 'agent2', topicId: 'topic3' },
});
});
// Agent 1 should be running (has 2 topics with operations)
expect(operationSelectors.isAgentRunning('agent1')(result.current)).toBe(true);
// Agent 2 should also be running
expect(operationSelectors.isAgentRunning('agent2')(result.current)).toBe(true);
// Agent 3 should not be running
expect(operationSelectors.isAgentRunning('agent3')(result.current)).toBe(false);
});
it('should detect server agent runtime operations', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.startOperation({
type: 'execServerAgentRuntime',
context: { agentId: 'agent1', groupId: 'group1' },
});
});
expect(operationSelectors.isAgentRunning('agent1')(result.current)).toBe(true);
expect(operationSelectors.isAgentRunning('agent2')(result.current)).toBe(false);
});
it('should not detect non-AI-runtime operations', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
// sendMessage is not an AI runtime operation type
result.current.startOperation({
type: 'sendMessage',
context: { agentId: 'agent1' },
});
});
// sendMessage is not in AI_RUNTIME_OPERATION_TYPES, so should return false
expect(operationSelectors.isAgentRunning('agent1')(result.current)).toBe(false);
});
});
describe('backward compatibility selectors', () => {
it('isAgentRuntimeRunning should work', () => {
const { result } = renderHook(() => useChatStore());

View File

@@ -234,6 +234,27 @@ const isAgentRuntimeRunningByContext =
};
// === Backward Compatibility ===
/**
* Check if a specific agent has running AI runtime operations
* Used for agent list item loading states where we need per-agent granularity
*/
const isAgentRunning =
(agentId: string) =>
(s: ChatStoreState): boolean => {
for (const type of AI_RUNTIME_OPERATION_TYPES) {
const operationIds = s.operationsByType[type] || [];
const hasRunning = operationIds.some((id) => {
const op = s.operations[id];
return (
op && op.status === 'running' && !op.metadata.isAborting && op.context.agentId === agentId
);
});
if (hasRunning) return true;
}
return false;
};
/**
* Check if agent runtime is running (including both main window and thread)
* Checks both client-side (execAgentRuntime) and server-side (execServerAgentRuntime) operations
@@ -477,6 +498,7 @@ export const operationSelectors = {
isAborting,
isAgentRunning,
isAgentRuntimeRunning,
isAgentRuntimeRunningByContext,
isAnyMessageLoading,