mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-03 22:45:32 +08:00
204 lines
10 KiB
Java
204 lines
10 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 java.util.Map;
|
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.Mockito.mock;
|
|
import static org.mockito.Mockito.when;
|
|
|
|
/** Regression coverage for reasoning loops, extended budgets, and tool transitions. */
|
|
class NodeStreamingChatHelperThinkingCapTest {
|
|
|
|
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("thinking >= cap + tool_call in same chunk: cap must NOT trigger; tool_call survives")
|
|
void thinkingAndToolCallSameChunk_doesNotTripSoftCap() {
|
|
// Build a single chunk that carries 40k thinking (well above the
|
|
// 32k cap) AND a tool call. With the buggy ordering this would
|
|
// dispose before accumulateToolCalls runs and the helper would
|
|
// return a partial "thinking_only_no_content" result.
|
|
String hugeThinking = "x".repeat(40_000);
|
|
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
|
"id-1", "function", "search", "{\"q\":\"foo\"}");
|
|
AssistantMessage msg = AssistantMessage.builder()
|
|
.content("")
|
|
.toolCalls(List.of(tc))
|
|
.properties(Map.of("reasoningContent", hugeThinking))
|
|
.build();
|
|
|
|
ChatModel m = singleChunkModel(msg);
|
|
var helper = new NodeStreamingChatHelper(streamTracker);
|
|
|
|
var result = helper.streamCall(m, smallPrompt(), "conv-thinking-tc", "reasoning");
|
|
|
|
assertTrue(result.hasToolCalls(),
|
|
"Tool call accompanying huge thinking in the same chunk must survive");
|
|
assertEquals(1, result.toolCalls().size());
|
|
assertEquals("search", result.toolCalls().get(0).name());
|
|
assertFalse(result.partial(),
|
|
"Result must not be marked partial when a tool_call was observed in the same chunk");
|
|
assertNotEquals("thinking_only_no_content", result.errorMessage(),
|
|
"Soft cap must not fire when the chunk carrying huge thinking also carried a tool call");
|
|
}
|
|
|
|
@Test
|
|
@DisplayName("thinking >= cap with NO tool_call and NO content: cap fires, result is partial+thinking_only_no_content")
|
|
void thinkingOnlyNoContent_capFires() {
|
|
// Symmetric positive case: confirms the cap still triggers in the
|
|
// genuine "深度思考 ... never finishes" scenario the cap was added for.
|
|
String hugeThinking = "y".repeat(40_000);
|
|
AssistantMessage msg = AssistantMessage.builder()
|
|
.content("")
|
|
.properties(Map.of("reasoningContent", hugeThinking))
|
|
.build();
|
|
|
|
ChatModel m = singleChunkModel(msg);
|
|
var helper = new NodeStreamingChatHelper(streamTracker);
|
|
|
|
var result = helper.streamCall(m, smallPrompt(), "conv-thinking-only", "reasoning");
|
|
|
|
assertFalse(result.hasToolCalls());
|
|
assertTrue(result.partial(), "Cap should mark the result as partial");
|
|
assertEquals("thinking_only_no_content", result.errorMessage());
|
|
assertEquals(hugeThinking, result.thinking(),
|
|
"Thinking transcript is preserved so the UI can show it in a collapse panel");
|
|
}
|
|
@Test
|
|
void diverseLongThinkingCanReachAnswerInLaterChunk() {
|
|
assertLongThinkingProgress(AssistantMessage.builder().content("Finished analysis").build());
|
|
}
|
|
|
|
@Test
|
|
void diverseLongThinkingCanReachToolInLaterChunk() {
|
|
assertLongThinkingProgress(AssistantMessage.builder().content("")
|
|
.toolCalls(List.of(new AssistantMessage.ToolCall("later", "function", "search", "{}"))).build());
|
|
}
|
|
|
|
private void assertLongThinkingProgress(AssistantMessage progress) {
|
|
StringBuilder reasoning = new StringBuilder();
|
|
for (int i = 0; i < 1400; i++) {
|
|
reasoning.append("Consider case ").append(i).append(": compare evidence ")
|
|
.append(i * 17).append(" with alternative ").append(i * 31).append(".\n");
|
|
}
|
|
assertTrue(reasoning.length() > 32768);
|
|
var thinking = AssistantMessage.builder().content("")
|
|
.properties(Map.of("reasoningContent", reasoning.toString())).build();
|
|
var model = mock(ChatModel.class);
|
|
when(model.stream(any(Prompt.class))).thenReturn(Flux.just(
|
|
new ChatResponse(List.of(new Generation(thinking))),
|
|
new ChatResponse(List.of(new Generation(progress)))));
|
|
var result = new NodeStreamingChatHelper(streamTracker)
|
|
.streamCall(model, smallPrompt(), "conv-long-valid", "reasoning");
|
|
assertFalse(result.partial(), "Diverse reasoning must be allowed to finish");
|
|
assertEquals(reasoning.toString(), result.thinking());
|
|
assertEquals(progress.getText(), result.text());
|
|
assertEquals(progress.hasToolCalls(), result.hasToolCalls());
|
|
}
|
|
|
|
@Test
|
|
void diverseThinkingStillHasFiniteMemorySafetyBound() {
|
|
StringBuilder reasoning = new StringBuilder();
|
|
for (int i = 0; i < 15000; i++) {
|
|
reasoning.append("Evidence ").append(i).append(" differs from ").append(i * 31)
|
|
.append(" so compare the next independent case.\n");
|
|
}
|
|
var message = AssistantMessage.builder().content("")
|
|
.properties(Map.of("reasoningContent", reasoning.toString())).build();
|
|
var result = new NodeStreamingChatHelper(streamTracker).streamCall(
|
|
singleChunkModel(message), smallPrompt(), "conv-memory-bound", "reasoning");
|
|
assertTrue(result.partial());
|
|
assertEquals("thinking_only_no_content", result.errorMessage());
|
|
}
|
|
|
|
@Test
|
|
void largerConfiguredTokenBudgetRaisesThinkingSafetyBound() {
|
|
StringBuilder reasoning = new StringBuilder();
|
|
for (int i = 0; i < 15000; i++) {
|
|
reasoning.append("Evidence ").append(i).append(" differs from ").append(i * 31)
|
|
.append(" so compare the next independent case.\n");
|
|
}
|
|
var thinking = AssistantMessage.builder().content("")
|
|
.properties(Map.of("reasoningContent", reasoning.toString())).build();
|
|
var model = mock(ChatModel.class);
|
|
when(model.stream(any(Prompt.class))).thenReturn(Flux.just(
|
|
new ChatResponse(List.of(new Generation(thinking))),
|
|
new ChatResponse(List.of(new Generation(new AssistantMessage("Finished"))))));
|
|
var prompt = new Prompt(List.of(new UserMessage("hi")),
|
|
org.springframework.ai.chat.prompt.ChatOptions.builder().maxTokens(100000).build());
|
|
var result = new NodeStreamingChatHelper(streamTracker)
|
|
.streamCall(model, prompt, "conv-configured-budget", "reasoning");
|
|
assertFalse(result.partial());
|
|
assertEquals("Finished", result.text());
|
|
}
|
|
|
|
@Test
|
|
void reasoningOnlyTokenLimitIsIncompleteRatherThanEmptyOrSuccessful() {
|
|
var message = AssistantMessage.builder().content("")
|
|
.properties(Map.of("reasoningContent", "still reasoning")).build();
|
|
var generation = new Generation(message, ChatGenerationMetadata.builder().finishReason("length").build());
|
|
var model = mock(ChatModel.class);
|
|
when(model.stream(any(Prompt.class))).thenReturn(Flux.just(new ChatResponse(List.of(generation))));
|
|
var result = new NodeStreamingChatHelper(streamTracker)
|
|
.streamCall(model, smallPrompt(), "conv-length", "reasoning");
|
|
assertTrue(result.partial());
|
|
assertEquals("thinking_token_limit", result.errorMessage());
|
|
assertEquals("still reasoning", result.thinking());
|
|
assertEquals("", result.text());
|
|
assertEquals(NodeStreamingChatHelper.ErrorType.NONE, result.errorType());
|
|
}
|
|
|
|
@Test
|
|
void tokenLimitWithContentOrToolsIsNotMisclassifiedAsThinkingOnly() {
|
|
for (var message : List.of(
|
|
AssistantMessage.builder().content("partial answer")
|
|
.properties(Map.of("reasoningContent", "reasoning")).build(),
|
|
AssistantMessage.builder().content("").properties(Map.of("reasoningContent", "reasoning"))
|
|
.toolCalls(List.of(new AssistantMessage.ToolCall("id", "function", "search", "{}"))).build())) {
|
|
var generation = new Generation(message, ChatGenerationMetadata.builder().finishReason("length").build());
|
|
var model = mock(ChatModel.class);
|
|
when(model.stream(any(Prompt.class))).thenReturn(Flux.just(new ChatResponse(List.of(generation))));
|
|
var result = new NodeStreamingChatHelper(streamTracker)
|
|
.streamCall(model, smallPrompt(), "conv-length-progress", "reasoning");
|
|
assertNotEquals("thinking_token_limit", result.errorMessage());
|
|
}
|
|
}
|
|
|
|
}
|