Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion internal/providers/openai/codex_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions internal/providers/openai/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 5 additions & 7 deletions internal/tui/sidebar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions internal/tui/sidebar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Loading