From 63e30ab850b28c975658a27d682bbef1cd91e8e3 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 18 May 2026 16:34:07 +0200 Subject: [PATCH] test(assistant): aggiunti test su draft plan minimo e cancellazione chiamata --- .../controllers/AssistantControllerTest.java | 335 +++++++++++++++++- 1 file changed, 334 insertions(+), 1 deletion(-) diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/AssistantControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/AssistantControllerTest.java index 9812ec5..8b98d2b 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/AssistantControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/AssistantControllerTest.java @@ -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 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 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 answer = invocation -> { String prompt = invocation.getArgument(1, String.class); @@ -630,6 +888,40 @@ public class AssistantControllerTest { .thenAnswer(answer); } + private void mockAssistantResponsesWithEmptyPlan() { + Answer 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 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 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);