package service import ( "encoding/json" "testing" agenttools "github.com/Tencent/WeKnora/internal/agent/tools" "github.com/Tencent/WeKnora/internal/models/chat" "github.com/Tencent/WeKnora/internal/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestBuildUserHistoryMessage_IgnoresLegacyRenderedContent verifies that old // prompt/context snapshots never re-enter the current model context. func TestBuildUserHistoryMessage_IgnoresLegacyRenderedContent(t *testing.T) { msg := &types.Message{ Role: "user", Content: "what about the chart?", RenderedContent: "what about the chart? [augmented]", Images: types.MessageImages{ {Caption: "a bar chart"}, }, } got := buildUserHistoryMessage(msg) assert.Equal(t, "user", got.Role) assert.Equal(t, "what about the chart?\n\n[用户上传图片内容]\na bar chart", got.Content) assert.NotContains(t, got.Content, "[augmented]") } func TestBuildUserHistoryMessage_FallsBackToContentWithCaptions(t *testing.T) { msg := &types.Message{ Role: "user", Content: "look at this", Images: types.MessageImages{ {Caption: "a bar chart"}, {Caption: "a pie chart"}, }, } got := buildUserHistoryMessage(msg) assert.Equal(t, "user", got.Role) assert.Equal(t, "look at this\n\n[用户上传图片内容]\na bar chart\na pie chart", got.Content) } // TestBuildUserHistoryMessage_AppendsAttachmentsWhenNoRenderedContent covers // the Agent-mode multi-turn path: AgentQA does not persist RenderedContent, so // the next turn's history must reconstruct the original attachment prompt from // the stored Attachments column. Otherwise, follow-up questions like "what is // in there?" lose all reference to the uploaded file. func TestBuildUserHistoryMessage_AppendsAttachmentsWhenNoRenderedContent(t *testing.T) { msg := &types.Message{ Role: "user", Content: "summarize this", Attachments: types.MessageAttachments{ { FileName: "report.pdf", FileType: ".pdf", FileSize: 2048, Content: "hello world", }, }, } got := buildUserHistoryMessage(msg) assert.Equal(t, "user", got.Role) assert.Contains(t, got.Content, "summarize this") assert.Contains(t, got.Content, ``) assert.Contains(t, got.Content, "hello world") } // TestBuildUserHistoryMessage_RenderedContentRebuildsAttachment verifies that // canonical attachment data is retained while the legacy rendered snapshot is // discarded. func TestBuildUserHistoryMessage_RenderedContentRebuildsAttachment(t *testing.T) { msg := &types.Message{ Role: "user", Content: "summarize this", RenderedContent: "summarize this [with retrieval context already included]", Attachments: types.MessageAttachments{ {FileName: "report.pdf", FileType: ".pdf", Content: "hello"}, }, } got := buildUserHistoryMessage(msg) assert.Contains(t, got.Content, "summarize this") assert.NotContains(t, got.Content, "[with retrieval context already included]") assert.Contains(t, got.Content, "… blocks stripped, so // internal-reasoning text doesn't leak into the next turn's context. func TestBuildAssistantHistoryMessages_StripsThinkBlocks(t *testing.T) { msg := &types.Message{ Role: "assistant", Content: "plotting...The answer is 42.", } got := buildAssistantHistoryMessages(msg) if assert.Len(t, got, 1) { assert.Equal(t, "The answer is 42.", got[0].Content) } } // TestBuildAssistantHistoryMessages_ToolCallsExpandIntoOpenAIShape covers the // option-B replay: non-terminal tool calls from AgentSteps become proper // assistant_with_tool_calls + tool messages, and the canonical final answer is // appended last. final_answer entries are filtered because they're terminal // signals — the trailing assistant message already carries the answer. func TestBuildAssistantHistoryMessages_ToolCallsExpandIntoOpenAIShape(t *testing.T) { msg := &types.Message{ Role: "assistant", Content: "Found 3 matches in the docs.", AgentSteps: types.AgentSteps{ { Iteration: 0, Thought: "Let me search.", ToolCalls: []types.ToolCall{ { ID: "call_1", Name: agenttools.ToolKnowledgeSearch, Args: map[string]interface{}{"query": "foo"}, Result: &types.ToolResult{ Success: true, Output: "doc A, doc B, doc C", }, }, }, }, { Iteration: 1, Thought: "", ToolCalls: []types.ToolCall{ { // Legacy persisted data: old conversations recorded a // final_answer terminal tool call. The filter still drops it. ID: "call_2", Name: "final_answer", Args: map[string]interface{}{"answer": "Found 3 matches in the docs."}, Result: &types.ToolResult{Success: true}, }, }, }, }, } got := buildAssistantHistoryMessages(msg) if !assert.Len(t, got, 3) { return } // 1. assistant message announcing the tool call assert.Equal(t, "assistant", got[0].Role) assert.Equal(t, "Let me search.", got[0].Content) if assert.Len(t, got[0].ToolCalls, 1) { assert.Equal(t, "call_1", got[0].ToolCalls[0].ID) assert.Equal(t, agenttools.ToolKnowledgeSearch, got[0].ToolCalls[0].Function.Name) assert.Contains(t, got[0].ToolCalls[0].Function.Arguments, "foo") } // 2. tool result paired with the call ID assert.Equal(t, "tool", got[1].Role) assert.Equal(t, "call_1", got[1].ToolCallID) assert.Equal(t, "doc A, doc B, doc C", got[1].Content) // 3. canonical final answer (final_answer tool call itself was filtered) assert.Equal(t, "assistant", got[2].Role) assert.Equal(t, "Found 3 matches in the docs.", got[2].Content) assert.Empty(t, got[2].ToolCalls) } // A fast-answer turn persists its retrieval stages so the UI can redraw the // timeline after a reload. Those calls came from the pipeline, not the model, so // replaying them would hand the model tool calls it never made — and, in a // KnowledgeQA turn, tools it was never offered. func TestBuildAssistantHistoryMessages_SkipsPipelineTimelineToolCalls(t *testing.T) { msg := &types.Message{ Role: "assistant", Content: "你好!很高兴见到你。", AgentSteps: types.AgentSteps{ { Iteration: 0, ToolCalls: []types.ToolCall{ { ID: types.PipelineToolCallIDPrefix + "abc", Name: agenttools.ToolKnowledgeSearch, Args: map[string]interface{}{"query": "你好"}, Result: &types.ToolResult{Success: true, Output: "未检索到相关内容"}, }, }, }, }, } got := buildAssistantHistoryMessages(msg) if !assert.Len(t, got, 1) { return } assert.Equal(t, "assistant", got[0].Role) assert.Equal(t, "你好!很高兴见到你。", got[0].Content) assert.Empty(t, got[0].ToolCalls) } // TestBuildAssistantHistoryMessages_ToolFailureSurfacesAsError ensures a // historical failed tool call is replayed as an "Error: …" tool message so the // model can see (and avoid retrying) the same failure path. func TestBuildAssistantHistoryMessages_ToolFailureSurfacesAsError(t *testing.T) { msg := &types.Message{ Role: "assistant", Content: "Sorry, I could not complete the search.", AgentSteps: types.AgentSteps{ { Iteration: 0, Thought: "Trying search.", ToolCalls: []types.ToolCall{ { ID: "call_err", Name: agenttools.ToolKnowledgeSearch, Args: map[string]interface{}{"query": "x"}, Result: &types.ToolResult{ Success: false, Error: "kb unreachable", }, }, }, }, }, } got := buildAssistantHistoryMessages(msg) if !assert.Len(t, got, 3) { return } assert.Equal(t, chat.Message{ Role: "tool", Content: "Error: kb unreachable", ToolCallID: "call_err", Name: agenttools.ToolKnowledgeSearch, }, got[1]) } func TestBuildAssistantHistoryMessages_SkillScriptFailureKeepsStdout(t *testing.T) { stdout := `{"chart":{"success":false,"error":{"error":"X轴字段不存在:工作项目"}}}` msg := &types.Message{ Role: "assistant", Content: "I will retry with a different axis.", AgentSteps: types.AgentSteps{ { Iteration: 0, Thought: "Plot the chart.", ToolCalls: []types.ToolCall{ { ID: "call_skill", Name: agenttools.ToolExecuteSkillScript, Args: map[string]interface{}{"skill_name": "smart-charts", "script_path": "scripts/cli.py"}, Result: &types.ToolResult{ Success: false, Output: "=== Script Execution ===\n" + stdout, Error: "Script exited with code 1", Data: map[string]interface{}{ "display_type": "shell_exec", "stdout": stdout, "exit_code": 1, }, }, }, }, }, }, } got := buildAssistantHistoryMessages(msg) require.Len(t, got, 3) assert.Equal(t, "tool", got[1].Role) assert.Contains(t, got[1].Content, "X轴字段不存在:工作项目") assert.Contains(t, got[1].Content, "Error: Script exited with code 1") } // TestFilterNonTerminalToolCalls confirms a legacy final_answer entry is // dropped — every other tool (KB search, web search, MCP tools…) must survive. func TestFilterNonTerminalToolCalls(t *testing.T) { in := []types.ToolCall{ {Name: agenttools.ToolKnowledgeSearch}, {Name: "final_answer"}, {Name: agenttools.ToolWebSearch}, } out := filterNonTerminalToolCalls(in) if assert.Len(t, out, 2) { assert.Equal(t, agenttools.ToolKnowledgeSearch, out[0].Name) assert.Equal(t, agenttools.ToolWebSearch, out[1].Name) } } // TestBuildAssistantHistoryMessages_ReplaysReasoningContent guards the // cross-turn replay path: AgentStep.ReasoningContent persisted on a prior turn // must be re-attached to the rebuilt assistant message, otherwise MiMo and // DeepSeek thinking-mode reject the next turn with HTTP 400 (issue #1302). func TestBuildAssistantHistoryMessages_ReplaysReasoningContent(t *testing.T) { msg := &types.Message{ Role: "assistant", Content: "Found 3 matches in the docs.", AgentSteps: types.AgentSteps{ { Iteration: 0, Thought: "Let me search.", ReasoningContent: "model's chain of thought", ToolCalls: []types.ToolCall{{ ID: "call_1", Name: agenttools.ToolKnowledgeSearch, Args: map[string]interface{}{"query": "foo"}, ProviderMetadata: types.ToolCallMetadata{"google": json.RawMessage(`{"thought_signature":"gemini-history-signature"}`)}, Result: &types.ToolResult{ Success: true, Output: "doc A", }, }}, }, }, } got := buildAssistantHistoryMessages(msg) if !assert.Len(t, got, 3) { return } assert.Equal(t, "model's chain of thought", got[0].ReasoningContent, "reasoning_content from AgentStep must be replayed onto the rebuilt assistant message "+ "so MiMo/DeepSeek thinking-mode does not 400 on multi-turn (issue #1302)") require.Len(t, got[0].ToolCalls, 1) assert.JSONEq(t, `{"thought_signature":"gemini-history-signature"}`, string(got[0].ToolCalls[0].ProviderMetadata["google"])) // Tool message and final answer message must NOT carry reasoning_content. assert.Empty(t, got[1].ReasoningContent) assert.Empty(t, got[2].ReasoningContent) }