diff --git a/agent/codex/appserver_session.go b/agent/codex/appserver_session.go index 15d8415c68..30945b7265 100644 --- a/agent/codex/appserver_session.go +++ b/agent/codex/appserver_session.go @@ -64,6 +64,10 @@ type turnStartResponse struct { } `json:"turn"` } +type turnSteerResponse struct { + TurnID string `json:"turnId"` +} + type turnNotification struct { ThreadID string `json:"threadId"` Turn struct { @@ -457,7 +461,8 @@ func (s *appServerSession) Send(prompt string, messageID string, images []core.I } s.stateMu.Lock() - if !s.preambleSent { + activeTurn := s.currentTurn + if activeTurn == "" && !s.preambleSent { prompt = prependCodexPromptPreamble(prompt, s.promptPreamble) s.preambleSent = true } @@ -481,6 +486,13 @@ func (s *appServerSession) Send(prompt string, messageID string, images []core.I }) } + if activeTurn != "" { + return s.steerTurn(threadID, activeTurn, input) + } + return s.startTurn(threadID, input) +} + +func (s *appServerSession) startTurn(threadID string, input []map[string]any) error { params := map[string]any{ "threadId": threadID, "input": input, @@ -511,6 +523,26 @@ func (s *appServerSession) Send(prompt string, messageID string, images []core.I return nil } +func (s *appServerSession) steerTurn(threadID, expectedTurnID string, input []map[string]any) error { + params := map[string]any{ + "threadId": threadID, + "expectedTurnId": expectedTurnID, + "input": input, + } + + var resp turnSteerResponse + if err := s.request("turn/steer", params, &resp); err != nil { + return fmt.Errorf("codex app-server turn/steer: %w", err) + } + if resp.TurnID == "" { + return fmt.Errorf("codex app-server turn/steer returned empty turn id") + } + if resp.TurnID != expectedTurnID { + return fmt.Errorf("codex app-server turn/steer returned turn id %q, want %q", resp.TurnID, expectedTurnID) + } + return nil +} + func (s *appServerSession) stageImages(prompt string, images []core.ImageAttachment) (string, []string, error) { if len(images) == 0 { return prompt, nil, nil diff --git a/agent/codex/appserver_session_test.go b/agent/codex/appserver_session_test.go index ce2b28d32d..ae48e9ee4c 100644 --- a/agent/codex/appserver_session_test.go +++ b/agent/codex/appserver_session_test.go @@ -177,6 +177,98 @@ func TestAppServerSession_RequestTimeoutIncludesBlockedStdinWrite(t *testing.T) } } +func TestAppServerSession_SendSteersActiveTurn(t *testing.T) { + s, stdin := newSendTestSession(t, "turn-1", "buffered answer") + request, err := sendAndRespond(t, s, stdin, "focus on the requested fix", map[string]any{"turnId": "turn-1"}) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if request.Method != "turn/steer" { + t.Fatalf("method = %q, want turn/steer", request.Method) + } + if got := request.Params["threadId"]; got != "thread-1" { + t.Fatalf("threadId = %v, want thread-1", got) + } + if got := request.Params["expectedTurnId"]; got != "turn-1" { + t.Fatalf("expectedTurnId = %v, want turn-1", got) + } + if got := request.Params["input"]; got == nil { + t.Fatal("input = nil, want structured steering input") + } + + currentTurn, pendingMsgs := sendTestState(s) + if currentTurn != "turn-1" { + t.Fatalf("current turn = %q, want turn-1", currentTurn) + } + if len(pendingMsgs) != 1 || pendingMsgs[0] != "buffered answer" { + t.Fatalf("pending messages = %v, want buffered answer", pendingMsgs) + } + + completedItem, err := json.Marshal(map[string]any{ + "threadId": "thread-1", + "turnId": "turn-1", + "item": map[string]any{"type": "agentMessage", "text": "complete final answer"}, + }) + if err != nil { + t.Fatalf("marshal completed item: %v", err) + } + s.handleNotification("item/completed", completedItem) + completedTurn, err := json.Marshal(map[string]any{ + "threadId": "thread-1", + "turn": map[string]any{"id": "turn-1", "status": "completed"}, + }) + if err != nil { + t.Fatalf("marshal completed turn: %v", err) + } + s.handleNotification("turn/completed", completedTurn) + + firstText := waitForEvent(t, s.events) + finalText := waitForEvent(t, s.events) + resultEvent := waitForEvent(t, s.events) + if firstText.Type != core.EventText || firstText.Content != "buffered answer" { + t.Fatalf("first text event = %#v, want buffered answer", firstText) + } + if finalText.Type != core.EventText || finalText.Content != "complete final answer" { + t.Fatalf("final text event = %#v, want complete final answer", finalText) + } + if resultEvent.Type != core.EventResult || !resultEvent.Done { + t.Fatalf("result event = %#v, want one completed result", resultEvent) + } +} + +func TestAppServerSession_SendStartsIdleTurn(t *testing.T) { + s, stdin := newSendTestSession(t, "", "stale message") + request, err := sendAndRespond(t, s, stdin, "start new work", map[string]any{"turn": map[string]any{"id": "turn-2"}}) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if request.Method != "turn/start" { + t.Fatalf("method = %q, want turn/start", request.Method) + } + currentTurn, pendingMsgs := sendTestState(s) + if currentTurn != "turn-2" { + t.Fatalf("current turn = %q, want turn-2", currentTurn) + } + if len(pendingMsgs) != 0 { + t.Fatalf("pending messages = %v, want none", pendingMsgs) + } +} + +func TestAppServerSession_SendRejectsMismatchedSteeringTurn(t *testing.T) { + s, stdin := newSendTestSession(t, "turn-1", "buffered answer") + _, err := sendAndRespond(t, s, stdin, "steer current work", map[string]any{"turnId": "turn-other"}) + if err == nil || !strings.Contains(err.Error(), "turn/steer") || !strings.Contains(err.Error(), "turn-other") { + t.Fatalf("Send() error = %v, want mismatched turn/steer error", err) + } + currentTurn, pendingMsgs := sendTestState(s) + if currentTurn != "turn-1" { + t.Fatalf("current turn = %q, want turn-1", currentTurn) + } + if len(pendingMsgs) != 1 || pendingMsgs[0] != "buffered answer" { + t.Fatalf("pending messages = %v, want buffered answer", pendingMsgs) + } +} + func TestMapAppServerRateLimits_PrefersMultiBucketView(t *testing.T) { report := mapAppServerRateLimits(appServerRateLimitsResponse{ RateLimits: appServerRateLimitSnapshot{ @@ -432,6 +524,80 @@ func serverRequestProbe(t *testing.T, idJSON, method string, params any) map[str } } +type clientRequestProbe struct { + ID any `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` +} + +func newSendTestSession(t *testing.T, currentTurn string, pendingMsgs ...string) (*appServerSession, *lockedWriteCloser) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + stdin := &lockedWriteCloser{} + s := &appServerSession{ + ctx: ctx, + cancel: cancel, + events: make(chan core.Event, 8), + stdin: stdin, + pending: make(map[int64]chan rpcResponseEnvelope), + preambleSent: true, + currentTurn: currentTurn, + pendingMsgs: append([]string(nil), pendingMsgs...), + } + s.alive.Store(true) + s.threadID.Store("thread-1") + return s, stdin +} + +func sendAndRespond(t *testing.T, s *appServerSession, stdin *lockedWriteCloser, prompt string, result any) (clientRequestProbe, error) { + t.Helper() + done := make(chan error, 1) + go func() { + done <- s.Send(prompt, "", nil, nil) + }() + request := waitForClientRequest(t, stdin) + resultJSON, err := json.Marshal(result) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + s.handleResponse(rpcResponseEnvelope{ID: request.ID, Result: resultJSON}) + select { + case err := <-done: + return request, err + case <-time.After(time.Second): + t.Fatal("timed out waiting for Send") + return clientRequestProbe{}, nil + } +} + +func sendTestState(s *appServerSession) (string, []string) { + s.stateMu.Lock() + defer s.stateMu.Unlock() + return s.currentTurn, append([]string(nil), s.pendingMsgs...) +} + +func waitForEvent(t *testing.T, events <-chan core.Event) core.Event { + t.Helper() + select { + case event := <-events: + return event + case <-time.After(time.Second): + t.Fatal("timed out waiting for app-server event") + return core.Event{} + } +} + +func waitForClientRequest(t *testing.T, w *lockedWriteCloser) clientRequestProbe { + t.Helper() + line := waitForWrittenJSONLine(t, w) + var request clientRequestProbe + if err := json.Unmarshal([]byte(line), &request); err != nil { + t.Fatalf("decode JSON-RPC request %q: %v", line, err) + } + return request +} + func waitForWrittenJSONLine(t *testing.T, w *lockedWriteCloser) string { t.Helper() deadline := time.After(time.Second) diff --git a/core/cuj_test.go b/core/cuj_test.go index f62495f910..ede8d050de 100644 --- a/core/cuj_test.go +++ b/core/cuj_test.go @@ -1138,16 +1138,10 @@ func TestCUJ_A3_ImageReachesAgent(t *testing.T) { e.ReceiveMessage(plat, msg) deadline := time.After(2 * time.Second) - for { - agent.mu.Lock() - n := len(agent.sessions) - agent.mu.Unlock() - if n > 0 { - break - } + for len(plat.getSent()) == 0 { select { case <-deadline: - t.Fatal("agent never received the message with image") + t.Fatal("agent never completed the message with image") default: time.Sleep(10 * time.Millisecond) } @@ -1202,16 +1196,10 @@ func TestCUJ_A5_FileReachesAgent(t *testing.T) { e.ReceiveMessage(plat, msg) deadline := time.After(2 * time.Second) - for { - agent.mu.Lock() - n := len(agent.sessions) - agent.mu.Unlock() - if n > 0 { - return - } + for len(plat.getSent()) == 0 { select { case <-deadline: - t.Fatal("agent never received the message with file attachment") + t.Fatal("agent never completed the message with file attachment") default: time.Sleep(10 * time.Millisecond) } diff --git a/core/engine_test.go b/core/engine_test.go index 2f9fb7d3f3..3a4df4f3ed 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -634,19 +634,19 @@ func (a *stubDeleteAgent) DeleteSession(_ context.Context, sessionID string) err return nil } -// waitDeleteModePhase polls the delete-mode state for the given session key -// until it reaches the target phase or the timeout expires. -func waitDeleteModePhase(t *testing.T, e *Engine, sessionKey, targetPhase string) { +// waitDeleteModeResult polls until the asynchronous delete operation updates +// both its state and the user-visible card. +func waitDeleteModeResult(t *testing.T, e *Engine, p *stubCardPlatform, sessionKey string) { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { dm := e.getDeleteModeState(sessionKey) - if dm != nil && dm.phase == targetPhase { + if dm != nil && dm.phase == "result" && len(p.getRefreshedCards()) > 0 { return } time.Sleep(10 * time.Millisecond) } - t.Fatalf("timed out waiting for delete mode phase %q", targetPhase) + t.Fatal("timed out waiting for delete mode result card") } type stubProviderAgent struct { @@ -4205,7 +4205,7 @@ func TestDeleteMode_ConfirmAndSubmitDeletesSelectedSessions(t *testing.T) { } // Submit is now async; the returned card is a "deleting" indicator. // Wait for the background goroutine to complete and push the result card. - waitDeleteModePhase(t, e, msg.SessionKey, "result") + waitDeleteModeResult(t, e, p, msg.SessionKey) if got, want := strings.Join(agent.deleted, ","), "session-1,session-3"; got != want { t.Fatalf("deleted = %q, want %q", got, want) } @@ -4243,7 +4243,7 @@ func TestDeleteMode_SubmitReportsMissingSelectedSessions(t *testing.T) { t.Fatal("expected deleting card after submit") } // Wait for async deletion to complete. - waitDeleteModePhase(t, e, msg.SessionKey, "result") + waitDeleteModeResult(t, e, p, msg.SessionKey) refreshed := p.getRefreshedCards() if len(refreshed) == 0 { t.Fatal("expected refreshed result card via RefreshCard") @@ -4345,7 +4345,7 @@ func TestDeleteMode_SubmitBlocksActiveSession(t *testing.T) { t.Fatal("expected deleting card") } // Wait for async deletion to complete. - waitDeleteModePhase(t, e, msg.SessionKey, "result") + waitDeleteModeResult(t, e, p, msg.SessionKey) if len(agent.deleted) != 0 { t.Fatalf("deleted = %v, want none", agent.deleted) } @@ -4421,7 +4421,7 @@ func TestDeleteMode_FormSubmitShowsConfirmThenDeletes(t *testing.T) { t.Fatal("expected deleting card after submit") } // Wait for async deletion to complete. - waitDeleteModePhase(t, e, msg.SessionKey, "result") + waitDeleteModeResult(t, e, p, msg.SessionKey) if got, want := strings.Join(agent.deleted, ","), "session-1,session-3"; got != want { t.Fatalf("deleted = %q, want %q", got, want) } diff --git a/daemon/launchd_test.go b/daemon/launchd_test.go index abced99530..3b00aaf495 100644 --- a/daemon/launchd_test.go +++ b/daemon/launchd_test.go @@ -70,6 +70,16 @@ func TestLaunchdStatusUsesUserDomainWhenGUIDomainUnavailable(t *testing.T) { orig := runLaunchctl t.Cleanup(func() { runLaunchctl = orig }) + dir := t.TempDir() + t.Setenv("HOME", dir) + plistPath := launchdPlistPath() + if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(plistPath, []byte("plist"), 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + guiDomain := launchdGUIDomain() userDomain := launchdUserDomain() guiTarget := launchdTarget(guiDomain) diff --git a/docs/superpowers/plans/2026-07-11-codex-ps-turn-steer.md b/docs/superpowers/plans/2026-07-11-codex-ps-turn-steer.md new file mode 100644 index 0000000000..d801ef5701 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-codex-ps-turn-steer.md @@ -0,0 +1,177 @@ +# Codex `/ps` Turn Steering Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `/ps` guide the active Codex app-server turn through `turn/steer` without losing the final answer. + +**Architecture:** Keep `core.AgentSession` and the `/ps` command unchanged. Inside `appServerSession.Send`, snapshot `currentTurn`: an active turn uses a dedicated steering request, while an idle session follows the existing start-turn path. Steering preserves the active turn and buffered messages. + +**Tech Stack:** Go, Codex app-server JSON-RPC v2, standard `testing` package. + +## Global Constraints + +- Match Codex's native guidance behavior with `turn/steer`, `threadId`, `expectedTurnId`, and structured `input`. +- Do not fall back to `turn/start` when steering fails. +- Do not mutate `currentTurn`, `pendingMsgs`, or preamble state during steering. +- Keep core agent-agnostic and retain current behavior for every non-Codex agent. +- Base the PR on current upstream `origin/main`; do not include unrelated local commits. + +--- + +### Task 1: Add the app-server steering regression test + +**Files:** +- Modify: `agent/codex/appserver_session_test.go` + +**Interfaces:** +- Consumes: `appServerSession.Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error` +- Produces: regression coverage for active-turn steering and idle-turn start behavior + +- [ ] **Step 1: Add a JSON-RPC response harness** + +Add a helper that waits for one request written to `lockedWriteCloser`, decodes its ID/method/params, and calls `s.handleResponse` with the supplied result. The helper must expose the decoded request to the test so assertions cover the real JSON-RPC payload. + +- [ ] **Step 2: Write the active-turn failing test** + +Construct an alive session with `threadID="thread-1"`, `currentTurn="turn-1"`, and one buffered agent message. Call `Send` in a goroutine, answer the outgoing request with `{"turnId":"turn-1"}`, and assert: + +```go +if request.Method != "turn/steer" { + t.Fatalf("method = %q, want turn/steer", request.Method) +} +if request.Params["threadId"] != "thread-1" { + t.Fatalf("threadId = %v, want thread-1", request.Params["threadId"]) +} +if request.Params["expectedTurnId"] != "turn-1" { + t.Fatalf("expectedTurnId = %v, want turn-1", request.Params["expectedTurnId"]) +} +if got := currentTurn(s); got != "turn-1" { + t.Fatalf("current turn = %q, want turn-1", got) +} +if got := pendingMessages(s); !reflect.DeepEqual(got, []string{"buffered answer"}) { + t.Fatalf("pending messages = %v, want buffered answer", got) +} +``` + +- [ ] **Step 3: Run the focused test and confirm RED** + +Run: + +```bash +go test ./agent/codex -run '^TestAppServerSession_SendSteersActiveTurn$' -count=1 -v +``` + +Expected: fail because the request method is `turn/start`, the payload lacks `expectedTurnId`, and buffered state is cleared. + +- [ ] **Step 4: Add the idle-turn and final-answer assertions** + +Cover that an idle session still emits `turn/start`, stores the returned turn ID, and clears stale state only at that new-turn boundary. After steering, feed an `item/completed(agentMessage)` and `turn/completed` notification for the original turn and assert one full `EventText` followed by one `EventResult`. + +### Task 2: Implement protocol-correct steering + +**Files:** +- Modify: `agent/codex/appserver_session.go` +- Test: `agent/codex/appserver_session_test.go` + +**Interfaces:** +- Consumes: current thread ID, `currentTurn`, structured app-server input, and `request` +- Produces: `startTurn(threadID string, input []map[string]any) error` and `steerTurn(threadID, expectedTurnID string, input []map[string]any) error` + +- [ ] **Step 1: Add the steering response type** + +```go +type turnSteerResponse struct { + TurnID string `json:"turnId"` +} +``` + +- [ ] **Step 2: Split start and steering paths** + +Snapshot `currentTurn` under `stateMu` after preparing the input. If it is non-empty, send: + +```go +params := map[string]any{ + "threadId": threadID, + "expectedTurnId": expectedTurnID, + "input": input, +} +``` + +through `turn/steer`. Validate a non-empty response `turnId` equal to `expectedTurnID`. Return without modifying turn or message state. + +If no turn is active, keep the current `turn/start` parameters, response validation, and new-turn state reset. + +- [ ] **Step 3: Run focused tests and confirm GREEN** + +```bash +go test ./agent/codex -run 'TestAppServerSession_Send(SteersActiveTurn|StartsIdleTurn)' -count=1 -v +``` + +Expected: both tests pass. + +- [ ] **Step 4: Add precondition failure coverage** + +Return an error for an empty or mismatched `turnId` from `turn/steer`. Assert that the error names `turn/steer` and that `currentTurn` plus `pendingMsgs` remain unchanged. + +- [ ] **Step 5: Format and run the Codex package** + +```bash +gofmt -w agent/codex/appserver_session.go agent/codex/appserver_session_test.go +go test ./agent/codex -count=1 +go test -race ./agent/codex -run 'TestAppServerSession_Send' -count=1 +``` + +Expected: pass with no race reports. + +- [ ] **Step 6: Commit the implementation** + +```bash +git add agent/codex/appserver_session.go agent/codex/appserver_session_test.go +git commit -m "fix(codex): steer active app-server turns" +``` + +### Task 3: Verify, review, and publish + +**Files:** +- Verify: `agent/codex/appserver_session.go` +- Verify: `agent/codex/appserver_session_test.go` +- Verify: `docs/superpowers/specs/2026-07-11-codex-ps-turn-steer-design.md` + +**Interfaces:** +- Consumes: completed branch diff +- Produces: a ready-for-review upstream pull request + +- [ ] **Step 1: Run repository checks** + +```bash +go test ./agent/codex -count=1 +go test ./core -run 'TestCmdPs|TestCUJ' -count=1 +go test ./... +go test -race ./agent/codex -count=1 +go vet ./... +go build ./... +git diff --check origin/main...HEAD +``` + +Diagnose and resolve every relevant failure. If an unrelated upstream baseline test remains environment-dependent, document exact before/after evidence without weakening or skipping the check. + +- [ ] **Step 2: Review the diff** + +Compare `origin/main...HEAD` against the approved design. Check protocol field names against generated Codex app-server schema and verify no secret, generated artifact, or unrelated file is tracked. + +- [ ] **Step 3: Request code review** + +Use the code-reviewer workflow with `BASE_SHA=$(git rev-parse origin/main)` and `HEAD_SHA=$(git rev-parse HEAD)`. Resolve every Critical or Important finding and rerun the relevant checks. + +- [ ] **Step 4: Push and open the PR** + +```bash +git push -u fork agent/fix-codex-ps-steer +gh pr create --repo chenhg5/cc-connect --base main --head AaronZ345:agent/fix-codex-ps-steer --title "fix(codex): steer active app-server turns" --body-file /tmp/cc-connect-ps-steer-pr-body.md +``` + +Create a ready-for-review PR. The body must explain the root cause, protocol change, preserved `/ps` behavior, and complete validation evidence. + +- [ ] **Step 5: Monitor checks until green** + +Use `gh pr checks --watch` and inspect any failed GitHub Actions logs. Fix, commit, push, and continue monitoring until all required checks pass. diff --git a/docs/superpowers/specs/2026-07-11-codex-ps-turn-steer-design.md b/docs/superpowers/specs/2026-07-11-codex-ps-turn-steer-design.md new file mode 100644 index 0000000000..6941efbf8b --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-codex-ps-turn-steer-design.md @@ -0,0 +1,47 @@ +# Codex `/ps` Turn Steering Design + +## Problem + +`/ps` is meant to add guidance to a turn that is already running. The engine currently implements that behavior by calling `AgentSession.Send`. For the Codex app-server backend, `Send` always issues `turn/start`, even when a turn is active. It then overwrites `currentTurn` and clears `pendingMsgs`. + +That does not match the Codex app-server protocol. Mid-turn guidance has a dedicated `turn/steer` request with an `expectedTurnId` precondition. Using `turn/start` for `/ps` creates a race between `item/completed`, `turn/completed`, and `thread/status/changed`. The model can finish a full answer after cc-connect has already emitted an empty result, leaving the user with `(empty response)`. + +## Chosen Approach + +Keep the public `AgentSession` contract and `/ps` command unchanged. Make the Codex app-server session choose the correct protocol method based on its state: + +- When no turn is active, `Send` starts a new turn with `turn/start`. +- When a turn is active, `Send` steers that turn with `turn/steer`. + +This keeps core agent-agnostic and gives every existing caller the behavior it already expects: a normal message starts work, while a message injected into a busy session guides the work in progress. + +## Protocol and State Handling + +For an active turn, the steering request contains: + +- `threadId`: the current Codex thread; +- `expectedTurnId`: the active turn captured before the request; +- `input`: the same structured text and image input used for a normal send. + +The response `turnId` must match the expected active turn. Steering must not change `currentTurn`, clear `pendingMsgs`, resend the prompt preamble, or create a second completion path. If Codex rejects the precondition because the turn finished concurrently, cc-connect returns the request error rather than silently starting another turn. + +For an idle session, the existing `turn/start` behavior remains unchanged. A newly returned turn ID becomes `currentTurn`, and stale pending messages are cleared at that new-turn boundary. + +## Error Handling + +Errors keep the existing contextual wrapping and distinguish `turn/start` from `turn/steer`. An empty or mismatched steering turn ID is treated as a protocol error. No fallback to `turn/start` is allowed after a steering failure because that would turn guidance into a separate user turn and recreate the behavior mismatch. + +## Tests + +Regression coverage will use the app-server JSON-RPC test harness and prove that: + +1. an idle `Send` still emits `turn/start`; +2. an active `Send` emits `turn/steer` with the correct `threadId`, `expectedTurnId`, and input; +3. steering preserves `currentTurn` and buffered agent messages; +4. the original turn still emits its complete final text followed by one result event; +5. a mismatched steering response is rejected without mutating turn state; +6. existing `/ps`, Codex app-server, CUJ, full test, build, vet, and relevant race checks remain green. + +## Pull Request Scope + +The PR will be based on current upstream `origin/main` and contain only the Codex steering fix, its regression tests, and this design record. Unrelated local baseline failures will be diagnosed separately and included only if they represent a repository defect required for the PR checks to pass. diff --git a/platform/cloud-web/gateway_test.go b/platform/cloud-web/gateway_test.go index 272607b324..8453372683 100644 --- a/platform/cloud-web/gateway_test.go +++ b/platform/cloud-web/gateway_test.go @@ -2,6 +2,7 @@ package cloudweb import ( "encoding/json" + "net" "net/http" "net/http/httptest" "strings" @@ -32,11 +33,11 @@ func TestGatewayWebhook(t *testing.T) { if gt.listener == nil { t.Fatal("gateway listener not started") } - // listener.Addr() returns the IPv6 wildcard "[::]:port" on dual-stack - // systems. The wildcard address is not dialable — the test client must - // use the loopback form to actually reach the listener. - addr := strings.Replace(gt.listener.Addr().String(), "[::]", "[::1]", 1) - url := "http://" + addr + gt.webhookPath + _, port, err := net.SplitHostPort(gt.listener.Addr().String()) + if err != nil { + t.Fatalf("split listener address: %v", err) + } + url := "http://" + net.JoinHostPort("127.0.0.1", port) + gt.webhookPath body, _ := json.Marshal(wireInboundMessage{ Type: "message", MsgID: "g1", SessionKey: "cloud_web:x:y", UserID: "y", Content: "from gateway", ReplyCtx: "ctx", diff --git a/platform/cloud-web/poll_test.go b/platform/cloud-web/poll_test.go index f74a90d20a..de66c73900 100644 --- a/platform/cloud-web/poll_test.go +++ b/platform/cloud-web/poll_test.go @@ -89,15 +89,20 @@ func TestLongPollIntegration(t *testing.T) { } func TestCapabilityDegradeCard(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer srv.Close() + p := mustNew(t, map[string]any{ - "token": "t", "transport": "long_poll", "base_url": "http://127.0.0.1", + "token": "t", "transport": "long_poll", "base_url": srv.URL, }) p.tp.(*pollTransport).setCaps(map[string]bool{"text": true}) card := &core.Card{Elements: []core.CardElement{core.CardMarkdown{Content: "hello card"}}} err := p.SendCard(context.Background(), replyContext{SessionKey: "s", ReplyCtx: "r"}, card) if err == nil { - t.Fatal("expected error when send path unreachable") + t.Fatal("expected error when send endpoint is unavailable") } }