Files
Memoh/internal/pipeline/turn_response_test.go
T
Acbox e94b4b58ed fix(pipeline): preserve tool calls and anchor driver cursor correctly
The discuss driver's RC+TR composition had three compounding bugs that
caused old tasks to be re-answered after idle timeouts and made the LLM
blind to its own prior tool usage:

- DecodeTurnResponseEntry only kept visible text via TextContent(), so
  assistant steps carrying only tool_call parts (the first half of every
  tool round) were dropped entirely. Rewritten to render tool_call and
  tool_result parts as <tool_call>/<tool_result> tags, covering both
  Vercel-style content parts and legacy OpenAI ToolCalls/role=tool
  envelopes. Reasoning parts remain stripped to avoid re-injection.

- loadTurnResponses hard-capped TRs at 24h while RC is replayed in full
  from the events table, producing asymmetric context (user messages
  from day 1 visible, matching bot replies missing). The cap is removed;
  any size-bound trimming belongs in compaction, not here.

- lastProcessedMs lived only in memory and was set to time.Now() at turn
  end. After the 10-minute idle timeout, the goroutine exited and the
  next turn started with cursor=0, treating the entire history as new
  traffic. Now initialised from the latest TR's requested_at on cold
  start, and advanced to max(consumed RC.ReceivedAtMs) per turn so that
  messages arriving mid-generation trigger a follow-up round instead of
  being wrongly marked processed.
2026-04-23 20:05:35 +08:00

263 lines
6.8 KiB
Go

