// operation_log_test.go -- 统一操作日志的单元测试. // // 覆盖场景: // - Record 记录操作 // - GetByMessage 按消息查询 // - RollbackMessage 按消息回滚(倒序执行 Undo) // - 不可逆操作跳过 // - 无 UndoInfo 的操作跳过 package engine import ( "context" "encoding/json" "errors" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // ───────────────────────────────────────────────────────────────────── // Mock UndoExecutor // ───────────────────────────────────────────────────────────────────── type mockUndoExecutor struct { executed []*tools.UndoInfo failOn string // 如果 ToolName 匹配这个值,返回错误 } func (m *mockUndoExecutor) ExecuteUndo(ctx context.Context, undo *tools.UndoInfo) error { if undo.ToolName == m.failOn { return errors.New("mock undo failed") } m.executed = append(m.executed, undo) return nil } // ───────────────────────────────────────────────────────────────────── // 测试 // ───────────────────────────────────────────────────────────────────── func TestOperationLog_Record(t *testing.T) { log := NewOperationLog(&NoopObserver{}) log.Record(&OperationEntry{ ID: "op-1", MessageID: "msg-1", ToolName: "Edit", Status: "success", }) if log.EntryCount() != 1 { t.Errorf("应该有 1 条记录,实际: %d", log.EntryCount()) } } // TestOperationLog_Record_PreservesTruncation asserts Truncated / // StoredPath survive Record + GetByMessage round-trip so audit consumers // walking the log can find the authoritative full result. Without this // wire, summary-only Output would be treated as the record of truth. // // TestOperationLog_Record_PreservesTruncation 断言 Truncated / StoredPath // 经 Record + GetByMessage 往返后仍在, 走 OperationLog 的审计消费者才能找 // 到权威完整结果. 没这条 wire, summary-only 的 Output 会被当作权威记录. func TestOperationLog_Record_PreservesTruncation(t *testing.T) { log := NewOperationLog(&NoopObserver{}) log.Record(&OperationEntry{ ID: "op-trunc", MessageID: "msg-1", ToolName: "Bash", Output: "[truncated: 300/10485760 bytes]", Status: "success", Truncated: true, StoredPath: "/tmp/flyto/results/op-trunc.txt", }) entries := log.GetByMessage("msg-1") if len(entries) != 1 { t.Fatalf("GetByMessage len = %d, want 1", len(entries)) } got := entries[0] if !got.Truncated { t.Errorf("Truncated lost on round-trip; got false") } if got.StoredPath != "/tmp/flyto/results/op-trunc.txt" { t.Errorf("StoredPath = %q, want /tmp/flyto/results/op-trunc.txt", got.StoredPath) } } func TestOperationLog_GetByMessage(t *testing.T) { log := NewOperationLog(&NoopObserver{}) log.Record(&OperationEntry{ID: "op-1", MessageID: "msg-1", ToolName: "Edit"}) log.Record(&OperationEntry{ID: "op-2", MessageID: "msg-2", ToolName: "Write"}) log.Record(&OperationEntry{ID: "op-3", MessageID: "msg-1", ToolName: "Grep"}) entries := log.GetByMessage("msg-1") if len(entries) != 2 { t.Errorf("msg-1 应该有 2 条操作,实际: %d", len(entries)) } entries = log.GetByMessage("msg-2") if len(entries) != 1 { t.Errorf("msg-2 应该有 1 条操作,实际: %d", len(entries)) } entries = log.GetByMessage("msg-999") if len(entries) != 0 { t.Errorf("msg-999 应该没有操作,实际: %d", len(entries)) } } func TestOperationLog_RollbackMessage(t *testing.T) { log := NewOperationLog(&NoopObserver{}) executor := &mockUndoExecutor{} log.Record(&OperationEntry{ ID: "op-1", MessageID: "msg-1", ToolName: "Edit", Status: "success", Input: json.RawMessage(`{"file_path": "a.txt"}`), UndoInfo: &tools.UndoInfo{ ToolName: "Write", Input: map[string]any{"file_path": "a.txt"}, Description: "恢复 a.txt", }, }) log.Record(&OperationEntry{ ID: "op-2", MessageID: "msg-1", ToolName: "Write", Status: "success", Input: json.RawMessage(`{"file_path": "b.txt"}`), UndoInfo: &tools.UndoInfo{ ToolName: "Write", Input: map[string]any{"file_path": "b.txt"}, Description: "恢复 b.txt", }, }) err := log.RollbackMessage(context.Background(), "msg-1", executor) if err != nil { t.Fatalf("回滚不应失败: %v", err) } // 应该倒序执行:先 op-2,再 op-1 if len(executor.executed) != 2 { t.Fatalf("应该执行 2 次 undo,实际: %d", len(executor.executed)) } if executor.executed[0].Input["file_path"] != "b.txt" { t.Errorf("第一个 undo 应该是 b.txt(倒序),实际: %v", executor.executed[0].Input) } if executor.executed[1].Input["file_path"] != "a.txt" { t.Errorf("第二个 undo 应该是 a.txt(倒序),实际: %v", executor.executed[1].Input) } } func TestOperationLog_RollbackMessage_SkipNoUndo(t *testing.T) { log := NewOperationLog(&NoopObserver{}) executor := &mockUndoExecutor{} log.Record(&OperationEntry{ ID: "op-1", MessageID: "msg-1", ToolName: "Grep", Status: "success", // 无 UndoInfo(只读工具) }) log.Record(&OperationEntry{ ID: "op-2", MessageID: "msg-1", ToolName: "Edit", Status: "success", UndoInfo: &tools.UndoInfo{ToolName: "Write", Description: "恢复"}, }) err := log.RollbackMessage(context.Background(), "msg-1", executor) if err != nil { t.Fatalf("回滚不应失败: %v", err) } // 只有 op-2 有 UndoInfo,所以只执行 1 次 if len(executor.executed) != 1 { t.Errorf("应该只执行 1 次 undo,实际: %d", len(executor.executed)) } } func TestOperationLog_RollbackMessage_SkipIrreversible(t *testing.T) { log := NewOperationLog(&NoopObserver{}) executor := &mockUndoExecutor{} log.Record(&OperationEntry{ ID: "op-1", MessageID: "msg-1", ToolName: "Bash", Status: "success", UndoInfo: &tools.UndoInfo{ Irreversible: true, ManualGuide: "请手动检查 bash 命令的副作用", }, }) err := log.RollbackMessage(context.Background(), "msg-1", executor) if err != nil { t.Fatalf("回滚不应失败: %v", err) } // 不可逆操作应该被跳过 if len(executor.executed) != 0 { t.Errorf("不可逆操作不应执行 undo,实际执行了: %d", len(executor.executed)) } } func TestOperationLog_RollbackMessage_NotFound(t *testing.T) { log := NewOperationLog(&NoopObserver{}) executor := &mockUndoExecutor{} err := log.RollbackMessage(context.Background(), "msg-999", executor) if err == nil { t.Error("回滚不存在的消息应该返回错误") } } func TestOperationLog_RollbackMessage_BestEffort(t *testing.T) { log := NewOperationLog(&NoopObserver{}) executor := &mockUndoExecutor{failOn: "FailTool"} log.Record(&OperationEntry{ ID: "op-1", MessageID: "msg-1", ToolName: "Edit", Status: "success", UndoInfo: &tools.UndoInfo{ToolName: "Write", Description: "恢复 a.txt"}, }) log.Record(&OperationEntry{ ID: "op-2", MessageID: "msg-1", ToolName: "Custom", Status: "success", UndoInfo: &tools.UndoInfo{ToolName: "FailTool", Description: "会失败的 undo"}, }) log.Record(&OperationEntry{ ID: "op-3", MessageID: "msg-1", ToolName: "Edit", Status: "success", UndoInfo: &tools.UndoInfo{ToolName: "Write", Description: "恢复 b.txt"}, }) // 即使 op-2 的 undo 失败,op-1 和 op-3 的 undo 仍应执行 err := log.RollbackMessage(context.Background(), "msg-1", executor) // 应该返回最后一个错误 if err == nil { t.Error("应该返回错误(因为 op-2 的 undo 失败了)") } // op-3 和 op-1 的 undo 应该成功执行 if len(executor.executed) != 2 { t.Errorf("应该执行 2 次 undo(跳过失败的),实际: %d", len(executor.executed)) } }