mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-05 23:29:44 +08:00
185 lines
8.2 KiB
Java
185 lines
8.2 KiB
Java
package vip.mate.agent.graph;
|
|
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.DisplayName;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
|
import org.springframework.ai.chat.messages.UserMessage;
|
|
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
|
|
import org.springframework.ai.chat.model.ChatModel;
|
|
import org.springframework.ai.chat.model.ChatResponse;
|
|
import org.springframework.ai.chat.model.Generation;
|
|
import org.springframework.ai.chat.prompt.Prompt;
|
|
import reactor.core.publisher.Flux;
|
|
import vip.mate.channel.web.ChatStreamTracker;
|
|
|
|
import java.util.List;
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.Mockito.mock;
|
|
import static org.mockito.Mockito.when;
|
|
|
|
/**
|
|
* Regression coverage for tool-call arguments sanitization.
|
|
*
|
|
* <p>Some OpenAI-compatible providers (aliyun-codingplan, others using the
|
|
* "coding" DashScope endpoint) reject the follow-up chat-completions request
|
|
* with HTTP 400 when the assistant message in history carries a tool call
|
|
* whose {@code function.arguments} is not parseable JSON. The streaming
|
|
* accumulator can produce empty or truncated argument strings, so the helper
|
|
* normalizes the final value to {@code "{}"} when it is missing or invalid.
|
|
*/
|
|
class NodeStreamingChatHelperToolCallArgsTest {
|
|
|
|
private ChatStreamTracker streamTracker;
|
|
|
|
@BeforeEach
|
|
void setUp() {
|
|
streamTracker = mock(ChatStreamTracker.class);
|
|
when(streamTracker.isStopRequested(any())).thenReturn(false);
|
|
}
|
|
|
|
private static Prompt smallPrompt() {
|
|
return new Prompt(List.of(new UserMessage("hi")));
|
|
}
|
|
|
|
private static ChatModel singleChunkModel(AssistantMessage msg) {
|
|
Generation gen = new Generation(msg, ChatGenerationMetadata.NULL);
|
|
ChatResponse resp = mock(ChatResponse.class);
|
|
when(resp.getResults()).thenReturn(List.of(gen));
|
|
when(resp.getResult()).thenReturn(gen);
|
|
when(resp.getMetadata()).thenReturn(null);
|
|
ChatModel m = mock(ChatModel.class);
|
|
when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
|
|
return m;
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("Empty tool-call arguments normalized to '{}'")
|
|
void emptyArguments_replacedWithEmptyJsonObject() {
|
|
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
|
"id-empty", "function", "list_skills", "");
|
|
AssistantMessage msg = AssistantMessage.builder()
|
|
.content("")
|
|
.toolCalls(List.of(tc))
|
|
.build();
|
|
|
|
var helper = new NodeStreamingChatHelper(streamTracker);
|
|
var result = helper.streamCall(singleChunkModel(msg), smallPrompt(),
|
|
"conv-empty-args", "reasoning");
|
|
|
|
assertTrue(result.hasToolCalls(), "tool call must survive");
|
|
assertEquals(1, result.toolCalls().size());
|
|
assertEquals("{}", result.toolCalls().get(0).arguments(),
|
|
"empty arguments must be replaced with '{}' so strict providers "
|
|
+ "(aliyun-codingplan, ...) accept the follow-up request");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("Truncated/invalid JSON arguments preserved for executor rejection")
|
|
void truncatedJsonArguments_preservedForExecutor() {
|
|
// Simulates a stream cut mid-token: model emitted '{"q":"hel' and stopped.
|
|
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
|
"id-truncated", "function", "search", "{\"q\":\"hel");
|
|
AssistantMessage msg = AssistantMessage.builder()
|
|
.content("")
|
|
.toolCalls(List.of(tc))
|
|
.build();
|
|
|
|
var helper = new NodeStreamingChatHelper(streamTracker);
|
|
var result = helper.streamCall(singleChunkModel(msg), smallPrompt(),
|
|
"conv-truncated-args", "reasoning");
|
|
|
|
assertTrue(result.hasToolCalls(), "tool call must survive");
|
|
assertEquals(1, result.toolCalls().size());
|
|
assertEquals("{\"q\":\"hel", result.toolCalls().get(0).arguments(),
|
|
"invalid streamed arguments must reach the executor so it can reject "
|
|
+ "the call without invoking the tool");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("Valid JSON arguments preserved verbatim")
|
|
void validJsonArguments_preservedAsIs() {
|
|
String validArgs = "{\"query\":\"foo\",\"limit\":5}";
|
|
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
|
"id-valid", "function", "search", validArgs);
|
|
AssistantMessage msg = AssistantMessage.builder()
|
|
.content("")
|
|
.toolCalls(List.of(tc))
|
|
.build();
|
|
|
|
var helper = new NodeStreamingChatHelper(streamTracker);
|
|
var result = helper.streamCall(singleChunkModel(msg), smallPrompt(),
|
|
"conv-valid-args", "reasoning");
|
|
|
|
assertTrue(result.hasToolCalls());
|
|
assertEquals(1, result.toolCalls().size());
|
|
assertEquals(validArgs, result.toolCalls().get(0).arguments(),
|
|
"valid JSON arguments must not be rewritten");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("Prompt-history tool call with empty arguments normalized before send")
|
|
void promptHistory_emptyArguments_normalized() {
|
|
// Mirrors a tool call replayed from persisted history (e.g. an earlier
|
|
// MCP call with no arguments): it never passes through the streaming
|
|
// aggregator, so the prompt-level pass must repair it.
|
|
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
|
"id-hist", "function", "mcp_excel_import", "");
|
|
AssistantMessage historyMsg = AssistantMessage.builder()
|
|
.content("calling tool")
|
|
.toolCalls(List.of(tc))
|
|
.build();
|
|
Prompt prompt = new Prompt(List.of(new UserMessage("hi"), historyMsg));
|
|
|
|
Prompt normalized = NodeStreamingChatHelper.normalizeToolCallArguments(prompt);
|
|
|
|
AssistantMessage out = (AssistantMessage) normalized.getInstructions().get(1);
|
|
assertEquals(1, out.getToolCalls().size());
|
|
assertEquals("{}", out.getToolCalls().get(0).arguments(),
|
|
"replayed empty arguments must be normalized to '{}'");
|
|
assertEquals("calling tool", out.getText(), "assistant content must be preserved");
|
|
assertEquals("mcp_excel_import", out.getToolCalls().get(0).name(),
|
|
"tool name must be preserved");
|
|
assertEquals("id-hist", out.getToolCalls().get(0).id(),
|
|
"tool call id must be preserved so the tool_call pairing holds");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("Prompt-history invalid arguments are normalized before provider replay")
|
|
void promptHistory_invalidArguments_normalizedBeforeSend() {
|
|
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
|
"id-hist-invalid", "function", "write_file", "{\"filePath\":\"x");
|
|
AssistantMessage historyMsg = AssistantMessage.builder()
|
|
.content("")
|
|
.toolCalls(List.of(tc))
|
|
.build();
|
|
|
|
Prompt normalized = NodeStreamingChatHelper.normalizeToolCallArguments(
|
|
new Prompt(List.of(new UserMessage("hi"), historyMsg)));
|
|
|
|
AssistantMessage out = (AssistantMessage) normalized.getInstructions().get(1);
|
|
assertEquals("{}", out.getToolCalls().get(0).arguments(),
|
|
"strict providers must never receive invalid JSON in replayed history");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("Prompt with only valid tool-call arguments returned unchanged")
|
|
void promptHistory_validArguments_returnsSameInstance() {
|
|
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
|
"id-ok", "function", "search", "{\"q\":\"x\"}");
|
|
AssistantMessage historyMsg = AssistantMessage.builder()
|
|
.content("")
|
|
.toolCalls(List.of(tc))
|
|
.build();
|
|
Prompt prompt = new Prompt(List.of(new UserMessage("hi"), historyMsg));
|
|
|
|
Prompt normalized = NodeStreamingChatHelper.normalizeToolCallArguments(prompt);
|
|
|
|
assertTrue(normalized == prompt,
|
|
"a prompt that needs no fix must be returned unchanged (no copy)");
|
|
}
|
|
}
|