mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🐛 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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>`);
|
||||
|
||||
@@ -11,10 +11,10 @@ import { useSendMenuItems } from './useSendMenuItems';
|
||||
const leftActions: ActionKeys[] = [
|
||||
'model',
|
||||
'search',
|
||||
'typo',
|
||||
'fileUpload',
|
||||
'tools',
|
||||
'---',
|
||||
['tools', 'params', 'clear'],
|
||||
['typo', 'params', 'clear'],
|
||||
'mainToken',
|
||||
];
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user