package pipeline
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/memohai/memoh/internal/conversation"
messagepkg "github.com/memohai/memoh/internal/message"
)
func TestDecodeTurnResponseEntryUsesVisibleText(t *testing.T) {
t.Parallel()
content, err := json.Marshal([]map[string]any{
{"type": "reasoning", "text": "thinking"},
{"type": "text", "text": "任务完成"},
})
if err != nil {
t.Fatalf("marshal content: %v", err)
}
modelMessage, err := json.Marshal(conversation.ModelMessage{
Role: "assistant",
Content: content,
})
if err != nil {
t.Fatalf("marshal model message: %v", err)
}
entry, ok := DecodeTurnResponseEntry(messagepkg.Message{
Role: "assistant",
Content: modelMessage,
CreatedAt: time.Unix(1710000000, 0).UTC(),
})
if !ok {
t.Fatal("expected turn response entry")
}
if entry.Content != "任务完成" {
t.Fatalf("content = %q, want %q", entry.Content, "任务完成")
}
// Reasoning must never leak into TRs to avoid re-injection into prompts.
if strings.Contains(entry.Content, "thinking") {
t.Fatalf("reasoning leaked into TR: %q", entry.Content)
}
}
func TestDecodeTurnResponseEntryPreservesToolCallOnlyPayload(t *testing.T) {
t.Parallel()
content, err := json.Marshal([]map[string]any{
{"type": "reasoning", "text": "thinking"},
{
"type": "tool-call",
"toolName": "read",
"toolCallId": "call-1",
"input": map[string]any{"path": "/tmp/a.txt"},
},
})
if err != nil {
t.Fatalf("marshal content: %v", err)
}
modelMessage, err := json.Marshal(conversation.ModelMessage{
Role: "assistant",
Content: content,
})
if err != nil {
t.Fatalf("marshal model message: %v", err)
}
entry, ok := DecodeTurnResponseEntry(messagepkg.Message{
Role: "assistant",
Content: modelMessage,
CreatedAt: time.Unix(1710000000, 0).UTC(),
})
if !ok {
t.Fatal("expected tool-call-only payload to be preserved as TR")
}
if !strings.Contains(entry.Content, `<tool_call id="call-1" name="read">`) {
t.Fatalf("missing tool_call tag: %q", entry.Content)
}
if !strings.Contains(entry.Content, `"path":"/tmp/a.txt"`) {
t.Fatalf("tool input missing: %q", entry.Content)
}
if strings.Contains(entry.Content, "thinking") {
t.Fatalf("reasoning leaked: %q", entry.Content)
}
}
func TestDecodeTurnResponseEntryRendersTextAndToolCall(t *testing.T) {
t.Parallel()
content, err := json.Marshal([]map[string]any{
{"type": "text", "text": "Let me check."},
{
"type": "tool-call",
"toolName": "web_search",
"toolCallId": "call-42",
"input": map[string]any{"query": "today news"},
},
})
if err != nil {
t.Fatalf("marshal content: %v", err)
}
modelMessage, err := json.Marshal(conversation.ModelMessage{
Role: "assistant",
Content: content,
})
if err != nil {
t.Fatalf("marshal model message: %v", err)
}
entry, ok := DecodeTurnResponseEntry(messagepkg.Message{
Role: "assistant",
Content: modelMessage,
})
if !ok {
t.Fatal("expected entry")
}
if !strings.Contains(entry.Content, "Let me check.") {
t.Fatalf("missing text portion: %q", entry.Content)
}
if !strings.Contains(entry.Content, `<tool_call id="call-42" name="web_search">`) {
t.Fatalf("missing tool_call tag: %q", entry.Content)
}
}
func TestDecodeTurnResponseEntryToolRoleWithPartsResult(t *testing.T) {
t.Parallel()
content, err := json.Marshal([]map[string]any{
{
"type": "tool-result",
"toolCallId": "call-1",
"toolName": "web_search",
"output": map[string]any{
"count": 3,
"summary": "ok",
},
},
})
if err != nil {
t.Fatalf("marshal content: %v", err)
}
modelMessage, err := json.Marshal(conversation.ModelMessage{
Role: "tool",
Content: content,
})
if err != nil {
t.Fatalf("marshal model message: %v", err)
}
entry, ok := DecodeTurnResponseEntry(messagepkg.Message{
Role: "tool",
Content: modelMessage,
})
if !ok {
t.Fatal("expected tool role entry")
}
if !strings.Contains(entry.Content, `<tool_result id="call-1" name="web_search">`) {
t.Fatalf("missing tool_result tag: %q", entry.Content)
}
if !strings.Contains(entry.Content, `"count":3`) || !strings.Contains(entry.Content, `"summary":"ok"`) {
t.Fatalf("structured tool output not preserved: %q", entry.Content)
}
}
func TestDecodeTurnResponseEntryToolRoleLegacyEnvelope(t *testing.T) {
t.Parallel()
// Old OpenAI-style: role=tool + ToolCallID on the envelope, Content is
// a JSON string carrying the result directly.
resultBody := json.RawMessage(`{"status":"ok"}`)
modelMessage, err := json.Marshal(conversation.ModelMessage{
Role: "tool",
ToolCallID: "call-99",
Name: "ping",
Content: resultBody,
})
if err != nil {
t.Fatalf("marshal model message: %v", err)
}
entry, ok := DecodeTurnResponseEntry(messagepkg.Message{
Role: "tool",
Content: modelMessage,
})
if !ok {
t.Fatal("expected entry for legacy tool envelope")
}
if !strings.Contains(entry.Content, `<tool_result id="call-99" name="ping">`) {
t.Fatalf("missing tool_result tag: %q", entry.Content)
}
if !strings.Contains(entry.Content, `"status":"ok"`) {
t.Fatalf("legacy tool body missing: %q", entry.Content)
}
}
func TestDecodeTurnResponseEntrySkipsEmpty(t *testing.T) {
t.Parallel()
// Only reasoning → nothing to expose to future prompts → skip.
content, err := json.Marshal([]map[string]any{
{"type": "reasoning", "text": "thinking out loud"},
})
if err != nil {
t.Fatalf("marshal content: %v", err)
}
modelMessage, err := json.Marshal(conversation.ModelMessage{
Role: "assistant",
Content: content,
})
if err != nil {
t.Fatalf("marshal model message: %v", err)
}
if _, ok := DecodeTurnResponseEntry(messagepkg.Message{
Role: "assistant",
Content: modelMessage,
}); ok {
t.Fatal("expected reasoning-only message to be skipped")
}
}
func TestDecodeTurnResponseEntryLegacyToolCallsField(t *testing.T) {
t.Parallel()
// Older OpenAI envelope: Content is empty string, ToolCalls carries
// the function-call structure.
modelMessage, err := json.Marshal(conversation.ModelMessage{
Role: "assistant",
Content: json.RawMessage(`""`),
ToolCalls: []conversation.ToolCall{
{
ID: "call-legacy",
Type: "function",
Function: conversation.ToolCallFunction{
Name: "send",
Arguments: `{"text":"hi"}`,
},
},
},
})
if err != nil {
t.Fatalf("marshal model message: %v", err)
}
entry, ok := DecodeTurnResponseEntry(messagepkg.Message{
Role: "assistant",
Content: modelMessage,
})
if !ok {
t.Fatal("expected legacy tool-calls envelope to decode")
}
if !strings.Contains(entry.Content, `<tool_call id="call-legacy" name="send">`) {
t.Fatalf("missing tool_call tag: %q", entry.Content)
}
if !strings.Contains(entry.Content, `"text":"hi"`) {
t.Fatalf("arguments missing: %q", entry.Content)
}
}