| 1 | package event |
| 2 | |
| 3 | import ( |
| 4 | "sync" |
| 5 | "testing" |
| 6 | |
| 7 | "reasonix/internal/evidence" |
| 8 | "reasonix/internal/provider" |
| 9 | ) |
| 10 | |
| 11 | // Kind constants |
| 12 | |
| 13 | func TestKindConstants(t *testing.T) { |
| 14 | // Verify the iota sequence is stable and sequential for the original |
| 15 | // core kinds. New kinds are appended before KindCount. |
| 16 | kinds := []Kind{ |
| 17 | TurnStarted, Reasoning, Text, Message, ToolDispatch, ToolResult, |
| 18 | Usage, Notice, Phase, ApprovalRequest, AskRequest, TurnDone, |
| 19 | } |
| 20 | for i, k := range kinds { |
| 21 | if int(k) != i { |
| 22 | t.Errorf("Kind %d: got %d", i, int(k)) |
| 23 | } |
| 24 | } |
| 25 | if TurnPhase >= KindCount || CompletionSummary >= KindCount { |
| 26 | t.Fatal("new kinds must sit before KindCount") |
| 27 | } |
| 28 | if TurnPhaseName(TurnPhaseWorking) != "working" || TurnPhaseName(TurnPhaseReviewing) != "reviewing" { |
| 29 | t.Fatal("turn phase names drifted") |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | // Level constants |
| 34 | |
| 35 | func TestLevelConstants(t *testing.T) { |
| 36 | if LevelInfo != 0 { |
| 37 | t.Errorf("LevelInfo = %d, want 0", LevelInfo) |
| 38 | } |
| 39 | if LevelWarn != 1 { |
| 40 | t.Errorf("LevelWarn = %d, want 1", LevelWarn) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestNoticeAudienceConstants(t *testing.T) { |
| 45 | if NoticeAudienceDefault != "" { |
| 46 | t.Errorf("NoticeAudienceDefault = %q, want empty for backward-compatible delivery", NoticeAudienceDefault) |
| 47 | } |
| 48 | if NoticeAudienceOperator != "operator" { |
| 49 | t.Errorf("NoticeAudienceOperator = %q, want operator", NoticeAudienceOperator) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // FuncSink |
| 54 | |
| 55 | func TestFuncSinkEmit(t *testing.T) { |
| 56 | var received Event |
| 57 | fs := FuncSink(func(e Event) { received = e }) |
| 58 | e := Event{Kind: Text, Text: "hello"} |
| 59 | fs.Emit(e) |
| 60 | if received.Kind != Text || received.Text != "hello" { |
| 61 | t.Errorf("FuncSink did not forward event: got %+v", received) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | func TestFuncSinkNilEmitIsNoop(t *testing.T) { |
| 66 | var fs FuncSink |
| 67 | |
| 68 | fs.Emit(Event{Kind: Text, Text: "hello"}) |
| 69 | } |
| 70 | |
| 71 | type typedNilSink struct{} |
| 72 | |
| 73 | func (*typedNilSink) Emit(Event) {} |
| 74 | |
| 75 | func TestSyncTreatsTypedNilSinkAsDiscard(t *testing.T) { |
| 76 | var base *typedNilSink |
| 77 | |
| 78 | Sync(base).Emit(Event{Kind: Text, Text: "hello"}) |
| 79 | } |
| 80 | |
| 81 | type readinessAuditRecorder struct { |
| 82 | events []evidence.ReadinessAudit |
| 83 | recovery []ProtocolRecoveryAudit |
| 84 | workspace []WorkspaceMutation |
| 85 | turns int |
| 86 | } |
| 87 | |
| 88 | func (r *readinessAuditRecorder) Emit(Event) {} |
| 89 | |
| 90 | func (r *readinessAuditRecorder) RecordReadinessAudit(a evidence.ReadinessAudit) { |
| 91 | r.events = append(r.events, a) |
| 92 | } |
| 93 | |
| 94 | func (r *readinessAuditRecorder) RecordProtocolRecovery(a ProtocolRecoveryAudit) { |
| 95 | r.recovery = append(r.recovery, a) |
| 96 | } |
| 97 | |
| 98 | func (r *readinessAuditRecorder) RecordTurnCompletion() { r.turns++ } |
| 99 | |
| 100 | func (r *readinessAuditRecorder) RecordWorkspaceMutation(m WorkspaceMutation) { |
| 101 | r.workspace = append(r.workspace, m) |
| 102 | } |
| 103 | |
| 104 | func TestSyncForwardsTurnCompletion(t *testing.T) { |
| 105 | rec := &readinessAuditRecorder{} |
| 106 | RecordTurnCompletion(Sync(rec)) |
| 107 | if rec.turns != 1 { |
| 108 | t.Fatalf("turn completions = %d, want 1", rec.turns) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestSyncForwardsWorkspaceMutationWithoutUIEvent(t *testing.T) { |
| 113 | rec := &readinessAuditRecorder{} |
| 114 | sink := Sync(rec) |
| 115 | RecordWorkspaceMutation(sink, WorkspaceMutation{ToolName: "write_file", Paths: []string{"a.go"}, Content: true}) |
| 116 | if len(rec.workspace) != 1 || rec.workspace[0].ToolName != "write_file" || len(rec.workspace[0].Paths) != 1 { |
| 117 | t.Fatalf("workspace mutation not forwarded through Sync: %+v", rec.workspace) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func TestSyncForwardsReadinessAuditReceipts(t *testing.T) { |
| 122 | rec := &readinessAuditRecorder{} |
| 123 | sink := Sync(rec) |
| 124 | |
| 125 | RecordReadinessAudit(sink, evidence.ReadinessAudit{ |
| 126 | Result: evidence.ReadinessBlocked, |
| 127 | MissingProjectChecks: 1, |
| 128 | CommandMismatchMissing: 1, |
| 129 | }) |
| 130 | |
| 131 | if len(rec.events) != 1 { |
| 132 | t.Fatalf("readiness audit events = %d, want 1", len(rec.events)) |
| 133 | } |
| 134 | if rec.events[0].Result != evidence.ReadinessBlocked || rec.events[0].MissingProjectChecks != 1 { |
| 135 | t.Fatalf("readiness audit not forwarded through Sync: %+v", rec.events[0]) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | func TestSyncForwardsProtocolRecoveryWithoutEmittingUIEvent(t *testing.T) { |
| 140 | rec := &readinessAuditRecorder{} |
| 141 | sink := Sync(rec) |
| 142 | |
| 143 | RecordProtocolRecovery(sink, ProtocolRecoveryAudit{Kind: ProtocolRecoveryMissingReasoningRetryReplaced}) |
| 144 | |
| 145 | if len(rec.recovery) != 1 || rec.recovery[0].Kind != ProtocolRecoveryMissingReasoningRetryReplaced { |
| 146 | t.Fatalf("protocol recovery not forwarded through Sync: %+v", rec.recovery) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // Discard |
| 151 | |
| 152 | func TestDiscardSink(t *testing.T) { |
| 153 | // Discard should accept any event without panic. |
| 154 | Discard.Emit(Event{Kind: TurnStarted}) |
| 155 | Discard.Emit(Event{Kind: Text, Text: "discarded"}) |
| 156 | Discard.Emit(Event{Kind: TurnDone}) |
| 157 | } |
| 158 | |
| 159 | // Event struct field access |
| 160 | |
| 161 | func TestEventFields(t *testing.T) { |
| 162 | usage := &provider.Usage{PromptTokens: 100, CompletionTokens: 50} |
| 163 | pricing := &provider.Pricing{Input: 2.0, Output: 10.0, Currency: "$"} |
| 164 | |
| 165 | e := Event{ |
| 166 | Kind: Usage, |
| 167 | Usage: usage, |
| 168 | Pricing: pricing, |
| 169 | SessionHit: 80, |
| 170 | SessionMiss: 20, |
| 171 | } |
| 172 | if e.Kind != Usage { |
| 173 | t.Errorf("Kind = %d, want %d", e.Kind, Usage) |
| 174 | } |
| 175 | if e.Usage.PromptTokens != 100 { |
| 176 | t.Errorf("PromptTokens = %d, want 100", e.Usage.PromptTokens) |
| 177 | } |
| 178 | if e.Pricing.Currency != "$" { |
| 179 | t.Errorf("Currency = %q, want $", e.Pricing.Currency) |
| 180 | } |
| 181 | if e.SessionHit != 80 || e.SessionMiss != 20 { |
| 182 | t.Errorf("SessionHit=%d, SessionMiss=%d", e.SessionHit, e.SessionMiss) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // Tool struct |
| 187 | |
| 188 | func TestToolStruct(t *testing.T) { |
| 189 | tool := Tool{ |
| 190 | ID: "call-1", |
| 191 | Name: "bash", |
| 192 | Args: `{"command":"echo hi"}`, |
| 193 | ReadOnly: false, |
| 194 | Partial: true, |
| 195 | ParentID: "parent-1", |
| 196 | } |
| 197 | if tool.ID != "call-1" || tool.Name != "bash" { |
| 198 | t.Errorf("unexpected tool: %+v", tool) |
| 199 | } |
| 200 | if !tool.Partial { |
| 201 | t.Error("Partial should be true") |
| 202 | } |
| 203 | if tool.ParentID != "parent-1" { |
| 204 | t.Errorf("ParentID = %q", tool.ParentID) |
| 205 | } |
| 206 | |
| 207 | result := Tool{ |
| 208 | ID: "call-1", |
| 209 | Name: "bash", |
| 210 | Output: "hi\n", |
| 211 | Err: "", |
| 212 | Truncated: false, |
| 213 | } |
| 214 | if result.Output != "hi\n" { |
| 215 | t.Errorf("Output = %q", result.Output) |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | // Approval struct |
| 220 | |
| 221 | func TestApprovalStruct(t *testing.T) { |
| 222 | a := Approval{ID: "42", Tool: "bash", Subject: "rm -rf /"} |
| 223 | if a.ID != "42" || a.Tool != "bash" || a.Subject != "rm -rf /" { |
| 224 | t.Errorf("unexpected approval: %+v", a) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | // Ask / AskQuestion / AskOption / AskAnswer |
| 229 | |
| 230 | func TestAskStructs(t *testing.T) { |
| 231 | q := AskQuestion{ |
| 232 | ID: "q1", |
| 233 | Header: "Confirm", |
| 234 | Prompt: "Are you sure?", |
| 235 | Options: []AskOption{ |
| 236 | {Label: "Yes", Description: "Proceed"}, |
| 237 | {Label: "No", Description: "Cancel"}, |
| 238 | }, |
| 239 | Multi: false, |
| 240 | } |
| 241 | ask := Ask{ |
| 242 | ID: "ask-1", |
| 243 | Questions: []AskQuestion{q}, |
| 244 | } |
| 245 | if len(ask.Questions) != 1 { |
| 246 | t.Fatalf("questions count = %d", len(ask.Questions)) |
| 247 | } |
| 248 | if ask.Questions[0].Options[0].Label != "Yes" { |
| 249 | t.Errorf("first option = %q", ask.Questions[0].Options[0].Label) |
| 250 | } |
| 251 | |
| 252 | ans := AskAnswer{QuestionID: "q1", Selected: []string{"Yes"}} |
| 253 | if len(ans.Selected) != 1 || ans.Selected[0] != "Yes" { |
| 254 | t.Errorf("answer = %+v", ans) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | // Multiple Emit via channel-backed sink |
| 259 | |
| 260 | func TestChannelBackedSink(t *testing.T) { |
| 261 | ch := make(chan Event, 8) |
| 262 | sink := FuncSink(func(e Event) { ch <- e }) |
| 263 | |
| 264 | events := []Event{ |
| 265 | {Kind: TurnStarted}, |
| 266 | {Kind: Text, Text: "hello"}, |
| 267 | {Kind: ToolDispatch, Tool: Tool{Name: "bash"}}, |
| 268 | {Kind: ToolResult, Tool: Tool{Output: "ok"}}, |
| 269 | {Kind: Usage, Usage: &provider.Usage{TotalTokens: 42}}, |
| 270 | {Kind: Notice, Level: LevelWarn, Text: "heads up", Detail: "diagnostics"}, |
| 271 | {Kind: TurnDone}, |
| 272 | } |
| 273 | for _, e := range events { |
| 274 | sink.Emit(e) |
| 275 | } |
| 276 | |
| 277 | for i, want := range events { |
| 278 | got := <-ch |
| 279 | if got.Kind != want.Kind { |
| 280 | t.Errorf("event %d: Kind = %d, want %d", i, got.Kind, want.Kind) |
| 281 | } |
| 282 | if got.Detail != want.Detail { |
| 283 | t.Errorf("event %d: Detail = %q, want %q", i, got.Detail, want.Detail) |
| 284 | } |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | // FuncSink forwards every concurrent Emit exactly once |
| 289 | |
| 290 | // FuncSink.Emit forwards to the wrapped func with no synchronization of its own, |
| 291 | // so a concurrency-safe callback is the caller's responsibility (here a |
| 292 | // mutex-guarded counter). This verifies that N concurrent Emits produce exactly |
| 293 | // N forwarded calls, and under `go test -race` that the forwarding itself is |
| 294 | // race-free. |
| 295 | func TestFuncSinkForwardsEachConcurrentEmit(t *testing.T) { |
| 296 | var mu sync.Mutex |
| 297 | var count int |
| 298 | sink := FuncSink(func(e Event) { |
| 299 | mu.Lock() |
| 300 | count++ |
| 301 | mu.Unlock() |
| 302 | }) |
| 303 | var wg sync.WaitGroup |
| 304 | for range 100 { |
| 305 | wg.Go(func() { |
| 306 | sink.Emit(Event{Kind: Text}) |
| 307 | }) |
| 308 | } |
| 309 | wg.Wait() |
| 310 | mu.Lock() |
| 311 | defer mu.Unlock() |
| 312 | if count != 100 { |
| 313 | t.Errorf("count = %d, want 100", count) |
| 314 | } |
| 315 | } |
| 316 |