iop/packages/go/agentprovider/cli/codex_app_server_session_test.go

844 lines
24 KiB
Go

package cli
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
runtime "iop/packages/go/agentruntime"
"iop/packages/go/config"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// fakeAppServer is a minimal fake of codex app-server --stdio.
// It responds to requests by dispatching to a handler func.
type fakeAppServer struct {
dec *json.Decoder
enc *json.Encoder
handler func(method string, id int64, params map[string]any) (map[string]any, error)
}
func newFakeAppServer(r io.Reader, w io.Writer) *fakeAppServer {
return &fakeAppServer{
dec: json.NewDecoder(r),
enc: json.NewEncoder(w),
}
}
func (f *fakeAppServer) serveOne() error {
var req struct {
ID int64 `json:"id"`
Method string `json:"method"`
Params map[string]any `json:"params"`
}
if err := f.dec.Decode(&req); err != nil {
return err
}
// Notifications (id == 0) get no response.
if req.ID == 0 {
return nil
}
result, rpcErr := f.handler(req.Method, req.ID, req.Params)
resp := map[string]any{"id": req.ID}
if rpcErr != nil {
resp["error"] = map[string]any{"code": -32000, "message": rpcErr.Error()}
} else {
resp["result"] = result
}
return f.enc.Encode(resp)
}
// makeStdioPipe creates an in-process pipe pair that looks like a subprocess stdio.
// Returns (stdinReader, stdinWriter, stdoutReader, stdoutWriter).
func makeStdioPipes() (io.Reader, io.WriteCloser, io.ReadCloser, io.WriteCloser) {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
return stdinR, stdinW, stdoutR, stdoutW
}
type captureWriter struct {
w io.WriteCloser
buf *strings.Builder
}
func (c *captureWriter) Write(p []byte) (int, error) {
c.buf.Write(p)
return c.w.Write(p)
}
func (c *captureWriter) Close() error { return c.w.Close() }
// TestCodexAppServerRPC_InitializeShape verifies the initialize request:
// - has "id", "method", and "params" fields
// - does NOT have a "jsonrpc" field
// - method == "initialize"
func TestCodexAppServerRPC_InitializeShape(t *testing.T) {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
var capturedReq map[string]json.RawMessage
serverDone := make(chan error, 1)
go func() {
defer close(serverDone)
dec := json.NewDecoder(stdinR)
// 1. initialize request
if err := dec.Decode(&capturedReq); err != nil {
serverDone <- fmt.Errorf("decode initialize: %w", err)
return
}
var id int64
_ = json.Unmarshal(capturedReq["id"], &id)
resp := map[string]any{"id": id, "result": map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
}}
_ = json.NewEncoder(stdoutW).Encode(resp)
// 2. initialized notification (no response)
var notif map[string]json.RawMessage
_ = dec.Decode(&notif)
// 3. thread/start
var tsReq map[string]json.RawMessage
if err := dec.Decode(&tsReq); err != nil {
serverDone <- fmt.Errorf("decode thread/start: %w", err)
return
}
var tsID int64
_ = json.Unmarshal(tsReq["id"], &tsID)
_ = json.NewEncoder(stdoutW).Encode(map[string]any{
"id": tsID,
"result": map[string]any{"threadId": "thread-abc"},
})
}()
// Build a proc directly (bypass exec.Command) using our pipes.
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()
threadID, err := codexAppServerInit(context.Background(), proc)
if err != nil {
t.Fatalf("codexAppServerInit: %v", err)
}
// Close stdin so the fake server goroutine can finish.
_ = stdinW.Close()
_ = stdoutW.Close()
if threadID != "thread-abc" {
t.Errorf("threadID = %q, want %q", threadID, "thread-abc")
}
// Verify initialize request shape.
if _, hasJSONRPC := capturedReq["jsonrpc"]; hasJSONRPC {
t.Error("initialize request must NOT contain 'jsonrpc' field")
}
var method string
_ = json.Unmarshal(capturedReq["method"], &method)
if method != "initialize" {
t.Errorf("method = %q, want %q", method, "initialize")
}
if _, hasID := capturedReq["id"]; !hasID {
t.Error("initialize request must have 'id' field")
}
if _, hasParams := capturedReq["params"]; !hasParams {
t.Error("initialize request must have 'params' field")
}
}
// TestCodexAppServerRPC_NoJSONRPCField confirms that send() never emits "jsonrpc".
func TestCodexAppServerRPC_NoJSONRPCField(t *testing.T) {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
go func() {
dec := json.NewDecoder(stdinR)
var raw map[string]json.RawMessage
if err := dec.Decode(&raw); err != nil {
return
}
var id int64
_ = json.Unmarshal(raw["id"], &id)
_ = json.NewEncoder(stdoutW).Encode(map[string]any{
"id": id,
"result": map[string]any{},
})
_ = stdinR.Close()
_ = 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()
// Capture raw bytes written to stdin.
var written strings.Builder
origStdin := proc.stdin
proc.stdin = &captureWriter{w: origStdin, buf: &written}
_, _ = proc.send(context.Background(), "ping", nil)
_ = stdinW.Close()
line := strings.TrimSpace(written.String())
var decoded map[string]json.RawMessage
if err := json.Unmarshal([]byte(line), &decoded); err != nil {
t.Fatalf("parse written line: %v", err)
}
if _, ok := decoded["jsonrpc"]; ok {
t.Error("send() must NOT include 'jsonrpc' field in request")
}
}
// TestCodexAppServerProcessLifecycle_CloseSafe verifies that proc.close() is safe
// to call multiple times (no panic on duplicate close of done channel).
func TestCodexAppServerProcessLifecycle_CloseSafe(t *testing.T) {
stdinR, stdinW := io.Pipe()
stdoutR, _ := io.Pipe()
_ = stdinR
proc := &codexAppServerProc{
stdin: stdinW,
stdout: bufio.NewScanner(stdoutR),
notifCh: make(chan appServerNotification, 8),
done: make(chan struct{}),
}
// close() twice must not panic.
proc.close()
proc.close()
}
// TestCodexAppServerProcessLifecycle_ExitSurfaces verifies that send() returns an
// error when the process stdout closes (simulating process exit).
func TestCodexAppServerProcessLifecycle_ExitSurfaces(t *testing.T) {
// Use a devnull reader for stdin so the write goroutine doesn't block.
stdinR, stdinW := io.Pipe()
go io.Copy(io.Discard, stdinR) // drain stdin so write doesn't block
stdoutR, stdoutW := io.Pipe()
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()
// Close stdout to signal process exit after a brief moment.
go func() {
_ = stdoutW.Close()
}()
_, err := proc.send(context.Background(), "initialize", nil)
if err == nil {
t.Fatal("expected error when process exits, got nil")
}
}
// TestCodexAppServerSessionResolve verifies that two Execute calls with the same
// target/sessionID reuse the same codexAppServerSession (without starting a real process).
func TestCodexAppServerSessionResolve(t *testing.T) {
e := &codexAppServerExecutor{
cli: &CLI{},
sessions: make(map[sessionKey]*codexAppServerSession),
}
// Pre-seed a session so resolveCodexAppServerSession returns it without spawning.
key := sessionKey{target: "codex-as", sessionID: runtime.DefaultSessionID}
existing := &codexAppServerSession{key: key, threadID: "tid-1"}
e.sessions[key] = existing
spec := runtime.ExecutionSpec{
Target: "codex-as",
SessionID: "",
}
profile := config.CLIProfileConf{Mode: modeCodexAppServer, Command: "false"}
got, err := e.resolveCodexAppServerSession(context.Background(), spec, profile)
if err != nil {
t.Fatalf("resolveCodexAppServerSession: %v", err)
}
if got != existing {
t.Error("expected same session pointer to be returned")
}
}
// TestCodexAppServerRequireExisting verifies that SessionModeRequireExisting returns
// an error when no session exists.
func TestCodexAppServerRequireExisting(t *testing.T) {
e := &codexAppServerExecutor{
cli: &CLI{},
sessions: make(map[sessionKey]*codexAppServerSession),
}
spec := runtime.ExecutionSpec{
Target: "codex-as",
SessionID: "missing",
SessionMode: runtime.SessionModeRequireExisting,
}
profile := config.CLIProfileConf{Mode: modeCodexAppServer}
_, err := e.resolveCodexAppServerSession(context.Background(), spec, profile)
if err == nil {
t.Fatal("expected error for SessionModeRequireExisting with no session, got nil")
}
}
// TestCodexAppServerSend_RequestIDMonotone verifies that successive send() calls
// use strictly increasing id values.
func TestCodexAppServerSend_RequestIDMonotone(t *testing.T) {
var counter atomic.Int64
var ids []int64
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
go func() {
dec := json.NewDecoder(stdinR)
enc := json.NewEncoder(stdoutW)
for i := 0; i < 3; i++ {
var raw map[string]json.RawMessage
if err := dec.Decode(&raw); err != nil {
return
}
var id int64
_ = json.Unmarshal(raw["id"], &id)
counter.Add(1)
_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{}})
}
_ = 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()
for i := 0; i < 3; i++ {
id := proc.nextID.Load() + 1
ids = append(ids, id)
if _, err := proc.send(context.Background(), fmt.Sprintf("method-%d", i), nil); err != nil {
t.Fatalf("send %d: %v", i, err)
}
}
_ = stdinW.Close()
for i := 1; i < len(ids); i++ {
if ids[i] <= ids[i-1] {
t.Errorf("ids not monotone: %v", ids)
}
}
}
// TestCodexAppServerThreadMissingID verifies that codexAppServerInit returns an error
// when thread/start responds without a thread id field.
func TestCodexAppServerThreadMissingID(t *testing.T) {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
go func() {
dec := json.NewDecoder(stdinR)
enc := json.NewEncoder(stdoutW)
// initialize request → respond with capabilities
var req map[string]json.RawMessage
_ = dec.Decode(&req)
var id int64
_ = json.Unmarshal(req["id"], &id)
_ = enc.Encode(map[string]any{
"id": id,
"result": map[string]any{"protocolVersion": "2024-11-05", "capabilities": map[string]any{}},
})
// initialized notification (no response needed)
_ = dec.Decode(&req)
// thread/start → respond with empty result (no threadId, no id)
_ = dec.Decode(&req)
var tsID int64
_ = json.Unmarshal(req["id"], &tsID)
_ = enc.Encode(map[string]any{
"id": tsID,
"result": map[string]any{},
})
_ = 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()
_, err := codexAppServerInit(context.Background(), proc)
_ = stdinW.Close()
if err == nil {
t.Fatal("expected error when thread/start returns no thread id, got nil")
}
}
// TestCodexAppServerSessionThreadMapping verifies that same IOP session reuses
// the same thread id and different sessions get different thread ids.
func TestCodexAppServerSessionThreadMapping(t *testing.T) {
e := &codexAppServerExecutor{
cli: &CLI{},
sessions: make(map[sessionKey]*codexAppServerSession),
}
key1 := sessionKey{target: "codex-as", sessionID: "sess-A"}
key2 := sessionKey{target: "codex-as", sessionID: "sess-B"}
e.sessions[key1] = &codexAppServerSession{key: key1, threadID: "thread-1"}
e.sessions[key2] = &codexAppServerSession{key: key2, threadID: "thread-2"}
// Same session must return same pointer (same thread id).
spec := runtime.ExecutionSpec{Target: "codex-as", SessionID: "sess-A"}
profile := config.CLIProfileConf{Mode: modeCodexAppServer, Command: "false"}
got, err := e.resolveCodexAppServerSession(context.Background(), spec, profile)
if err != nil {
t.Fatalf("resolveCodexAppServerSession: %v", err)
}
if got.threadID != "thread-1" {
t.Errorf("expected thread-1, got %q", got.threadID)
}
// Different session must return different thread id.
spec2 := runtime.ExecutionSpec{Target: "codex-as", SessionID: "sess-B"}
got2, err := e.resolveCodexAppServerSession(context.Background(), spec2, profile)
if err != nil {
t.Fatalf("resolveCodexAppServerSession sess-B: %v", err)
}
if got2.threadID != "thread-2" {
t.Errorf("expected thread-2, got %q", got2.threadID)
}
if got.threadID == got2.threadID {
t.Error("different sessions must have different thread ids")
}
}
// TestCodexAppServerTurnStartRequestShape verifies that codexAppServerTurnStart sends
// a turn/start request with the required TurnStartParams shape:
// { threadId, input: [{ type, text, text_elements }] }
func TestCodexAppServerTurnStartRequestShape(t *testing.T) {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
var capturedParams map[string]json.RawMessage
serverDone := make(chan struct{})
go func() {
defer close(serverDone)
dec := json.NewDecoder(stdinR)
enc := json.NewEncoder(stdoutW)
var req map[string]json.RawMessage
if err := dec.Decode(&req); err != nil {
return
}
capturedParams = req
var id int64
_ = json.Unmarshal(req["id"], &id)
_ = enc.Encode(map[string]any{
"id": id,
"result": map[string]any{"turn": map[string]any{"id": "turn-shape-test"}},
})
_ = 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()
turnID, err := codexAppServerTurnStart(context.Background(), proc, "thread-shape-1", "hello codex")
_ = stdinW.Close()
<-serverDone
if err != nil {
t.Fatalf("codexAppServerTurnStart: %v", err)
}
if turnID != "turn-shape-test" {
t.Errorf("turnID = %q, want %q", turnID, "turn-shape-test")
}
// Verify threadId field.
var threadID string
_ = json.Unmarshal(capturedParams["params"], &map[string]json.RawMessage{})
var paramsMap map[string]json.RawMessage
_ = json.Unmarshal(capturedParams["params"], &paramsMap)
_ = json.Unmarshal(paramsMap["threadId"], &threadID)
if threadID != "thread-shape-1" {
t.Errorf("threadId = %q, want %q", threadID, "thread-shape-1")
}
// Verify input array shape.
var inputArr []map[string]json.RawMessage
if err := json.Unmarshal(paramsMap["input"], &inputArr); err != nil {
t.Fatalf("parse input: %v", err)
}
if len(inputArr) != 1 {
t.Fatalf("input length = %d, want 1", len(inputArr))
}
item := inputArr[0]
var typ string
_ = json.Unmarshal(item["type"], &typ)
if typ != "text" {
t.Errorf("input[0].type = %q, want %q", typ, "text")
}
var text string
_ = json.Unmarshal(item["text"], &text)
if text != "hello codex" {
t.Errorf("input[0].text = %q, want %q", text, "hello codex")
}
// text_elements must be present (required by UserInput contract), value must be an array.
rawTE, ok := item["text_elements"]
if !ok {
t.Fatal("input[0].text_elements field missing")
}
var textElements []any
if err := json.Unmarshal(rawTE, &textElements); err != nil {
t.Fatalf("input[0].text_elements is not an array: %v", err)
}
}
// TestCodexAppServerTurnStartRequestShape_EmptyPrompt verifies that an empty prompt
// still sends input with a text item (explicit design: always send input array).
func TestCodexAppServerTurnStartRequestShape_EmptyPrompt(t *testing.T) {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
var capturedParams map[string]json.RawMessage
serverDone := make(chan struct{})
go func() {
defer close(serverDone)
dec := json.NewDecoder(stdinR)
enc := json.NewEncoder(stdoutW)
var req map[string]json.RawMessage
if err := dec.Decode(&req); err != nil {
return
}
capturedParams = req
var id int64
_ = json.Unmarshal(req["id"], &id)
_ = enc.Encode(map[string]any{
"id": id,
"result": map[string]any{"turn": map[string]any{"id": "turn-empty"}},
})
_ = 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()
_, err := codexAppServerTurnStart(context.Background(), proc, "thread-empty", "")
_ = stdinW.Close()
<-serverDone
if err != nil {
t.Fatalf("codexAppServerTurnStart: %v", err)
}
var paramsMap map[string]json.RawMessage
_ = json.Unmarshal(capturedParams["params"], &paramsMap)
// input must be present even for empty prompt.
if _, ok := paramsMap["input"]; !ok {
t.Fatal("input field missing for empty prompt")
}
var inputArr []map[string]json.RawMessage
if err := json.Unmarshal(paramsMap["input"], &inputArr); err != nil {
t.Fatalf("parse input: %v", err)
}
if len(inputArr) != 1 {
t.Fatalf("input length = %d, want 1", len(inputArr))
}
if _, ok := inputArr[0]["text_elements"]; !ok {
t.Fatal("input[0].text_elements missing for empty prompt")
}
}
// TestCodexAppServerThreadStartResponseShape verifies that codexAppServerInit parses
// the actual ThreadStartResponse shape: result.thread.id.
func TestCodexAppServerThreadStartResponseShape(t *testing.T) {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
go func() {
dec := json.NewDecoder(stdinR)
enc := json.NewEncoder(stdoutW)
// initialize
var req map[string]json.RawMessage
_ = dec.Decode(&req)
var id int64
_ = json.Unmarshal(req["id"], &id)
_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{
"protocolVersion": "2024-11-05", "capabilities": map[string]any{},
}})
// initialized notification
_ = dec.Decode(&req)
// thread/start → actual shape: result.thread.id
_ = dec.Decode(&req)
var tsID int64
_ = json.Unmarshal(req["id"], &tsID)
_ = enc.Encode(map[string]any{
"id": tsID,
"result": map[string]any{"thread": map[string]any{"id": "thread-nested-id"}},
})
_ = 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()
threadID, err := codexAppServerInit(context.Background(), proc)
_ = stdinW.Close()
if err != nil {
t.Fatalf("codexAppServerInit: %v", err)
}
if threadID != "thread-nested-id" {
t.Errorf("threadID = %q, want %q", threadID, "thread-nested-id")
}
}
// TestCodexAppServerTurnStartResponseShape verifies that codexAppServerTurnStart parses
// the actual TurnStartResponse shape: result.turn.id.
func TestCodexAppServerTurnStartResponseShape(t *testing.T) {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
go func() {
dec := json.NewDecoder(stdinR)
enc := json.NewEncoder(stdoutW)
var req map[string]json.RawMessage
_ = dec.Decode(&req)
var id int64
_ = json.Unmarshal(req["id"], &id)
_ = enc.Encode(map[string]any{
"id": id,
"result": map[string]any{"turn": map[string]any{"id": "turn-nested-id"}},
})
_ = 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()
turnID, err := codexAppServerTurnStart(context.Background(), proc, "thread-1", "hello")
_ = stdinW.Close()
if err != nil {
t.Fatalf("codexAppServerTurnStart: %v", err)
}
if turnID != "turn-nested-id" {
t.Errorf("turnID = %q, want %q", turnID, "turn-nested-id")
}
}
// TestCodexAppServerTurnStartMissingID verifies that a missing turn id returns an error.
func TestCodexAppServerTurnStartMissingID(t *testing.T) {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
go func() {
dec := json.NewDecoder(stdinR)
enc := json.NewEncoder(stdoutW)
var req map[string]json.RawMessage
_ = dec.Decode(&req)
var id int64
_ = json.Unmarshal(req["id"], &id)
_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{}})
_ = 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()
_, err := codexAppServerTurnStart(context.Background(), proc, "thread-1", "")
_ = stdinW.Close()
if err == nil {
t.Fatal("expected error when turn id is missing, got nil")
}
}
// TestCodexAppServerInit_ThreadStartHasParams verifies that codexAppServerInit sends
// thread/start with a "params" field (not null/absent). Real Codex app-server returns
// -32600 "missing field `params`" if the field is omitted.
func TestCodexAppServerInit_ThreadStartHasParams(t *testing.T) {
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
var threadStartReq map[string]json.RawMessage
serverDone := make(chan struct{})
go func() {
defer close(serverDone)
dec := json.NewDecoder(stdinR)
enc := json.NewEncoder(stdoutW)
// initialize
var req map[string]json.RawMessage
_ = dec.Decode(&req)
var id int64
_ = json.Unmarshal(req["id"], &id)
_ = enc.Encode(map[string]any{"id": id, "result": map[string]any{
"protocolVersion": "2024-11-05", "capabilities": map[string]any{},
}})
// initialized notification (no response)
_ = dec.Decode(&req)
// thread/start — capture full request
_ = dec.Decode(&threadStartReq)
var tsID int64
_ = json.Unmarshal(threadStartReq["id"], &tsID)
_ = enc.Encode(map[string]any{
"id": tsID,
"result": map[string]any{"thread": map[string]any{"id": "thread-params-check"}},
})
_ = 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()
threadID, err := codexAppServerInit(context.Background(), proc)
_ = stdinW.Close()
<-serverDone
if err != nil {
t.Fatalf("codexAppServerInit: %v", err)
}
if threadID != "thread-params-check" {
t.Errorf("threadID = %q, want %q", threadID, "thread-params-check")
}
// "params" must be present in thread/start request — real server rejects null/absent.
rawParams, ok := threadStartReq["params"]
if !ok {
t.Fatal("thread/start request must include 'params' field")
}
// params must be a JSON object (not null).
var paramsObj map[string]json.RawMessage
if err := json.Unmarshal(rawParams, &paramsObj); err != nil {
t.Fatalf("thread/start 'params' must be a JSON object, got: %s", string(rawParams))
}
}
func TestCodexAppServerProcCwd(t *testing.T) {
tmpDir := t.TempDir()
workspace := t.TempDir()
scriptPath := filepath.Join(tmpDir, "fake-codex-server.sh")
scriptContent := fmt.Sprintf(`#!/bin/sh
pwd > "%s/cwd.txt"
echo '{"id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{}}}'
`, workspace)
if err := os.WriteFile(scriptPath, []byte(scriptContent), 0755); err != nil {
t.Fatalf("write fake script: %v", err)
}
profile := config.CLIProfileConf{
Command: scriptPath,
Args: []string{},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
proc, err := startCodexAppServerProc(ctx, profile, workspace)
if err != nil {
t.Fatalf("startCodexAppServerProc: %v", err)
}
defer proc.close()
markerPath := filepath.Join(workspace, "cwd.txt")
cwdBytes := readFileEventually(t, markerPath, 5*time.Second)
cwd := strings.TrimSpace(string(cwdBytes))
resolvedWorkspace, err := os.Readlink(workspace)
if err != nil {
resolvedWorkspace = workspace
}
if cwd != workspace && cwd != resolvedWorkspace {
t.Errorf("expected process cwd to be %q or %q, got %q", workspace, resolvedWorkspace, cwd)
}
}
func readFileEventually(t *testing.T, path string, timeout time.Duration) []byte {
t.Helper()
deadline := time.Now().Add(timeout)
var lastErr error
for {
b, err := os.ReadFile(path)
if err == nil {
return b
}
lastErr = err
if time.Now().After(deadline) {
t.Fatalf("failed to read file %s within %s: %v", path, timeout, lastErr)
}
time.Sleep(10 * time.Millisecond)
}
}