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
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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user