package vip.mate.tool.builtin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.Spy;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentService.ChatResult;
import vip.mate.agent.delegation.SubagentRegistry;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.audit.service.AuditEventService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.workspace.conversation.ConversationService;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* Minimal E2E-style test verifying the delegation event sequence:
* delegation_start → delegation_progress → delegation_end.
*
* To cover delegation_progress, the test captures the relay listener registered via
* {@code addEventRelay} and simulates child events during {@code agentService.chat()},
* triggering the relay path that broadcasts progress to the parent conversation.
*/
@ExtendWith(MockitoExtension.class)
class DelegateEventSequenceTest {
@Mock AgentService agentService;
@Mock AgentMapper agentMapper;
@Mock ChatStreamTracker streamTracker;
@Mock ConversationService conversationService;
@Mock AuditEventService auditEventService;
@Spy SubagentRegistry subagentRegistry = new SubagentRegistry();
@InjectMocks DelegateAgentTool delegateAgentTool;
private final ObjectMapper objectMapper = new ObjectMapper();
@BeforeAll
static void initMyBatisPlusCache() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""),
AgentEntity.class);
}
@BeforeEach
void setUp() throws Exception {
var field = DelegateAgentTool.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(delegateAgentTool, objectMapper);
}
@AfterEach
void cleanup() {
while (DelegationContext.currentDepth() > 0) {
DelegationContext.exit();
}
ToolExecutionContext.clear();
}
private AgentEntity makeAgent(Long id, String name) {
AgentEntity agent = new AgentEntity();
agent.setId(id);
agent.setName(name);
agent.setEnabled(true);
agent.setWorkspaceId(1L);
agent.setAgentType("react");
return agent;
}
// ===== Full sequence: delegation_start → delegation_progress → delegation_end =====
@Test
@DisplayName("Single delegation produces start → progress → end event sequence")
void singleDelegationFullEventSequence() {
AgentEntity target = makeAgent(100L, "HelperAgent");
when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(target);
String parentConvId = "parent-conv-123";
ToolExecutionContext.set(parentConvId, "admin");
when(streamTracker.isRunning(parentConvId)).thenReturn(true);
// Capture the relay listener so we can simulate child events
AtomicReference> relayRef = new AtomicReference<>();
// Single + parallel delegation now route the relay through the batched API.
when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any()))
.thenAnswer(invocation -> {
relayRef.set(invocation.getArgument(4));
return (Runnable) () -> {};
});
// During chat(), simulate the child broadcasting a tool_call_started event
when(agentService.chatWithUsage(eq(100L), eq("summarize the report"), anyString(), any()))
.thenAnswer(invocation -> {
// The relay listener should have been registered by now — fire it
BiConsumer relay = relayRef.get();
assertNotNull(relay, "Relay should be registered before child chat starts");
relay.accept("tool_call_started", "{\"name\":\"searchWeb\"}");
relay.accept("tool_call_completed", "{\"name\":\"searchWeb\",\"success\":true}");
return ChatResult.contentOnly("The report shows growth of 15% YoY.");
});
// Act
String result = delegateAgentTool.delegateToAgent("HelperAgent", "summarize the report", null, null);
// Assert: result is successful
assertTrue(result.contains("15%"), "Should contain the child's response");
// Capture all broadcastObject calls
ArgumentCaptor convIdCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(String.class);
verify(streamTracker, atLeast(3)).broadcastObject(
convIdCaptor.capture(), eventCaptor.capture(), any());
List eventNames = eventCaptor.getAllValues();
// Verify full sequence: start → progress(es) → end
assertTrue(eventNames.size() >= 3,
"Should have at least 3 events (start + progress + end), got: " + eventNames);
assertEquals("delegation_start", eventNames.get(0),
"First event should be delegation_start");
// There should be at least one delegation_progress between start and end
List middle = eventNames.subList(1, eventNames.size() - 1);
assertTrue(middle.contains("delegation_progress"),
"Should have delegation_progress between start and end, got: " + eventNames);
assertEquals("delegation_end", eventNames.get(eventNames.size() - 1),
"Last event should be delegation_end");
// All events target the parent conversation
for (String convId : convIdCaptor.getAllValues()) {
assertEquals(parentConvId, convId, "Events should target parent conversation");
}
}
// ===== Parallel delegation event sequence =====
@Test
@DisplayName("Parallel delegation broadcasts delegation_start and delegation_end with parallel=true")
void parallelDelegationEventSequence() {
AgentEntity agentA = makeAgent(101L, "AgentA");
AgentEntity agentB = makeAgent(102L, "AgentB");
when(agentMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(agentA)
.thenReturn(agentB);
String parentConvId = "parent-parallel-456";
ToolExecutionContext.set(parentConvId, "admin");
when(streamTracker.isRunning(parentConvId)).thenReturn(true);
when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any()))
.thenReturn(() -> {});
when(agentService.chatWithUsage(eq(101L), anyString(), anyString(), any()))
.thenReturn(ChatResult.contentOnly("Result A"));
when(agentService.chatWithUsage(eq(102L), anyString(), anyString(), any()))
.thenReturn(ChatResult.contentOnly("Result B"));
String json = "[{\"agentName\":\"AgentA\",\"task\":\"task A\"},{\"agentName\":\"AgentB\",\"task\":\"task B\"}]";
// Act
String result = delegateAgentTool.delegateParallel(json, null);
assertTrue(result.contains("AgentA"), "Should mention AgentA");
assertTrue(result.contains("AgentB"), "Should mention AgentB");
// Capture events
ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(String.class);
verify(streamTracker, atLeast(2)).broadcastObject(
eq(parentConvId), eventCaptor.capture(), any());
List eventNames = eventCaptor.getAllValues();
assertEquals("delegation_start", eventNames.get(0), "First event should be delegation_start");
assertEquals("delegation_end", eventNames.get(eventNames.size() - 1),
"Last event should be delegation_end");
}
// ===== No events when parent inactive =====
@Test
@DisplayName("No events are broadcast when parent conversation is not active")
void noEventsWhenParentInactive() {
AgentEntity target = makeAgent(200L, "QuietAgent");
when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(target);
ToolExecutionContext.set("inactive-parent", "admin");
when(streamTracker.isRunning("inactive-parent")).thenReturn(false);
when(agentService.chatWithUsage(eq(200L), anyString(), anyString(), any()))
.thenReturn(ChatResult.contentOnly("done"));
// Act
delegateAgentTool.delegateToAgent("QuietAgent", "quiet task", null, null);
// Assert: no events broadcast, no relay registered
verify(streamTracker, never()).broadcastObject(anyString(), anyString(), any());
verify(streamTracker, never()).addEventRelay(anyString(), any());
verify(streamTracker, never())
.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any());
}
// ===== Relay only forwards recognized event types =====
@Test
@DisplayName("Relay ignores unrecognized event types, only forwards tool_call_started/completed/phase")
void relayFiltersEventTypes() {
AgentEntity target = makeAgent(300L, "FilterAgent");
when(agentMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(target);
String parentConvId = "parent-filter-789";
ToolExecutionContext.set(parentConvId, "admin");
when(streamTracker.isRunning(parentConvId)).thenReturn(true);
AtomicReference> relayRef = new AtomicReference<>();
// Single + parallel delegation now route the relay through the batched API.
when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any()))
.thenAnswer(invocation -> {
relayRef.set(invocation.getArgument(4));
return (Runnable) () -> {};
});
when(agentService.chatWithUsage(eq(300L), anyString(), anyString(), any()))
.thenAnswer(invocation -> {
BiConsumer relay = relayRef.get();
// These should produce delegation_progress:
relay.accept("tool_call_started", "{\"name\":\"search\"}");
relay.accept("phase", "{\"phase\":\"reasoning\"}");
// These should be ignored by the relay filter:
relay.accept("heartbeat", "{}");
relay.accept("token", "{\"text\":\"hello\"}");
return ChatResult.contentOnly("filtered result");
});
delegateAgentTool.delegateToAgent("FilterAgent", "filter task", null, null);
ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(String.class);
verify(streamTracker, atLeast(1)).broadcastObject(
eq(parentConvId), eventCaptor.capture(), any());
List events = eventCaptor.getAllValues();
long progressCount = events.stream().filter("delegation_progress"::equals).count();
// 2 recognized events → 2 progress broadcasts (heartbeat and token are filtered out)
assertEquals(2, progressCount,
"Should have exactly 2 delegation_progress events (tool_call_started + phase), got: " + events);
}
// ===== Nested delegation: grandchild events route to root with tree identity =====
@Test
@DisplayName("A child delegating a grandchild broadcasts to root with parentSubagentId + depth=2")
@SuppressWarnings("unchecked")
void nestedDelegationRoutesGrandchildToRootWithIdentity() {
AgentEntity child = makeAgent(100L, "Child");
AgentEntity grandchild = makeAgent(200L, "Grandchild");
when(agentMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(child) // root delegates Child
.thenReturn(grandchild); // Child delegates Grandchild
String rootConv = "root-conv";
ToolExecutionContext.set(rootConv, "admin");
when(streamTracker.isRunning(rootConv)).thenReturn(true);
when(streamTracker.addBatchedEventRelay(anyString(), anyString(), anyInt(), anyLong(), any()))
.thenReturn(() -> {});
// Capture each created child conversation + its immediate parent so we can
// assert the grandchild's immediate parent is the Child's conversation,
// not the root — the createChildConversation(childConvId, ..., parent) call.
List createdConvs = new java.util.ArrayList<>();
List createdParents = new java.util.ArrayList<>();
doAnswer(inv -> {
createdConvs.add(inv.getArgument(0));
createdParents.add(inv.getArgument(4));
return null;
}).when(conversationService).createChildConversation(
anyString(), anyLong(), anyString(), anyLong(), anyString());
// When the Child runs, the real ToolExecutionExecutor would switch the
// ToolExecutionContext to the Child's own conversation. Reproduce that so
// the grandchild's immediate parent resolves to childConv, while its
// events must still target rootConv (carried via DelegationContext).
when(agentService.chatWithUsage(eq(100L), anyString(), anyString(), any()))
.thenAnswer(inv -> {
String childConv = inv.getArgument(2);
ToolExecutionContext.set(childConv, "admin");
try {
return ChatResult.contentOnly(
delegateAgentTool.delegateToAgent("Grandchild", "gtask", null, null));
} finally {
ToolExecutionContext.set(rootConv, "admin");
}
});
when(agentService.chatWithUsage(eq(200L), anyString(), anyString(), any()))
.thenReturn(ChatResult.contentOnly("grandchild done"));
delegateAgentTool.delegateToAgent("Child", "ctask", null, null);
ArgumentCaptor convCap = ArgumentCaptor.forClass(String.class);
ArgumentCaptor evCap = ArgumentCaptor.forClass(String.class);
ArgumentCaptor