detached 9265275

fix(subagents): harden deadline transcript recovery

commit
926527527c6cbcbcc0bdafd0c11675c84a5438b3
author
brice · · 2026-08-08 04:10 UTC
parent
01a09fb
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +152 -23
M packages/agent/subagents/agent.go +9 -3
@@ -445,11 +445,11 @@ func (a *Agent) setActivity(msg string) {
445445 }
446446
447447 func (a *Agent) appendTranscript(chunk string) {
448- a.appendTranscriptLocked(chunk, "", false)
448+ a.appendTranscriptChunk(chunk, "", false)
449449 }
450450
451451 func (a *Agent) appendUserMessage(text string) {
452- a.appendTranscriptLocked(text, "user: ", false)
452+ a.appendTranscriptChunk(text, "user: ", false)
453453 }
454454
455455 func (a *Agent) appendAssistantMessage(text string) {
@@ -488,7 +488,7 @@ func (a *Agent) appendAssistantDelta(text string) {
488488 a.markActivity(time.Now())
489489 }
490490
491-func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) {
491+func (a *Agent) appendTranscriptChunk(chunk, linePrefix string, assistant bool) {
492492 if strings.TrimRight(chunk, "\n") == "" {
493493 return
494494 }
@@ -499,6 +499,12 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
499499 a.markActivity(time.Now())
500500 }
501501
502+func (a *Agent) resetStreamingAssistant() {
503+ a.mu.Lock()
504+ a.clearStreamingAssistantLocked()
505+ a.mu.Unlock()
506+}
507+
502508 func (a *Agent) replaceStreamingAssistantLocked(text string) {
503509 a.removeStreamingAssistantLocked()
504510 a.streamingAssistantStart = len(a.transcript)
@@ -445,11 +445,11 @@ func (a *Agent) setActivity(msg string) {
445 }445 }
446 446
447 func (a *Agent) appendTranscript(chunk string) {447 func (a *Agent) appendTranscript(chunk string) {
448- a.appendTranscriptLocked(chunk, "", false)448+ a.appendTranscriptChunk(chunk, "", false)
449 }449 }
450 450
451 func (a *Agent) appendUserMessage(text string) {451 func (a *Agent) appendUserMessage(text string) {
452- a.appendTranscriptLocked(text, "user: ", false)452+ a.appendTranscriptChunk(text, "user: ", false)
453 }453 }
454 454
455 func (a *Agent) appendAssistantMessage(text string) {455 func (a *Agent) appendAssistantMessage(text string) {
@@ -488,7 +488,7 @@ func (a *Agent) appendAssistantDelta(text string) {
488 a.markActivity(time.Now())488 a.markActivity(time.Now())
489 }489 }
490 490
491-func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) {491+func (a *Agent) appendTranscriptChunk(chunk, linePrefix string, assistant bool) {
492 if strings.TrimRight(chunk, "\n") == "" {492 if strings.TrimRight(chunk, "\n") == "" {
493 return493 return
494 }494 }
@@ -499,6 +499,12 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
499 a.markActivity(time.Now())499 a.markActivity(time.Now())
500 }500 }
501 501
502+func (a *Agent) resetStreamingAssistant() {
503+ a.mu.Lock()
504+ a.clearStreamingAssistantLocked()
505+ a.mu.Unlock()
506+}
507+
502 func (a *Agent) replaceStreamingAssistantLocked(text string) {508 func (a *Agent) replaceStreamingAssistantLocked(text string) {
503 a.removeStreamingAssistantLocked()509 a.removeStreamingAssistantLocked()
504 a.streamingAssistantStart = len(a.transcript)510 a.streamingAssistantStart = len(a.transcript)
M packages/agent/subagents/agent_test.go +31 -1
@@ -1,6 +1,9 @@
11 package subagents
22
3-import "testing"
3+import (
4+ "sync"
5+ "testing"
6+)
47
58 func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {
69 a := &Agent{maxOutputBytes: 6, maxOutputLines: 10}
@@ -47,3 +50,30 @@ func TestAgentTranscriptLineCapAccounting(t *testing.T) {
4750 t.Fatalf("line-cap accounting = (%d bytes, %d lines), want (10, 2)", a.outputBytes, a.outputLines)
4851 }
4952 }
53+
54+func TestAgentSnapshotConcurrentTranscriptTruncation(t *testing.T) {
55+ a := &Agent{maxOutputBytes: 8, maxOutputLines: 1}
56+ start := make(chan struct{})
57+ var wg sync.WaitGroup
58+ wg.Add(2)
59+ go func() {
60+ defer wg.Done()
61+ <-start
62+ for range 10_000 {
63+ a.appendTranscript("overflow")
64+ }
65+ }()
66+ go func() {
67+ defer wg.Done()
68+ <-start
69+ for range 10_000 {
70+ _ = a.Snapshot()
71+ }
72+ }()
73+ close(start)
74+ wg.Wait()
75+
76+ if !a.Snapshot().OutputTruncated {
77+ t.Fatal("snapshot did not report transcript truncation")
78+ }
79+}
@@ -1,6 +1,9 @@
1 package subagents1 package subagents
2 2
3-import "testing"3+import (
4+ "sync"
5+ "testing"
6+)
4 7
5 func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {8 func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {
6 a := &Agent{maxOutputBytes: 6, maxOutputLines: 10}9 a := &Agent{maxOutputBytes: 6, maxOutputLines: 10}
@@ -47,3 +50,30 @@ func TestAgentTranscriptLineCapAccounting(t *testing.T) {
47 t.Fatalf("line-cap accounting = (%d bytes, %d lines), want (10, 2)", a.outputBytes, a.outputLines)50 t.Fatalf("line-cap accounting = (%d bytes, %d lines), want (10, 2)", a.outputBytes, a.outputLines)
48 }51 }
49 }52 }
53+
54+func TestAgentSnapshotConcurrentTranscriptTruncation(t *testing.T) {
55+ a := &Agent{maxOutputBytes: 8, maxOutputLines: 1}
56+ start := make(chan struct{})
57+ var wg sync.WaitGroup
58+ wg.Add(2)
59+ go func() {
60+ defer wg.Done()
61+ <-start
62+ for range 10_000 {
63+ a.appendTranscript("overflow")
64+ }
65+ }()
66+ go func() {
67+ defer wg.Done()
68+ <-start
69+ for range 10_000 {
70+ _ = a.Snapshot()
71+ }
72+ }()
73+ close(start)
74+ wg.Wait()
75+
76+ if !a.Snapshot().OutputTruncated {
77+ t.Fatal("snapshot did not report transcript truncation")
78+ }
79+}
M packages/agent/subagents/persist.go +4 -2
@@ -598,6 +598,7 @@ func resumePromptAcknowledged(events []Event, acceptedAt time.Time) bool {
598598 func replayEventsIntoAgent(a *Agent, evs []Event) {
599599 terminal := false
600600 for _, ev := range evs {
601+ replayEventTranscript(a, ev)
601602 switch ev.Type {
602603 case EventTurnStarted, "turn_start":
603604 turnID := ev.TurnID
@@ -627,8 +628,6 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
627628 a.status = StatusDone
628629 a.activity = "done (offline)"
629630 a.mu.Unlock()
630- case "assistant_message", "user_message", "stdout", "stderr", "error":
631- replayEventTranscript(a, ev)
632631 case "agent_stopped":
633632 terminal = true
634633 reason, _ := ev.Data["reason"].(string)
@@ -677,6 +676,9 @@ func replayTranscriptIntoAgent(a *Agent, evs []Event) {
677676 }
678677
679678 func replayEventTranscript(a *Agent, ev Event) {
679+ if isAssistantStreamBoundary(ev.Type) {
680+ a.resetStreamingAssistant()
681+ }
680682 switch ev.Type {
681683 case "message.delta":
682684 if delta, _ := ev.Data["delta"].(string); delta != "" {
@@ -598,6 +598,7 @@ func resumePromptAcknowledged(events []Event, acceptedAt time.Time) bool {
598 func replayEventsIntoAgent(a *Agent, evs []Event) {598 func replayEventsIntoAgent(a *Agent, evs []Event) {
599 terminal := false599 terminal := false
600 for _, ev := range evs {600 for _, ev := range evs {
601+ replayEventTranscript(a, ev)
601 switch ev.Type {602 switch ev.Type {
602 case EventTurnStarted, "turn_start":603 case EventTurnStarted, "turn_start":
603 turnID := ev.TurnID604 turnID := ev.TurnID
@@ -627,8 +628,6 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
627 a.status = StatusDone628 a.status = StatusDone
628 a.activity = "done (offline)"629 a.activity = "done (offline)"
629 a.mu.Unlock()630 a.mu.Unlock()
630- case "assistant_message", "user_message", "stdout", "stderr", "error":
631- replayEventTranscript(a, ev)
632 case "agent_stopped":631 case "agent_stopped":
633 terminal = true632 terminal = true
634 reason, _ := ev.Data["reason"].(string)633 reason, _ := ev.Data["reason"].(string)
@@ -677,6 +676,9 @@ func replayTranscriptIntoAgent(a *Agent, evs []Event) {
677 }676 }
678 677
679 func replayEventTranscript(a *Agent, ev Event) {678 func replayEventTranscript(a *Agent, ev Event) {
679+ if isAssistantStreamBoundary(ev.Type) {
680+ a.resetStreamingAssistant()
681+ }
680 switch ev.Type {682 switch ev.Type {
681 case "message.delta":683 case "message.delta":
682 if delta, _ := ev.Data["delta"].(string); delta != "" {684 if delta, _ := ev.Data["delta"].(string); delta != "" {
M packages/agent/subagents/result.go +9 -8
@@ -471,11 +471,12 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
471471 result = nil
472472 }
473473 }
474- if result != nil && result.Output == "" && partialOutput != "" {
475- // A canceled worker turn may emit its terminal result before its
476- // provider turns accumulated text into a final assistant message.
474+ if result != nil && result.Output == "" && partialOutput != "" &&
475+ (result.Status == ResultFailed || result.Status == ResultCanceled || status == StatusFailed || status == StatusKilled) {
476+ // A canceled or failed worker turn may emit its terminal result before
477+ // its provider turns accumulated text into a final assistant message.
477478 // The event stream has already projected that text onto the Agent, so
478- // retain it in the durable result rather than returning an empty timeout.
479+ // retain it in the durable result rather than returning an empty failure.
479480 result.Output = partialOutput
480481 }
481482 if result == nil {
@@ -521,10 +522,10 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
521522 result.Status = ResultFailed
522523 }
523524 if errors.Is(runErr, context.DeadlineExceeded) {
524- // The worker was asked to stop cleanly before its process group was
525- // force-killed. Any streamed output received before that point remains
526- // in this result and HistoryRef(agentID), even when the worker's own
527- // cancellation result arrived before the parent classified the deadline.
525+ // The parent deadline is authoritative for terminal categorization, so
526+ // it intentionally overrides any child result error, including
527+ // context_limit. The worker was first asked to stop cleanly, and any
528+ // streamed output remains in this result and HistoryRef(agentID).
528529 message := "subagent deadline exceeded; inspect history for received output"
529530 if result.Output != "" {
530531 message = "subagent deadline exceeded; partial output is preserved in the result and history"
@@ -471,11 +471,12 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
471 result = nil471 result = nil
472 }472 }
473 }473 }
474- if result != nil && result.Output == "" && partialOutput != "" {474+ if result != nil && result.Output == "" && partialOutput != "" &&
475- // A canceled worker turn may emit its terminal result before its475+ (result.Status == ResultFailed || result.Status == ResultCanceled || status == StatusFailed || status == StatusKilled) {
476- // provider turns accumulated text into a final assistant message.476+ // A canceled or failed worker turn may emit its terminal result before
477+ // its provider turns accumulated text into a final assistant message.
477 // The event stream has already projected that text onto the Agent, so478 // The event stream has already projected that text onto the Agent, so
478- // retain it in the durable result rather than returning an empty timeout.479+ // retain it in the durable result rather than returning an empty failure.
479 result.Output = partialOutput480 result.Output = partialOutput
480 }481 }
481 if result == nil {482 if result == nil {
@@ -521,10 +522,10 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
521 result.Status = ResultFailed522 result.Status = ResultFailed
522 }523 }
523 if errors.Is(runErr, context.DeadlineExceeded) {524 if errors.Is(runErr, context.DeadlineExceeded) {
524- // The worker was asked to stop cleanly before its process group was525+ // The parent deadline is authoritative for terminal categorization, so
525- // force-killed. Any streamed output received before that point remains526+ // it intentionally overrides any child result error, including
526- // in this result and HistoryRef(agentID), even when the worker's own527+ // context_limit. The worker was first asked to stop cleanly, and any
527- // cancellation result arrived before the parent classified the deadline.528+ // streamed output remains in this result and HistoryRef(agentID).
528 message := "subagent deadline exceeded; inspect history for received output"529 message := "subagent deadline exceeded; inspect history for received output"
529 if result.Output != "" {530 if result.Output != "" {
530 message = "subagent deadline exceeded; partial output is preserved in the result and history"531 message = "subagent deadline exceeded; partial output is preserved in the result and history"
M packages/agent/subagents/result_test.go +27 -1
@@ -272,6 +272,7 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
272272 name string
273273 status Status
274274 runErr error
275+ childCode string
275276 wantState TurnStatus
276277 wantCode string
277278 wantOutput string
@@ -279,17 +280,24 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
279280 {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed, wantCode: "runner_failed"},
280281 {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
281282 {name: "deadline preserves recovery guidance", status: StatusFailed, runErr: context.DeadlineExceeded, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
283+ {name: "deadline overrides child error", status: StatusFailed, runErr: context.DeadlineExceeded, childCode: "context_limit", wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
282284 {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
283285 }
284286 for _, tt := range tests {
285287 t.Run(tt.name, func(t *testing.T) {
286288 stateDir := t.TempDir()
289+ childStatus := ResultSucceeded
290+ var childError *ResultError
291+ if tt.childCode != "" {
292+ childStatus = ResultFailed
293+ childError = &ResultError{Code: tt.childCode, Message: "child error"}
294+ }
287295 agent := &Agent{
288296 ID: "agent-1",
289297 stateDir: stateDir,
290298 lastAssistant: tt.wantOutput,
291299 result: &TurnResult{
292- AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
300+ AgentID: "agent-1", TurnID: "turn-1", Status: childStatus, Error: childError,
293301 },
294302 }
295303 supervisor := &Supervisor{}
@@ -323,3 +331,21 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
323331 })
324332 }
325333 }
334+
335+func TestEnsureResultDoesNotBackfillSuccessfulEmptyOutput(t *testing.T) {
336+ stateDir := t.TempDir()
337+ agent := &Agent{
338+ ID: "agent-1",
339+ stateDir: stateDir,
340+ lastAssistant: "streamed text",
341+ result: &TurnResult{
342+ AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
343+ },
344+ }
345+
346+ (&Supervisor{}).ensureResult(agent, StatusDone, nil)
347+
348+ if result := agent.Result(); result == nil || result.Output != "" {
349+ t.Fatalf("successful result = %#v, want intentionally empty output", result)
350+ }
351+}
@@ -272,6 +272,7 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
272 name string272 name string
273 status Status273 status Status
274 runErr error274 runErr error
275+ childCode string
275 wantState TurnStatus276 wantState TurnStatus
276 wantCode string277 wantCode string
277 wantOutput string278 wantOutput string
@@ -279,17 +280,24 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
279 {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed, wantCode: "runner_failed"},280 {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed, wantCode: "runner_failed"},
280 {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},281 {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
281 {name: "deadline preserves recovery guidance", status: StatusFailed, runErr: context.DeadlineExceeded, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},282 {name: "deadline preserves recovery guidance", status: StatusFailed, runErr: context.DeadlineExceeded, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
283+ {name: "deadline overrides child error", status: StatusFailed, runErr: context.DeadlineExceeded, childCode: "context_limit", wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
282 {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},284 {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
283 }285 }
284 for _, tt := range tests {286 for _, tt := range tests {
285 t.Run(tt.name, func(t *testing.T) {287 t.Run(tt.name, func(t *testing.T) {
286 stateDir := t.TempDir()288 stateDir := t.TempDir()
289+ childStatus := ResultSucceeded
290+ var childError *ResultError
291+ if tt.childCode != "" {
292+ childStatus = ResultFailed
293+ childError = &ResultError{Code: tt.childCode, Message: "child error"}
294+ }
287 agent := &Agent{295 agent := &Agent{
288 ID: "agent-1",296 ID: "agent-1",
289 stateDir: stateDir,297 stateDir: stateDir,
290 lastAssistant: tt.wantOutput,298 lastAssistant: tt.wantOutput,
291 result: &TurnResult{299 result: &TurnResult{
292- AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,300+ AgentID: "agent-1", TurnID: "turn-1", Status: childStatus, Error: childError,
293 },301 },
294 }302 }
295 supervisor := &Supervisor{}303 supervisor := &Supervisor{}
@@ -323,3 +331,21 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
323 })331 })
324 }332 }
325 }333 }
334+
335+func TestEnsureResultDoesNotBackfillSuccessfulEmptyOutput(t *testing.T) {
336+ stateDir := t.TempDir()
337+ agent := &Agent{
338+ ID: "agent-1",
339+ stateDir: stateDir,
340+ lastAssistant: "streamed text",
341+ result: &TurnResult{
342+ AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
343+ },
344+ }
345+
346+ (&Supervisor{}).ensureResult(agent, StatusDone, nil)
347+
348+ if result := agent.Result(); result == nil || result.Output != "" {
349+ t.Fatalf("successful result = %#v, want intentionally empty output", result)
350+ }
351+}
M packages/agent/subagents/runner.go +20 -2
@@ -5,6 +5,7 @@ import (
55 "bytes"
66 "context"
77 "encoding/json"
8+ "errors"
89 "fmt"
910 "os"
1011 "os/exec"
@@ -478,7 +479,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
478479 }
479480 if ctxErr := ctx.Err(); ctxErr != nil {
480481 reason := "cancelled"
481- if ctxErr == context.DeadlineExceeded {
482+ if errors.Is(ctxErr, context.DeadlineExceeded) {
482483 reason = "deadline"
483484 }
484485 appendLog(NewEvent("agent_stopped", map[string]any{"reason": reason}))
@@ -511,7 +512,7 @@ func (r *execRunner) stopOnContextDone(ctx context.Context, cmd *exec.Cmd, runne
511512 return
512513 case <-ctx.Done():
513514 }
514- if ctx.Err() != context.DeadlineExceeded {
515+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
515516 if cmd.Cancel != nil {
516517 _ = cmd.Cancel()
517518 return
@@ -765,6 +766,15 @@ func notifyPromptTurnEnd(a *Agent, ev Event) {
765766 go fn(int(step), errMsg)
766767 }
767768
769+func isAssistantStreamBoundary(eventType string) bool {
770+ switch eventType {
771+ case EventTurnStarted, "turn_start", EventTurnResult, "turn_result", EventTurnFailed, "turn_failed", "turn_end":
772+ return true
773+ default:
774+ return false
775+ }
776+}
777+
768778 // applyEventToSink translates an Event into Sink updates. Only a
769779 // few event types are interpreted; the rest still land in the
770780 // durable log via the caller.
@@ -776,6 +786,14 @@ func applyEventToSink(ev Event, sink Sink) {
776786 type streamingSink interface {
777787 assistantDelta(string)
778788 }
789+ type streamResetSink interface {
790+ resetStreamingAssistant()
791+ }
792+ if isAssistantStreamBoundary(ev.Type) {
793+ if streaming, ok := sink.(streamResetSink); ok {
794+ streaming.resetStreamingAssistant()
795+ }
796+ }
779797 appendMessage := func(text string, assistant bool) {
780798 if text == "" {
781799 return
@@ -5,6 +5,7 @@ import (
5 "bytes"5 "bytes"
6 "context"6 "context"
7 "encoding/json"7 "encoding/json"
8+ "errors"
8 "fmt"9 "fmt"
9 "os"10 "os"
10 "os/exec"11 "os/exec"
@@ -478,7 +479,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
478 }479 }
479 if ctxErr := ctx.Err(); ctxErr != nil {480 if ctxErr := ctx.Err(); ctxErr != nil {
480 reason := "cancelled"481 reason := "cancelled"
481- if ctxErr == context.DeadlineExceeded {482+ if errors.Is(ctxErr, context.DeadlineExceeded) {
482 reason = "deadline"483 reason = "deadline"
483 }484 }
484 appendLog(NewEvent("agent_stopped", map[string]any{"reason": reason}))485 appendLog(NewEvent("agent_stopped", map[string]any{"reason": reason}))
@@ -511,7 +512,7 @@ func (r *execRunner) stopOnContextDone(ctx context.Context, cmd *exec.Cmd, runne
511 return512 return
512 case <-ctx.Done():513 case <-ctx.Done():
513 }514 }
514- if ctx.Err() != context.DeadlineExceeded {515+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
515 if cmd.Cancel != nil {516 if cmd.Cancel != nil {
516 _ = cmd.Cancel()517 _ = cmd.Cancel()
517 return518 return
@@ -765,6 +766,15 @@ func notifyPromptTurnEnd(a *Agent, ev Event) {
765 go fn(int(step), errMsg)766 go fn(int(step), errMsg)
766 }767 }
767 768
769+func isAssistantStreamBoundary(eventType string) bool {
770+ switch eventType {
771+ case EventTurnStarted, "turn_start", EventTurnResult, "turn_result", EventTurnFailed, "turn_failed", "turn_end":
772+ return true
773+ default:
774+ return false
775+ }
776+}
777+
768 // applyEventToSink translates an Event into Sink updates. Only a778 // applyEventToSink translates an Event into Sink updates. Only a
769 // few event types are interpreted; the rest still land in the779 // few event types are interpreted; the rest still land in the
770 // durable log via the caller.780 // durable log via the caller.
@@ -776,6 +786,14 @@ func applyEventToSink(ev Event, sink Sink) {
776 type streamingSink interface {786 type streamingSink interface {
777 assistantDelta(string)787 assistantDelta(string)
778 }788 }
789+ type streamResetSink interface {
790+ resetStreamingAssistant()
791+ }
792+ if isAssistantStreamBoundary(ev.Type) {
793+ if streaming, ok := sink.(streamResetSink); ok {
794+ streaming.resetStreamingAssistant()
795+ }
796+ }
779 appendMessage := func(text string, assistant bool) {797 appendMessage := func(text string, assistant bool) {
780 if text == "" {798 if text == "" {
781 return799 return
M packages/agent/subagents/runner_e2e_test.go +6 -4
@@ -25,6 +25,8 @@ func (s notifyingAgentSink) assistantDelta(text string) {
2525 }
2626 }
2727
28+const runnerProcessTimeout = 10 * time.Second
29+
2830 type controlledDeadlineContext struct{ done chan struct{} }
2931
3032 func (c *controlledDeadlineContext) Deadline() (time.Time, bool) {
@@ -87,7 +89,7 @@ func TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput(t *te
8789 go func() { runDone <- r.Run(ctx, notifyingAgentSink{agentSink: agentSink{a: a}, deltas: deltas}) }()
8890 select {
8991 case <-deltas:
90- case <-time.After(time.Second):
92+ case <-time.After(runnerProcessTimeout):
9193 t.Fatal("worker did not emit its initial delta")
9294 }
9395 ctx.expire()
@@ -96,7 +98,7 @@ func TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput(t *te
9698 if !errors.Is(err, context.DeadlineExceeded) {
9799 t.Fatalf("Run error = %v, want context deadline exceeded", err)
98100 }
99- case <-time.After(time.Second):
101+ case <-time.After(runnerProcessTimeout):
100102 t.Fatal("Run did not return after deadline expiry")
101103 }
102104
@@ -166,7 +168,7 @@ func TestExecRunnerCancelsBlockedWorker(t *testing.T) {
166168
167169 select {
168170 case <-deltas:
169- case <-time.After(time.Second):
171+ case <-time.After(runnerProcessTimeout):
170172 t.Fatal("worker did not emit its initial delta")
171173 }
172174 cancel()
@@ -175,7 +177,7 @@ func TestExecRunnerCancelsBlockedWorker(t *testing.T) {
175177 if !errors.Is(err, context.Canceled) {
176178 t.Fatalf("Run error = %v, want context canceled", err)
177179 }
178- case <-time.After(time.Second):
180+ case <-time.After(runnerProcessTimeout):
179181 t.Fatal("Run did not return after context cancellation")
180182 }
181183 }
@@ -25,6 +25,8 @@ func (s notifyingAgentSink) assistantDelta(text string) {
25 }25 }
26 }26 }
27 27
28+const runnerProcessTimeout = 10 * time.Second
29+
28 type controlledDeadlineContext struct{ done chan struct{} }30 type controlledDeadlineContext struct{ done chan struct{} }
29 31
30 func (c *controlledDeadlineContext) Deadline() (time.Time, bool) {32 func (c *controlledDeadlineContext) Deadline() (time.Time, bool) {
@@ -87,7 +89,7 @@ func TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput(t *te
87 go func() { runDone <- r.Run(ctx, notifyingAgentSink{agentSink: agentSink{a: a}, deltas: deltas}) }()89 go func() { runDone <- r.Run(ctx, notifyingAgentSink{agentSink: agentSink{a: a}, deltas: deltas}) }()
88 select {90 select {
89 case <-deltas:91 case <-deltas:
90- case <-time.After(time.Second):92+ case <-time.After(runnerProcessTimeout):
91 t.Fatal("worker did not emit its initial delta")93 t.Fatal("worker did not emit its initial delta")
92 }94 }
93 ctx.expire()95 ctx.expire()
@@ -96,7 +98,7 @@ func TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput(t *te
96 if !errors.Is(err, context.DeadlineExceeded) {98 if !errors.Is(err, context.DeadlineExceeded) {
97 t.Fatalf("Run error = %v, want context deadline exceeded", err)99 t.Fatalf("Run error = %v, want context deadline exceeded", err)
98 }100 }
99- case <-time.After(time.Second):101+ case <-time.After(runnerProcessTimeout):
100 t.Fatal("Run did not return after deadline expiry")102 t.Fatal("Run did not return after deadline expiry")
101 }103 }
102 104
@@ -166,7 +168,7 @@ func TestExecRunnerCancelsBlockedWorker(t *testing.T) {
166 168
167 select {169 select {
168 case <-deltas:170 case <-deltas:
169- case <-time.After(time.Second):171+ case <-time.After(runnerProcessTimeout):
170 t.Fatal("worker did not emit its initial delta")172 t.Fatal("worker did not emit its initial delta")
171 }173 }
172 cancel()174 cancel()
@@ -175,7 +177,7 @@ func TestExecRunnerCancelsBlockedWorker(t *testing.T) {
175 if !errors.Is(err, context.Canceled) {177 if !errors.Is(err, context.Canceled) {
176 t.Fatalf("Run error = %v, want context canceled", err)178 t.Fatalf("Run error = %v, want context canceled", err)
177 }179 }
178- case <-time.After(time.Second):180+ case <-time.After(runnerProcessTimeout):
179 t.Fatal("Run did not return after context cancellation")181 t.Fatal("Run did not return after context cancellation")
180 }182 }
181 }183 }
M packages/agent/subagents/runner_test.go +44 -0
@@ -112,6 +112,50 @@ func TestMessageDeltaBoundsLiveProjection(t *testing.T) {
112112 if !snapshot.OutputTruncated {
113113 t.Fatal("snapshot did not expose truncated streamed output")
114114 }
115+
116+ events := []Event{
117+ NewEvent(EventMessageDelta, map[string]any{"delta": "first partial"}),
118+ NewEvent("turn_end", map[string]any{"stop": "cancelled"}),
119+ NewEvent(EventMessageDelta, map[string]any{"delta": "second partial"}),
120+ }
121+ want := "first partial\nsecond partial"
122+ assertProjection := func(t *testing.T, agent *Agent) {
123+ t.Helper()
124+ if got := strings.Join(agent.Transcript(), "\n"); got != want {
125+ t.Fatalf("transcript = %q, want %q", got, want)
126+ }
127+ agent.mu.Lock()
128+ truncated := agent.streamingAssistantTruncated
129+ agent.mu.Unlock()
130+ if truncated {
131+ t.Fatal("fresh second-turn stream was incorrectly truncated")
132+ }
133+ }
134+
135+ live := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
136+ for _, event := range events {
137+ applyEventToSink(event, agentSink{a: live})
138+ }
139+ assertProjection(t, live)
140+
141+ replayed := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
142+ replayTranscriptIntoAgent(replayed, events)
143+ assertProjection(t, replayed)
144+
145+ legacy := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
146+ replayEventsIntoAgent(legacy, events)
147+ assertProjection(t, legacy)
148+
149+ truncatedTurn := &Agent{maxOutputBytes: 16, maxOutputLines: 100, transcriptLoaded: true}
150+ applyEventToSink(NewEvent(EventMessageDelta, map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: truncatedTurn})
151+ applyEventToSink(NewEvent("turn_end", map[string]any{"stop": "cancelled"}), agentSink{a: truncatedTurn})
152+ truncatedTurn.mu.Lock()
153+ streamText := truncatedTurn.streamingAssistantText
154+ streamTruncated := truncatedTurn.streamingAssistantTruncated
155+ truncatedTurn.mu.Unlock()
156+ if streamText != "" || streamTruncated {
157+ t.Fatalf("stream state after turn boundary = (%q, %v), want empty and not truncated", streamText, streamTruncated)
158+ }
115159 }
116160
117161 func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
@@ -112,6 +112,50 @@ func TestMessageDeltaBoundsLiveProjection(t *testing.T) {
112 if !snapshot.OutputTruncated {112 if !snapshot.OutputTruncated {
113 t.Fatal("snapshot did not expose truncated streamed output")113 t.Fatal("snapshot did not expose truncated streamed output")
114 }114 }
115+
116+ events := []Event{
117+ NewEvent(EventMessageDelta, map[string]any{"delta": "first partial"}),
118+ NewEvent("turn_end", map[string]any{"stop": "cancelled"}),
119+ NewEvent(EventMessageDelta, map[string]any{"delta": "second partial"}),
120+ }
121+ want := "first partial\nsecond partial"
122+ assertProjection := func(t *testing.T, agent *Agent) {
123+ t.Helper()
124+ if got := strings.Join(agent.Transcript(), "\n"); got != want {
125+ t.Fatalf("transcript = %q, want %q", got, want)
126+ }
127+ agent.mu.Lock()
128+ truncated := agent.streamingAssistantTruncated
129+ agent.mu.Unlock()
130+ if truncated {
131+ t.Fatal("fresh second-turn stream was incorrectly truncated")
132+ }
133+ }
134+
135+ live := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
136+ for _, event := range events {
137+ applyEventToSink(event, agentSink{a: live})
138+ }
139+ assertProjection(t, live)
140+
141+ replayed := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
142+ replayTranscriptIntoAgent(replayed, events)
143+ assertProjection(t, replayed)
144+
145+ legacy := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
146+ replayEventsIntoAgent(legacy, events)
147+ assertProjection(t, legacy)
148+
149+ truncatedTurn := &Agent{maxOutputBytes: 16, maxOutputLines: 100, transcriptLoaded: true}
150+ applyEventToSink(NewEvent(EventMessageDelta, map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: truncatedTurn})
151+ applyEventToSink(NewEvent("turn_end", map[string]any{"stop": "cancelled"}), agentSink{a: truncatedTurn})
152+ truncatedTurn.mu.Lock()
153+ streamText := truncatedTurn.streamingAssistantText
154+ streamTruncated := truncatedTurn.streamingAssistantTruncated
155+ truncatedTurn.mu.Unlock()
156+ if streamText != "" || streamTruncated {
157+ t.Fatalf("stream state after turn boundary = (%q, %v), want empty and not truncated", streamText, streamTruncated)
158+ }
115 }159 }
116 160
117 func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {161 func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
M packages/agent/subagents/supervisor.go +2 -2
@@ -1133,7 +1133,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
11331133 status := a.status
11341134 activity := a.activity
11351135 lastAssistant := a.lastAssistant
1136- streamingOutputTruncated := a.streamingAssistantTruncated
1136+ outputTruncated := a.outputTruncated || a.streamingAssistantTruncated
11371137 finished := a.finished
11381138 a.mu.Unlock()
11391139
@@ -1148,7 +1148,6 @@ func (a *Agent) Snapshot() AgentSnapshot {
11481148 resultRef := a.resultRef
11491149 patchRef := a.patchRef
11501150 changedFiles := append([]string(nil), a.changedFiles...)
1151- outputTruncated := a.outputTruncated || streamingOutputTruncated
11521151 a.lifecycleMu.Unlock()
11531152 return AgentSnapshot{
11541153 ID: a.ID, Task: a.Task, Dir: a.Dir,
@@ -1214,6 +1213,7 @@ func (s agentSink) Transcript(chunk string) {
12141213 func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }
12151214 func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }
12161215 func (s agentSink) assistantDelta(text string) { s.a.appendAssistantDelta(text) }
1216+func (s agentSink) resetStreamingAssistant() { s.a.resetStreamingAssistant() }
12171217
12181218 func truncate(s string, n int) string {
12191219 runes := []rune(s)
@@ -1133,7 +1133,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
1133 status := a.status1133 status := a.status
1134 activity := a.activity1134 activity := a.activity
1135 lastAssistant := a.lastAssistant1135 lastAssistant := a.lastAssistant
1136- streamingOutputTruncated := a.streamingAssistantTruncated1136+ outputTruncated := a.outputTruncated || a.streamingAssistantTruncated
1137 finished := a.finished1137 finished := a.finished
1138 a.mu.Unlock()1138 a.mu.Unlock()
1139 1139
@@ -1148,7 +1148,6 @@ func (a *Agent) Snapshot() AgentSnapshot {
1148 resultRef := a.resultRef1148 resultRef := a.resultRef
1149 patchRef := a.patchRef1149 patchRef := a.patchRef
1150 changedFiles := append([]string(nil), a.changedFiles...)1150 changedFiles := append([]string(nil), a.changedFiles...)
1151- outputTruncated := a.outputTruncated || streamingOutputTruncated
1152 a.lifecycleMu.Unlock()1151 a.lifecycleMu.Unlock()
1153 return AgentSnapshot{1152 return AgentSnapshot{
1154 ID: a.ID, Task: a.Task, Dir: a.Dir,1153 ID: a.ID, Task: a.Task, Dir: a.Dir,
@@ -1214,6 +1213,7 @@ func (s agentSink) Transcript(chunk string) {
1214 func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }1213 func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }
1215 func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }1214 func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }
1216 func (s agentSink) assistantDelta(text string) { s.a.appendAssistantDelta(text) }1215 func (s agentSink) assistantDelta(text string) { s.a.appendAssistantDelta(text) }
1216+func (s agentSink) resetStreamingAssistant() { s.a.resetStreamingAssistant() }
1217 1217
1218 func truncate(s string, n int) string {1218 func truncate(s string, n int) string {
1219 runes := []rune(s)1219 runes := []rune(s)

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