344 lines
12 KiB
Go
344 lines
12 KiB
Go
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, `<attachment index="1" name="report.pdf">`)
|
||
|
|
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, "<attachment")
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestBuildAssistantHistoryMessages_NaturalFinishEmitsSingleAnswer covers the
|
||
|
|
// most common path: a turn with no tool calls (model answered directly). The
|
||
|
|
// result must be a single assistant message holding the canonical answer —
|
||
|
|
// duplicates would inflate token usage every turn.
|
||
|
|
func TestBuildAssistantHistoryMessages_NaturalFinishEmitsSingleAnswer(t *testing.T) {
|
||
|
|
msg := &types.Message{
|
||
|
|
Role: "assistant",
|
||
|
|
Content: "Hello, nice to meet you!",
|
||
|
|
AgentSteps: types.AgentSteps{
|
||
|
|
{Iteration: 0, Thought: "Hello, nice to meet you!", ToolCalls: nil},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
got := buildAssistantHistoryMessages(msg)
|
||
|
|
if assert.Len(t, got, 1) {
|
||
|
|
assert.Equal(t, "assistant", got[0].Role)
|
||
|
|
assert.Equal(t, "Hello, nice to meet you!", got[0].Content)
|
||
|
|
assert.Empty(t, got[0].ToolCalls)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestBuildAssistantHistoryMessages_StripsThinkBlocks ensures the trailing
|
||
|
|
// final-answer assistant message has any <think>…</think> 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: "<think>plotting...</think>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)
|
||
|
|
}
|