diff --git a/internal/providers/openai/codex_responses.go b/internal/providers/openai/codex_responses.go index 9c5857207..d61784195 100644 --- a/internal/providers/openai/codex_responses.go +++ b/internal/providers/openai/codex_responses.go @@ -59,6 +59,7 @@ const ( responsesEventOutputItemAdded = "response.output_item.added" responsesEventContentPartAdded = "response.content_part.added" responsesEventOutputTextDelta = "response.output_text.delta" + responsesEventReasoningDelta = "response.reasoning_summary_text.delta" responsesEventOutputTextDone = "response.output_text.done" responsesEventFunctionArgsDelta = "response.function_call_arguments.delta" responsesEventContentPartDone = "response.content_part.done" @@ -86,6 +87,10 @@ type responsesRequest struct { // here; omitted entirely when the caller requests no (or an unsupported) effort. type responsesReasoning struct { Effort string `json:"effort,omitempty"` + // Summary requests a streamed reasoning summary ("auto" lets the API pick a + // level). Without it the backend emits no reasoning events, so a long thinking + // phase produces zero visible output and reads as a hang in the UI. + Summary string `json:"summary,omitempty"` } // inputItem is one element of the Responses `input` array. The Type field @@ -256,7 +261,9 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ // and an empty or unsupported effort simply omits the field — without this the // caller's chosen effort was silently dropped for every Codex model. if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" { - req.Reasoning = &responsesReasoning{Effort: effort} + // Summary "auto" makes the backend stream reasoning_summary_text deltas so a + // long thinking phase shows live progress instead of looking hung. + req.Reasoning = &responsesReasoning{Effort: effort, Summary: "auto"} } return req, nil } @@ -501,6 +508,17 @@ func (p *CodexProvider) emitResponsesEvent( }) } return true + case responsesEventReasoningDelta: + // Reasoning summary deltas: surface as live "thinking" so a long reasoning + // phase shows progress (and keeps the activity clock fresh) instead of + // looking like a hang. Requested via reasoning.summary="auto". + if event.Delta != "" { + providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{ + Type: zeroruntime.StreamEventReasoning, + Content: event.Delta, + }) + } + return true case responsesEventFunctionArgsDelta: p.handleFunctionArgsDelta(ctx, &event, state, events) return true diff --git a/internal/providers/openai/codex_test.go b/internal/providers/openai/codex_test.go index 8c860673d..0478bdf62 100644 --- a/internal/providers/openai/codex_test.go +++ b/internal/providers/openai/codex_test.go @@ -485,6 +485,58 @@ func TestCodexProviderForwardsReasoningEffort(t *testing.T) { if reasoning["effort"] != "high" { t.Fatalf("body.reasoning.effort = %#v, want high", reasoning["effort"]) } + // A reasoning summary must be requested so the backend streams + // reasoning_summary_text deltas — otherwise a long think shows no live output. + if reasoning["summary"] != "auto" { + t.Fatalf("body.reasoning.summary = %#v, want auto", reasoning["summary"]) + } +} + +func TestCodexProviderStreamsReasoningSummaryDeltas(t *testing.T) { + // reasoning_summary_text deltas must surface as StreamEventReasoning (live + // "thinking"), in order, alongside the normal text output. Without this a long + // reasoning phase produces zero visible output and reads as a hang. + var rec codexRequest + srv := newCodexResponsesServer(t, &rec, + `{"type":"response.created","response":{"id":"resp-1","status":"in_progress"}}`, + `{"type":"response.reasoning_summary_text.delta","delta":"Thinking. "}`, + `{"type":"response.reasoning_summary_text.delta","delta":"Planning."}`, + `{"type":"response.output_text.delta","delta":"done"}`, + `{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`, + ) + defer srv.Close() + + provider, err := NewCodexProvider(CodexOptions{ + Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5"}, + AccountID: "acc-x", + }) + if err != nil { + t.Fatalf("NewCodexProvider: %v", err) + } + stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{ + Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "solve it"}}, + ReasoningEffort: "high", + }) + if err != nil { + t.Fatalf("StreamCompletion: %v", err) + } + var reasoning, text []string + for ev := range stream { + switch ev.Type { + case zeroruntime.StreamEventReasoning: + reasoning = append(reasoning, ev.Content) + case zeroruntime.StreamEventText: + text = append(text, ev.Content) + case zeroruntime.StreamEventError: + t.Fatalf("unexpected error event: %q", ev.Error) + } + } + if got := strings.Join(reasoning, ""); got != "Thinking. Planning." { + t.Fatalf("reasoning deltas = %q, want %q", got, "Thinking. Planning.") + } + if got := strings.Join(text, ""); got != "done" { + t.Fatalf("text deltas = %q, want %q", got, "done") + } } func TestCodexProviderOmitsReasoningWhenUnset(t *testing.T) { diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index b33071c0f..73a8f45af 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -261,13 +261,11 @@ func (m model) swarmSpawnedAgents() []swarmAgent { switch a.state { case "done", "failed", "completed", "cancelled": a.finishing = true - if m.pending { - // Run still going: keep it visible and clickable, no fade. - a.finishedAt = time.Time{} - live = append(live, a) - continue - } - // Turn ended: fade out over the linger window, then drop. + // A member drops once ITS OWN task completes — not when the whole turn + // ends: fade out over the linger window from when it was first seen + // finished (stamped each tick by stampSwarmDone), then remove. This + // holds whether or not the overall run is still in flight, mirroring + // how finished specialists drop (sidebarSpecialists). doneAt, stamped := m.swarmDoneAt[a.id] if stamped && m.now().Sub(doneAt) >= sidebarAgentLinger { continue // past the linger window — remove diff --git a/internal/tui/sidebar_test.go b/internal/tui/sidebar_test.go index a86991ca6..45ffdac6a 100644 --- a/internal/tui/sidebar_test.go +++ b/internal/tui/sidebar_test.go @@ -391,32 +391,32 @@ func TestSidebarShowsSwarmSpawnedAgents(t *testing.T) { // row (e.g. a resumed transcript that dropped the call): the member is still // A finished member stays visible (and clickable) while the run is still in // flight, so the user can inspect it; only once the turn ends does it drop. -func TestSwarmAgentsPersistWhileRunInFlight(t *testing.T) { +func TestSwarmAgentDropsOnOwnCompletionEvenMidRun(t *testing.T) { base := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) m := sidebarTestModel() m.now = func() time.Time { return base } - m.pending = true // run still going + m.pending = true // run STILL going — a member must still drop on its OWN completion m.activeRunID = 7 // exercise the run-scoped filter with a non-zero id m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build homepage", runID: 7}, transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 7}, transcriptRow{kind: rowToolResult, tool: "swarm_collect", detail: "Results: 1 task(s)\n- subagent-1 [done] build homepage", runID: 7}, ) - // Long past the linger window — but it must still show while pending. - m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-10 * sidebarAgentLinger)} - + // Just finished, still within the linger window: shows briefly with a fading ✓. + m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-sidebarAgentLinger / 2)} agents := m.swarmSpawnedAgents() if len(agents) != 1 { - t.Fatalf("a finished member must stay while the run is in flight, got %d: %+v", len(agents), agents) + t.Fatalf("within the linger window a finished member should still show (fading), got %d: %+v", len(agents), agents) } if !agents[0].finishing { t.Fatalf("a finished member should render done (✓), got %+v", agents[0]) } - // Once the turn ends, the long-finished member fades out and drops. - m.pending = false + // Past the linger window: it drops — even though the overall run is still in + // flight (previously a finished member lingered until the whole turn ended). + m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-2 * sidebarAgentLinger)} if got := len(m.swarmSpawnedAgents()); got != 0 { - t.Fatalf("after the turn ends a long-finished member should drop, got %d", got) + t.Fatalf("a member past its linger window must drop on its own completion mid-run, got %d", got) } }