// session_resume_test.go - 会话恢复:孤立 thinking 过滤 + 中断哨兵测试. package engine import ( "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/query" ) // --- maybeInjectResumeSentinel --- func TestMaybeInjectResumeSentinel_AssistantLast_NoSentinel(t *testing.T) { // 最后是 assistant 消息:正常完成的轮次,无需哨兵 messages := []query.Message{ {Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "hello"}}}, {Role: query.RoleAssistant, Content: []query.Content{{Type: query.ContentText, Text: "world"}}}, } result := maybeInjectResumeSentinel(messages) if len(result) != 2 { t.Errorf("expected 2 messages, got %d", len(result)) } if result[len(result)-1].Role != query.RoleAssistant { t.Error("last message should still be assistant") } } func TestMaybeInjectResumeSentinel_UserTextLast_NoSentinel(t *testing.T) { // 最后是 user 文本消息:用户发送了请求但 AI 未响应,无需哨兵(Run 会直接响应) messages := []query.Message{ {Role: query.RoleAssistant, Content: []query.Content{{Type: query.ContentText, Text: "done"}}}, {Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "what is the time?"}}}, } result := maybeInjectResumeSentinel(messages) if len(result) != 2 { t.Errorf("expected 2 messages, got %d (should NOT inject sentinel for user text)", len(result)) } } func TestMaybeInjectResumeSentinel_ToolResultLast_InjectSentinel(t *testing.T) { // 最后是 tool_result:AI 在处理工具结果时崩溃,注入哨兵 messages := []query.Message{ {Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "list files"}}}, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentToolUse, ID: "t1", Name: "Bash", Input: map[string]any{"command": "ls"}}, }, }, { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentToolResult, ToolUseID: "t1", Text: "file1.go\nfile2.go"}, }, }, } result := maybeInjectResumeSentinel(messages) if len(result) != 4 { t.Fatalf("expected 4 messages (sentinel injected), got %d", len(result)) } sentinel := result[3] if sentinel.Role != query.RoleUser { t.Error("sentinel should be user role") } if len(sentinel.Content) != 1 || sentinel.Content[0].Type != query.ContentText { t.Error("sentinel should have one text block") } if sentinel.Content[0].Text != "Continue from where you left off." { t.Errorf("sentinel text: got %q", sentinel.Content[0].Text) } // 验证哨兵有 is_resume_sentinel 元数据 if sentinel.Metadata == nil || sentinel.Metadata["is_resume_sentinel"] != true { t.Error("sentinel should have is_resume_sentinel=true in metadata") } } func TestMaybeInjectResumeSentinel_Empty_NoSentinel(t *testing.T) { // 空消息列表:无操作 result := maybeInjectResumeSentinel([]query.Message{}) if len(result) != 0 { t.Errorf("expected 0 messages, got %d", len(result)) } } func TestMaybeInjectResumeSentinel_SystemOnly_NoSentinel(t *testing.T) { // 只有 system 消息:无操作 messages := []query.Message{ {Role: query.RoleSystem, Content: []query.Content{{Type: query.ContentText, Text: "system"}}}, } result := maybeInjectResumeSentinel(messages) if len(result) != 1 { t.Errorf("expected 1 message, got %d", len(result)) } } func TestMaybeInjectResumeSentinel_SkipsSystemAtEnd(t *testing.T) { // system 消息在末尾:应该看 system 前面的消息决定是否注入 messages := []query.Message{ {Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "hello"}}}, {Role: query.RoleAssistant, Content: []query.Content{{Type: query.ContentText, Text: "hi"}}}, {Role: query.RoleSystem, Content: []query.Content{{Type: query.ContentText, Text: "[system note]"}}}, } result := maybeInjectResumeSentinel(messages) // 最后有意义的消息是 assistant → 无需哨兵 if len(result) != 3 { t.Errorf("expected 3 messages, got %d", len(result)) } } func TestMaybeInjectResumeSentinel_MultipleToolResults_InjectSentinel(t *testing.T) { // 多个 tool_result:所有内容都是 tool_result → 注入哨兵 messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentToolResult, ToolUseID: "t1", Text: "result1"}, {Type: query.ContentToolResult, ToolUseID: "t2", Text: "result2"}, }, }, } result := maybeInjectResumeSentinel(messages) if len(result) != 2 { t.Fatalf("expected 2 messages after sentinel injection, got %d", len(result)) } } func TestMaybeInjectResumeSentinel_MixedContent_NoSentinel(t *testing.T) { // user 消息同时包含 text 和 tool_result → 视为用户请求,不注入哨兵 messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentText, Text: "what happened?"}, {Type: query.ContentToolResult, ToolUseID: "t1", Text: "some result"}, }, }, } result := maybeInjectResumeSentinel(messages) // text 内容存在 → 不是纯 tool_result → 不注入哨兵 if len(result) != 1 { t.Errorf("mixed content should not inject sentinel, got %d messages", len(result)) } } // --- isToolResultOnlyMessage --- func TestIsToolResultOnlyMessage(t *testing.T) { tests := []struct { msg query.Message want bool desc string }{ { msg: query.Message{ Role: query.RoleUser, Content: []query.Content{{Type: query.ContentToolResult, ToolUseID: "t1"}}, }, want: true, desc: "单个 tool_result", }, { msg: query.Message{ Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "hello"}}, }, want: false, desc: "文本消息", }, { msg: query.Message{ Role: query.RoleUser, Content: []query.Content{}, }, want: false, desc: "空内容", }, { msg: query.Message{ Role: query.RoleUser, Content: []query.Content{ {Type: query.ContentToolResult}, {Type: query.ContentText}, }, }, want: false, desc: "混合内容", }, } for _, tt := range tests { got := isToolResultOnlyMessage(tt.msg) if got != tt.want { t.Errorf("isToolResultOnlyMessage [%s] = %v, want %v", tt.desc, got, tt.want) } } } // --- OrphanThinkingFilter 在恢复时的行为 --- func TestOrphanThinkingFilter_InNormalizePipeline(t *testing.T) { // 验证 DefaultNormalizePipeline 包含 OrphanThinkingFilter 并正确过滤 messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "hello"}}, }, { // 只有 thinking 块的 assistant 消息 → 应被过滤 Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentThinking, Text: "let me think..."}, }, }, { Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "continue"}}, }, } result := DefaultNormalizePipeline().Run(messages) // orphan thinking 消息被删除后,可能触发 consecutive role merge 或 empty filter // 关键:原始序列 [user, orphan-thinking-assistant, user] 规范化后不能包含 orphan thinking for _, msg := range result { if msg.Role == query.RoleAssistant && isThinkingOnly(msg) { t.Error("normalized messages should not contain orphan thinking-only assistant message") } } } func TestOrphanThinkingFilter_KeepsNormalThinking(t *testing.T) { // 包含 thinking 和 text 的 assistant 消息不应被过滤 messages := []query.Message{ { Role: query.RoleUser, Content: []query.Content{{Type: query.ContentText, Text: "hello"}}, }, { Role: query.RoleAssistant, Content: []query.Content{ {Type: query.ContentThinking, Text: "thinking..."}, {Type: query.ContentText, Text: "response"}, }, }, } result := DefaultNormalizePipeline().Run(messages) found := false for _, msg := range result { if msg.Role == query.RoleAssistant { for _, c := range msg.Content { if c.Type == query.ContentThinking { found = true } } } } if !found { t.Error("normal thinking in mixed assistant message should be preserved") } }