276 lines
8.7 KiB
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")
|
|
}
|
|
}
|