Files
Enterprise-Knowledge-Base/internal/agent/observe_test.go
T
wizardchen b86efe3f92 fix(agent): harden @mention scope and simplify must_use hints
Tighten per-turn @MCP/@Skill/@tag handling after review: enforce agent
MCP/Skill whitelist, ignore mentions when selection mode is none, tag-scope
grep_chunks, and simplified English must_use lines without tool dumps.
Skip persisting scope envelopes to history rendered_content.
2026-06-29 18:09:44 +08:00

227 lines
7.5 KiB
Go

package agent
import (
"context"
"encoding/json"
"testing"
"time"
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"
)
// TestAnalyzeResponse_ToolCall_DoesNotTerminate is a regression guard: the
// agent has no dedicated terminal tool — any round that requests tool calls is
// non-terminal and must keep the loop running. The agent ends only by stopping
// naturally with its answer as plain text.
func TestAnalyzeResponse_ToolCall_DoesNotTerminate(t *testing.T) {
engine := newTestEngine(t, &mockChat{})
resp := &types.ChatResponse{
FinishReason: "tool_calls",
ToolCalls: []types.LLMToolCall{
{
ID: "call-1",
Type: "function",
Function: types.FunctionCall{
Name: agenttools.ToolKnowledgeSearch,
Arguments: `{"query": "hi"}`,
},
},
},
}
verdict := engine.analyzeResponse(
context.Background(), resp, types.AgentStep{}, 0, "sess-1", time.Now(),
)
assert.False(t, verdict.isDone,
"non-terminal tool calls must keep the loop running")
}
// TestAnalyzeResponse_NaturalStop_Terminates guards the sole termination path:
// finish_reason == "stop" with no tool calls ends the loop and surfaces the
// plain content as the final answer.
func TestAnalyzeResponse_NaturalStop_Terminates(t *testing.T) {
engine := newTestEngine(t, &mockChat{})
resp := &types.ChatResponse{
FinishReason: "stop",
Content: "Here is the answer.",
}
verdict := engine.analyzeResponse(
context.Background(), resp, types.AgentStep{}, 0, "sess-1", time.Now(),
)
assert.True(t, verdict.isDone, "a natural stop with no tool calls must terminate the loop")
assert.Equal(t, "Here is the answer.", verdict.finalAnswer)
}
// TestAppendToolResults_PreservesReasoningContent verifies that the assistant
// message produced by appendToolResults carries the reasoning_content emitted
// by the model in the same round. Without this, MiMo and DeepSeek V3.2+
// thinking-mode reject the next ReAct round with HTTP 400
// "The reasoning_content in the thinking mode must be passed back to the API."
// (issue #1302).
func TestAppendToolResults_PreservesReasoningContent(t *testing.T) {
engine := &AgentEngine{}
t.Run("assistant message carries reasoning_content alongside thought and tool_calls", func(t *testing.T) {
step := types.AgentStep{
Iteration: 0,
Thought: "I will call search.",
ReasoningContent: "Detailed chain of thought from MiMo/DeepSeek.",
ToolCalls: []types.ToolCall{{
ID: "call_1",
Name: "knowledge_search",
Args: map[string]interface{}{"query": "hi"},
ProviderMetadata: types.ToolCallMetadata{"google": json.RawMessage(`{"thought_signature":"gemini-thought-signature"}`)},
Result: &types.ToolResult{
Success: true,
Output: "result text",
},
}},
Timestamp: time.Now(),
}
out := engine.appendToolResults(nil, step)
require.Len(t, out, 2, "expect one assistant + one tool message")
assert.Equal(t, "assistant", out[0].Role)
assert.Equal(t, "I will call search.", out[0].Content)
assert.Equal(t, "Detailed chain of thought from MiMo/DeepSeek.", out[0].ReasoningContent,
"reasoning_content must be propagated to the assistant message so providers like MiMo "+
"and DeepSeek thinking-mode see it on the next round (issue #1302)")
require.Len(t, out[0].ToolCalls, 1)
assert.Equal(t, "call_1", out[0].ToolCalls[0].ID)
assert.JSONEq(t, `{"thought_signature":"gemini-thought-signature"}`,
string(out[0].ToolCalls[0].ProviderMetadata["google"]))
assert.Equal(t, "tool", out[1].Role)
assert.Equal(t, "result text", out[1].Content)
})
t.Run("reasoning_content alone produces an assistant message", func(t *testing.T) {
// A pure thinking emission with no visible content / tool calls is
// unusual but legal — preserve it so the next round's request still
// carries reasoning_content for strict providers.
step := types.AgentStep{
Iteration: 0,
ReasoningContent: "reasoning only",
Timestamp: time.Now(),
}
out := engine.appendToolResults(nil, step)
require.Len(t, out, 1)
assert.Equal(t, "assistant", out[0].Role)
assert.Equal(t, "reasoning only", out[0].ReasoningContent)
assert.Empty(t, out[0].Content)
assert.Empty(t, out[0].ToolCalls)
})
t.Run("step without thought/tool_calls/reasoning produces no assistant message", func(t *testing.T) {
step := types.AgentStep{Iteration: 0, Timestamp: time.Now()}
out := engine.appendToolResults(nil, step)
assert.Empty(t, out, "empty steps must not inject empty assistant messages")
})
t.Run("appends to existing message slice", func(t *testing.T) {
prior := []chat.Message{
{Role: "system", Content: "sys"},
{Role: "user", Content: "hi"},
}
step := types.AgentStep{
Iteration: 1,
Thought: "answer",
ReasoningContent: "thinking",
Timestamp: time.Now(),
}
out := engine.appendToolResults(prior, step)
require.Len(t, out, 3)
assert.Equal(t, "system", out[0].Role)
assert.Equal(t, "user", out[1].Role)
assert.Equal(t, "assistant", out[2].Role)
assert.Equal(t, "thinking", out[2].ReasoningContent)
})
}
func TestBuildRuntimeContextBlock_PinnedDocuments(t *testing.T) {
block := buildRuntimeContextBlock(
"sess-1",
nil,
[]*SelectedDocumentInfo{{
KnowledgeID: "kid-1",
Title: "Report.pdf",
FileType: "pdf",
}},
)
assert.Contains(t, block, "<pinned_documents")
assert.Contains(t, block, `knowledge_id="kid-1"`)
assert.Contains(t, block, `title="Report.pdf"`)
assert.Contains(t, block, `file_type="pdf"`)
assert.Contains(t, block, "list_knowledge_chunks")
assert.NotContains(t, block, "<must_use>")
}
func TestBuildMustUseBlock_MCPAndSkills(t *testing.T) {
block := buildMustUseBlock(
[]*PinnedMCPServiceInfo{{
ID: "mcp-1",
Name: "ChemDB",
ToolNames: []string{"mcp_chemdb_search"},
}},
[]*PinnedSkillInfo{{
Name: "data-analysis",
}},
)
assert.Contains(t, block, "<must_use>")
assert.NotContains(t, block, "<runtime_context")
assert.NotContains(t, block, "<instruction>")
assert.Contains(t, block, "Must use MCP tools whose names start with mcp_chemdb_")
assert.Contains(t, block, "@ChemDB")
assert.Contains(t, block, `Must call read_skill(skill_name="data-analysis")`)
assert.Contains(t, block, `@Skill "data-analysis"`)
}
func TestBuildMustUseBlock_MCPToolPrefixOnly(t *testing.T) {
block := buildMustUseBlock(
[]*PinnedMCPServiceInfo{{
ID: "mcp-1",
Name: "iwiki",
ToolNames: []string{"mcp_iwiki_aisearchdocument", "mcp_iwiki_getdocument"},
}},
nil,
)
assert.Contains(t, block, "mcp_iwiki_")
assert.NotContains(t, block, "aisearchdocument")
assert.NotContains(t, block, `tools="`)
}
func TestBuildMustUseBlock_SkipsMCPWithoutTools(t *testing.T) {
block := buildMustUseBlock(
[]*PinnedMCPServiceInfo{{
ID: "mcp-1",
Name: "DisabledMCP",
}},
[]*PinnedSkillInfo{{Name: "data-analysis"}},
)
assert.Contains(t, block, `Must call read_skill(skill_name="data-analysis")`)
assert.NotContains(t, block, "DisabledMCP")
}
func TestRenderUserTurnContent_IncludesScopeBlocks(t *testing.T) {
engine := &AgentEngine{
knowledgeBasesInfo: []*KnowledgeBaseInfo{{ID: "kb-1", Name: "Docs"}},
pinnedSkills: []*PinnedSkillInfo{{Name: "analysis"}},
}
out := engine.RenderUserTurnContent("sess-1", "hello")
assert.Contains(t, out, "<runtime_context")
assert.Contains(t, out, "<must_use>")
assert.Contains(t, out, "hello")
}