detached 87f6ee8

fix(subagents): reconnect idle provider streams

commit
87f6ee855c5f2c672b5da4d6f13528eb2522f2ea
author
brice · · 2026-08-12 14:17 UTC
parent
68a8513
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +130 -82
M packages/agent/modes/subagent_indicator_test.go +3 -3
@@ -21,7 +21,7 @@ func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) {
2121 "alive": {AgentID: "alive", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(-time.Second)}},
2222 }
2323 got := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", snapshots, views, 80, now))
24- want := []string{" / reviewer · tool open · 1m12s"}
24+ want := []string{" / reviewer · running tool · 1m12s"}
2525 if strings.Join(got, "\n") != strings.Join(want, "\n") {
2626 t.Fatalf("activity lines = %#v, want %#v", got, want)
2727 }
@@ -32,7 +32,7 @@ func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) {
3232 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
3333 "review-123": {PrimaryOperation: &subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},
3434 }, 100, now))
35- if got := strings.Join(lines, "\n"); !strings.Contains(got, "turn open · assistant streaming 1s ago") || strings.Contains(got, "delta") {
35+ if got := strings.Join(lines, "\n"); !strings.Contains(got, "processing turn · assistant streaming 1s ago") || strings.Contains(got, "delta") {
3636 t.Fatalf("activity lines = %#v", lines)
3737 }
3838 }
@@ -42,7 +42,7 @@ func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) {
4242 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
4343 "review-123": {PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},
4444 }, 100, now))
45- if got := strings.Join(lines, "\n"); strings.Contains(got, "assistant streaming") || !strings.Contains(got, "tool open") || !strings.Contains(got, "1m") {
45+ if got := strings.Join(lines, "\n"); strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") {
4646 t.Fatalf("activity lines = %#v", lines)
4747 }
4848 }
@@ -21,7 +21,7 @@ func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) {
21 "alive": {AgentID: "alive", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(-time.Second)}},21 "alive": {AgentID: "alive", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(-time.Second)}},
22 }22 }
23 got := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", snapshots, views, 80, now))23 got := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", snapshots, views, 80, now))
24- want := []string{" / reviewer · tool open · 1m12s"}24+ want := []string{" / reviewer · running tool · 1m12s"}
25 if strings.Join(got, "\n") != strings.Join(want, "\n") {25 if strings.Join(got, "\n") != strings.Join(want, "\n") {
26 t.Fatalf("activity lines = %#v, want %#v", got, want)26 t.Fatalf("activity lines = %#v, want %#v", got, want)
27 }27 }
@@ -32,7 +32,7 @@ func TestRenderSubagentActivityLinesShowsSafeLastObservation(t *testing.T) {
32 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{32 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
33 "review-123": {PrimaryOperation: &subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},33 "review-123": {PrimaryOperation: &subagents.Operation{Type: "turn.started", TurnID: "turn-1", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},
34 }, 100, now))34 }, 100, now))
35- if got := strings.Join(lines, "\n"); !strings.Contains(got, "turn open · assistant streaming 1s ago") || strings.Contains(got, "delta") {35+ if got := strings.Join(lines, "\n"); !strings.Contains(got, "processing turn · assistant streaming 1s ago") || strings.Contains(got, "delta") {
36 t.Fatalf("activity lines = %#v", lines)36 t.Fatalf("activity lines = %#v", lines)
37 }37 }
38 }38 }
@@ -42,7 +42,7 @@ func TestRenderSubagentActivityLinesOmitsPriorTurnObservation(t *testing.T) {
42 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{42 lines := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
43 "review-123": {PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},43 "review-123": {PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)}, LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)}},
44 }, 100, now))44 }, 100, now))
45- if got := strings.Join(lines, "\n"); strings.Contains(got, "assistant streaming") || !strings.Contains(got, "tool open") || !strings.Contains(got, "1m") {45+ if got := strings.Join(lines, "\n"); strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") {
46 t.Fatalf("activity lines = %#v", lines)46 t.Fatalf("activity lines = %#v", lines)
47 }47 }
48 }48 }
M packages/agent/modes/subagents_dialog_test.go +4 -4
@@ -41,7 +41,7 @@ func TestFormatSupervisorRowOmitsPriorTurnObservationAge(t *testing.T) {
4141 PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)},
4242 LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)},
4343 }, 120)
44- if strings.Contains(got, "assistant streaming") || !strings.Contains(got, "tool open") || !strings.Contains(got, "1m") {
44+ if strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") {
4545 t.Fatalf("row = %q", got)
4646 }
4747 }
@@ -88,7 +88,7 @@ func TestSubagentsDialogRendersRows(t *testing.T) {
8888 })
8989 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
9090 out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
91- for _, want := range []string{"alpha-1", "beta-2", "tool open", "completed", "OPERATION", "TASK"} {
91+ for _, want := range []string{"alpha-1", "beta-2", "running tool", "completed", "OPERATION", "TASK"} {
9292 if !strings.Contains(out, want) {
9393 t.Fatalf("render missing %q:\n%s", want, out)
9494 }
@@ -1350,7 +1350,7 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
13501350 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
13511351
13521352 out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1353- if !strings.Contains(out, "tool open") {
1353+ if !strings.Contains(out, "running tool") {
13541354 t.Fatalf("busy spinner did not surface an open tool near the editor:\n%s", out)
13551355 }
13561356 if d.transcriptSpin == nil {
@@ -1363,7 +1363,7 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
13631363 if d.transcriptSpin != nil {
13641364 t.Error("spinner stayed live after agent reported idle")
13651365 }
1366- if strings.Contains(out, "tool open, ") {
1366+ if strings.Contains(out, "running tool, ") {
13671367 t.Errorf("busy line still rendered after operation finished:\n%s", out)
13681368 }
13691369 }
@@ -41,7 +41,7 @@ func TestFormatSupervisorRowOmitsPriorTurnObservationAge(t *testing.T) {
41 PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)},41 PrimaryOperation: &subagents.Operation{Type: "tool.started", TurnID: "turn-2", StartedAt: now.Add(-time.Minute)},
42 LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)},42 LastObservation: &subagents.LiveObservation{Type: "assistant.stream.observed", TurnID: "turn-1", At: now.Add(-time.Second)},
43 }, 120)43 }, 120)
44- if strings.Contains(got, "assistant streaming") || !strings.Contains(got, "tool open") || !strings.Contains(got, "1m") {44+ if strings.Contains(got, "assistant streaming") || !strings.Contains(got, "running tool") || !strings.Contains(got, "1m") {
45 t.Fatalf("row = %q", got)45 t.Fatalf("row = %q", got)
46 }46 }
47 }47 }
@@ -88,7 +88,7 @@ func TestSubagentsDialogRendersRows(t *testing.T) {
88 })88 })
89 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")89 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
90 out := strings.Join(d.Render(tui.Theme{}, 100), "\n")90 out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
91- for _, want := range []string{"alpha-1", "beta-2", "tool open", "completed", "OPERATION", "TASK"} {91+ for _, want := range []string{"alpha-1", "beta-2", "running tool", "completed", "OPERATION", "TASK"} {
92 if !strings.Contains(out, want) {92 if !strings.Contains(out, want) {
93 t.Fatalf("render missing %q:\n%s", want, out)93 t.Fatalf("render missing %q:\n%s", want, out)
94 }94 }
@@ -1350,7 +1350,7 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
1350 d.HandleKey(tui.Key{Kind: tui.KeyEnter})1350 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1351 1351
1352 out := strings.Join(d.Render(tui.Theme{}, 80), "\n")1352 out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1353- if !strings.Contains(out, "tool open") {1353+ if !strings.Contains(out, "running tool") {
1354 t.Fatalf("busy spinner did not surface an open tool near the editor:\n%s", out)1354 t.Fatalf("busy spinner did not surface an open tool near the editor:\n%s", out)
1355 }1355 }
1356 if d.transcriptSpin == nil {1356 if d.transcriptSpin == nil {
@@ -1363,7 +1363,7 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
1363 if d.transcriptSpin != nil {1363 if d.transcriptSpin != nil {
1364 t.Error("spinner stayed live after agent reported idle")1364 t.Error("spinner stayed live after agent reported idle")
1365 }1365 }
1366- if strings.Contains(out, "tool open, ") {1366+ if strings.Contains(out, "running tool, ") {
1367 t.Errorf("busy line still rendered after operation finished:\n%s", out)1367 t.Errorf("busy line still rendered after operation finished:\n%s", out)
1368 }1368 }
1369 }1369 }
M packages/agent/subagent_worker.go +5 -11
@@ -164,11 +164,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
164164 runErr = joinSessionCloseError(runErr, sess)
165165 }()
166166 }
167- // A silent provider stream is neither progress nor a safe reason to leave
168- // a delegated worker occupied until its broad turn deadline. Set this after
169- // session resume because resume may rebuild the agent for another model.
170- ag.StreamIdleTimeout = time.Minute
171-
172167 // Resolve retains metadata for catalog models and for valid synthesized
173168 // local/routed/open-catalog models. The active agent also survives a session
174169 // resume that rebuilt it for a different provider/model pair.
@@ -256,13 +251,12 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
256251 mu.Unlock()
257252
258253 turnStarted := false
259- toolStarted := false
260- ag.MaxRetries = 2
254+ // Match Codex's Responses stream policy: five minutes without an
255+ // event marks only the current stream as lost, and up to five retries
256+ // reconnect the sampling request without ending the delegated turn.
257+ ag.StreamIdleTimeout = 5 * time.Minute
258+ ag.MaxRetries = 5
261259 sink := func(ev core.AgentEvent) {
262- if ev.Type() == "tool_execution_started" && !toolStarted {
263- toolStarted = true
264- ag.MaxRetries = 0
265- }
266260 data := subagentEventData(ev)
267261 if ev.Type() == "user_message" && !turnStarted {
268262 // Prompt invokes OnMessageAppended before delivering this sink
@@ -164,11 +164,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
164 runErr = joinSessionCloseError(runErr, sess)164 runErr = joinSessionCloseError(runErr, sess)
165 }()165 }()
166 }166 }
167- // A silent provider stream is neither progress nor a safe reason to leave
168- // a delegated worker occupied until its broad turn deadline. Set this after
169- // session resume because resume may rebuild the agent for another model.
170- ag.StreamIdleTimeout = time.Minute
171-
172 // Resolve retains metadata for catalog models and for valid synthesized167 // Resolve retains metadata for catalog models and for valid synthesized
173 // local/routed/open-catalog models. The active agent also survives a session168 // local/routed/open-catalog models. The active agent also survives a session
174 // resume that rebuilt it for a different provider/model pair.169 // resume that rebuilt it for a different provider/model pair.
@@ -256,13 +251,12 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
256 mu.Unlock()251 mu.Unlock()
257 252
258 turnStarted := false253 turnStarted := false
259- toolStarted := false254+ // Match Codex's Responses stream policy: five minutes without an
260- ag.MaxRetries = 2255+ // event marks only the current stream as lost, and up to five retries
256+ // reconnect the sampling request without ending the delegated turn.
257+ ag.StreamIdleTimeout = 5 * time.Minute
258+ ag.MaxRetries = 5
261 sink := func(ev core.AgentEvent) {259 sink := func(ev core.AgentEvent) {
262- if ev.Type() == "tool_execution_started" && !toolStarted {
263- toolStarted = true
264- ag.MaxRetries = 0
265- }
266 data := subagentEventData(ev)260 data := subagentEventData(ev)
267 if ev.Type() == "user_message" && !turnStarted {261 if ev.Type() == "user_message" && !turnStarted {
268 // Prompt invokes OnMessageAppended before delivering this sink262 // Prompt invokes OnMessageAppended before delivering this sink
M packages/agent/subagents/projection.go +15 -5
@@ -34,13 +34,23 @@ func (o Operation) Duration(now time.Time) time.Duration {
3434 return now.Sub(o.StartedAt)
3535 }
3636
37-// Label returns the shared concise label for an open operation.
37+// Label returns a concise user-facing description of an open operation.
3838 func (o Operation) Label() string {
39- label := strings.TrimSuffix(o.Type, ".started")
40- if o.Name != "" {
41- label += " " + o.Name
39+ switch o.Type {
40+ case "provider.request.started":
41+ return "waiting for model response"
42+ case "tool.started":
43+ if o.Name != "" {
44+ return "running " + o.Name
45+ }
46+ return "running tool"
47+ case "turn.started":
48+ return "processing turn"
49+ case "agent.wait.started":
50+ return "waiting"
51+ default:
52+ return strings.ReplaceAll(strings.TrimSuffix(o.Type, ".started"), ".", " ")
4253 }
43- return label + " open"
4454 }
4555
4656 // LiveObservation is the last safe, factual signal received from a worker.
@@ -34,13 +34,23 @@ func (o Operation) Duration(now time.Time) time.Duration {
34 return now.Sub(o.StartedAt)34 return now.Sub(o.StartedAt)
35 }35 }
36 36
37-// Label returns the shared concise label for an open operation.37+// Label returns a concise user-facing description of an open operation.
38 func (o Operation) Label() string {38 func (o Operation) Label() string {
39- label := strings.TrimSuffix(o.Type, ".started")39+ switch o.Type {
40- if o.Name != "" {40+ case "provider.request.started":
41- label += " " + o.Name41+ return "waiting for model response"
42+ case "tool.started":
43+ if o.Name != "" {
44+ return "running " + o.Name
45+ }
46+ return "running tool"
47+ case "turn.started":
48+ return "processing turn"
49+ case "agent.wait.started":
50+ return "waiting"
51+ default:
52+ return strings.ReplaceAll(strings.TrimSuffix(o.Type, ".started"), ".", " ")
42 }53 }
43- return label + " open"
44 }54 }
45 55
46 // LiveObservation is the last safe, factual signal received from a worker.56 // LiveObservation is the last safe, factual signal received from a worker.
M packages/agent/subagents/projection_test.go +19 -3
@@ -75,11 +75,27 @@ func TestProjectTraceSelectsSpecificActivityAndProjectsResultDelivery(t *testing
7575 if view.Result == nil || !view.Result.Available || !view.Result.Delivered || view.Result.Ref != "subagent://agent-1/result" {
7676 t.Fatalf("result = %#v", view.Result)
7777 }
78- if got, want := view.Summary(), "tool bash open · assistant streaming"; got != want {
78+ if got, want := view.Summary(), "running bash · assistant streaming"; got != want {
7979 t.Fatalf("summary = %q, want %q", got, want)
8080 }
8181 }
8282
83+func TestOperationLabelUsesHumanFacingActivity(t *testing.T) {
84+ cases := []struct {
85+ operation Operation
86+ want string
87+ }{
88+ {operation: Operation{Type: "provider.request.started"}, want: "waiting for model response"},
89+ {operation: Operation{Type: "tool.started", Name: "bash"}, want: "running bash"},
90+ {operation: Operation{Type: "turn.started"}, want: "processing turn"},
91+ }
92+ for _, tc := range cases {
93+ if got := tc.operation.Label(); got != tc.want {
94+ t.Fatalf("Label(%q) = %q, want %q", tc.operation.Type, got, tc.want)
95+ }
96+ }
97+}
98+
8399 func TestProjectTraceRetainsSafeProviderAttemptDiagnostic(t *testing.T) {
84100 started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
85101 view := ProjectTrace([]TraceEvent{
@@ -108,7 +124,7 @@ func TestProjectTraceOmitsObservationForAnotherToolInSameTurn(t *testing.T) {
108124 if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {
109125 t.Fatalf("another tool's observation = %#v, want omitted", observation)
110126 }
111- if got, want := view.Summary(), "tool bash open"; got != want {
127+ if got, want := view.Summary(), "running bash"; got != want {
112128 t.Fatalf("summary = %q, want %q", got, want)
113129 }
114130 }
@@ -128,7 +144,7 @@ func TestProjectTraceOmitsPriorTurnObservation(t *testing.T) {
128144 if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {
129145 t.Fatalf("prior turn observation = %#v, want omitted", observation)
130146 }
131- if got, want := view.Summary(), "turn open"; got != want {
147+ if got, want := view.Summary(), "processing turn"; got != want {
132148 t.Fatalf("summary = %q, want %q", got, want)
133149 }
134150 }
@@ -75,11 +75,27 @@ func TestProjectTraceSelectsSpecificActivityAndProjectsResultDelivery(t *testing
75 if view.Result == nil || !view.Result.Available || !view.Result.Delivered || view.Result.Ref != "subagent://agent-1/result" {75 if view.Result == nil || !view.Result.Available || !view.Result.Delivered || view.Result.Ref != "subagent://agent-1/result" {
76 t.Fatalf("result = %#v", view.Result)76 t.Fatalf("result = %#v", view.Result)
77 }77 }
78- if got, want := view.Summary(), "tool bash open · assistant streaming"; got != want {78+ if got, want := view.Summary(), "running bash · assistant streaming"; got != want {
79 t.Fatalf("summary = %q, want %q", got, want)79 t.Fatalf("summary = %q, want %q", got, want)
80 }80 }
81 }81 }
82 82
83+func TestOperationLabelUsesHumanFacingActivity(t *testing.T) {
84+ cases := []struct {
85+ operation Operation
86+ want string
87+ }{
88+ {operation: Operation{Type: "provider.request.started"}, want: "waiting for model response"},
89+ {operation: Operation{Type: "tool.started", Name: "bash"}, want: "running bash"},
90+ {operation: Operation{Type: "turn.started"}, want: "processing turn"},
91+ }
92+ for _, tc := range cases {
93+ if got := tc.operation.Label(); got != tc.want {
94+ t.Fatalf("Label(%q) = %q, want %q", tc.operation.Type, got, tc.want)
95+ }
96+ }
97+}
98+
83 func TestProjectTraceRetainsSafeProviderAttemptDiagnostic(t *testing.T) {99 func TestProjectTraceRetainsSafeProviderAttemptDiagnostic(t *testing.T) {
84 started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)100 started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
85 view := ProjectTrace([]TraceEvent{101 view := ProjectTrace([]TraceEvent{
@@ -108,7 +124,7 @@ func TestProjectTraceOmitsObservationForAnotherToolInSameTurn(t *testing.T) {
108 if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {124 if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {
109 t.Fatalf("another tool's observation = %#v, want omitted", observation)125 t.Fatalf("another tool's observation = %#v, want omitted", observation)
110 }126 }
111- if got, want := view.Summary(), "tool bash open"; got != want {127+ if got, want := view.Summary(), "running bash"; got != want {
112 t.Fatalf("summary = %q, want %q", got, want)128 t.Fatalf("summary = %q, want %q", got, want)
113 }129 }
114 }130 }
@@ -128,7 +144,7 @@ func TestProjectTraceOmitsPriorTurnObservation(t *testing.T) {
128 if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {144 if observation := view.ObservationFor(*view.PrimaryOperation); observation != nil {
129 t.Fatalf("prior turn observation = %#v, want omitted", observation)145 t.Fatalf("prior turn observation = %#v, want omitted", observation)
130 }146 }
131- if got, want := view.Summary(), "turn open"; got != want {147+ if got, want := view.Summary(), "processing turn"; got != want {
132 t.Fatalf("summary = %q, want %q", got, want)148 t.Fatalf("summary = %q, want %q", got, want)
133 }149 }
134 }150 }
M packages/agent/tools/subagent_status.go +1 -1
@@ -184,7 +184,7 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent
184184 if snapshot.Result != nil {
185185 entry.Result = &subagentStatusResult{
186186 State: publicResultState(snapshot.Result.Status),
187- Available: true,
187+ Available: snapshot.ResultRef != "",
188188 Ref: snapshot.ResultRef,
189189 }
190190 }
@@ -184,7 +184,7 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.Agent
184 if snapshot.Result != nil {184 if snapshot.Result != nil {
185 entry.Result = &subagentStatusResult{185 entry.Result = &subagentStatusResult{
186 State: publicResultState(snapshot.Result.Status),186 State: publicResultState(snapshot.Result.Status),
187- Available: true,187+ Available: snapshot.ResultRef != "",
188 Ref: snapshot.ResultRef,188 Ref: snapshot.ResultRef,
189 }189 }
190 }190 }
M packages/agent/tools/subagent_status_test.go +10 -0
@@ -86,6 +86,16 @@ func TestPublicSubagentStatusUsesOnlyPrimaryOperation(t *testing.T) {
8686 }
8787 }
8888
89+func TestPublicSubagentStatusDoesNotInferAvailabilityWithoutResultReference(t *testing.T) {
90+ entry := publicSubagentStatus(subagents.AgentSnapshot{
91+ ID: "agent-1",
92+ Result: &subagents.TurnResult{Status: subagents.ResultFailed},
93+ }, subagents.AgentTraceView{})
94+ if entry.Result == nil || entry.Result.Available {
95+ t.Fatalf("result availability = %#v, want unavailable without result ref", entry.Result)
96+ }
97+}
98+
8999 func TestPublicSubagentStatusIncludesResultDeliveryFacts(t *testing.T) {
90100 entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{
91101 Result: &subagents.ResultFact{Available: true, Ref: "subagent://agent-1/result"},
@@ -86,6 +86,16 @@ func TestPublicSubagentStatusUsesOnlyPrimaryOperation(t *testing.T) {
86 }86 }
87 }87 }
88 88
89+func TestPublicSubagentStatusDoesNotInferAvailabilityWithoutResultReference(t *testing.T) {
90+ entry := publicSubagentStatus(subagents.AgentSnapshot{
91+ ID: "agent-1",
92+ Result: &subagents.TurnResult{Status: subagents.ResultFailed},
93+ }, subagents.AgentTraceView{})
94+ if entry.Result == nil || entry.Result.Available {
95+ t.Fatalf("result availability = %#v, want unavailable without result ref", entry.Result)
96+ }
97+}
98+
89 func TestPublicSubagentStatusIncludesResultDeliveryFacts(t *testing.T) {99 func TestPublicSubagentStatusIncludesResultDeliveryFacts(t *testing.T) {
90 entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{100 entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{
91 Result: &subagents.ResultFact{Available: true, Ref: "subagent://agent-1/result"},101 Result: &subagents.ResultFact{Available: true, Ref: "subagent://agent-1/result"},
M packages/core/agent.go +35 -55
@@ -952,76 +952,56 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
952952 timer.Reset(a.StreamIdleTimeout)
953953 }
954954 }
955+ handleStreamEvent := func(ev provider.Event) (done bool) {
956+ if resetIdle != nil {
957+ resetIdle()
958+ }
959+ switch e := ev.(type) {
960+ case provider.EventStart:
961+ // nothing
962+ case provider.EventTextDelta:
963+ sink(EvTextDelta{Delta: e.Delta})
964+ case provider.EventToolStart:
965+ sink(EvToolUseStart{ID: e.ID, Name: e.Name})
966+ case provider.EventToolArgs:
967+ sink(EvToolUseArgs{ID: e.ID, Delta: e.Delta})
968+ case provider.EventToolEnd:
969+ sink(EvToolUseEnd{ID: e.ID})
970+ case provider.EventUsage:
971+ cum := a.addUsage(e.Usage)
972+ sink(EvUsage{Usage: e.Usage, Cumulative: cum})
973+ if a.OnUsage != nil {
974+ a.OnUsage(cum)
975+ }
976+ case provider.EventDone:
977+ stop = e.Stop
978+ finalErr = e.Err
979+ finalMsg = e.Message
980+ return true
981+ }
982+ return false
983+ }
955984 for {
956- // Prefer an already-buffered terminal provider event over a concurrent
957- // context cancellation so retry classification retains its real cause.
985+ // Consume no more than one buffered event before honoring cancellation.
958986 select {
959987 case ev, ok := <-stream:
960- if !ok {
988+ if !ok || handleStreamEvent(ev) {
961989 goto streamDone
962990 }
963- if resetIdle != nil {
964- resetIdle()
965- }
966- switch e := ev.(type) {
967- case provider.EventStart:
968- // nothing
969- case provider.EventTextDelta:
970- sink(EvTextDelta{Delta: e.Delta})
971- case provider.EventToolStart:
972- sink(EvToolUseStart{ID: e.ID, Name: e.Name})
973- case provider.EventToolArgs:
974- sink(EvToolUseArgs{ID: e.ID, Delta: e.Delta})
975- case provider.EventToolEnd:
976- sink(EvToolUseEnd{ID: e.ID})
977- case provider.EventUsage:
978- cum := a.addUsage(e.Usage)
979- sink(EvUsage{Usage: e.Usage, Cumulative: cum})
980- if a.OnUsage != nil {
981- a.OnUsage(cum)
982- }
983- case provider.EventDone:
984- stop = e.Stop
985- finalErr = e.Err
986- finalMsg = e.Message
987- }
988- continue
989991 default:
990992 }
993+ if err := streamCtx.Err(); err != nil {
994+ return provider.StopError, finalMsg, err
995+ }
991996 select {
992997 case <-streamCtx.Done():
993998 return provider.StopError, finalMsg, streamCtx.Err()
994999 case <-idle:
9951000 return provider.StopError, finalMsg, ErrStreamIdleTimeout
9961001 case ev, ok := <-stream:
997- if !ok {
1002+ if !ok || handleStreamEvent(ev) {
9981003 goto streamDone
9991004 }
1000- if resetIdle != nil {
1001- resetIdle()
1002- }
1003- switch e := ev.(type) {
1004- case provider.EventStart:
1005- // nothing
1006- case provider.EventTextDelta:
1007- sink(EvTextDelta{Delta: e.Delta})
1008- case provider.EventToolStart:
1009- sink(EvToolUseStart{ID: e.ID, Name: e.Name})
1010- case provider.EventToolArgs:
1011- sink(EvToolUseArgs{ID: e.ID, Delta: e.Delta})
1012- case provider.EventToolEnd:
1013- sink(EvToolUseEnd{ID: e.ID})
1014- case provider.EventUsage:
1015- cum := a.addUsage(e.Usage)
1016- sink(EvUsage{Usage: e.Usage, Cumulative: cum})
1017- if a.OnUsage != nil {
1018- a.OnUsage(cum)
1019- }
1020- case provider.EventDone:
1021- stop = e.Stop
1022- finalErr = e.Err
1023- finalMsg = e.Message
1024- }
10251005 }
10261006 }
10271007 streamDone:
@@ -952,76 +952,56 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
952 timer.Reset(a.StreamIdleTimeout)952 timer.Reset(a.StreamIdleTimeout)
953 }953 }
954 }954 }
955+ handleStreamEvent := func(ev provider.Event) (done bool) {
956+ if resetIdle != nil {
957+ resetIdle()
958+ }
959+ switch e := ev.(type) {
960+ case provider.EventStart:
961+ // nothing
962+ case provider.EventTextDelta:
963+ sink(EvTextDelta{Delta: e.Delta})
964+ case provider.EventToolStart:
965+ sink(EvToolUseStart{ID: e.ID, Name: e.Name})
966+ case provider.EventToolArgs:
967+ sink(EvToolUseArgs{ID: e.ID, Delta: e.Delta})
968+ case provider.EventToolEnd:
969+ sink(EvToolUseEnd{ID: e.ID})
970+ case provider.EventUsage:
971+ cum := a.addUsage(e.Usage)
972+ sink(EvUsage{Usage: e.Usage, Cumulative: cum})
973+ if a.OnUsage != nil {
974+ a.OnUsage(cum)
975+ }
976+ case provider.EventDone:
977+ stop = e.Stop
978+ finalErr = e.Err
979+ finalMsg = e.Message
980+ return true
981+ }
982+ return false
983+ }
955 for {984 for {
956- // Prefer an already-buffered terminal provider event over a concurrent985+ // Consume no more than one buffered event before honoring cancellation.
957- // context cancellation so retry classification retains its real cause.
958 select {986 select {
959 case ev, ok := <-stream:987 case ev, ok := <-stream:
960- if !ok {988+ if !ok || handleStreamEvent(ev) {
961 goto streamDone989 goto streamDone
962 }990 }
963- if resetIdle != nil {
964- resetIdle()
965- }
966- switch e := ev.(type) {
967- case provider.EventStart:
968- // nothing
969- case provider.EventTextDelta:
970- sink(EvTextDelta{Delta: e.Delta})
971- case provider.EventToolStart:
972- sink(EvToolUseStart{ID: e.ID, Name: e.Name})
973- case provider.EventToolArgs:
974- sink(EvToolUseArgs{ID: e.ID, Delta: e.Delta})
975- case provider.EventToolEnd:
976- sink(EvToolUseEnd{ID: e.ID})
977- case provider.EventUsage:
978- cum := a.addUsage(e.Usage)
979- sink(EvUsage{Usage: e.Usage, Cumulative: cum})
980- if a.OnUsage != nil {
981- a.OnUsage(cum)
982- }
983- case provider.EventDone:
984- stop = e.Stop
985- finalErr = e.Err
986- finalMsg = e.Message
987- }
988- continue
989 default:991 default:
990 }992 }
993+ if err := streamCtx.Err(); err != nil {
994+ return provider.StopError, finalMsg, err
995+ }
991 select {996 select {
992 case <-streamCtx.Done():997 case <-streamCtx.Done():
993 return provider.StopError, finalMsg, streamCtx.Err()998 return provider.StopError, finalMsg, streamCtx.Err()
994 case <-idle:999 case <-idle:
995 return provider.StopError, finalMsg, ErrStreamIdleTimeout1000 return provider.StopError, finalMsg, ErrStreamIdleTimeout
996 case ev, ok := <-stream:1001 case ev, ok := <-stream:
997- if !ok {1002+ if !ok || handleStreamEvent(ev) {
998 goto streamDone1003 goto streamDone
999 }1004 }
1000- if resetIdle != nil {
1001- resetIdle()
1002- }
1003- switch e := ev.(type) {
1004- case provider.EventStart:
1005- // nothing
1006- case provider.EventTextDelta:
1007- sink(EvTextDelta{Delta: e.Delta})
1008- case provider.EventToolStart:
1009- sink(EvToolUseStart{ID: e.ID, Name: e.Name})
1010- case provider.EventToolArgs:
1011- sink(EvToolUseArgs{ID: e.ID, Delta: e.Delta})
1012- case provider.EventToolEnd:
1013- sink(EvToolUseEnd{ID: e.ID})
1014- case provider.EventUsage:
1015- cum := a.addUsage(e.Usage)
1016- sink(EvUsage{Usage: e.Usage, Cumulative: cum})
1017- if a.OnUsage != nil {
1018- a.OnUsage(cum)
1019- }
1020- case provider.EventDone:
1021- stop = e.Stop
1022- finalErr = e.Err
1023- finalMsg = e.Message
1024- }
1025 }1005 }
1026 }1006 }
1027 streamDone:1007 streamDone:
M packages/core/agent_retry_test.go +38 -0
@@ -61,6 +61,28 @@ func (silentStreamClient) Stream(ctx context.Context, _ provider.Request) (<-cha
6161 return out, nil
6262 }
6363
64+type readableAfterCancellationClient struct {
65+ cancel context.CancelFunc
66+}
67+
68+func (c readableAfterCancellationClient) Name() string { return "readable-after-cancellation" }
69+
70+func (c readableAfterCancellationClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
71+ out := make(chan provider.Event, 1)
72+ out <- provider.EventStart{}
73+ c.cancel()
74+ return out, nil
75+}
76+
77+func TestAgentStopsReadingStreamAfterCancellation(t *testing.T) {
78+ ctx, cancel := context.WithCancel(context.Background())
79+ a := NewAgent(readableAfterCancellationClient{cancel: cancel}, "fake-model", "system", Registry{})
80+ a.MaxRetries = 0
81+ if err := a.Prompt(ctx, "hello", nil, nil); !errors.Is(err, context.Canceled) {
82+ t.Fatalf("Prompt error = %v, want context.Canceled", err)
83+ }
84+}
85+
6486 func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) {
6587 a := NewAgent(silentStreamClient{}, "fake-model", "system", Registry{})
6688 a.MaxRetries = 0
@@ -70,6 +92,22 @@ func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) {
7092 }
7193 }
7294
95+func TestAgentRetriesSilentStreamWithoutEndingTurn(t *testing.T) {
96+ client := &retryFakeClient{firstErr: ErrStreamIdleTimeout}
97+ a := NewAgent(client, "fake-model", "system", Registry{})
98+ a.MaxRetries = 1
99+ a.RetryBaseDelay = time.Millisecond
100+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
101+ t.Fatalf("Prompt returned %v", err)
102+ }
103+ if got := atomic.LoadInt32(&client.calls); got != 2 {
104+ t.Fatalf("Stream calls = %d; want reconnect after idle timeout", got)
105+ }
106+ if got := extractText(a.Messages()[1]); got != "ok" {
107+ t.Fatalf("final assistant text = %q; want ok", got)
108+ }
109+}
110+
73111 func TestAgentRetriesOverloadedStreamError(t *testing.T) {
74112 client := &retryFakeClient{}
75113 a := NewAgent(client, "fake-model", "system", Registry{})
@@ -61,6 +61,28 @@ func (silentStreamClient) Stream(ctx context.Context, _ provider.Request) (<-cha
61 return out, nil61 return out, nil
62 }62 }
63 63
64+type readableAfterCancellationClient struct {
65+ cancel context.CancelFunc
66+}
67+
68+func (c readableAfterCancellationClient) Name() string { return "readable-after-cancellation" }
69+
70+func (c readableAfterCancellationClient) Stream(_ context.Context, _ provider.Request) (<-chan provider.Event, error) {
71+ out := make(chan provider.Event, 1)
72+ out <- provider.EventStart{}
73+ c.cancel()
74+ return out, nil
75+}
76+
77+func TestAgentStopsReadingStreamAfterCancellation(t *testing.T) {
78+ ctx, cancel := context.WithCancel(context.Background())
79+ a := NewAgent(readableAfterCancellationClient{cancel: cancel}, "fake-model", "system", Registry{})
80+ a.MaxRetries = 0
81+ if err := a.Prompt(ctx, "hello", nil, nil); !errors.Is(err, context.Canceled) {
82+ t.Fatalf("Prompt error = %v, want context.Canceled", err)
83+ }
84+}
85+
64 func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) {86 func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) {
65 a := NewAgent(silentStreamClient{}, "fake-model", "system", Registry{})87 a := NewAgent(silentStreamClient{}, "fake-model", "system", Registry{})
66 a.MaxRetries = 088 a.MaxRetries = 0
@@ -70,6 +92,22 @@ func TestAgentStopsSilentStreamAtIdleDeadline(t *testing.T) {
70 }92 }
71 }93 }
72 94
95+func TestAgentRetriesSilentStreamWithoutEndingTurn(t *testing.T) {
96+ client := &retryFakeClient{firstErr: ErrStreamIdleTimeout}
97+ a := NewAgent(client, "fake-model", "system", Registry{})
98+ a.MaxRetries = 1
99+ a.RetryBaseDelay = time.Millisecond
100+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
101+ t.Fatalf("Prompt returned %v", err)
102+ }
103+ if got := atomic.LoadInt32(&client.calls); got != 2 {
104+ t.Fatalf("Stream calls = %d; want reconnect after idle timeout", got)
105+ }
106+ if got := extractText(a.Messages()[1]); got != "ok" {
107+ t.Fatalf("final assistant text = %q; want ok", got)
108+ }
109+}
110+
73 func TestAgentRetriesOverloadedStreamError(t *testing.T) {111 func TestAgentRetriesOverloadedStreamError(t *testing.T) {
74 client := &retryFakeClient{}112 client := &retryFakeClient{}
75 a := NewAgent(client, "fake-model", "system", Registry{})113 a := NewAgent(client, "fake-model", "system", Registry{})

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close