detached 01a09fb

fix(subagents): preserve output after worker deadlines

commit
01a09fb21a7128454e0869d6a25c7075d16d3241
author
brice · · 2026-08-08 03:33 UTC
committer
brice · · 2026-08-08 03:44 UTC
parent
ab30a57
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +514 -69
M docs/subagents.md +4 -0
@@ -123,6 +123,10 @@ subagent://<id>/result
123123 subagent://<id>/patch
124124 ```
125125
126+`events.jsonl` is appended as the worker emits events, including partial `message.delta` output. The dashboard and `subagent://<id>/history` replay those deltas, so output received before a worker finishes remains recoverable rather than waiting for a final assistant message.
127+
128+When a subagent deadline expires, the supervisor requests worker shutdown and allows the configured grace period for the worker to cancel its turn and write its session and result. The terminal result remains a failure with error code `deadline_exceeded`, preserves any output already received, and directs the caller to its history. If the worker does not stop during that grace period, the supervisor forcefully cancels it to preserve the timeout limit.
129+
126130 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
127131
128132 When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths.
@@ -123,6 +123,10 @@ subagent://<id>/result
123 subagent://<id>/patch123 subagent://<id>/patch
124 ```124 ```
125 125
126+`events.jsonl` is appended as the worker emits events, including partial `message.delta` output. The dashboard and `subagent://<id>/history` replay those deltas, so output received before a worker finishes remains recoverable rather than waiting for a final assistant message.
127+
128+When a subagent deadline expires, the supervisor requests worker shutdown and allows the configured grace period for the worker to cancel its turn and write its session and result. The terminal result remains a failure with error code `deadline_exceeded`, preserves any output already received, and directs the caller to its history. If the worker does not stop during that grace period, the supervisor forcefully cancels it to preserve the timeout limit.
129+
126 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.130 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
127 131
128 When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths.132 When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths.
M packages/agent/subagents/agent.go +124 -30
@@ -116,16 +116,20 @@ type Agent struct {
116116 // Supervisor.Spawn so callers do not need to manage the socket directly.
117117 inbox *Inbox
118118
119- mu sync.Mutex
120- status Status
121- activity string
122- transcript []string
123- lastAssistant string
124- finished time.Time
125- lastErr error
126- transcriptLoaded bool
127- legacyEventState bool
128- transcriptMu sync.Mutex
119+ mu sync.Mutex
120+ status Status
121+ activity string
122+ transcript []string
123+ lastAssistant string
124+ streamingAssistantText string
125+ streamingAssistantTruncated bool
126+ streamingAssistantStart int
127+ streamingAssistantLines int
128+ finished time.Time
129+ lastErr error
130+ transcriptLoaded bool
131+ legacyEventState bool
132+ transcriptMu sync.Mutex
129133
130134 lifecycleMu sync.Mutex
131135 processState ProcessState
@@ -449,17 +453,100 @@ func (a *Agent) appendUserMessage(text string) {
449453 }
450454
451455 func (a *Agent) appendAssistantMessage(text string) {
452- a.appendTranscriptLocked(text, "", true)
456+ if text == "" {
457+ return
458+ }
459+ a.mu.Lock()
460+ if a.streamingAssistantText != "" {
461+ a.replaceStreamingAssistantLocked(text)
462+ a.clearStreamingAssistantLocked()
463+ } else {
464+ a.appendTranscriptLinesLocked(text, "", true)
465+ a.boundTranscriptLocked()
466+ }
467+ a.mu.Unlock()
468+ a.markActivity(time.Now())
469+}
470+
471+// appendAssistantDelta exposes an in-progress assistant response in the
472+// dashboard without duplicating it when the final assistant_message arrives.
473+// The JSONL event log remains the durable source; this maintains its live and
474+// replayed in-memory projection.
475+func (a *Agent) appendAssistantDelta(text string) {
476+ if text == "" {
477+ return
478+ }
479+ a.mu.Lock()
480+ if !a.streamingAssistantTruncated {
481+ value := a.streamingAssistantText + text
482+ byteCap, lineCap := a.outputLimits()
483+ a.streamingAssistantText = boundInlineText(value, byteCap, lineCap)
484+ a.streamingAssistantTruncated = a.streamingAssistantText != value
485+ }
486+ a.replaceStreamingAssistantLocked(a.streamingAssistantText)
487+ a.mu.Unlock()
488+ a.markActivity(time.Now())
453489 }
454490
455491 func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) {
492+ if strings.TrimRight(chunk, "\n") == "" {
493+ return
494+ }
495+ a.mu.Lock()
496+ a.appendTranscriptLinesLocked(chunk, linePrefix, assistant)
497+ a.boundTranscriptLocked()
498+ a.mu.Unlock()
499+ a.markActivity(time.Now())
500+}
501+
502+func (a *Agent) replaceStreamingAssistantLocked(text string) {
503+ a.removeStreamingAssistantLocked()
504+ a.streamingAssistantStart = len(a.transcript)
505+ a.streamingAssistantLines = a.appendTranscriptLinesLocked(text, "", true)
506+ a.boundTranscriptLocked()
507+}
508+
509+func (a *Agent) removeStreamingAssistantLocked() {
510+ if a.streamingAssistantLines == 0 {
511+ return
512+ }
513+ start := a.streamingAssistantStart
514+ if start < 0 {
515+ start = 0
516+ }
517+ if start > len(a.transcript) {
518+ start = len(a.transcript)
519+ }
520+ end := start + a.streamingAssistantLines
521+ if end > len(a.transcript) {
522+ end = len(a.transcript)
523+ }
524+ for _, line := range a.transcript[start:end] {
525+ a.outputBytes -= len(line) + 1
526+ a.outputLines--
527+ }
528+ a.transcript = append(a.transcript[:start], a.transcript[end:]...)
529+ a.streamingAssistantLines = 0
530+ if a.outputLines == 0 {
531+ a.outputBytes = 0
532+ }
533+}
534+
535+func (a *Agent) clearStreamingAssistantLocked() {
536+ a.streamingAssistantText = ""
537+ a.streamingAssistantTruncated = false
538+ a.streamingAssistantStart = 0
539+ a.streamingAssistantLines = 0
540+}
541+
542+func (a *Agent) appendTranscriptLinesLocked(chunk, linePrefix string, assistant bool) int {
456543 message := chunk
457544 chunk = strings.TrimRight(chunk, "\n")
458545 if chunk == "" {
459- return
546+ return 0
460547 }
461- a.mu.Lock()
462- for _, line := range strings.Split(chunk, "\n") {
548+ lines := strings.Split(chunk, "\n")
549+ for _, line := range lines {
463550 line = linePrefix + line
464551 a.transcript = append(a.transcript, line)
465552 a.outputBytes += len(line) + 1
@@ -468,35 +555,42 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
468555 if assistant {
469556 a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines)
470557 }
471- // Bound the inline dashboard copy. The durable event log and session
472- // remain available through the logical history reference.
473- lineCap := a.maxOutputLines
474- if lineCap <= 0 {
475- lineCap = 2_000
476- }
477- byteCap := a.maxOutputBytes
558+ return len(lines)
559+}
560+
561+func (a *Agent) outputLimits() (byteCap, lineCap int) {
562+ byteCap = a.maxOutputBytes
478563 if byteCap <= 0 {
479564 byteCap = 500_000
480565 }
481- for a.outputLines > lineCap {
482- line := a.transcript[0]
483- a.transcript = a.transcript[1:]
484- a.outputBytes -= len(line) + 1
485- a.outputLines--
486- a.outputTruncated = true
566+ lineCap = a.maxOutputLines
567+ if lineCap <= 0 {
568+ lineCap = 2_000
487569 }
488- for a.outputLines > 0 && a.outputBytes > byteCap {
570+ return byteCap, lineCap
571+}
572+
573+func (a *Agent) boundTranscriptLocked() {
574+ // Bound the inline dashboard copy. The durable event log and session
575+ // remain available through the logical history reference.
576+ byteCap, lineCap := a.outputLimits()
577+ for a.outputLines > lineCap || (a.outputLines > 0 && a.outputBytes > byteCap) {
489578 line := a.transcript[0]
490579 a.transcript = a.transcript[1:]
491580 a.outputBytes -= len(line) + 1
492581 a.outputLines--
582+ if a.streamingAssistantLines > 0 {
583+ if a.streamingAssistantStart > 0 {
584+ a.streamingAssistantStart--
585+ } else {
586+ a.streamingAssistantLines--
587+ }
588+ }
493589 a.outputTruncated = true
494590 }
495591 if a.outputLines == 0 {
496592 a.outputBytes = 0
497593 }
498- a.mu.Unlock()
499- a.markActivity(time.Now())
500594 }
501595
502596 // newAgentID returns a short, mostly-collision-free identifier of the
@@ -116,16 +116,20 @@ type Agent struct {
116 // Supervisor.Spawn so callers do not need to manage the socket directly.116 // Supervisor.Spawn so callers do not need to manage the socket directly.
117 inbox *Inbox117 inbox *Inbox
118 118
119- mu sync.Mutex119+ mu sync.Mutex
120- status Status120+ status Status
121- activity string121+ activity string
122- transcript []string122+ transcript []string
123- lastAssistant string123+ lastAssistant string
124- finished time.Time124+ streamingAssistantText string
125- lastErr error125+ streamingAssistantTruncated bool
126- transcriptLoaded bool126+ streamingAssistantStart int
127- legacyEventState bool127+ streamingAssistantLines int
128- transcriptMu sync.Mutex128+ finished time.Time
129+ lastErr error
130+ transcriptLoaded bool
131+ legacyEventState bool
132+ transcriptMu sync.Mutex
129 133
130 lifecycleMu sync.Mutex134 lifecycleMu sync.Mutex
131 processState ProcessState135 processState ProcessState
@@ -449,17 +453,100 @@ func (a *Agent) appendUserMessage(text string) {
449 }453 }
450 454
451 func (a *Agent) appendAssistantMessage(text string) {455 func (a *Agent) appendAssistantMessage(text string) {
452- a.appendTranscriptLocked(text, "", true)456+ if text == "" {
457+ return
458+ }
459+ a.mu.Lock()
460+ if a.streamingAssistantText != "" {
461+ a.replaceStreamingAssistantLocked(text)
462+ a.clearStreamingAssistantLocked()
463+ } else {
464+ a.appendTranscriptLinesLocked(text, "", true)
465+ a.boundTranscriptLocked()
466+ }
467+ a.mu.Unlock()
468+ a.markActivity(time.Now())
469+}
470+
471+// appendAssistantDelta exposes an in-progress assistant response in the
472+// dashboard without duplicating it when the final assistant_message arrives.
473+// The JSONL event log remains the durable source; this maintains its live and
474+// replayed in-memory projection.
475+func (a *Agent) appendAssistantDelta(text string) {
476+ if text == "" {
477+ return
478+ }
479+ a.mu.Lock()
480+ if !a.streamingAssistantTruncated {
481+ value := a.streamingAssistantText + text
482+ byteCap, lineCap := a.outputLimits()
483+ a.streamingAssistantText = boundInlineText(value, byteCap, lineCap)
484+ a.streamingAssistantTruncated = a.streamingAssistantText != value
485+ }
486+ a.replaceStreamingAssistantLocked(a.streamingAssistantText)
487+ a.mu.Unlock()
488+ a.markActivity(time.Now())
453 }489 }
454 490
455 func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) {491 func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) {
492+ if strings.TrimRight(chunk, "\n") == "" {
493+ return
494+ }
495+ a.mu.Lock()
496+ a.appendTranscriptLinesLocked(chunk, linePrefix, assistant)
497+ a.boundTranscriptLocked()
498+ a.mu.Unlock()
499+ a.markActivity(time.Now())
500+}
501+
502+func (a *Agent) replaceStreamingAssistantLocked(text string) {
503+ a.removeStreamingAssistantLocked()
504+ a.streamingAssistantStart = len(a.transcript)
505+ a.streamingAssistantLines = a.appendTranscriptLinesLocked(text, "", true)
506+ a.boundTranscriptLocked()
507+}
508+
509+func (a *Agent) removeStreamingAssistantLocked() {
510+ if a.streamingAssistantLines == 0 {
511+ return
512+ }
513+ start := a.streamingAssistantStart
514+ if start < 0 {
515+ start = 0
516+ }
517+ if start > len(a.transcript) {
518+ start = len(a.transcript)
519+ }
520+ end := start + a.streamingAssistantLines
521+ if end > len(a.transcript) {
522+ end = len(a.transcript)
523+ }
524+ for _, line := range a.transcript[start:end] {
525+ a.outputBytes -= len(line) + 1
526+ a.outputLines--
527+ }
528+ a.transcript = append(a.transcript[:start], a.transcript[end:]...)
529+ a.streamingAssistantLines = 0
530+ if a.outputLines == 0 {
531+ a.outputBytes = 0
532+ }
533+}
534+
535+func (a *Agent) clearStreamingAssistantLocked() {
536+ a.streamingAssistantText = ""
537+ a.streamingAssistantTruncated = false
538+ a.streamingAssistantStart = 0
539+ a.streamingAssistantLines = 0
540+}
541+
542+func (a *Agent) appendTranscriptLinesLocked(chunk, linePrefix string, assistant bool) int {
456 message := chunk543 message := chunk
457 chunk = strings.TrimRight(chunk, "\n")544 chunk = strings.TrimRight(chunk, "\n")
458 if chunk == "" {545 if chunk == "" {
459- return546+ return 0
460 }547 }
461- a.mu.Lock()548+ lines := strings.Split(chunk, "\n")
462- for _, line := range strings.Split(chunk, "\n") {549+ for _, line := range lines {
463 line = linePrefix + line550 line = linePrefix + line
464 a.transcript = append(a.transcript, line)551 a.transcript = append(a.transcript, line)
465 a.outputBytes += len(line) + 1552 a.outputBytes += len(line) + 1
@@ -468,35 +555,42 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
468 if assistant {555 if assistant {
469 a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines)556 a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines)
470 }557 }
471- // Bound the inline dashboard copy. The durable event log and session558+ return len(lines)
472- // remain available through the logical history reference.559+}
473- lineCap := a.maxOutputLines560+
474- if lineCap <= 0 {561+func (a *Agent) outputLimits() (byteCap, lineCap int) {
475- lineCap = 2_000562+ byteCap = a.maxOutputBytes
476- }
477- byteCap := a.maxOutputBytes
478 if byteCap <= 0 {563 if byteCap <= 0 {
479 byteCap = 500_000564 byteCap = 500_000
480 }565 }
481- for a.outputLines > lineCap {566+ lineCap = a.maxOutputLines
482- line := a.transcript[0]567+ if lineCap <= 0 {
483- a.transcript = a.transcript[1:]568+ lineCap = 2_000
484- a.outputBytes -= len(line) + 1
485- a.outputLines--
486- a.outputTruncated = true
487 }569 }
488- for a.outputLines > 0 && a.outputBytes > byteCap {570+ return byteCap, lineCap
571+}
572+
573+func (a *Agent) boundTranscriptLocked() {
574+ // Bound the inline dashboard copy. The durable event log and session
575+ // remain available through the logical history reference.
576+ byteCap, lineCap := a.outputLimits()
577+ for a.outputLines > lineCap || (a.outputLines > 0 && a.outputBytes > byteCap) {
489 line := a.transcript[0]578 line := a.transcript[0]
490 a.transcript = a.transcript[1:]579 a.transcript = a.transcript[1:]
491 a.outputBytes -= len(line) + 1580 a.outputBytes -= len(line) + 1
492 a.outputLines--581 a.outputLines--
582+ if a.streamingAssistantLines > 0 {
583+ if a.streamingAssistantStart > 0 {
584+ a.streamingAssistantStart--
585+ } else {
586+ a.streamingAssistantLines--
587+ }
588+ }
493 a.outputTruncated = true589 a.outputTruncated = true
494 }590 }
495 if a.outputLines == 0 {591 if a.outputLines == 0 {
496 a.outputBytes = 0592 a.outputBytes = 0
497 }593 }
498- a.mu.Unlock()
499- a.markActivity(time.Now())
500 }594 }
501 595
502 // newAgentID returns a short, mostly-collision-free identifier of the596 // newAgentID returns a short, mostly-collision-free identifier of the
M packages/agent/subagents/persist.go +4 -0
@@ -678,6 +678,10 @@ func replayTranscriptIntoAgent(a *Agent, evs []Event) {
678678
679679 func replayEventTranscript(a *Agent, ev Event) {
680680 switch ev.Type {
681+ case "message.delta":
682+ if delta, _ := ev.Data["delta"].(string); delta != "" {
683+ a.appendAssistantDelta(delta)
684+ }
681685 case "assistant_message", "user_message":
682686 var text []string
683687 if c, ok := ev.Data["content"].([]any); ok {
@@ -678,6 +678,10 @@ func replayTranscriptIntoAgent(a *Agent, evs []Event) {
678 678
679 func replayEventTranscript(a *Agent, ev Event) {679 func replayEventTranscript(a *Agent, ev Event) {
680 switch ev.Type {680 switch ev.Type {
681+ case "message.delta":
682+ if delta, _ := ev.Data["delta"].(string); delta != "" {
683+ a.appendAssistantDelta(delta)
684+ }
681 case "assistant_message", "user_message":685 case "assistant_message", "user_message":
682 var text []string686 var text []string
683 if c, ok := ev.Data["content"].([]any); ok {687 if c, ok := ev.Data["content"].([]any); ok {
M packages/agent/subagents/result.go +25 -4
@@ -443,6 +443,9 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
443443 return
444444 }
445445 attempt := a.AttemptValue()
446+ a.mu.Lock()
447+ partialOutput := a.lastAssistant
448+ a.mu.Unlock()
446449 result := a.Result()
447450 if result != nil {
448451 if err := validateTurnResultAgent(result, a.ID); err != nil {
@@ -468,14 +471,19 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
468471 result = nil
469472 }
470473 }
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.
477+ // 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+ result.Output = partialOutput
480+ }
471481 if result == nil {
472482 turnID := a.CurrentTurnID()
473483 if turnID == "" {
474484 turnID = fmt.Sprintf("turn-%d", attempt)
475485 }
476- a.mu.Lock()
477- output := a.lastAssistant
478- a.mu.Unlock()
486+ output := partialOutput
479487 resultStatus := ResultSucceeded
480488 switch {
481489 case status == StatusFailed && errors.Is(runErr, context.Canceled):
@@ -512,7 +520,20 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
512520 } else {
513521 result.Status = ResultFailed
514522 }
515- if result.Error == nil {
523+ 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.
528+ message := "subagent deadline exceeded; inspect history for received output"
529+ if result.Output != "" {
530+ message = "subagent deadline exceeded; partial output is preserved in the result and history"
531+ }
532+ result.Error = &ResultError{
533+ Code: "deadline_exceeded",
534+ Message: message,
535+ }
536+ } else if result.Error == nil {
516537 result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}
517538 }
518539 }
@@ -443,6 +443,9 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
443 return443 return
444 }444 }
445 attempt := a.AttemptValue()445 attempt := a.AttemptValue()
446+ a.mu.Lock()
447+ partialOutput := a.lastAssistant
448+ a.mu.Unlock()
446 result := a.Result()449 result := a.Result()
447 if result != nil {450 if result != nil {
448 if err := validateTurnResultAgent(result, a.ID); err != nil {451 if err := validateTurnResultAgent(result, a.ID); err != nil {
@@ -468,14 +471,19 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
468 result = nil471 result = nil
469 }472 }
470 }473 }
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.
477+ // 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+ result.Output = partialOutput
480+ }
471 if result == nil {481 if result == nil {
472 turnID := a.CurrentTurnID()482 turnID := a.CurrentTurnID()
473 if turnID == "" {483 if turnID == "" {
474 turnID = fmt.Sprintf("turn-%d", attempt)484 turnID = fmt.Sprintf("turn-%d", attempt)
475 }485 }
476- a.mu.Lock()486+ output := partialOutput
477- output := a.lastAssistant
478- a.mu.Unlock()
479 resultStatus := ResultSucceeded487 resultStatus := ResultSucceeded
480 switch {488 switch {
481 case status == StatusFailed && errors.Is(runErr, context.Canceled):489 case status == StatusFailed && errors.Is(runErr, context.Canceled):
@@ -512,7 +520,20 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
512 } else {520 } else {
513 result.Status = ResultFailed521 result.Status = ResultFailed
514 }522 }
515- if result.Error == nil {523+ 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.
528+ message := "subagent deadline exceeded; inspect history for received output"
529+ if result.Output != "" {
530+ message = "subagent deadline exceeded; partial output is preserved in the result and history"
531+ }
532+ result.Error = &ResultError{
533+ Code: "deadline_exceeded",
534+ Message: message,
535+ }
536+ } else if result.Error == nil {
516 result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}537 result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}
517 }538 }
518 }539 }
M packages/agent/subagents/result_test.go +25 -9
@@ -269,21 +269,25 @@ func checkCaptureFailureResult(t *testing.T, agent *Agent, stateDir string) {
269269
270270 func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
271271 tests := []struct {
272- name string
273- status Status
274- runErr error
275- wantState TurnStatus
272+ name string
273+ status Status
274+ runErr error
275+ wantState TurnStatus
276+ wantCode string
277+ wantOutput string
276278 }{
277- {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed},
278- {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled},
279- {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled},
279+ {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: "deadline preserves recovery guidance", status: StatusFailed, runErr: context.DeadlineExceeded, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
282+ {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
280283 }
281284 for _, tt := range tests {
282285 t.Run(tt.name, func(t *testing.T) {
283286 stateDir := t.TempDir()
284287 agent := &Agent{
285- ID: "agent-1",
286- stateDir: stateDir,
288+ ID: "agent-1",
289+ stateDir: stateDir,
290+ lastAssistant: tt.wantOutput,
287291 result: &TurnResult{
288292 AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
289293 },
@@ -297,6 +301,12 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
297301 if result.Error == nil {
298302 t.Fatal("runner error was not recorded")
299303 }
304+ if result.Error.Code != tt.wantCode {
305+ t.Fatalf("result error code = %q, want %q", result.Error.Code, tt.wantCode)
306+ }
307+ if result.Output != tt.wantOutput {
308+ t.Fatalf("result output = %q, want %q", result.Output, tt.wantOutput)
309+ }
300310 durable, err := readTurnResult(stateDir)
301311 if err != nil {
302312 t.Fatal(err)
@@ -304,6 +314,12 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
304314 if durable.Status != tt.wantState {
305315 t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState)
306316 }
317+ if durable.Output != tt.wantOutput {
318+ t.Fatalf("durable result output = %q, want %q", durable.Output, tt.wantOutput)
319+ }
320+ if durable.Error == nil || durable.Error.Code != tt.wantCode {
321+ t.Fatalf("durable result error = %#v, want code %q", durable.Error, tt.wantCode)
322+ }
307323 })
308324 }
309325 }
@@ -269,21 +269,25 @@ func checkCaptureFailureResult(t *testing.T, agent *Agent, stateDir string) {
269 269
270 func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {270 func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
271 tests := []struct {271 tests := []struct {
272- name string272+ name string
273- status Status273+ status Status
274- runErr error274+ runErr error
275- wantState TurnStatus275+ wantState TurnStatus
276+ wantCode string
277+ wantOutput string
276 }{278 }{
277- {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed},279+ {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed, wantCode: "runner_failed"},
278- {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled},280+ {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
279- {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled},281+ {name: "deadline preserves recovery guidance", status: StatusFailed, runErr: context.DeadlineExceeded, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
282+ {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
280 }283 }
281 for _, tt := range tests {284 for _, tt := range tests {
282 t.Run(tt.name, func(t *testing.T) {285 t.Run(tt.name, func(t *testing.T) {
283 stateDir := t.TempDir()286 stateDir := t.TempDir()
284 agent := &Agent{287 agent := &Agent{
285- ID: "agent-1",288+ ID: "agent-1",
286- stateDir: stateDir,289+ stateDir: stateDir,
290+ lastAssistant: tt.wantOutput,
287 result: &TurnResult{291 result: &TurnResult{
288 AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,292 AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
289 },293 },
@@ -297,6 +301,12 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
297 if result.Error == nil {301 if result.Error == nil {
298 t.Fatal("runner error was not recorded")302 t.Fatal("runner error was not recorded")
299 }303 }
304+ if result.Error.Code != tt.wantCode {
305+ t.Fatalf("result error code = %q, want %q", result.Error.Code, tt.wantCode)
306+ }
307+ if result.Output != tt.wantOutput {
308+ t.Fatalf("result output = %q, want %q", result.Output, tt.wantOutput)
309+ }
300 durable, err := readTurnResult(stateDir)310 durable, err := readTurnResult(stateDir)
301 if err != nil {311 if err != nil {
302 t.Fatal(err)312 t.Fatal(err)
@@ -304,6 +314,12 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
304 if durable.Status != tt.wantState {314 if durable.Status != tt.wantState {
305 t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState)315 t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState)
306 }316 }
317+ if durable.Output != tt.wantOutput {
318+ t.Fatalf("durable result output = %q, want %q", durable.Output, tt.wantOutput)
319+ }
320+ if durable.Error == nil || durable.Error.Code != tt.wantCode {
321+ t.Fatalf("durable result error = %#v, want code %q", durable.Error, tt.wantCode)
322+ }
307 })323 })
308 }324 }
309 }325 }
M packages/agent/subagents/runner.go +77 -4
@@ -52,6 +52,11 @@ type execRunner struct {
5252 // would litter the user's repo — every agent's Dir points
5353 // at it directly.
5454 SessionPath string
55+
56+ // GracePeriod is the time a deadline-expired worker gets to handle an
57+ // inbox shutdown and write its session/result before the process group is
58+ // force-killed. Zero uses the standard ten-second cancellation grace.
59+ GracePeriod time.Duration
5560 }
5661
5762 // subagentWorkerArgsOpts captures every dynamic input to subagentWorkerArgs.
@@ -302,7 +307,14 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
302307 args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath)
303308 }
304309
305- cmd := exec.CommandContext(ctx, args[0], args[1:]...)
310+ // Do not bind the process directly to ctx: CommandContext sends an
311+ // immediate SIGKILL at the deadline, which prevents the worker from
312+ // handling cancellation and persisting the session it has built so far.
313+ // A deadline watcher below requests the worker's graceful inbox shutdown
314+ // first, then force-stops it only after the bounded grace period. Use a
315+ // non-canceling CommandContext so configureWorkerProcess can retain its
316+ // Cmd.Cancel fallback without tying it to the worker lifetime context.
317+ cmd := exec.CommandContext(context.Background(), args[0], args[1:]...)
306318 cmd.Dir = r.agent.Dir
307319 cmd.Env = append(workerEnvironment(r.agent.Provider),
308320 "ZUT_SUBAGENT_AGENT_ID="+r.agent.ID,
@@ -353,6 +365,10 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
353365 r.agent.persistFn(r.agent)
354366 }
355367
368+ runnerDone := make(chan struct{})
369+ defer close(runnerDone)
370+ go r.stopOnContextDone(ctx, cmd, runnerDone)
371+
356372 var logErrMu sync.Mutex
357373 var logErr error
358374 var stopOnLogErr sync.Once
@@ -460,12 +476,16 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
460476 if ee, ok := err.(*exec.ExitError); ok {
461477 exit = ee.ExitCode()
462478 }
463- if err != nil && ctx.Err() != nil {
464- appendLog(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"}))
479+ if ctxErr := ctx.Err(); ctxErr != nil {
480+ reason := "cancelled"
481+ if ctxErr == context.DeadlineExceeded {
482+ reason = "deadline"
483+ }
484+ appendLog(NewEvent("agent_stopped", map[string]any{"reason": reason}))
465485 if logErr := firstLogErr(); logErr != nil {
466486 return fmt.Errorf("subagent event log: %w", logErr)
467487 }
468- return ctx.Err()
488+ return ctxErr
469489 }
470490 if err != nil {
471491 appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()}))
@@ -482,6 +502,48 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
482502 return nil
483503 }
484504
505+// stopOnContextDone stops a worker when its run context ends. A deadline gets
506+// one bounded chance to process an inbox shutdown and persist its session;
507+// explicit cancellation force-stops immediately so Run cannot remain blocked.
508+func (r *execRunner) stopOnContextDone(ctx context.Context, cmd *exec.Cmd, runnerDone <-chan struct{}) {
509+ select {
510+ case <-runnerDone:
511+ return
512+ case <-ctx.Done():
513+ }
514+ if ctx.Err() != context.DeadlineExceeded {
515+ if cmd.Cancel != nil {
516+ _ = cmd.Cancel()
517+ return
518+ }
519+ _ = killProcessGroup(cmd)
520+ return
521+ }
522+
523+ grace := r.GracePeriod
524+ if grace <= 0 {
525+ grace = 10 * time.Second
526+ }
527+ shutdownCtx, cancel := context.WithTimeout(context.Background(), grace)
528+ defer cancel()
529+ if r.agent != nil && r.agent.inbox != nil {
530+ _ = r.agent.inbox.SendCommandContext(shutdownCtx, NewCommand(
531+ CommandAgentShutdown, r.agent.ID, r.agent.CurrentTurnID(), AgentShutdownPayload{},
532+ ))
533+ }
534+
535+ select {
536+ case <-runnerDone:
537+ return
538+ case <-shutdownCtx.Done():
539+ }
540+ if cmd.Cancel != nil {
541+ _ = cmd.Cancel()
542+ return
543+ }
544+ _ = killProcessGroup(cmd)
545+}
546+
485547 func workerEnvironment(provider string) []string {
486548 // Provider credentials are resolved by the supervisor and transferred
487549 // over the child's stdin. Do not inherit API-key/token variables into the
@@ -711,6 +773,9 @@ func applyEventToSink(ev Event, sink Sink) {
711773 userMessage(string)
712774 assistantMessage(string)
713775 }
776+ type streamingSink interface {
777+ assistantDelta(string)
778+ }
714779 appendMessage := func(text string, assistant bool) {
715780 if text == "" {
716781 return
@@ -731,6 +796,14 @@ func applyEventToSink(ev Event, sink Sink) {
731796 }
732797
733798 switch ev.Type {
799+ case "message.delta":
800+ if delta, _ := ev.Data["delta"].(string); delta != "" {
801+ if streaming, ok := sink.(streamingSink); ok {
802+ streaming.assistantDelta(delta)
803+ } else {
804+ sink.Transcript(delta)
805+ }
806+ }
734807 case "assistant_message", "user_message":
735808 var text []string
736809 if c, ok := ev.Data["content"].([]any); ok {
@@ -52,6 +52,11 @@ type execRunner struct {
52 // would litter the user's repo — every agent's Dir points52 // would litter the user's repo — every agent's Dir points
53 // at it directly.53 // at it directly.
54 SessionPath string54 SessionPath string
55+
56+ // GracePeriod is the time a deadline-expired worker gets to handle an
57+ // inbox shutdown and write its session/result before the process group is
58+ // force-killed. Zero uses the standard ten-second cancellation grace.
59+ GracePeriod time.Duration
55 }60 }
56 61
57 // subagentWorkerArgsOpts captures every dynamic input to subagentWorkerArgs.62 // subagentWorkerArgsOpts captures every dynamic input to subagentWorkerArgs.
@@ -302,7 +307,14 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
302 args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath)307 args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath)
303 }308 }
304 309
305- cmd := exec.CommandContext(ctx, args[0], args[1:]...)310+ // Do not bind the process directly to ctx: CommandContext sends an
311+ // immediate SIGKILL at the deadline, which prevents the worker from
312+ // handling cancellation and persisting the session it has built so far.
313+ // A deadline watcher below requests the worker's graceful inbox shutdown
314+ // first, then force-stops it only after the bounded grace period. Use a
315+ // non-canceling CommandContext so configureWorkerProcess can retain its
316+ // Cmd.Cancel fallback without tying it to the worker lifetime context.
317+ cmd := exec.CommandContext(context.Background(), args[0], args[1:]...)
306 cmd.Dir = r.agent.Dir318 cmd.Dir = r.agent.Dir
307 cmd.Env = append(workerEnvironment(r.agent.Provider),319 cmd.Env = append(workerEnvironment(r.agent.Provider),
308 "ZUT_SUBAGENT_AGENT_ID="+r.agent.ID,320 "ZUT_SUBAGENT_AGENT_ID="+r.agent.ID,
@@ -353,6 +365,10 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
353 r.agent.persistFn(r.agent)365 r.agent.persistFn(r.agent)
354 }366 }
355 367
368+ runnerDone := make(chan struct{})
369+ defer close(runnerDone)
370+ go r.stopOnContextDone(ctx, cmd, runnerDone)
371+
356 var logErrMu sync.Mutex372 var logErrMu sync.Mutex
357 var logErr error373 var logErr error
358 var stopOnLogErr sync.Once374 var stopOnLogErr sync.Once
@@ -460,12 +476,16 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
460 if ee, ok := err.(*exec.ExitError); ok {476 if ee, ok := err.(*exec.ExitError); ok {
461 exit = ee.ExitCode()477 exit = ee.ExitCode()
462 }478 }
463- if err != nil && ctx.Err() != nil {479+ if ctxErr := ctx.Err(); ctxErr != nil {
464- appendLog(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"}))480+ reason := "cancelled"
481+ if ctxErr == context.DeadlineExceeded {
482+ reason = "deadline"
483+ }
484+ appendLog(NewEvent("agent_stopped", map[string]any{"reason": reason}))
465 if logErr := firstLogErr(); logErr != nil {485 if logErr := firstLogErr(); logErr != nil {
466 return fmt.Errorf("subagent event log: %w", logErr)486 return fmt.Errorf("subagent event log: %w", logErr)
467 }487 }
468- return ctx.Err()488+ return ctxErr
469 }489 }
470 if err != nil {490 if err != nil {
471 appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()}))491 appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()}))
@@ -482,6 +502,48 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
482 return nil502 return nil
483 }503 }
484 504
505+// stopOnContextDone stops a worker when its run context ends. A deadline gets
506+// one bounded chance to process an inbox shutdown and persist its session;
507+// explicit cancellation force-stops immediately so Run cannot remain blocked.
508+func (r *execRunner) stopOnContextDone(ctx context.Context, cmd *exec.Cmd, runnerDone <-chan struct{}) {
509+ select {
510+ case <-runnerDone:
511+ return
512+ case <-ctx.Done():
513+ }
514+ if ctx.Err() != context.DeadlineExceeded {
515+ if cmd.Cancel != nil {
516+ _ = cmd.Cancel()
517+ return
518+ }
519+ _ = killProcessGroup(cmd)
520+ return
521+ }
522+
523+ grace := r.GracePeriod
524+ if grace <= 0 {
525+ grace = 10 * time.Second
526+ }
527+ shutdownCtx, cancel := context.WithTimeout(context.Background(), grace)
528+ defer cancel()
529+ if r.agent != nil && r.agent.inbox != nil {
530+ _ = r.agent.inbox.SendCommandContext(shutdownCtx, NewCommand(
531+ CommandAgentShutdown, r.agent.ID, r.agent.CurrentTurnID(), AgentShutdownPayload{},
532+ ))
533+ }
534+
535+ select {
536+ case <-runnerDone:
537+ return
538+ case <-shutdownCtx.Done():
539+ }
540+ if cmd.Cancel != nil {
541+ _ = cmd.Cancel()
542+ return
543+ }
544+ _ = killProcessGroup(cmd)
545+}
546+
485 func workerEnvironment(provider string) []string {547 func workerEnvironment(provider string) []string {
486 // Provider credentials are resolved by the supervisor and transferred548 // Provider credentials are resolved by the supervisor and transferred
487 // over the child's stdin. Do not inherit API-key/token variables into the549 // over the child's stdin. Do not inherit API-key/token variables into the
@@ -711,6 +773,9 @@ func applyEventToSink(ev Event, sink Sink) {
711 userMessage(string)773 userMessage(string)
712 assistantMessage(string)774 assistantMessage(string)
713 }775 }
776+ type streamingSink interface {
777+ assistantDelta(string)
778+ }
714 appendMessage := func(text string, assistant bool) {779 appendMessage := func(text string, assistant bool) {
715 if text == "" {780 if text == "" {
716 return781 return
@@ -731,6 +796,14 @@ func applyEventToSink(ev Event, sink Sink) {
731 }796 }
732 797
733 switch ev.Type {798 switch ev.Type {
799+ case "message.delta":
800+ if delta, _ := ev.Data["delta"].(string); delta != "" {
801+ if streaming, ok := sink.(streamingSink); ok {
802+ streaming.assistantDelta(delta)
803+ } else {
804+ sink.Transcript(delta)
805+ }
806+ }
734 case "assistant_message", "user_message":807 case "assistant_message", "user_message":
735 var text []string808 var text []string
736 if c, ok := ev.Data["content"].([]any); ok {809 if c, ok := ev.Data["content"].([]any); ok {
M packages/agent/subagents/runner_e2e_test.go +171 -19
@@ -12,25 +12,177 @@ import (
1212 "time"
1313 )
1414
15-// TestRunnerEndToEndWithStubChild is the integration test for the
16-// new daemon-mode runner. It compiles the stubchild binary in
17-// testdata/cmd/stubchild, points an execRunner at it, and drives
18-// a Supervisor through one Spawn + one SendUserTurn + Stop cycle.
19-//
20-// What this proves:
21-//
22-// - The default argv shape (subagentWorkerArgs) is one the child can
23-// actually parse — locks the shape against silent breakage.
24-// - The stdout JSONL parser ingests events and writes them to the
25-// durable log.
26-// - applyEventToSink turns events into Activity / Transcript
27-// updates the dashboard reads.
28-// - The supervisor inbox dials the child's socket and a follow-up
29-// line round-trips back as a user_message event.
30-// - Stop closes the inbox AND cancels the child's context so the
31-// stub exits cleanly.
32-//
33-// The test is skipped on platforms without unix sockets (Windows).
15+type notifyingAgentSink struct {
16+ agentSink
17+ deltas chan<- struct{}
18+}
19+
20+func (s notifyingAgentSink) assistantDelta(text string) {
21+ s.agentSink.assistantDelta(text)
22+ select {
23+ case s.deltas <- struct{}{}:
24+ default:
25+ }
26+}
27+
28+type controlledDeadlineContext struct{ done chan struct{} }
29+
30+func (c *controlledDeadlineContext) Deadline() (time.Time, bool) {
31+ return time.Now().Add(time.Hour), true
32+}
33+func (c *controlledDeadlineContext) Done() <-chan struct{} { return c.done }
34+func (c *controlledDeadlineContext) Err() error {
35+ select {
36+ case <-c.done:
37+ return context.DeadlineExceeded
38+ default:
39+ return nil
40+ }
41+}
42+func (c *controlledDeadlineContext) Value(any) any { return nil }
43+func (c *controlledDeadlineContext) expire() {
44+ select {
45+ case <-c.done:
46+ default:
47+ close(c.done)
48+ }
49+}
50+
51+// TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput
52+// verifies that deadline expiry shuts a worker down through its inbox rather
53+// than killing it before its terminal events can be recorded.
54+func TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput(t *testing.T) {
55+ if runtime.GOOS == "windows" {
56+ t.Skip("unix sockets not supported")
57+ }
58+ if testing.Short() {
59+ t.Skip("skip end-to-end runner test in -short mode")
60+ }
61+
62+ exe := buildStubChild(t)
63+ t.Setenv("ZUT_STUB_PROTOCOL", "1")
64+ t.Setenv("ZUT_STUB_BLOCK_INITIAL", "1")
65+
66+ root := t.TempDir()
67+ a := &Agent{
68+ ID: "deadline-test",
69+ Task: "long-running task",
70+ Dir: root,
71+ SessionPath: filepath.Join(root, "session.jsonl"),
72+ InboxPath: filepath.Join(root, "inbox.sock"),
73+ EventLogPath: filepath.Join(root, "events.jsonl"),
74+ }
75+ a.inbox = NewInbox(a.InboxPath)
76+ t.Cleanup(func() { _ = a.inbox.Close() })
77+
78+ r := &execRunner{
79+ agent: a,
80+ Command: subagentWorkerArgs(subagentWorkerArgsOpts{Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task}),
81+ GracePeriod: time.Second,
82+ }
83+ ctx := &controlledDeadlineContext{done: make(chan struct{})}
84+ defer ctx.expire()
85+ deltas := make(chan struct{}, 1)
86+ runDone := make(chan error, 1)
87+ go func() { runDone <- r.Run(ctx, notifyingAgentSink{agentSink: agentSink{a: a}, deltas: deltas}) }()
88+ select {
89+ case <-deltas:
90+ case <-time.After(time.Second):
91+ t.Fatal("worker did not emit its initial delta")
92+ }
93+ ctx.expire()
94+ select {
95+ case err := <-runDone:
96+ if !errors.Is(err, context.DeadlineExceeded) {
97+ t.Fatalf("Run error = %v, want context deadline exceeded", err)
98+ }
99+ case <-time.After(time.Second):
100+ t.Fatal("Run did not return after deadline expiry")
101+ }
102+
103+ events, err := ReadEventLog(a.EventLogPath)
104+ if err != nil {
105+ t.Fatalf("ReadEventLog: %v", err)
106+ }
107+ var sawDelta, sawGracefulTurnEnd bool
108+ for _, event := range events {
109+ if event.Type == "message.delta" && event.Data["delta"] == "partial answer" {
110+ sawDelta = true
111+ }
112+ if event.Type == "turn_end" && event.Data["stop"] == "cancelled" {
113+ sawGracefulTurnEnd = true
114+ }
115+ }
116+ if !sawDelta || !sawGracefulTurnEnd {
117+ t.Fatalf("deadline did not preserve a graceful partial turn: %s", formatEvents(events))
118+ }
119+
120+ snapshot := a.Snapshot()
121+ if snapshot.LastAssistant != "partial answer" {
122+ t.Fatalf("live partial output = %q, want %q", snapshot.LastAssistant, "partial answer")
123+ }
124+ replayed := &Agent{}
125+ for _, event := range events {
126+ replayEventTranscript(replayed, event)
127+ }
128+ if snapshot := replayed.Snapshot(); snapshot.LastAssistant != "partial answer" {
129+ t.Fatalf("replayed partial output = %q, want %q", snapshot.LastAssistant, "partial answer")
130+ }
131+}
132+
133+func TestExecRunnerCancelsBlockedWorker(t *testing.T) {
134+ if runtime.GOOS == "windows" {
135+ t.Skip("unix sockets not supported")
136+ }
137+ if testing.Short() {
138+ t.Skip("skip end-to-end runner test in -short mode")
139+ }
140+
141+ exe := buildStubChild(t)
142+ t.Setenv("ZUT_STUB_PROTOCOL", "1")
143+ t.Setenv("ZUT_STUB_BLOCK_INITIAL", "1")
144+
145+ root := t.TempDir()
146+ a := &Agent{
147+ ID: "cancel-test",
148+ Task: "blocked task",
149+ Dir: root,
150+ SessionPath: filepath.Join(root, "session.jsonl"),
151+ InboxPath: filepath.Join(root, "inbox.sock"),
152+ EventLogPath: filepath.Join(root, "events.jsonl"),
153+ }
154+ a.inbox = NewInbox(a.InboxPath)
155+ t.Cleanup(func() { _ = a.inbox.Close() })
156+
157+ r := &execRunner{
158+ agent: a,
159+ Command: subagentWorkerArgs(subagentWorkerArgsOpts{Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task}),
160+ }
161+ ctx, cancel := context.WithCancel(context.Background())
162+ defer cancel()
163+ deltas := make(chan struct{}, 1)
164+ runDone := make(chan error, 1)
165+ go func() { runDone <- r.Run(ctx, notifyingAgentSink{agentSink: agentSink{a: a}, deltas: deltas}) }()
166+
167+ select {
168+ case <-deltas:
169+ case <-time.After(time.Second):
170+ t.Fatal("worker did not emit its initial delta")
171+ }
172+ cancel()
173+ select {
174+ case err := <-runDone:
175+ if !errors.Is(err, context.Canceled) {
176+ t.Fatalf("Run error = %v, want context canceled", err)
177+ }
178+ case <-time.After(time.Second):
179+ t.Fatal("Run did not return after context cancellation")
180+ }
181+}
182+
183+// TestRunnerEndToEndWithStubChild drives the daemon-mode runner through an
184+// initial turn, a follow-up, and a graceful stop using the stub child. It is
185+// skipped on platforms without Unix sockets.
34186 func TestRunnerEndToEndWithStubChild(t *testing.T) {
35187 if runtime.GOOS == "windows" {
36188 t.Skip("unix sockets not supported")
@@ -12,25 +12,177 @@ import (
12 "time"12 "time"
13 )13 )
14 14
15-// TestRunnerEndToEndWithStubChild is the integration test for the15+type notifyingAgentSink struct {
16-// new daemon-mode runner. It compiles the stubchild binary in16+ agentSink
17-// testdata/cmd/stubchild, points an execRunner at it, and drives17+ deltas chan<- struct{}
18-// a Supervisor through one Spawn + one SendUserTurn + Stop cycle.18+}
19-//19+
20-// What this proves:20+func (s notifyingAgentSink) assistantDelta(text string) {
21-//21+ s.agentSink.assistantDelta(text)
22-// - The default argv shape (subagentWorkerArgs) is one the child can22+ select {
23-// actually parse — locks the shape against silent breakage.23+ case s.deltas <- struct{}{}:
24-// - The stdout JSONL parser ingests events and writes them to the24+ default:
25-// durable log.25+ }
26-// - applyEventToSink turns events into Activity / Transcript26+}
27-// updates the dashboard reads.27+
28-// - The supervisor inbox dials the child's socket and a follow-up28+type controlledDeadlineContext struct{ done chan struct{} }
29-// line round-trips back as a user_message event.29+
30-// - Stop closes the inbox AND cancels the child's context so the30+func (c *controlledDeadlineContext) Deadline() (time.Time, bool) {
31-// stub exits cleanly.31+ return time.Now().Add(time.Hour), true
32-//32+}
33-// The test is skipped on platforms without unix sockets (Windows).33+func (c *controlledDeadlineContext) Done() <-chan struct{} { return c.done }
34+func (c *controlledDeadlineContext) Err() error {
35+ select {
36+ case <-c.done:
37+ return context.DeadlineExceeded
38+ default:
39+ return nil
40+ }
41+}
42+func (c *controlledDeadlineContext) Value(any) any { return nil }
43+func (c *controlledDeadlineContext) expire() {
44+ select {
45+ case <-c.done:
46+ default:
47+ close(c.done)
48+ }
49+}
50+
51+// TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput
52+// verifies that deadline expiry shuts a worker down through its inbox rather
53+// than killing it before its terminal events can be recorded.
54+func TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput(t *testing.T) {
55+ if runtime.GOOS == "windows" {
56+ t.Skip("unix sockets not supported")
57+ }
58+ if testing.Short() {
59+ t.Skip("skip end-to-end runner test in -short mode")
60+ }
61+
62+ exe := buildStubChild(t)
63+ t.Setenv("ZUT_STUB_PROTOCOL", "1")
64+ t.Setenv("ZUT_STUB_BLOCK_INITIAL", "1")
65+
66+ root := t.TempDir()
67+ a := &Agent{
68+ ID: "deadline-test",
69+ Task: "long-running task",
70+ Dir: root,
71+ SessionPath: filepath.Join(root, "session.jsonl"),
72+ InboxPath: filepath.Join(root, "inbox.sock"),
73+ EventLogPath: filepath.Join(root, "events.jsonl"),
74+ }
75+ a.inbox = NewInbox(a.InboxPath)
76+ t.Cleanup(func() { _ = a.inbox.Close() })
77+
78+ r := &execRunner{
79+ agent: a,
80+ Command: subagentWorkerArgs(subagentWorkerArgsOpts{Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task}),
81+ GracePeriod: time.Second,
82+ }
83+ ctx := &controlledDeadlineContext{done: make(chan struct{})}
84+ defer ctx.expire()
85+ deltas := make(chan struct{}, 1)
86+ runDone := make(chan error, 1)
87+ go func() { runDone <- r.Run(ctx, notifyingAgentSink{agentSink: agentSink{a: a}, deltas: deltas}) }()
88+ select {
89+ case <-deltas:
90+ case <-time.After(time.Second):
91+ t.Fatal("worker did not emit its initial delta")
92+ }
93+ ctx.expire()
94+ select {
95+ case err := <-runDone:
96+ if !errors.Is(err, context.DeadlineExceeded) {
97+ t.Fatalf("Run error = %v, want context deadline exceeded", err)
98+ }
99+ case <-time.After(time.Second):
100+ t.Fatal("Run did not return after deadline expiry")
101+ }
102+
103+ events, err := ReadEventLog(a.EventLogPath)
104+ if err != nil {
105+ t.Fatalf("ReadEventLog: %v", err)
106+ }
107+ var sawDelta, sawGracefulTurnEnd bool
108+ for _, event := range events {
109+ if event.Type == "message.delta" && event.Data["delta"] == "partial answer" {
110+ sawDelta = true
111+ }
112+ if event.Type == "turn_end" && event.Data["stop"] == "cancelled" {
113+ sawGracefulTurnEnd = true
114+ }
115+ }
116+ if !sawDelta || !sawGracefulTurnEnd {
117+ t.Fatalf("deadline did not preserve a graceful partial turn: %s", formatEvents(events))
118+ }
119+
120+ snapshot := a.Snapshot()
121+ if snapshot.LastAssistant != "partial answer" {
122+ t.Fatalf("live partial output = %q, want %q", snapshot.LastAssistant, "partial answer")
123+ }
124+ replayed := &Agent{}
125+ for _, event := range events {
126+ replayEventTranscript(replayed, event)
127+ }
128+ if snapshot := replayed.Snapshot(); snapshot.LastAssistant != "partial answer" {
129+ t.Fatalf("replayed partial output = %q, want %q", snapshot.LastAssistant, "partial answer")
130+ }
131+}
132+
133+func TestExecRunnerCancelsBlockedWorker(t *testing.T) {
134+ if runtime.GOOS == "windows" {
135+ t.Skip("unix sockets not supported")
136+ }
137+ if testing.Short() {
138+ t.Skip("skip end-to-end runner test in -short mode")
139+ }
140+
141+ exe := buildStubChild(t)
142+ t.Setenv("ZUT_STUB_PROTOCOL", "1")
143+ t.Setenv("ZUT_STUB_BLOCK_INITIAL", "1")
144+
145+ root := t.TempDir()
146+ a := &Agent{
147+ ID: "cancel-test",
148+ Task: "blocked task",
149+ Dir: root,
150+ SessionPath: filepath.Join(root, "session.jsonl"),
151+ InboxPath: filepath.Join(root, "inbox.sock"),
152+ EventLogPath: filepath.Join(root, "events.jsonl"),
153+ }
154+ a.inbox = NewInbox(a.InboxPath)
155+ t.Cleanup(func() { _ = a.inbox.Close() })
156+
157+ r := &execRunner{
158+ agent: a,
159+ Command: subagentWorkerArgs(subagentWorkerArgsOpts{Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task}),
160+ }
161+ ctx, cancel := context.WithCancel(context.Background())
162+ defer cancel()
163+ deltas := make(chan struct{}, 1)
164+ runDone := make(chan error, 1)
165+ go func() { runDone <- r.Run(ctx, notifyingAgentSink{agentSink: agentSink{a: a}, deltas: deltas}) }()
166+
167+ select {
168+ case <-deltas:
169+ case <-time.After(time.Second):
170+ t.Fatal("worker did not emit its initial delta")
171+ }
172+ cancel()
173+ select {
174+ case err := <-runDone:
175+ if !errors.Is(err, context.Canceled) {
176+ t.Fatalf("Run error = %v, want context canceled", err)
177+ }
178+ case <-time.After(time.Second):
179+ t.Fatal("Run did not return after context cancellation")
180+ }
181+}
182+
183+// TestRunnerEndToEndWithStubChild drives the daemon-mode runner through an
184+// initial turn, a follow-up, and a graceful stop using the stub child. It is
185+// skipped on platforms without Unix sockets.
34 func TestRunnerEndToEndWithStubChild(t *testing.T) {186 func TestRunnerEndToEndWithStubChild(t *testing.T) {
35 if runtime.GOOS == "windows" {187 if runtime.GOOS == "windows" {
36 t.Skip("unix sockets not supported")188 t.Skip("unix sockets not supported")
M packages/agent/subagents/runner_test.go +56 -0
@@ -58,6 +58,62 @@ func TestCredentialStdinHelperProcess(t *testing.T) {
5858 os.Exit(0)
5959 }
6060
61+// TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage verifies
62+// that the durable event stream has the same visible result while live and
63+// after history replay.
64+func TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage(t *testing.T) {
65+ events := []Event{
66+ NewEvent("message.delta", map[string]any{"delta": "partial "}),
67+ NewEvent("message.delta", map[string]any{"delta": "answer"}),
68+ NewEvent("assistant_message", map[string]any{"content": []any{
69+ map[string]any{"type": "text", "text": "partial answer"},
70+ }}),
71+ }
72+
73+ live := &Agent{transcriptLoaded: true}
74+ for _, event := range events {
75+ applyEventToSink(event, agentSink{a: live})
76+ }
77+ if got := strings.Join(live.Transcript(), "\n"); got != "partial answer" {
78+ t.Fatalf("live transcript = %q, want final assistant message once", got)
79+ }
80+ if got := live.Snapshot().LastAssistant; got != "partial answer" {
81+ t.Fatalf("live assistant output = %q, want %q", got, "partial answer")
82+ }
83+
84+ replayed := &Agent{transcriptLoaded: true}
85+ for _, event := range events {
86+ replayEventTranscript(replayed, event)
87+ }
88+ if got := strings.Join(replayed.Transcript(), "\n"); got != "partial answer" {
89+ t.Fatalf("replayed transcript = %q, want final assistant message once", got)
90+ }
91+}
92+
93+func TestMessageDeltaBoundsLiveProjection(t *testing.T) {
94+ a := &Agent{maxOutputBytes: 32, maxOutputLines: 100, transcriptLoaded: true}
95+ applyEventToSink(NewEvent("message.delta", map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: a})
96+ applyEventToSink(NewEvent("message.delta", map[string]any{"delta": "discarded"}), agentSink{a: a})
97+
98+ a.mu.Lock()
99+ streamed := a.streamingAssistantText
100+ truncated := a.streamingAssistantTruncated
101+ a.mu.Unlock()
102+ if !truncated {
103+ t.Fatal("streamed output was not marked truncated")
104+ }
105+ if got := len([]byte(streamed)); got > 32 {
106+ t.Fatalf("streamed output length = %d, want at most 32", got)
107+ }
108+ snapshot := a.Snapshot()
109+ if got := snapshot.LastAssistant; got != streamed {
110+ t.Fatalf("live assistant output = %q, want bounded stream %q", got, streamed)
111+ }
112+ if !snapshot.OutputTruncated {
113+ t.Fatal("snapshot did not expose truncated streamed output")
114+ }
115+}
116+
61117 func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
62118 t.Setenv("OPENAI_API_KEY", "openai-secret")
63119 t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret")
@@ -58,6 +58,62 @@ func TestCredentialStdinHelperProcess(t *testing.T) {
58 os.Exit(0)58 os.Exit(0)
59 }59 }
60 60
61+// TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage verifies
62+// that the durable event stream has the same visible result while live and
63+// after history replay.
64+func TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage(t *testing.T) {
65+ events := []Event{
66+ NewEvent("message.delta", map[string]any{"delta": "partial "}),
67+ NewEvent("message.delta", map[string]any{"delta": "answer"}),
68+ NewEvent("assistant_message", map[string]any{"content": []any{
69+ map[string]any{"type": "text", "text": "partial answer"},
70+ }}),
71+ }
72+
73+ live := &Agent{transcriptLoaded: true}
74+ for _, event := range events {
75+ applyEventToSink(event, agentSink{a: live})
76+ }
77+ if got := strings.Join(live.Transcript(), "\n"); got != "partial answer" {
78+ t.Fatalf("live transcript = %q, want final assistant message once", got)
79+ }
80+ if got := live.Snapshot().LastAssistant; got != "partial answer" {
81+ t.Fatalf("live assistant output = %q, want %q", got, "partial answer")
82+ }
83+
84+ replayed := &Agent{transcriptLoaded: true}
85+ for _, event := range events {
86+ replayEventTranscript(replayed, event)
87+ }
88+ if got := strings.Join(replayed.Transcript(), "\n"); got != "partial answer" {
89+ t.Fatalf("replayed transcript = %q, want final assistant message once", got)
90+ }
91+}
92+
93+func TestMessageDeltaBoundsLiveProjection(t *testing.T) {
94+ a := &Agent{maxOutputBytes: 32, maxOutputLines: 100, transcriptLoaded: true}
95+ applyEventToSink(NewEvent("message.delta", map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: a})
96+ applyEventToSink(NewEvent("message.delta", map[string]any{"delta": "discarded"}), agentSink{a: a})
97+
98+ a.mu.Lock()
99+ streamed := a.streamingAssistantText
100+ truncated := a.streamingAssistantTruncated
101+ a.mu.Unlock()
102+ if !truncated {
103+ t.Fatal("streamed output was not marked truncated")
104+ }
105+ if got := len([]byte(streamed)); got > 32 {
106+ t.Fatalf("streamed output length = %d, want at most 32", got)
107+ }
108+ snapshot := a.Snapshot()
109+ if got := snapshot.LastAssistant; got != streamed {
110+ t.Fatalf("live assistant output = %q, want bounded stream %q", got, streamed)
111+ }
112+ if !snapshot.OutputTruncated {
113+ t.Fatal("snapshot did not expose truncated streamed output")
114+ }
115+}
116+
61 func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {117 func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
62 t.Setenv("OPENAI_API_KEY", "openai-secret")118 t.Setenv("OPENAI_API_KEY", "openai-secret")
63 t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret")119 t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret")
M packages/agent/subagents/supervisor.go +8 -2
@@ -155,7 +155,11 @@ func New(cfg Config) *Supervisor {
155155 }
156156 if cfg.NewRunner == nil {
157157 cfg.NewRunner = func(a *Agent) Runner {
158- return &execRunner{agent: a, resolveCredential: cfg.ResolveCredential}
158+ return &execRunner{
159+ agent: a,
160+ resolveCredential: cfg.ResolveCredential,
161+ GracePeriod: cfg.Policy.CancelGracePeriod,
162+ }
159163 }
160164 }
161165 cfg.Policy.normalize()
@@ -1129,6 +1133,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
11291133 status := a.status
11301134 activity := a.activity
11311135 lastAssistant := a.lastAssistant
1136+ streamingOutputTruncated := a.streamingAssistantTruncated
11321137 finished := a.finished
11331138 a.mu.Unlock()
11341139
@@ -1143,7 +1148,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
11431148 resultRef := a.resultRef
11441149 patchRef := a.patchRef
11451150 changedFiles := append([]string(nil), a.changedFiles...)
1146- outputTruncated := a.outputTruncated
1151+ outputTruncated := a.outputTruncated || streamingOutputTruncated
11471152 a.lifecycleMu.Unlock()
11481153 return AgentSnapshot{
11491154 ID: a.ID, Task: a.Task, Dir: a.Dir,
@@ -1208,6 +1213,7 @@ func (s agentSink) Transcript(chunk string) {
12081213 }
12091214 func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }
12101215 func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }
1216+func (s agentSink) assistantDelta(text string) { s.a.appendAssistantDelta(text) }
12111217
12121218 func truncate(s string, n int) string {
12131219 runes := []rune(s)
@@ -155,7 +155,11 @@ func New(cfg Config) *Supervisor {
155 }155 }
156 if cfg.NewRunner == nil {156 if cfg.NewRunner == nil {
157 cfg.NewRunner = func(a *Agent) Runner {157 cfg.NewRunner = func(a *Agent) Runner {
158- return &execRunner{agent: a, resolveCredential: cfg.ResolveCredential}158+ return &execRunner{
159+ agent: a,
160+ resolveCredential: cfg.ResolveCredential,
161+ GracePeriod: cfg.Policy.CancelGracePeriod,
162+ }
159 }163 }
160 }164 }
161 cfg.Policy.normalize()165 cfg.Policy.normalize()
@@ -1129,6 +1133,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
1129 status := a.status1133 status := a.status
1130 activity := a.activity1134 activity := a.activity
1131 lastAssistant := a.lastAssistant1135 lastAssistant := a.lastAssistant
1136+ streamingOutputTruncated := a.streamingAssistantTruncated
1132 finished := a.finished1137 finished := a.finished
1133 a.mu.Unlock()1138 a.mu.Unlock()
1134 1139
@@ -1143,7 +1148,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
1143 resultRef := a.resultRef1148 resultRef := a.resultRef
1144 patchRef := a.patchRef1149 patchRef := a.patchRef
1145 changedFiles := append([]string(nil), a.changedFiles...)1150 changedFiles := append([]string(nil), a.changedFiles...)
1146- outputTruncated := a.outputTruncated1151+ outputTruncated := a.outputTruncated || streamingOutputTruncated
1147 a.lifecycleMu.Unlock()1152 a.lifecycleMu.Unlock()
1148 return AgentSnapshot{1153 return AgentSnapshot{
1149 ID: a.ID, Task: a.Task, Dir: a.Dir,1154 ID: a.ID, Task: a.Task, Dir: a.Dir,
@@ -1208,6 +1213,7 @@ func (s agentSink) Transcript(chunk string) {
1208 }1213 }
1209 func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }1214 func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }
1210 func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }1215 func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }
1216+func (s agentSink) assistantDelta(text string) { s.a.appendAssistantDelta(text) }
1211 1217
1212 func truncate(s string, n int) string {1218 func truncate(s string, n int) string {
1213 runes := []rune(s)1219 runes := []rune(s)
M packages/agent/subagents/testdata/cmd/stubchild/main.go +20 -1
@@ -72,8 +72,20 @@ func main() {
7272
7373 // Initial task lives in the positional. Process it as the first
7474 // user turn so the supervisor's "initial task" path is exercised.
75+ initialTurnOpen := false
7576 if task := flag.Arg(0); task != "" {
76- runTurn(emit, task, 0)
77+ if os.Getenv("ZUT_STUB_BLOCK_INITIAL") == "1" {
78+ initialTurnOpen = true
79+ emit("turn_start", map[string]any{"step": 0})
80+ emit("user_message", map[string]any{
81+ "content": []any{
82+ map[string]any{"type": "text", "text": task},
83+ },
84+ })
85+ emit("message.delta", map[string]any{"delta": "partial answer"})
86+ } else {
87+ runTurn(emit, task, 0)
88+ }
7789 }
7890
7991 turn := 1
@@ -91,10 +103,17 @@ func main() {
91103 if parseErr == nil {
92104 switch command.Type {
93105 case subagents.CommandAgentShutdown:
106+ if initialTurnOpen {
107+ emit("turn_end", map[string]any{"stop": "cancelled"})
108+ initialTurnOpen = false
109+ }
94110 emit("agent_stopped", map[string]any{"reason": "shutdown"})
95111 _ = c.Close()
96112 return
97113 case subagents.CommandTurnCancel:
114+ if initialTurnOpen {
115+ initialTurnOpen = false
116+ }
98117 emit("turn_end", map[string]any{"stop": "cancelled"})
99118 case subagents.CommandTurnStart:
100119 var payload subagents.TurnStartPayload
@@ -72,8 +72,20 @@ func main() {
72 72
73 // Initial task lives in the positional. Process it as the first73 // Initial task lives in the positional. Process it as the first
74 // user turn so the supervisor's "initial task" path is exercised.74 // user turn so the supervisor's "initial task" path is exercised.
75+ initialTurnOpen := false
75 if task := flag.Arg(0); task != "" {76 if task := flag.Arg(0); task != "" {
76- runTurn(emit, task, 0)77+ if os.Getenv("ZUT_STUB_BLOCK_INITIAL") == "1" {
78+ initialTurnOpen = true
79+ emit("turn_start", map[string]any{"step": 0})
80+ emit("user_message", map[string]any{
81+ "content": []any{
82+ map[string]any{"type": "text", "text": task},
83+ },
84+ })
85+ emit("message.delta", map[string]any{"delta": "partial answer"})
86+ } else {
87+ runTurn(emit, task, 0)
88+ }
77 }89 }
78 90
79 turn := 191 turn := 1
@@ -91,10 +103,17 @@ func main() {
91 if parseErr == nil {103 if parseErr == nil {
92 switch command.Type {104 switch command.Type {
93 case subagents.CommandAgentShutdown:105 case subagents.CommandAgentShutdown:
106+ if initialTurnOpen {
107+ emit("turn_end", map[string]any{"stop": "cancelled"})
108+ initialTurnOpen = false
109+ }
94 emit("agent_stopped", map[string]any{"reason": "shutdown"})110 emit("agent_stopped", map[string]any{"reason": "shutdown"})
95 _ = c.Close()111 _ = c.Close()
96 return112 return
97 case subagents.CommandTurnCancel:113 case subagents.CommandTurnCancel:
114+ if initialTurnOpen {
115+ initialTurnOpen = false
116+ }
98 emit("turn_end", map[string]any{"stop": "cancelled"})117 emit("turn_end", map[string]any{"stop": "cancelled"})
99 case subagents.CommandTurnStart:118 case subagents.CommandTurnStart:
100 var payload subagents.TurnStartPayload119 var payload subagents.TurnStartPayload

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 477

↑↓ moveenter openesc close