test(assistant): aggiunti test su draft plan minimo e cancellazione chiamata

This commit is contained in:
Lucio Lelii 2026-05-18 16:34:07 +02:00
parent c005a09b6f
commit 63e30ab850
1 changed files with 334 additions and 1 deletions

View File

@ -6,6 +6,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
@ -163,6 +166,23 @@ public class AssistantControllerTest {
assertEquals(0, response.flow().flow().getConnections().size());
}
@Test
public void draftUsesMinimalLlmPlanWhenAssistantReturnsNoBlocks() {
mockAssistantResponsesWithEmptyPlan();
AssistantFlowResponse response = assistantController.draft(
new AssistantGenerationRequest(
"create a flow to summarize customer emails",
MODEL,
1));
assertNotNull(response);
assertTrue(response.valid());
assertTrue(response.validationErrors().isEmpty());
assertEquals(1, response.flow().flow().getBlocks().size());
assertEquals(LLMBlockType.TYPE, response.flow().flow().getBlocks().getFirst().getType().getName());
}
@Test
public void draftIgnoresAssistantProvidedLlmDescriptorPlaceholder() {
mockAssistantResponsesWithLlmDescriptorPlaceholder();
@ -336,6 +356,46 @@ public class AssistantControllerTest {
assertEquals(accepted.callId(), updatedSession.lastCallId());
}
@Test
public void cancelRunningSessionCallStopsAssistantAndDoesNotUpdateFlow() throws Exception {
CountDownLatch assistantStarted = new CountDownLatch(1);
AtomicBoolean interrupted = new AtomicBoolean(false);
mockBlockingAssistantResponse(assistantStarted, interrupted);
LoginEntity user = new LoginEntity("testuser", "testpassword");
AssistantSessionView session = assistantController.createSession(
new AssistantSessionCreateRequest(MODEL),
user);
AssistantCallAcceptedResponse accepted = assistantController.submitMessage(
session.id(),
new AssistantSessionMessageRequest("Create a slow assistant flow"),
user);
assertTrue(assistantStarted.await(5, TimeUnit.SECONDS));
AssistantCallView cancelled = assistantController.cancelCall(accepted.callId(), user);
assertEquals(AssistantCallStatus.CANCELLED, cancelled.status());
assertEquals("cancelled", cancelled.phase());
assertEquals("Assistant request cancelled", cancelled.progressMessage());
assertNotNull(cancelled.updatedAt());
AssistantCallView reloaded = assistantController.getCall(accepted.callId(), user);
assertEquals(AssistantCallStatus.CANCELLED, reloaded.status());
assertEquals("cancelled", reloaded.phase());
AssistantSessionView updatedSession = assistantController.getSession(session.id(), user);
assertEquals(accepted.callId(), updatedSession.lastCallId());
assertEquals(null, updatedSession.currentFlow());
assertTrue(updatedSession.messages().stream()
.anyMatch(message -> "Assistant request cancelled.".equals(message.content())));
waitForInterrupt(interrupted);
AssistantCallView idempotent = assistantController.cancelCall(accepted.callId(), user);
assertEquals(AssistantCallStatus.CANCELLED, idempotent.status());
}
@Test
public void sessionRefineStoresReloadableCurrentFlowWithNewValidationNode() throws Exception {
mockFileIndexQueryThenValidationRefineResponses();
@ -387,6 +447,56 @@ public class AssistantControllerTest {
assertSharedMemoryFlowIsExecutable(afterRefine.currentFlow());
}
@Test
public void draftNormalizesIndexAndCreateSessionBlockAsSharedMemoryProducer() {
mockIndexCreateSessionResponsesWithConsumerFlags();
AssistantFlowResponse response = assistantController.draft(
new AssistantGenerationRequest(
"download a file, index it in a shared MCP session, then query the indexed data",
MODEL,
1));
assertNotNull(response);
assertTrue(response.valid());
Block<?> indexBlock = response.flow().flow().getBlocks().stream()
.filter(block -> "Index file and create session".equals(block.getName()))
.findFirst()
.orElseThrow();
Block<?> queryBlock = response.flow().flow().getBlocks().stream()
.filter(block -> "Query indexed data".equals(block.getName()))
.findFirst()
.orElseThrow();
MCPAgentBlockConfiguration indexConfiguration =
(MCPAgentBlockConfiguration) indexBlock.getSpecificConfiguration();
MCPAgentBlockConfiguration queryConfiguration =
(MCPAgentBlockConfiguration) queryBlock.getSpecificConfiguration();
assertTrue(indexConfiguration.getShareSession());
assertFalse(indexConfiguration.getUseSharedSession());
assertEquals("sharedMemorySession", indexConfiguration.getSharedSessionName());
assertFalse(queryConfiguration.getShareSession());
assertTrue(queryConfiguration.getUseSharedSession());
assertEquals("sharedMemorySession", queryConfiguration.getSharedSessionRef());
}
@Test
public void draftDoesNotFailWhenSharedMemoryFlagsAreIncomplete() {
mockSharedMemoryResponsesWithIncompleteFlags();
AssistantFlowResponse response = assistantController.draft(
new AssistantGenerationRequest(
"index data then query it using shared memory across workflow steps",
MODEL,
1));
assertNotNull(response);
assertTrue(response.valid());
assertEquals(2, response.flow().flow().getBlocks().size());
}
private void assertSharedMemoryFlowIsExecutable(FlowCreateRequest flow) {
Block<?> indexBlock = flow.flow().getBlocks().stream()
.filter(block -> "Index file".equals(block.getName()))
@ -510,6 +620,154 @@ public class AssistantControllerTest {
.thenAnswer(answer);
}
private void mockIndexCreateSessionResponsesWithConsumerFlags() {
Answer<String> answer = invocation -> {
String prompt = invocation.getArgument(1, String.class);
if (prompt.contains("TASK: PLAN")) {
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "Planned download, indexing session, and query.",
"plan", java.util.Map.of(
"name", "Index and query file",
"description", "Download a file, index it, and query indexed data.",
"blocks", java.util.List.of(
java.util.Map.of(
"blockId", "b1",
"blockType", "HTTPServerCall",
"purpose", "DownloadFile"),
java.util.Map.of(
"blockId", "b2",
"blockType", "MCPAgent",
"purpose", "IndexFileAndCreateSession"),
java.util.Map.of(
"blockId", "b3",
"blockType", "MCPAgent",
"purpose", "QueryIndexedData")))));
}
if (prompt.contains("TASK: BLOCK_CONFIG")
&& prompt.contains("Current block to configure:\n{\n \"blockId\" : \"b1\"")) {
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "Configured file download.",
"block", java.util.Map.of(
"blockId", "b1",
"name", "Download file",
"config", java.util.Map.of(
"url", "https://example.com/file.txt",
"method", "GET",
"requiresAuthorization", false,
"authorizationType", "NONE"))));
}
if (prompt.contains("TASK: BLOCK_CONFIG")
&& prompt.contains("Current block to configure:\n{\n \"blockId\" : \"b2\"")) {
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "Configured indexing but incorrectly marked it as a consumer.",
"block", java.util.Map.of(
"blockId", "b2",
"name", "Index file and create session",
"config", java.util.Map.of(
"prompt", "Index the downloaded file content: ${{file_content}}",
"shareSession", false,
"sharedSessionName", "sharedMemorySession",
"useSharedSession", true,
"sharedSessionRef", "sharedMemorySession"))));
}
if (prompt.contains("TASK: BLOCK_CONFIG")
&& prompt.contains("Current block to configure:\n{\n \"blockId\" : \"b3\"")) {
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "Configured query consumer.",
"block", java.util.Map.of(
"blockId", "b3",
"name", "Query indexed data",
"config", java.util.Map.of(
"prompt", "The shared state is ready: ${{state_ready}}. Query it: ${{query}}",
"useSharedSession", true,
"sharedSessionRef", "sharedMemorySession"))));
}
if (prompt.contains("TASK: CONNECTIONS")) {
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "Connected download, indexing, and query.",
"connections", java.util.List.of(
java.util.Map.of(
"fromBlockId", "b1",
"fromOutput", "response",
"toBlockId", "b2",
"toInput", "file_content"),
java.util.Map.of(
"fromBlockId", "b2",
"fromOutput", "response",
"toBlockId", "b3",
"toInput", "state_ready"))));
}
throw new IllegalStateException("Unexpected assistant prompt:\n" + prompt);
};
Mockito.when(internalOllamaLLMProvider.generate(Mockito.eq(MODEL), Mockito.anyString()))
.thenAnswer(answer);
Mockito.when(internalOllamaLLMProvider.generateJson(Mockito.eq(MODEL), Mockito.anyString()))
.thenAnswer(answer);
}
private void mockSharedMemoryResponsesWithIncompleteFlags() {
Answer<String> answer = invocation -> {
String prompt = invocation.getArgument(1, String.class);
if (prompt.contains("TASK: PLAN")) {
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "Planned shared-memory steps with incomplete flags.",
"plan", java.util.Map.of(
"name", "Shared memory index and query",
"description", "Index data, keep shared memory, and query it later.",
"blocks", java.util.List.of(
java.util.Map.of(
"blockId", "b1",
"blockType", "MCPAgent",
"purpose", "PrepareSharedContext"),
java.util.Map.of(
"blockId", "b2",
"blockType", "MCPAgent",
"purpose", "UseSharedContext")))));
}
if (prompt.contains("TASK: BLOCK_CONFIG")
&& prompt.contains("Current block to configure:\n{\n \"blockId\" : \"b1\"")) {
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "Configured first shared-context step with consumer-only flags.",
"block", java.util.Map.of(
"blockId", "b1",
"name", "Prepare shared context",
"config", java.util.Map.of(
"prompt", "Prepare context from input: ${{input}}",
"useSharedSession", true,
"sharedSessionRef", "sharedMemorySession"))));
}
if (prompt.contains("TASK: BLOCK_CONFIG")
&& prompt.contains("Current block to configure:\n{\n \"blockId\" : \"b2\"")) {
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "Configured second shared-context step with consumer-only flags.",
"block", java.util.Map.of(
"blockId", "b2",
"name", "Use shared context",
"config", java.util.Map.of(
"prompt", "Use prepared context: ${{state_ready}}",
"useSharedSession", true,
"sharedSessionRef", "sharedMemorySession"))));
}
if (prompt.contains("TASK: CONNECTIONS")) {
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "Connected the two advisory shared-memory steps.",
"connections", java.util.List.of(
java.util.Map.of(
"fromBlockId", "b1",
"fromOutput", "response",
"toBlockId", "b2",
"toInput", "state_ready"))));
}
throw new IllegalStateException("Unexpected assistant prompt:\n" + prompt);
};
Mockito.when(internalOllamaLLMProvider.generate(Mockito.eq(MODEL), Mockito.anyString()))
.thenAnswer(answer);
Mockito.when(internalOllamaLLMProvider.generateJson(Mockito.eq(MODEL), Mockito.anyString()))
.thenAnswer(answer);
}
private void mockTruncatedBlockConfigurationResponse() {
Answer<String> answer = invocation -> {
String prompt = invocation.getArgument(1, String.class);
@ -630,6 +888,40 @@ public class AssistantControllerTest {
.thenAnswer(answer);
}
private void mockAssistantResponsesWithEmptyPlan() {
Answer<String> answer = invocation -> {
String prompt = invocation.getArgument(1, String.class);
if (prompt.contains("TASK: PLAN")) {
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "The model returned an empty plan.",
"plan", java.util.Map.of(
"name", "Email summarization",
"description", "Summarize customer emails.",
"blocks", java.util.List.of())));
}
if (prompt.contains("TASK: BLOCK_CONFIG")) {
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "Configured the fallback summarizer block.",
"block", java.util.Map.of(
"blockId", "b1",
"name", "Email summarizer",
"config", java.util.Map.of(
"prompt", "Summarize the customer email: ${{email}}"))));
}
if (prompt.contains("TASK: CONNECTIONS")) {
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "No extra connections needed.",
"connections", java.util.List.of()));
}
throw new IllegalStateException("Unexpected assistant prompt:\n" + prompt);
};
Mockito.when(internalOllamaLLMProvider.generate(Mockito.eq(MODEL), Mockito.anyString()))
.thenAnswer(answer);
Mockito.when(internalOllamaLLMProvider.generateJson(Mockito.eq(MODEL), Mockito.anyString()))
.thenAnswer(answer);
}
private void mockAssistantResponsesWithLlmDescriptorPlaceholder() {
Answer<String> answer = invocation -> {
String prompt = invocation.getArgument(1, String.class);
@ -934,11 +1226,52 @@ public class AssistantControllerTest {
.thenAnswer(answer);
}
private void mockBlockingAssistantResponse(CountDownLatch assistantStarted, AtomicBoolean interrupted) {
Answer<String> answer = invocation -> {
assistantStarted.countDown();
try {
Thread.sleep(60_000);
} catch (InterruptedException e) {
interrupted.set(true);
Thread.currentThread().interrupt();
throw new RuntimeException("assistant call interrupted", e);
}
return TestAssistantResponses.wrap(java.util.Map.of(
"rationale", "Should not be used after cancellation.",
"plan", java.util.Map.of(
"name", "Cancelled flow",
"description", "This response should not update the session.",
"blocks", java.util.List.of(
java.util.Map.of(
"blockId", "b1",
"blockType", "LLMBlock",
"purpose", "Cancelled block")))));
};
Mockito.when(internalOllamaLLMProvider.generate(Mockito.eq(MODEL), Mockito.anyString()))
.thenAnswer(answer);
Mockito.when(internalOllamaLLMProvider.generateJson(Mockito.eq(MODEL), Mockito.anyString()))
.thenAnswer(answer);
}
private void waitForInterrupt(AtomicBoolean interrupted) throws Exception {
long deadline = System.currentTimeMillis() + 5_000;
while (System.currentTimeMillis() < deadline) {
if (interrupted.get()) {
return;
}
Thread.sleep(25);
}
throw new AssertionError("Assistant task was not interrupted");
}
private AssistantCallView waitForCallCompletion(String callId, LoginEntity user) throws Exception {
long deadline = System.currentTimeMillis() + 15_000;
while (System.currentTimeMillis() < deadline) {
AssistantCallView call = assistantController.getCall(callId, user);
if (call.status() == AssistantCallStatus.COMPLETED || call.status() == AssistantCallStatus.FAILED) {
if (call.status() == AssistantCallStatus.COMPLETED
|| call.status() == AssistantCallStatus.FAILED
|| call.status() == AssistantCallStatus.CANCELLED) {
return call;
}
Thread.sleep(25);