From cd7c2736dc705a7a7ba0d241105e93e2c9c4726e Mon Sep 17 00:00:00 2001 From: Gnanam Date: Tue, 30 Jun 2026 21:04:23 +0530 Subject: [PATCH] fix: stream Codex reasoning + drop swarm agents on their own completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two agent-visibility fixes surfaced diagnosing a 20-minute gpt-5.5 step and an 'agents never disappear' report. A) Codex Responses path streamed no reasoning, so a long thinking phase produced zero visible output and read as a hang (the activity clock never advanced). Root cause: the request never asked for a reasoning summary and the parser had no case for reasoning deltas. Now request reasoning.summary="auto" and forward response.reasoning_summary_text.delta as StreamEventReasoning — the existing TUI handler renders it live and refreshes the activity clock. B) A finished swarm member stayed in the AGENTS panel until the whole turn ended. Now it drops once ITS OWN task completes (fade over the linger window, then remove), independent of the overall run — matching how finished specialists already behave. Running members and mid-flight collect are unaffected. --- internal/providers/openai/codex_responses.go | 20 +++++++- internal/providers/openai/codex_test.go | 52 ++++++++++++++++++++ internal/tui/sidebar.go | 12 ++--- internal/tui/sidebar_test.go | 18 +++---- 4 files changed, 85 insertions(+), 17 deletions(-) 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) } }