package cli import ( "bufio" "context" "encoding/json" "fmt" "io" runtime "iop/packages/go/agentruntime" "testing" ) // TestCodexAppServerEventMap_Delta verifies AgentMessageDeltaNotification shape: // { threadId, turnId, delta } → EventTypeDelta with source/thread_id/turn_id metadata. func TestCodexAppServerEventMap_Delta(t *testing.T) { notif := appServerNotification{ Method: "item/agentMessage/delta", Params: map[string]any{ "threadId": "th-1", "turnId": "tn-1", "delta": "hello world", }, } events, done, err := decodeAppServerNotification(notif, "run-1", "th-ctx", "tn-ctx") if err != nil { t.Fatalf("unexpected error: %v", err) } if done { t.Fatal("delta should not signal done") } if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } ev := events[0] if ev.Type != runtime.EventTypeDelta { t.Errorf("Type = %q, want %q", ev.Type, runtime.EventTypeDelta) } if ev.Delta != "hello world" { t.Errorf("Delta = %q, want %q", ev.Delta, "hello world") } if ev.Metadata["source"] != "codex-app-server" { t.Errorf("source metadata = %q", ev.Metadata["source"]) } // params threadId/turnId should override execution-context values if ev.Metadata["thread_id"] != "th-1" { t.Errorf("thread_id metadata = %q, want %q", ev.Metadata["thread_id"], "th-1") } if ev.Metadata["turn_id"] != "tn-1" { t.Errorf("turn_id metadata = %q, want %q", ev.Metadata["turn_id"], "tn-1") } } // TestCodexAppServerEventMap_DeltaMetadata verifies fallback to execution-context ids // when notification params do not carry threadId/turnId. func TestCodexAppServerEventMap_DeltaMetadata(t *testing.T) { notif := appServerNotification{ Method: "item/agentMessage/delta", Params: map[string]any{"delta": "chunk"}, } events, _, err := decodeAppServerNotification(notif, "run-x", "th-fallback", "tn-fallback") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if events[0].Metadata["thread_id"] != "th-fallback" { t.Errorf("thread_id = %q, want %q", events[0].Metadata["thread_id"], "th-fallback") } if events[0].Metadata["turn_id"] != "tn-fallback" { t.Errorf("turn_id = %q, want %q", events[0].Metadata["turn_id"], "tn-fallback") } } // TestCodexAppServerEventMap_TurnCompleted verifies that turn/completed with status // "completed" maps to EventTypeComplete and signals done. func TestCodexAppServerEventMap_TurnCompleted(t *testing.T) { notif := appServerNotification{ Method: "turn/completed", Params: map[string]any{ "threadId": "th-2", "turn": map[string]any{"id": "tn-2", "status": "completed"}, }, } events, done, err := decodeAppServerNotification(notif, "run-2", "", "") if err != nil { t.Fatalf("unexpected error: %v", err) } if !done { t.Fatal("turn/completed should signal done") } if len(events) != 1 || events[0].Type != runtime.EventTypeComplete { t.Fatalf("expected 1 complete event, got %+v", events) } if events[0].Metadata["thread_id"] != "th-2" { t.Errorf("thread_id = %q, want %q", events[0].Metadata["thread_id"], "th-2") } } // TestCodexAppServerEventMap_TurnCompletedFailed verifies that turn/completed with // status "failed" maps to EventTypeError (not complete). func TestCodexAppServerEventMap_TurnCompletedFailed(t *testing.T) { notif := appServerNotification{ Method: "turn/completed", Params: map[string]any{ "threadId": "th-3", "turn": map[string]any{ "id": "tn-3", "status": "failed", "error": map[string]any{"message": "context window exceeded"}, }, }, } events, done, err := decodeAppServerNotification(notif, "run-3", "", "") if err == nil { t.Fatal("expected non-nil error for failed turn") } if done { t.Fatal("failed turn should not signal done as complete") } if len(events) != 1 || events[0].Type != runtime.EventTypeError { t.Fatalf("expected 1 error event, got %+v", events) } if events[0].Error != "context window exceeded" { t.Errorf("Error = %q, want %q", events[0].Error, "context window exceeded") } } // TestCodexAppServerEventMap_Error verifies ErrorNotification shape: // { error: { message }, threadId, turnId } → EventTypeError with full metadata. func TestCodexAppServerEventMap_Error(t *testing.T) { notif := appServerNotification{ Method: "error", Params: map[string]any{ "error": map[string]any{"message": "quota exceeded"}, "threadId": "th-4", "turnId": "tn-4", }, } events, _, err := decodeAppServerNotification(notif, "run-4", "", "") if err == nil { t.Fatal("expected non-nil error for error notification") } if len(events) != 1 || events[0].Type != runtime.EventTypeError { t.Fatalf("expected 1 error event, got %+v", events) } if events[0].Error != "quota exceeded" { t.Errorf("Error = %q, want %q", events[0].Error, "quota exceeded") } if events[0].Metadata["thread_id"] != "th-4" { t.Errorf("thread_id = %q, want %q", events[0].Metadata["thread_id"], "th-4") } if events[0].Metadata["turn_id"] != "tn-4" { t.Errorf("turn_id = %q, want %q", events[0].Metadata["turn_id"], "tn-4") } } // TestCodexAppServerEventMap_TurnFailed verifies that turn/failed (legacy) maps to EventTypeError. func TestCodexAppServerEventMap_TurnFailed(t *testing.T) { notif := appServerNotification{ Method: "turn/failed", Params: map[string]any{"error": map[string]any{"message": "rate limit"}}, } events, _, err := decodeAppServerNotification(notif, "run-5", "", "") if err == nil { t.Fatal("expected non-nil error for turn/failed") } if len(events) != 1 || events[0].Type != runtime.EventTypeError { t.Fatalf("expected 1 error event, got %+v", events) } if events[0].Error != "rate limit" { t.Errorf("Error = %q, want %q", events[0].Error, "rate limit") } } // TestCodexAppServerDrainNotifications_DoesNotDropBurst verifies that a burst of // more than notifCh capacity (32) deltas followed by a terminal turn/completed are // all delivered without dropping the terminal event. func TestCodexAppServerDrainNotifications_DoesNotDropBurst(t *testing.T) { const burstSize = 40 // > default channel capacity of 32 stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() _ = stdinR proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 32), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() // Write burstSize delta notifications then a turn/completed. enc := json.NewEncoder(stdoutW) go func() { for i := 0; i < burstSize; i++ { _ = enc.Encode(map[string]any{ "method": "item/agentMessage/delta", "params": map[string]any{"delta": fmt.Sprintf("chunk-%d", i)}, }) } _ = enc.Encode(map[string]any{ "method": "turn/completed", "params": map[string]any{"turn": map[string]any{"status": "completed"}}, }) _ = stdoutW.Close() }() sink := &testSink{} err := codexAppServerDrainNotifications(context.Background(), proc, "run-burst", "th-burst", "tn-burst", sink) _ = stdinW.Close() if err != nil { t.Fatalf("unexpected error: %v", err) } var deltas, completes int for _, ev := range sink.events { switch ev.Type { case runtime.EventTypeDelta: deltas++ case runtime.EventTypeComplete: completes++ } } if deltas != burstSize { t.Errorf("delta count = %d, want %d", deltas, burstSize) } if completes != 1 { t.Errorf("complete count = %d, want 1", completes) } } // TestCodexAppServerSend_ResponsePriorityOverDone verifies that send() returns // the response (not a process-exited error) when the fake server sends a // response and then closes stdout simultaneously. func TestCodexAppServerSend_ResponsePriorityOverDone(t *testing.T) { stdinR, stdinW := io.Pipe() stdoutR, stdoutW := io.Pipe() // Fake server: read one request, respond, then close stdout (simulate exit). // stdinR is owned exclusively by this goroutine — no concurrent reader. go func() { defer stdinR.Close() dec := json.NewDecoder(stdinR) enc := json.NewEncoder(stdoutW) var req map[string]json.RawMessage if err := dec.Decode(&req); err != nil { _ = stdoutW.Close() return } var id int64 _ = json.Unmarshal(req["id"], &id) _ = enc.Encode(map[string]any{"id": id, "result": map[string]any{"ok": true}}) _ = stdoutW.Close() }() proc := &codexAppServerProc{ stdin: stdinW, stdout: bufio.NewScanner(stdoutR), notifCh: make(chan appServerNotification, 8), done: make(chan struct{}), } proc.stdout.Buffer(make([]byte, 64*1024), 4*1024*1024) go proc.readLoop() resp, err := proc.send(context.Background(), "ping", nil) _ = stdinW.Close() if err != nil { t.Fatalf("send() returned error instead of response: %v", err) } if resp.Result == nil { t.Fatal("send() returned nil result") } }