iop/packages/go/agentprovider/cli/codex_app_server_events_test.go

276 lines
8.7 KiB
Go

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")
}
}