WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
@@ -0,0 +1,343 @@
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)
}