on main help sign in
fix(noninteractive): crush run reliability in client/server mode

This fixes premature exits during tool use, hangs after certain tool-calling stops, and issues when continuing into a session that is already busy. Co-Authored-By: Charm Crush <crush@charm.land>

commite14d47a8135cef6950d6a337a8f7e3c62157bc27 authorChristian Rocha <christian@rocha.is> dateThu 28 May 2026, 10:55 (5 months ago) parent7cd9f5b
16 files changed+965−46
internal/agent/agent.go+95−7 internal/agent/coordinator.go+34−1 internal/agent/notify/notify.go+28 internal/agent/run_complete_test.go+89 internal/agent/runid.go+33 internal/app/app.go+42 internal/app/testing.go+3 internal/backend/agent.go+11 internal/client/proto.go+14−1 internal/cmd/run.go+140−36 internal/cmd/run_stream_test.go+329 internal/proto/proto.go+39 internal/pubsub/events.go+8 internal/server/events.go+12 internal/server/events_test.go+65 internal/workspace/client_workspace.go+23−1
internal/agent/agent.go+95−7
@@ -70,7 +70,16 @@ var (
7070)
7171
7272type SessionAgentCall struct {
73− SessionID string
73+ SessionID string
74+ // RunID, when non-empty, is the caller-supplied correlator that
75+ // gets echoed back on the notify.RunComplete event emitted for
76+ // this turn. It is preserved when the call is enqueued behind a
77+ // busy session so the queued turn's terminal event is still
78+ // recognisable to the original caller. Callers that need a
79+ // reliable completion contract (e.g. `crush run` against a
80+ // session that may be busy) MUST set it; SessionID alone is
81+ // ambiguous when concurrent turns share the same session.
82+ RunID string
7483 Prompt string
7584 ProviderOptions fantasy.ProviderOptions
7685 Attachments []message.Attachment
@@ -81,6 +90,19 @@ type SessionAgentCall struct {
8190 FrequencyPenalty *float64
8291 PresencePenalty *float64
8392 NonInteractive bool
93+ // OnComplete, when non-nil, replaces the default RunComplete
94+ // publish path: the inner Run hands the terminal payload to this
95+ // callback instead of emitting it on the RunComplete broker. The
96+ // coordinator uses this hook to coalesce the unauthorized →
97+ // re-auth → retry chain into a single user-visible terminal
98+ // event, so non-interactive clients (e.g. `crush run`) don't
99+ // exit on a stale failed-attempt RunComplete before the
100+ // successful retry. It is intentionally stripped when queueing
101+ // a busy-session call (see Run): the originating
102+ // coordinator.Run has long returned by the time the queued
103+ // recursion drains, so falling back to the default broker
104+ // publish keeps the event visible to subscribers.
105+ OnComplete func(notify.RunComplete)
84106}
85107
86108type SessionAgent interface {
@@ -119,6 +141,7 @@ type sessionAgent struct {
119141 disableAutoSummarize bool
120142 isYolo bool
121143 notify pubsub.Publisher[notify.Notification]
144+ runComplete pubsub.Publisher[notify.RunComplete]
122145
123146 messageQueue *csync.Map[string, []SessionAgentCall]
124147 activeRequests *csync.Map[string, context.CancelFunc]
@@ -136,6 +159,7 @@ type SessionAgentOptions struct {
136159 Messages message.Service
137160 Tools []fantasy.AgentTool
138161 Notify pubsub.Publisher[notify.Notification]
162+ RunComplete pubsub.Publisher[notify.RunComplete]
139163}
140164
141165func NewSessionAgent(
@@ -153,12 +177,13 @@ func NewSessionAgent(
153177 tools: csync.NewSliceFrom(opts.Tools),
154178 isYolo: opts.IsYolo,
155179 notify: opts.Notify,
180+ runComplete: opts.RunComplete,
156181 messageQueue: csync.NewMap[string, []SessionAgentCall](),
157182 activeRequests: csync.NewMap[string, context.CancelFunc](),
158183 }
159184}
160185
161−func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
186+func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (result *fantasy.AgentResult, retErr error) {
162187 if call.Prompt == "" && !message.ContainsTextAttachment(call.Attachments) {
163188 return nil, ErrEmptyPrompt
164189 }
@@ -166,13 +191,21 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
166191 return nil, ErrSessionMissing
167192 }
168193
169− // Queue the message if busy
194+ // Queue the message if busy. Strip OnComplete: the caller that
195+ // supplied the hook (typically coordinator.Run) has its own
196+ // retry/coalesce scope that ends when it returns, so by the time
197+ // the queue drains nobody is left to consume the buffered
198+ // terminal event. The recursive Run will fall back to the
199+ // default broker publish, which is what existing subscribers
200+ // expect for queued turns.
170201 if a.IsSessionBusy(call.SessionID) {
171202 existing, ok := a.messageQueue.Get(call.SessionID)
172203 if !ok {
173204 existing = []SessionAgentCall{}
174205 }
175− existing = append(existing, call)
206+ queued := call
207+ queued.OnComplete = nil
208+ existing = append(existing, queued)
176209 a.messageQueue.Set(call.SessionID, existing)
177210 return nil, nil
178211 }
@@ -245,14 +278,65 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
245278
246279 defer cancel()
247280 defer a.activeRequests.Del(call.SessionID)
281+ // skipRunComplete is set just before the queued-recursion path so
282+ // the outer Run doesn't publish a RunComplete that would race
283+ // with — and be superseded by — the recursive call's own
284+ // RunComplete (each queued user prompt is its own turn and
285+ // publishes exactly one terminal event).
286+ var skipRunComplete bool
287+ // currentAssistant is declared here so the deferred RunComplete
288+ // publish below can capture the pointer that PrepareStep will
289+ // later (re)assign for each streaming step. The final assistant
290+ // message of the turn is the value reachable through this
291+ // pointer when the defer runs.
292+ var currentAssistant *message.Message
248293 // Drain any debounced message updates before returning. message.Service
249294 // already flushes synchronously on terminal updates, but a defer here
250295 // guarantees the contract at every Run exit (success, error, panic
251296 // recovery upstream) without callers needing to know.
297+ //
298+ // After the flush completes — meaning all per-message
299+ // Publish(UpdatedEvent) calls have fired and been buffered into
300+ // every subscriber's channel — publish the authoritative
301+ // RunComplete event for this turn. The flush-then-publish order
302+ // gives well-behaved clients the best chance of seeing the final
303+ // message event before RunComplete; the embedded Text field
304+ // reconciles for clients that observe the events out of order
305+ // (the pubsub broker fan-in does not serialize publishes from
306+ // different upstream brokers).
252307 defer func() {
253308 if flushErr := a.messages.FlushAll(ctx); flushErr != nil {
254309 slog.Error("Failed to flush pending message updates after run", "error", flushErr)
255310 }
311+ if skipRunComplete {
312+ return
313+ }
314+ complete := notify.RunComplete{SessionID: call.SessionID, RunID: call.RunID}
315+ if currentAssistant != nil {
316+ complete.MessageID = currentAssistant.ID
317+ complete.Text = currentAssistant.Content().String()
318+ }
319+ if retErr != nil {
320+ complete.Error = retErr.Error()
321+ complete.Cancelled = errors.Is(retErr, context.Canceled)
322+ } else if ctx.Err() != nil {
323+ complete.Cancelled = true
324+ }
325+ // Prefer the per-call hook when supplied so the coordinator
326+ // can coalesce retries (e.g. unauthorized → re-auth → retry)
327+ // into a single user-visible terminal event. The fallback
328+ // must-deliver publish applies bounded-blocking semantics to
329+ // the authoritative terminal event so a momentarily-full
330+ // subscriber channel can't silently drop it and hang
331+ // non-interactive clients waiting on RunComplete.
332+ if call.OnComplete != nil {
333+ call.OnComplete(complete)
334+ return
335+ }
336+ if a.runComplete == nil {
337+ return
338+ }
339+ a.runComplete.PublishMustDeliver(ctx, pubsub.UpdatedEvent, complete)
256340 }()
257341
258342 history, files := a.preparePrompt(msgs, largeModel.CatwalkCfg.SupportsImages, call.Attachments...)
@@ -260,7 +344,6 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
260344 startTime := time.Now()
261345 a.eventPromptSent(call.SessionID)
262346
263− var currentAssistant *message.Message
264347 var stepMessages []fantasy.Message
265348 var shouldSummarize bool
266349 // Don't send MaxOutputTokens if 0 — some providers (e.g. LM Studio) reject it
@@ -268,7 +351,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
268351 if call.MaxOutputTokens > 0 {
269352 maxOutputTokens = &call.MaxOutputTokens
270353 }
271− result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
354+ result, err = agent.Stream(genCtx, fantasy.AgentStreamCall{
272355 Prompt: message.PromptWithTextAttachments(call.Prompt, call.Attachments),
273356 Files: files,
274357 Messages: history,
@@ -634,7 +717,12 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
634717 if !ok || len(queuedMessages) == 0 {
635718 return result, err
636719 }
637− // There are queued messages restart the loop.
720+ // There are queued messages restart the loop. The recursive Run
721+ // publishes its own RunComplete for the queued prompt, so suppress
722+ // the outer defer's emit to avoid a duplicate event whose Error
723+ // field would belong to the recursive turn but whose MessageID/Text
724+ // would belong to the outer turn.
725+ skipRunComplete = true
638726 firstQueuedMessage := queuedMessages[0]
639727 a.messageQueue.Set(call.SessionID, queuedMessages[1:])
640728 return a.Run(ctx, firstQueuedMessage)
internal/agent/coordinator.go+34−1
@@ -97,6 +97,7 @@ type coordinator struct {
9797 filetracker filetracker.Service
9898 lspManager *lsp.Manager
9999 notify pubsub.Publisher[notify.Notification]
100+ runComplete pubsub.Publisher[notify.RunComplete]
100101
101102 currentAgent SessionAgent
102103 agents map[string]SessionAgent
@@ -119,6 +120,7 @@ func NewCoordinator(
119120 filetracker filetracker.Service,
120121 lspManager *lsp.Manager,
121122 notify pubsub.Publisher[notify.Notification],
123+ runComplete pubsub.Publisher[notify.RunComplete],
122124 skillsMgr *skills.Manager,
123125) (Coordinator, error) {
124126 // Skills are pre-discovered by the caller (see app.New /
@@ -143,6 +145,7 @@ func NewCoordinator(
143145 filetracker: filetracker,
144146 lspManager: lspManager,
145147 notify: notify,
148+ runComplete: runComplete,
146149 agents: make(map[string]SessionAgent),
147150 allSkills: allSkills,
148151 activeSkills: activeSkills,
@@ -210,9 +213,34 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
210213 slog.Error("Failed to refresh OAuth2 token. Proceeding with existing token.", "error", err)
211214 }
212215
216+ // Coalesce per-attempt RunComplete payloads so only the final
217+ // outcome reaches subscribers. Without this, the first attempt's
218+ // failed RunComplete (unauthorized) would race ahead of the
219+ // retry's success, and `crush run` would exit on the stale error
220+ // before ever seeing the retry result. Each attempt's
221+ // SessionAgentCall.OnComplete hook overwrites latest; we publish
222+ // exactly once after retries resolve, via PublishMustDeliver, so
223+ // a momentarily-full subscriber buffer can't silently drop the
224+ // terminal event.
225+ var (
226+ latest notify.RunComplete
227+ hasLatest bool
228+ )
229+ onComplete := func(rc notify.RunComplete) {
230+ latest = rc
231+ hasLatest = true
232+ }
233+ // Propagate the caller-supplied RunID (set via agent.WithRunID
234+ // at the HTTP boundary in backend.SendMessage) onto the
235+ // SessionAgentCall so the terminal RunComplete event echoes it
236+ // back. Both attempts in the retry chain reuse the same RunID;
237+ // the coalesce closure publishes the final outcome under that
238+ // same correlator.
239+ runID := RunIDFromContext(ctx)
213240 run := func() (*fantasy.AgentResult, error) {
214241 return c.currentAgent.Run(ctx, SessionAgentCall{
215242 SessionID: sessionID,
243+ RunID: runID,
216244 Prompt: prompt,
217245 Attachments: attachments,
218246 MaxOutputTokens: maxTokens,
@@ -222,6 +250,7 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
222250 TopK: topK,
223251 FrequencyPenalty: freqPenalty,
224252 PresencePenalty: presPenalty,
253+ OnComplete: onComplete,
225254 })
226255 }
227256 beforeLoaded := c.skillTracker.LoadedNames()
@@ -230,10 +259,13 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
230259
231260 if c.isUnauthorized(originalErr) {
232261 if err := c.retryAfterUnauthorized(ctx, providerCfg); err == nil {
233− return run()
262+ result, originalErr = run()
234263 }
235264 }
236265
266+ if hasLatest && c.runComplete != nil {
267+ c.runComplete.PublishMustDeliver(ctx, pubsub.UpdatedEvent, latest)
268+ }
237269 return result, originalErr
238270}
239271
@@ -452,6 +484,7 @@ func (c *coordinator) buildAgent(ctx context.Context, prompt *prompt.Prompt, age
452484 Messages: c.messages,
453485 Tools: nil,
454486 Notify: c.notify,
487+ RunComplete: c.runComplete,
455488 })
456489
457490 c.readyWg.Go(func() error {
internal/agent/notify/notify.go+28
@@ -21,3 +21,31 @@ type Notification struct {
2121 Type Type
2222 ProviderID string
2323}
24+
25+// RunComplete is the authoritative end-of-run signal for a session.
26+// It is published exactly once per top-level agent run (per
27+// [sessionAgent.Run] invocation that actually executed) after all
28+// message updates for the turn have been flushed via
29+// message.Service.FlushAll. Carries the final assistant text and
30+// message ID so non-interactive clients can reconcile stdout even if
31+// SSE events arrive out of order or are dropped by the broker. Error
32+// is non-empty when the run terminated with an error; Cancelled is
33+// true when the run terminated due to context cancellation. The two
34+// are mutually exclusive in the success case but may overlap when a
35+// cancel triggers a downstream error.
36+//
37+// RunID identifies the specific request that produced this event.
38+// It is the value the caller set on `proto.AgentMessage.RunID` (or
39+// equivalently propagated via agent.WithRunID on the context that
40+// reaches the coordinator); empty when no caller set one. Filtering
41+// by RunID lets a client correlate a SendMessage call with its
42+// terminal event even when the session is busy and other turns are
43+// finishing on the same session.
44+type RunComplete struct {
45+ SessionID string
46+ RunID string
47+ MessageID string
48+ Text string
49+ Error string
50+ Cancelled bool
51+}
internal/agent/run_complete_test.gonew file+89
@@ -0,0 +1,89 @@
1+package agent
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/charmbracelet/crush/internal/agent/notify"
9+ "github.com/charmbracelet/crush/internal/pubsub"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// TestSessionAgentRun_QueueStripsOnComplete verifies that when a Run
14+// call is enqueued (because the session is already busy), the
15+// OnComplete hook is NOT propagated onto the queued copy. The hook
16+// belongs to the caller's retry/coalesce scope (typically
17+// coordinator.Run) which has already returned by the time the queue
18+// drains; carrying it forward would silently funnel the terminal
19+// event into a closure nobody reads, and subscribers (`crush run`)
20+// would hang waiting for a RunComplete that never publishes.
21+func TestSessionAgentRun_QueueStripsOnComplete(t *testing.T) {
22+ t.Parallel()
23+
24+ env := testEnv(t)
25+ a := NewSessionAgent(SessionAgentOptions{
26+ Sessions: env.sessions,
27+ Messages: env.messages,
28+ }).(*sessionAgent)
29+
30+ const sessionID = "queued-session"
31+ // Mark the session as busy so Run takes the queue branch
32+ // without needing a real model.
33+ a.activeRequests.Set(sessionID, func() {})
34+
35+ var called bool
36+ hook := func(notify.RunComplete) { called = true }
37+
38+ res, err := a.Run(t.Context(), SessionAgentCall{
39+ SessionID: sessionID,
40+ RunID: "run-xyz",
41+ Prompt: "queued prompt",
42+ OnComplete: hook,
43+ })
44+ require.NoError(t, err)
45+ require.Nil(t, res, "queued Run must return (nil, nil)")
46+ require.False(t, called,
47+ "OnComplete must not fire on the enqueue path; the caller's scope is still live")
48+
49+ queued, ok := a.messageQueue.Get(sessionID)
50+ require.True(t, ok)
51+ require.Len(t, queued, 1)
52+ require.Nil(t, queued[0].OnComplete,
53+ "queued SessionAgentCall must have OnComplete stripped so the drain falls back to the default broker publish")
54+ require.Equal(t, "queued prompt", queued[0].Prompt,
55+ "all other fields must be preserved on the queued copy")
56+ require.Equal(t, "run-xyz", queued[0].RunID,
57+ "RunID must be preserved on the queued copy so the drained turn's "+
58+ "RunComplete still correlates with the originating SendMessage")
59+}
60+
61+// TestRunCompletePublisher_MustDeliverOverTakesPublish exercises the
62+// pubsub.Publisher interface change end-to-end: a Broker is the only
63+// concrete Publisher implementation and must satisfy both Publish and
64+// PublishMustDeliver. The coordinator's final RunComplete emit relies
65+// on PublishMustDeliver to apply bounded-blocking semantics so a
66+// momentarily-full subscriber buffer can't silently drop the
67+// authoritative end-of-run event.
68+func TestRunCompletePublisher_MustDeliverOverTakesPublish(t *testing.T) {
69+ t.Parallel()
70+
71+ broker := pubsub.NewBroker[notify.RunComplete]()
72+ t.Cleanup(broker.Shutdown)
73+
74+ // Subscribe before publishing so the event is delivered.
75+ ctx, cancel := context.WithCancel(t.Context())
76+ defer cancel()
77+ ch := broker.Subscribe(ctx)
78+
79+ rc := notify.RunComplete{SessionID: "S", MessageID: "m", Text: "ok"}
80+ var pub pubsub.Publisher[notify.RunComplete] = broker
81+ pub.PublishMustDeliver(t.Context(), pubsub.UpdatedEvent, rc)
82+
83+ select {
84+ case got := <-ch:
85+ require.Equal(t, rc, got.Payload)
86+ case <-time.After(time.Second):
87+ t.Fatal("PublishMustDeliver did not deliver event")
88+ }
89+}
internal/agent/runid.gonew file+33
@@ -0,0 +1,33 @@
1+package agent
2+
3+import "context"
4+
5+// runIDContextKey is the unexported context key used to carry a
6+// caller-supplied RunID from the workspace HTTP boundary
7+// (backend.SendMessage) down into coordinator.Run without forcing a
8+// breaking change to the Coordinator.Run signature. The value is
9+// then copied onto SessionAgentCall.RunID by the coordinator so the
10+// agent's terminal RunComplete event can echo it back to the
11+// originating caller.
12+type runIDContextKey struct{}
13+
14+// WithRunID returns ctx tagged with a per-request RunID. It is the
15+// boundary helper for callers that need their SendMessage→Run
16+// terminal event to be uniquely correlatable (e.g. `crush run`
17+// against a session that may be busy). Empty runIDs are stored
18+// as-is; downstream code treats an empty RunID as "caller did not
19+// supply one" and falls back to SessionID-only correlation.
20+func WithRunID(ctx context.Context, runID string) context.Context {
21+ return context.WithValue(ctx, runIDContextKey{}, runID)
22+}
23+
24+// RunIDFromContext returns the RunID set by [WithRunID], or "" if
25+// none was set or the value is not a string. Exported because the
26+// coordinator and tests in other packages need to read it; safe to
27+// call on any context.
28+func RunIDFromContext(ctx context.Context) string {
29+ if v, ok := ctx.Value(runIDContextKey{}).(string); ok {
30+ return v
31+ }
32+ return ""
33+}
internal/app/app.go+42
@@ -75,6 +75,13 @@ type App struct {
7575 globalCtx context.Context
7676 cleanupFuncs []func(context.Context) error
7777 agentNotifications *pubsub.Broker[notify.Notification]
78+ // runCompletions is the authoritative per-run completion signal,
79+ // emitted once per top-level agent turn after all message
80+ // updates have been flushed. Bridged into app.events so SSE
81+ // subscribers (notably `crush run` in client/server mode) can
82+ // drive their exit on a deterministic, payload-bearing event
83+ // instead of guessing from message finish parts.
84+ runCompletions *pubsub.Broker[notify.RunComplete]
7885}
7986
8087// New initializes a new application instance. skillsMgr carries the
@@ -110,6 +117,7 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore, skillsMgr
110117 serviceEventsWG: &sync.WaitGroup{},
111118 tuiWG: &sync.WaitGroup{},
112119 agentNotifications: pubsub.NewBroker[notify.Notification](),
120+ runCompletions: pubsub.NewBroker[notify.RunComplete](),
113121 }
114122
115123 app.setupEvents()
@@ -485,6 +493,7 @@ func (app *App) setupEvents() {
485493 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
486494 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
487495 setupSubscriber(ctx, app.serviceEventsWG, "agent-notifications", app.agentNotifications.Subscribe, app.events)
496+ setupSubscriberMustDeliver(ctx, app.serviceEventsWG, "run-completions", app.runCompletions.Subscribe, app.events)
488497 setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
489498 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
490499 if app.Skills != nil {
@@ -524,6 +533,38 @@ func setupSubscriber[T any](
524533 })
525534}
526535
536+// setupSubscriberMustDeliver is the bounded-blocking fan-in variant of
537+// setupSubscriber: it re-publishes upstream events onto the shared
538+// app.events broker using PublishMustDeliver instead of Publish. Use
539+// this for terminal events that subscribers cannot tolerate losing —
540+// notably RunComplete, which is the authoritative end-of-run signal
541+// for `crush run`. A lossy fan-in here can drop the only terminal
542+// event and hang non-interactive clients waiting on it.
543+func setupSubscriberMustDeliver[T any](
544+ ctx context.Context,
545+ wg *sync.WaitGroup,
546+ name string,
547+ subscriber func(context.Context) <-chan pubsub.Event[T],
548+ broker *pubsub.Broker[tea.Msg],
549+) {
550+ wg.Go(func() {
551+ subCh := subscriber(ctx)
552+ for {
553+ select {
554+ case event, ok := <-subCh:
555+ if !ok {
556+ slog.Debug("Subscription channel closed", "name", name)
557+ return
558+ }
559+ broker.PublishMustDeliver(ctx, pubsub.UpdatedEvent, tea.Msg(event))
560+ case <-ctx.Done():
561+ slog.Debug("Subscription cancelled", "name", name)
562+ return
563+ }
564+ }
565+ })
566+}
567+
527568func (app *App) InitCoderAgent(ctx context.Context) error {
528569 coderAgentCfg := app.config.Config().Agents[config.AgentCoder]
529570 if coderAgentCfg.ID == "" {
@@ -540,6 +581,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
540581 app.FileTracker,
541582 app.LSPManager,
542583 app.agentNotifications,
584+ app.runCompletions,
543585 app.Skills,
544586 )
545587 if err != nil {
internal/app/testing.go+3
@@ -34,6 +34,7 @@ func NewForTest(ctx context.Context) *App {
3434 serviceEventsWG: &sync.WaitGroup{},
3535 tuiWG: &sync.WaitGroup{},
3636 agentNotifications: pubsub.NewBroker[notify.Notification](),
37+ runCompletions: pubsub.NewBroker[notify.RunComplete](),
3738 }
3839
3940 eventsCtx, cancel := context.WithCancel(ctx)
@@ -44,6 +45,8 @@ func NewForTest(ctx context.Context) *App {
4445 app.Permissions.SubscribeNotifications, app.events)
4546 setupSubscriber(eventsCtx, app.serviceEventsWG, "agent-notifications",
4647 app.agentNotifications.Subscribe, app.events)
48+ setupSubscriber(eventsCtx, app.serviceEventsWG, "run-completions",
49+ app.runCompletions.Subscribe, app.events)
4750 app.cleanupFuncs = append(app.cleanupFuncs, func(context.Context) error {
4851 cancel()
4952 app.serviceEventsWG.Wait()
internal/backend/agent.go+11
@@ -3,12 +3,20 @@ package backend
33import (
44 "context"
55
6+ "github.com/charmbracelet/crush/internal/agent"
67 "github.com/charmbracelet/crush/internal/config"
78 "github.com/charmbracelet/crush/internal/proto"
89)
910
1011// SendMessage sends a prompt to the agent coordinator for the given
1112// workspace and session.
13+//
14+// When msg.RunID is non-empty it is attached to the context via
15+// agent.WithRunID so the coordinator can stamp the resulting
16+// SessionAgentCall (and therefore the terminal notify.RunComplete
17+// event) with that correlator. This is the only way for the
18+// originating client to distinguish its own turn's RunComplete from
19+// any concurrent turn that finishes on the same session.
1220func (b *Backend) SendMessage(ctx context.Context, workspaceID string, msg proto.AgentMessage) error {
1321 ws, err := b.GetWorkspace(workspaceID)
1422 if err != nil {
@@ -19,6 +27,9 @@ func (b *Backend) SendMessage(ctx context.Context, workspaceID string, msg proto
1927 return ErrAgentNotInitialized
2028 }
2129
30+ if msg.RunID != "" {
31+ ctx = agent.WithRunID(ctx, msg.RunID)
32+ }
2233 _, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt, proto.AttachmentsToMessage(msg.Attachments)...)
2334 return err
2435}
internal/client/proto.go+14−1
@@ -231,6 +231,12 @@ func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, er
231231 if !sendEvent(ctx, events, e) {
232232 return
233233 }
234+ case pubsub.PayloadTypeRunComplete:
235+ var e pubsub.Event[proto.RunComplete]
236+ _ = json.Unmarshal(p.Payload, &e)
237+ if !sendEvent(ctx, events, e) {
238+ return
239+ }
234240 default:
235241 slog.Warn("Unknown event type", "type", p.Type)
236242 continue
@@ -400,9 +406,16 @@ func (c *Client) UpdateAgent(ctx context.Context, id string) error {
400406}
401407
402408// SendMessage sends a message to the agent for a workspace.
403−func (c *Client) SendMessage(ctx context.Context, id string, sessionID, prompt string, attachments ...message.Attachment) error {
409+//
410+// When runID is non-empty it is echoed back on the resulting
411+// proto.RunComplete event, giving the caller a unique correlator
412+// for completion detection. Pass "" when the caller does not need
413+// to distinguish its own turn's terminal event from any concurrent
414+// turn on the same session (e.g. interactive TUI usage).
415+func (c *Client) SendMessage(ctx context.Context, id string, sessionID, runID, prompt string, attachments ...message.Attachment) error {
404416 rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent", id), nil, jsonBody(proto.AgentMessage{
405417 SessionID: sessionID,
418+ RunID: runID,
406419 Prompt: prompt,
407420 Attachments: proto.AttachmentsFromMessage(attachments),
408421 }), http.Header{"Content-Type": []string{"application/json"}})
internal/cmd/run.go+140−36
@@ -3,6 +3,7 @@ package cmd
33import (
44 "context"
55 "fmt"
6+ "io"
67 "log/slog"
78 "os"
89 "os/signal"
@@ -24,6 +25,7 @@ import (
2425 "github.com/charmbracelet/x/ansi"
2526 "github.com/charmbracelet/x/exp/charmtone"
2627 "github.com/charmbracelet/x/term"
28+ "github.com/google/uuid"
2729 "github.com/spf13/cobra"
2830)
2931
@@ -243,12 +245,22 @@ func runNonInteractive(
243245 return fmt.Errorf("failed to subscribe to events: %w", err)
244246 }
245247
246− if err := c.SendMessage(ctx, ws.ID, sess.ID, prompt); err != nil {
248+ // Mint a per-call RunID so we can correlate the terminal
249+ // RunComplete with *this* SendMessage even if the session was
250+ // busy and another turn finished first. Without it the stream
251+ // loop would exit on whichever RunComplete arrived first for
252+ // the same session and drop the queued prompt's output.
253+ runID := uuid.New().String()
254+ if err := c.SendMessage(ctx, ws.ID, sess.ID, runID, prompt); err != nil {
247255 return fmt.Errorf("failed to send message: %w", err)
248256 }
249257
250− messageReadBytes := make(map[string]int)
251− var printed bool
258+ stream := &runStream{
259+ sessionID: sess.ID,
260+ runID: runID,
261+ out: os.Stdout,
262+ read: make(map[string]int),
263+ }
252264
253265 defer func() {
254266 if progress && stderrTTY {
@@ -269,49 +281,141 @@ func runNonInteractive(
269281 return nil
270282 }
271283
272− switch e := ev.(type) {
273− case pubsub.Event[proto.Message]:
274− msg := e.Payload
275− if msg.SessionID != sess.ID || msg.Role != proto.Assistant || len(msg.Parts) == 0 {
276− continue
277− }
278− stopSpinner()
284+ done, err := stream.handle(ev, stopSpinner)
285+ if err != nil {
286+ return err
287+ }
288+ if done {
289+ return nil
290+ }
279291
280− content := msg.Content().String()
281− readBytes := messageReadBytes[msg.ID]
292+ case <-ctx.Done():
293+ stopSpinner()
294+ return ctx.Err()
295+ }
296+ }
297+}
282298
283− if len(content) < readBytes {
284− slog.Error("Non-interactive: message content shorter than read bytes",
285− "message_length", len(content), "read_bytes", readBytes)
286− return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(content), readBytes)
287− }
299+// runStream tracks the per-message stdout cursor and the
300+// reconciliation state used by [runNonInteractive] to translate
301+// streaming SSE events into a final, complete stdout for `crush run`.
302+// It is split out so the state machine can be exercised in unit tests
303+// without spinning up the full server/client harness.
304+//
305+// runID, when non-empty, is the authoritative correlator for the
306+// terminal RunComplete event: the stream suppresses live message
307+// events and only exits on a RunComplete whose RunID matches, so a
308+// turn that finishes first on the same session (e.g. when our prompt
309+// was queued behind a busy session) cannot contaminate stdout or
310+// terminate us prematurely. When empty (older servers, tests that
311+// don't supply one) the stream falls back to SessionID-only matching
312+// and live message streaming, which is still correct for the
313+// single-turn case.
314+type runStream struct {
315+ sessionID string
316+ runID string
317+ out io.Writer
318+ read map[string]int
319+ printed bool
320+}
288321
289− part := content[readBytes:]
290− if readBytes == 0 {
291− part = strings.TrimLeft(part, " \t")
292− }
293− if printed || strings.TrimSpace(part) != "" {
294− printed = true
295− fmt.Fprint(os.Stdout, part)
296− }
297− messageReadBytes[msg.ID] = len(content)
322+// handle processes one SSE event. Returns done=true when the run
323+// loop should exit (RunComplete observed); returns an error only
324+// when the agent run failed (not on context cancel — that path is
325+// handled by the caller's select). stopSpinner is called on the
326+// first observable assistant output and on completion; passing nil
327+// is safe for tests.
328+func (s *runStream) handle(ev any, stopSpinner func()) (done bool, err error) {
329+ stop := func() {
330+ if stopSpinner != nil {
331+ stopSpinner()
332+ }
333+ }
334+ switch e := ev.(type) {
335+ case pubsub.Event[proto.Message]:
336+ msg := e.Payload
337+ if msg.SessionID != s.sessionID || msg.Role != proto.Assistant || len(msg.Parts) == 0 {
338+ return false, nil
339+ }
340+ if s.runID != "" {
341+ return false, nil
342+ }
343+ stop()
344+
345+ content := msg.Content().String()
346+ readBytes := s.read[msg.ID]
347+ if len(content) < readBytes {
348+ slog.Error("Non-interactive: message content shorter than read bytes",
349+ "message_length", len(content), "read_bytes", readBytes)
350+ return false, fmt.Errorf("message content is shorter than read bytes: %d < %d", len(content), readBytes)
351+ }
298352
299− if msg.IsFinished() {
300− return nil
353+ part := content[readBytes:]
354+ if readBytes == 0 {
355+ part = strings.TrimLeft(part, " \t")
356+ }
357+ if s.printed || strings.TrimSpace(part) != "" {
358+ s.printed = true
359+ fmt.Fprint(s.out, part)
360+ }
361+ s.read[msg.ID] = len(content)
362+ return false, nil
363+
364+ case pubsub.Event[proto.RunComplete]:
365+ // RunComplete is the authoritative end-of-run signal. We
366+ // exit on it instead of guessing from message finish parts,
367+ // which fire on every tool-call step too and were the
368+ // source of the regression where `crush run` exited
369+ // mid-turn on finish.reason == tool_use.
370+ //
371+ // Correlation:
372+ // - if we minted a RunID for this SendMessage, only the
373+ // event whose RunID matches is ours; any other turn
374+ // finishing first on the same session (busy-session
375+ // queue path) must be ignored.
376+ // - if we have no RunID (older server, tests), fall back
377+ // to SessionID matching.
378+ if s.runID != "" {
379+ if e.Payload.RunID != s.runID {
380+ return false, nil
381+ }
382+ } else if e.Payload.SessionID != s.sessionID {
383+ return false, nil
384+ }
385+ stop()
386+ if e.Payload.Error != "" && !e.Payload.Cancelled {
387+ return true, fmt.Errorf("agent run failed: %s", e.Payload.Error)
388+ }
389+ // Reconcile stdout against the authoritative final
390+ // assistant text carried in the event. The pubsub fan-in
391+ // does not serialize publishes across upstream brokers, so
392+ // the final message event may not have reached this loop
393+ // yet; the embedded Text field is the backstop that
394+ // guarantees the full final text always appears on stdout.
395+ if e.Payload.MessageID != "" {
396+ full := e.Payload.Text
397+ readBytes := s.read[e.Payload.MessageID]
398+ if readBytes < len(full) {
399+ tail := full[readBytes:]
400+ if readBytes == 0 {
401+ tail = strings.TrimLeft(tail, " \t")
301402 }
302−
303− case pubsub.Event[proto.AgentEvent]:
304− if e.Payload.Error != nil {
305− stopSpinner()
306− return fmt.Errorf("agent error: %w", e.Payload.Error)
403+ if s.printed || strings.TrimSpace(tail) != "" {
404+ s.printed = true
405+ fmt.Fprint(s.out, tail)
307406 }
308407 }
408+ }
409+ return true, nil
309410
310− case <-ctx.Done():
311− stopSpinner()
312− return ctx.Err()
411+ case pubsub.Event[proto.AgentEvent]:
412+ if e.Payload.Error != nil {
413+ stop()
414+ return true, fmt.Errorf("agent error: %w", e.Payload.Error)
313415 }
416+ return false, nil
314417 }
418+ return false, nil
315419}
316420
317421// waitForAgent polls GetAgentInfo until the agent is ready, with a
internal/cmd/run_stream_test.gonew file+329
@@ -0,0 +1,329 @@
1+package cmd
2+
3+import (
4+ "bytes"
5+ "testing"
6+ "time"
7+
8+ "github.com/charmbracelet/crush/internal/proto"
9+ "github.com/charmbracelet/crush/internal/pubsub"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// TestRunStream_ToolUseDoesNotTerminate is the regression test for
14+// the original bug: a tool-call assistant message has a Finish part
15+// with reason=tool_use and used to terminate `crush run` early via
16+// the discarded `msg.IsFinished()` exit condition. With the new
17+// RunComplete-driven loop, tool_use finishes must keep the stream
18+// alive so the post-tool final text still reaches stdout.
19+func TestRunStream_ToolUseDoesNotTerminate(t *testing.T) {
20+ t.Parallel()
21+
22+ s := &runStream{sessionID: "S", out: &bytes.Buffer{}, read: map[string]int{}}
23+
24+ toolUse := proto.Message{
25+ ID: "m1",
26+ SessionID: "S",
27+ Role: proto.Assistant,
28+ Parts: []proto.ContentPart{
29+ proto.TextContent{Text: ""},
30+ proto.Finish{Reason: proto.FinishReasonToolUse, Time: time.Now().Unix()},
31+ },
32+ }
33+ done, err := s.handle(pubsub.Event[proto.Message]{Payload: toolUse}, nil)
34+ require.NoError(t, err)
35+ require.False(t, done, "tool_use finish must NOT terminate the run loop")
36+}
37+
38+// TestRunStream_RunCompleteExits verifies the happy path: streaming
39+// assistant text then RunComplete terminates with the full final
40+// text on stdout. Together with the tool_use test above this
41+// nails down the "tool use + final text" sequence that the original
42+// bug truncated.
43+func TestRunStream_RunCompleteExits(t *testing.T) {
44+ t.Parallel()
45+
46+ buf := &bytes.Buffer{}
47+ s := &runStream{sessionID: "S", out: buf, read: map[string]int{}}
48+
49+ // Tool-use step.
50+ done, err := s.handle(pubsub.Event[proto.Message]{Payload: proto.Message{
51+ ID: "m1", SessionID: "S", Role: proto.Assistant,
52+ Parts: []proto.ContentPart{
53+ proto.TextContent{Text: ""},
54+ proto.Finish{Reason: proto.FinishReasonToolUse},
55+ },
56+ }}, nil)
57+ require.NoError(t, err)
58+ require.False(t, done)
59+
60+ // Final assistant message stream.
61+ done, err = s.handle(pubsub.Event[proto.Message]{Payload: proto.Message{
62+ ID: "m2", SessionID: "S", Role: proto.Assistant,
63+ Parts: []proto.ContentPart{
64+ proto.TextContent{Text: "VERDICT: APPROVED"},
65+ proto.Finish{Reason: proto.FinishReasonEndTurn},
66+ },
67+ }}, nil)
68+ require.NoError(t, err)
69+ require.False(t, done, "message finish (even end_turn) must not exit; RunComplete is the only terminal signal")
70+
71+ // RunComplete.
72+ done, err = s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
73+ SessionID: "S",
74+ MessageID: "m2",
75+ Text: "VERDICT: APPROVED",
76+ }}, nil)
77+ require.NoError(t, err)
78+ require.True(t, done)
79+ require.Equal(t, "VERDICT: APPROVED", buf.String())
80+}
81+
82+// TestRunStream_ReconcilesOnOutOfOrderRunComplete is the worst-case
83+// ordering scenario: RunComplete reaches the client BEFORE any of
84+// the streaming assistant message events for the turn (the pubsub
85+// fan-in across upstream brokers does not preserve cross-broker
86+// ordering). The embedded Text field must rescue stdout so the
87+// caller still sees the complete final text.
88+func TestRunStream_ReconcilesOnOutOfOrderRunComplete(t *testing.T) {
89+ t.Parallel()
90+
91+ buf := &bytes.Buffer{}
92+ s := &runStream{sessionID: "S", out: buf, read: map[string]int{}}
93+
94+ done, err := s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
95+ SessionID: "S",
96+ MessageID: "m2",
97+ Text: "VERDICT: APPROVED",
98+ }}, nil)
99+ require.NoError(t, err)
100+ require.True(t, done)
101+ require.Equal(t, "VERDICT: APPROVED", buf.String(),
102+ "RunComplete must reconcile stdout when message events did not arrive in time")
103+}
104+
105+// TestRunStream_ReconcilesPartialStream covers the realistic case
106+// where some streaming output reached stdout before RunComplete
107+// arrived: the reconciliation pass must append only the unread tail,
108+// never duplicate the prefix.
109+func TestRunStream_ReconcilesPartialStream(t *testing.T) {
110+ t.Parallel()
111+
112+ buf := &bytes.Buffer{}
113+ s := &runStream{sessionID: "S", out: buf, read: map[string]int{}}
114+
115+ _, err := s.handle(pubsub.Event[proto.Message]{Payload: proto.Message{
116+ ID: "m2", SessionID: "S", Role: proto.Assistant,
117+ Parts: []proto.ContentPart{proto.TextContent{Text: "VERDICT: "}},
118+ }}, nil)
119+ require.NoError(t, err)
120+
121+ _, err = s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
122+ SessionID: "S",
123+ MessageID: "m2",
124+ Text: "VERDICT: APPROVED",
125+ }}, nil)
126+ require.NoError(t, err)
127+ require.Equal(t, "VERDICT: APPROVED", buf.String())
128+}
129+
130+// TestRunStream_IgnoresOtherSessions ensures multi-session
131+// subscribers (e.g. a TUI watching workspace events while `crush
132+// run` is in flight against the same workspace) do not cause
133+// premature exit on RunComplete for a different session.
134+func TestRunStream_IgnoresOtherSessions(t *testing.T) {
135+ t.Parallel()
136+
137+ s := &runStream{sessionID: "S", out: &bytes.Buffer{}, read: map[string]int{}}
138+ done, err := s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
139+ SessionID: "OTHER",
140+ MessageID: "x",
141+ Text: "noise",
142+ }}, nil)
143+ require.NoError(t, err)
144+ require.False(t, done)
145+}
146+
147+// TestRunStream_ErrorRunComplete surfaces a failing run as a
148+// non-nil error from `crush run` so shells and CI catch it via
149+// exit status.
150+func TestRunStream_ErrorRunComplete(t *testing.T) {
151+ t.Parallel()
152+
153+ s := &runStream{sessionID: "S", out: &bytes.Buffer{}, read: map[string]int{}}
154+ done, err := s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
155+ SessionID: "S",
156+ Error: "model temporarily unavailable",
157+ }}, nil)
158+ require.True(t, done)
159+ require.Error(t, err)
160+ require.Contains(t, err.Error(), "model temporarily unavailable")
161+}
162+
163+// TestRunStream_CancelledRunCompleteIsClean ensures a cancelled
164+// run (e.g. Ctrl+C while `crush run` waits) exits cleanly rather
165+// than reporting the cancellation as a failure.
166+func TestRunStream_CancelledRunCompleteIsClean(t *testing.T) {
167+ t.Parallel()
168+
169+ s := &runStream{sessionID: "S", out: &bytes.Buffer{}, read: map[string]int{}}
170+ done, err := s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
171+ SessionID: "S",
172+ Error: "context canceled",
173+ Cancelled: true,
174+ }}, nil)
175+ require.True(t, done)
176+ require.NoError(t, err)
177+}
178+
179+// TestRunStream_LeadingWhitespaceTrimmedOnce mirrors the
180+// pre-existing trim of leading whitespace on the first byte of
181+// stdout: the trim must happen exactly once even when stdout is
182+// first produced by the RunComplete reconciliation path rather
183+// than the live stream.
184+func TestRunStream_LeadingWhitespaceTrimmedOnce(t *testing.T) {
185+ t.Parallel()
186+
187+ buf := &bytes.Buffer{}
188+ s := &runStream{sessionID: "S", out: buf, read: map[string]int{}}
189+
190+ done, err := s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
191+ SessionID: "S",
192+ MessageID: "m2",
193+ Text: " \tactual output",
194+ }}, nil)
195+ require.NoError(t, err)
196+ require.True(t, done)
197+ require.Equal(t, "actual output", buf.String())
198+}
199+
200+// TestRunStream_StopSpinnerInvokedOnFirstOutput verifies the
201+// spinner is stopped exactly when meaningful output starts (either
202+// a streamed assistant message or the reconciliation tail). This
203+// matches the prior behaviour and prevents the spinner from
204+// painting over the final response on TTYs.
205+func TestRunStream_StopSpinnerInvokedOnFirstOutput(t *testing.T) {
206+ t.Parallel()
207+
208+ calls := 0
209+ stop := func() { calls++ }
210+ s := &runStream{sessionID: "S", out: &bytes.Buffer{}, read: map[string]int{}}
211+ _, _ = s.handle(pubsub.Event[proto.Message]{Payload: proto.Message{
212+ ID: "m1", SessionID: "S", Role: proto.Assistant,
213+ Parts: []proto.ContentPart{proto.TextContent{Text: "hi"}},
214+ }}, stop)
215+ require.GreaterOrEqual(t, calls, 1, "spinner must stop once stdout has content")
216+}
217+
218+// TestRunStream_RunIDFiltersForeignTurns covers the busy-session
219+// queue scenario: `crush run --continue` attaches to a session
220+// whose currently running turn finishes first, publishing its
221+// RunComplete on the same session ID. Without per-run correlation
222+// the stream would exit on that foreign event and drop our own
223+// queued turn's output. With RunID filtering the foreign event is
224+// ignored and only the matching RunComplete terminates the stream.
225+func TestRunStream_RunIDFiltersForeignTurns(t *testing.T) {
226+ t.Parallel()
227+
228+ const sessionID = "S"
229+ const myRun = "run-mine"
230+ const otherRun = "run-other"
231+
232+ buf := &bytes.Buffer{}
233+ s := &runStream{
234+ sessionID: sessionID,
235+ runID: myRun,
236+ out: buf,
237+ read: map[string]int{},
238+ }
239+
240+ // The busy session's existing turn emits more text before it finishes.
241+ done, err := s.handle(pubsub.Event[proto.Message]{Payload: proto.Message{
242+ ID: "other-msg",
243+ SessionID: sessionID,
244+ Role: proto.Assistant,
245+ Parts: []proto.ContentPart{proto.TextContent{Text: "noise from another turn"}},
246+ }}, nil)
247+ require.NoError(t, err)
248+ require.False(t, done,
249+ "foreign message on same session must not terminate our run")
250+ require.Empty(t, buf.String(),
251+ "foreign message on same session must not write to our stdout")
252+
253+ // The busy session's existing turn finishes first.
254+ done, err = s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
255+ SessionID: sessionID,
256+ RunID: otherRun,
257+ MessageID: "other-msg",
258+ Text: "noise from another turn",
259+ }}, nil)
260+ require.NoError(t, err)
261+ require.False(t, done,
262+ "foreign RunComplete on same session must not terminate our run")
263+ require.Empty(t, buf.String(),
264+ "foreign RunComplete must not write to our stdout")
265+
266+ // Our own queued turn eventually finishes.
267+ done, err = s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
268+ SessionID: sessionID,
269+ RunID: myRun,
270+ MessageID: "my-msg",
271+ Text: "OK",
272+ }}, nil)
273+ require.NoError(t, err)
274+ require.True(t, done, "matching RunID must terminate the stream")
275+ require.Equal(t, "OK", buf.String())
276+}
277+
278+func TestRunStream_RunIDSuppressesLiveMessagesAndPrintsRunComplete(t *testing.T) {
279+ t.Parallel()
280+
281+ buf := &bytes.Buffer{}
282+ s := &runStream{
283+ sessionID: "S",
284+ runID: "run-mine",
285+ out: buf,
286+ read: map[string]int{},
287+ }
288+
289+ done, err := s.handle(pubsub.Event[proto.Message]{Payload: proto.Message{
290+ ID: "my-msg",
291+ SessionID: "S",
292+ Role: proto.Assistant,
293+ Parts: []proto.ContentPart{proto.TextContent{Text: "streamed prefix"}},
294+ }}, nil)
295+ require.NoError(t, err)
296+ require.False(t, done)
297+ require.Empty(t, buf.String())
298+
299+ done, err = s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
300+ SessionID: "S",
301+ RunID: "run-mine",
302+ MessageID: "my-msg",
303+ Text: "streamed prefix final",
304+ }}, nil)
305+ require.NoError(t, err)
306+ require.True(t, done)
307+ require.Equal(t, "streamed prefix final", buf.String())
308+}
309+
310+// TestRunStream_NoRunIDFallsBackToSessionID preserves the older
311+// behaviour for callers (and tests) that don't supply a RunID:
312+// SessionID-only matching still terminates the stream on the
313+// session's RunComplete. This keeps the contract backwards
314+// compatible with servers that don't echo RunID and with the
315+// pre-existing TestRunStream_* assertions.
316+func TestRunStream_NoRunIDFallsBackToSessionID(t *testing.T) {
317+ t.Parallel()
318+
319+ buf := &bytes.Buffer{}
320+ s := &runStream{sessionID: "S", out: buf, read: map[string]int{}}
321+ done, err := s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
322+ SessionID: "S",
323+ MessageID: "m2",
324+ Text: "DONE",
325+ }}, nil)
326+ require.NoError(t, err)
327+ require.True(t, done)
328+ require.Equal(t, "DONE", buf.String())
329+}
internal/proto/proto.go+39
@@ -46,6 +46,31 @@ type CurrentSession struct {
4646 SessionID string `json:"session_id"`
4747}
4848
49+// RunComplete is the authoritative end-of-run signal for a session,
50+// emitted exactly once per top-level agent turn after all message
51+// updates for the turn have flushed. Clients that need a reliable
52+// completion contract (notably `crush run` in client/server mode)
53+// should listen for this event filtered by RunID (preferred) — or
54+// by SessionID when no RunID was supplied — and use Text and
55+// MessageID to reconcile any output they have already streamed from
56+// earlier message events. Error is non-empty when the run terminated
57+// with an error; Cancelled is true when terminated due to context
58+// cancellation.
59+//
60+// RunID echoes the value the caller set on AgentMessage.RunID. It is
61+// the only safe correlator when the caller's prompt was queued
62+// behind a busy session: another turn's RunComplete for the same
63+// SessionID may arrive first, and filtering by SessionID alone
64+// would terminate the caller before its own turn ran.
65+type RunComplete struct {
66+ SessionID string `json:"session_id"`
67+ RunID string `json:"run_id,omitempty"`
68+ MessageID string `json:"message_id"`
69+ Text string `json:"text,omitempty"`
70+ Error string `json:"error,omitempty"`
71+ Cancelled bool `json:"cancelled,omitempty"`
72+}
73+
4974// SkillInfo describes a visible skill exposed to a frontend.
5075type SkillInfo struct {
5176 ID string `json:"id"`
@@ -89,8 +114,22 @@ func (a AgentInfo) IsZero() bool {
89114}
90115
91116// AgentMessage represents a message sent to the agent.
117+//
118+// RunID, when non-empty, is echoed back on the [RunComplete] event
119+// emitted for the resulting turn. Callers that need to correlate a
120+// specific SendMessage with its terminal event (notably
121+// `crush run`, which may attach to a busy session whose currently
122+// running turn finishes first) should set it to a fresh unique
123+// value before the request. Server-side propagation flows through
124+// agent.WithRunID on the request context into the
125+// SessionAgentCall; it is preserved across the busy-session queue.
126+// When empty the resulting RunComplete carries an empty RunID and
127+// callers must fall back to SessionID-only filtering, which
128+// remains correct only when no other turns are in flight for the
129+// same session.
92130type AgentMessage struct {
93131 SessionID string `json:"session_id"`
132+ RunID string `json:"run_id,omitempty"`
94133 Prompt string `json:"prompt"`
95134 Attachments []Attachment `json:"attachments,omitempty"`
96135}
internal/pubsub/events.go+8
@@ -26,6 +26,7 @@ const (
2626 PayloadTypeAgentEvent PayloadType = "agent_event"
2727 PayloadTypeConfigChanged PayloadType = "config_changed"
2828 PayloadTypeSkillsEvent PayloadType = "skills_event"
29+ PayloadTypeRunComplete PayloadType = "run_complete"
2930)
3031
3132// Payload wraps a discriminated JSON payload with a type tag.
@@ -50,7 +51,14 @@ type (
5051 }
5152
5253 // Publisher can publish events of type T.
54+ //
55+ // Publish is best-effort and lossy under back-pressure;
56+ // PublishMustDeliver applies the bounded-blocking semantics used
57+ // for terminal events that must reach subscribers (finish, tool
58+ // result, error, cancel, RunComplete). See [Broker.Publish] and
59+ // [Broker.PublishMustDeliver].
5360 Publisher[T any] interface {
5461 Publish(EventType, T)
62+ PublishMustDeliver(context.Context, EventType, T)
5563 }
5664)
internal/server/events.go+12
@@ -93,6 +93,18 @@ func wrapEvent(ev any) *pubsub.Payload {
9393 Type: proto.AgentEventType(e.Payload.Type),
9494 },
9595 })
96+ case pubsub.Event[notify.RunComplete]:
97+ return envelope(pubsub.PayloadTypeRunComplete, pubsub.Event[proto.RunComplete]{
98+ Type: e.Type,
99+ Payload: proto.RunComplete{
100+ SessionID: e.Payload.SessionID,
101+ RunID: e.Payload.RunID,
102+ MessageID: e.Payload.MessageID,
103+ Text: e.Payload.Text,
104+ Error: e.Payload.Error,
105+ Cancelled: e.Payload.Cancelled,
106+ },
107+ })
96108 case pubsub.Event[proto.ConfigChanged]:
97109 return envelope(pubsub.PayloadTypeConfigChanged, e)
98110 case pubsub.Event[skills.Event]:
internal/server/events_test.go+65
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "testing"
77
8+ "github.com/charmbracelet/crush/internal/agent/notify"
89 "github.com/charmbracelet/crush/internal/message"
910 "github.com/charmbracelet/crush/internal/proto"
1011 "github.com/charmbracelet/crush/internal/pubsub"
@@ -83,3 +84,67 @@ func TestSkillsEventToProto_RoundTrip(t *testing.T) {
8384 require.Equal(t, proto.SkillStateError, decoded.Payload.States[1].State)
8485 require.Equal(t, "bad frontmatter", decoded.Payload.States[1].Error)
8586}
87+
88+// TestRunCompleteToProto_RoundTrip verifies that the authoritative
89+// per-run completion event survives the SSE envelope conversion with
90+// all reconciliation fields intact. SessionID, MessageID, and Text
91+// are what non-interactive clients (e.g. `crush run`) rely on to
92+// terminate the run loop and guarantee final text on stdout when
93+// message events arrive out of order.
94+func TestRunCompleteToProto_RoundTrip(t *testing.T) {
95+ t.Parallel()
96+
97+ src := pubsub.Event[notify.RunComplete]{
98+ Type: pubsub.UpdatedEvent,
99+ Payload: notify.RunComplete{
100+ SessionID: "S",
101+ RunID: "run-42",
102+ MessageID: "M",
103+ Text: "VERDICT: APPROVED",
104+ Error: "",
105+ Cancelled: false,
106+ },
107+ }
108+
109+ env := wrapEvent(src)
110+ require.NotNil(t, env)
111+ require.Equal(t, pubsub.PayloadTypeRunComplete, env.Type)
112+
113+ var decoded pubsub.Event[proto.RunComplete]
114+ require.NoError(t, json.Unmarshal(env.Payload, &decoded))
115+ require.Equal(t, pubsub.UpdatedEvent, decoded.Type)
116+ require.Equal(t, "S", decoded.Payload.SessionID)
117+ require.Equal(t, "run-42", decoded.Payload.RunID,
118+ "RunID must survive the SSE envelope so clients can correlate "+
119+ "this event with the SendMessage call that produced it")
120+ require.Equal(t, "M", decoded.Payload.MessageID)
121+ require.Equal(t, "VERDICT: APPROVED", decoded.Payload.Text)
122+ require.Empty(t, decoded.Payload.Error)
123+ require.False(t, decoded.Payload.Cancelled)
124+}
125+
126+// TestRunCompleteToProto_Error verifies that error- and cancel-shaped
127+// RunComplete events round-trip cleanly so clients can distinguish
128+// "agent failed" (returns non-zero from `crush run`) from "agent
129+// cancelled by user" (clean exit).
130+func TestRunCompleteToProto_Error(t *testing.T) {
131+ t.Parallel()
132+
133+ src := pubsub.Event[notify.RunComplete]{
134+ Type: pubsub.UpdatedEvent,
135+ Payload: notify.RunComplete{
136+ SessionID: "S",
137+ MessageID: "M",
138+ Text: "partial",
139+ Error: "context canceled",
140+ Cancelled: true,
141+ },
142+ }
143+
144+ env := wrapEvent(src)
145+ require.NotNil(t, env)
146+ var decoded pubsub.Event[proto.RunComplete]
147+ require.NoError(t, json.Unmarshal(env.Payload, &decoded))
148+ require.Equal(t, "context canceled", decoded.Payload.Error)
149+ require.True(t, decoded.Payload.Cancelled)
150+}
internal/workspace/client_workspace.go+23−1
@@ -179,7 +179,11 @@ func (w *ClientWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Me
179179// -- Agent --
180180
181181func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
182− return w.client.SendMessage(ctx, w.workspaceID(), sessionID, prompt, attachments...)
182+ // The interactive TUI does not consume notify.RunComplete for
183+ // completion detection (it observes message events directly),
184+ // so passing an empty RunID is correct here: it skips the
185+ // correlator stamping path without functional consequences.
186+ return w.client.SendMessage(ctx, w.workspaceID(), sessionID, "", prompt, attachments...)
183187}
184188
185189func (w *ClientWorkspace) AgentCancel(sessionID string) {
@@ -707,6 +711,24 @@ func (w *ClientWorkspace) translateEvent(ev any) tea.Msg {
707711 Type: notify.Type(e.Payload.Type),
708712 },
709713 }
714+ case pubsub.Event[proto.RunComplete]:
715+ // Translate the wire-level proto.RunComplete back into the
716+ // agent's domain notify.RunComplete. Without this case the
717+ // default branch below warns on every run completion in the
718+ // server-mode TUI, even though the TUI itself doesn't act
719+ // on RunComplete — converting silently keeps the workspace
720+ // event bridge symmetric with the server-side wrapEvent.
721+ return pubsub.Event[notify.RunComplete]{
722+ Type: e.Type,
723+ Payload: notify.RunComplete{
724+ SessionID: e.Payload.SessionID,
725+ RunID: e.Payload.RunID,
726+ MessageID: e.Payload.MessageID,
727+ Text: e.Payload.Text,
728+ Error: e.Payload.Error,
729+ Cancelled: e.Payload.Cancelled,
730+ },
731+ }
710732 case pubsub.Event[proto.SkillsEvent]:
711733 states := protoToSkillStates(e.Payload.States)
712734 if w.skills != nil {