@@ -0,0 +1,329 @@
8+ "github.com/charmbracelet/crush/internal/proto"
9+ "github.com/charmbracelet/crush/internal/pubsub"
10+ "github.com/stretchr/testify/require"
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) {
22+ s := &runStream{sessionID: "S", out: &bytes.Buffer{}, read: map[string]int{}}
24+ toolUse := proto.Message{
27+ Role: proto.Assistant,
28+ Parts: []proto.ContentPart{
29+ proto.TextContent{Text: ""},
30+ proto.Finish{Reason: proto.FinishReasonToolUse, Time: time.Now().Unix()},
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")
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
43+func TestRunStream_RunCompleteExits(t *testing.T) {
46+ buf := &bytes.Buffer{}
47+ s := &runStream{sessionID: "S", out: buf, read: map[string]int{}}
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},
57+ require.NoError(t, err)
58+ require.False(t, done)
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},
68+ require.NoError(t, err)
69+ require.False(t, done, "message finish (even end_turn) must not exit; RunComplete is the only terminal signal")
72+ done, err = s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
75+ Text: "VERDICT: APPROVED",
77+ require.NoError(t, err)
78+ require.True(t, done)
79+ require.Equal(t, "VERDICT: APPROVED", buf.String())
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) {
91+ buf := &bytes.Buffer{}
92+ s := &runStream{sessionID: "S", out: buf, read: map[string]int{}}
94+ done, err := s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
97+ Text: "VERDICT: APPROVED",
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")
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) {
112+ buf := &bytes.Buffer{}
113+ s := &runStream{sessionID: "S", out: buf, read: map[string]int{}}
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: "}},
119+ require.NoError(t, err)
121+ _, err = s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
124+ Text: "VERDICT: APPROVED",
126+ require.NoError(t, err)
127+ require.Equal(t, "VERDICT: APPROVED", buf.String())
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) {
137+ s := &runStream{sessionID: "S", out: &bytes.Buffer{}, read: map[string]int{}}
138+ done, err := s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
143+ require.NoError(t, err)
144+ require.False(t, done)
147+// TestRunStream_ErrorRunComplete surfaces a failing run as a
148+// non-nil error from `crush run` so shells and CI catch it via
150+func TestRunStream_ErrorRunComplete(t *testing.T) {
153+ s := &runStream{sessionID: "S", out: &bytes.Buffer{}, read: map[string]int{}}
154+ done, err := s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
156+ Error: "model temporarily unavailable",
158+ require.True(t, done)
159+ require.Error(t, err)
160+ require.Contains(t, err.Error(), "model temporarily unavailable")
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) {
169+ s := &runStream{sessionID: "S", out: &bytes.Buffer{}, read: map[string]int{}}
170+ done, err := s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
172+ Error: "context canceled",
175+ require.True(t, done)
176+ require.NoError(t, err)
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) {
187+ buf := &bytes.Buffer{}
188+ s := &runStream{sessionID: "S", out: buf, read: map[string]int{}}
190+ done, err := s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
193+ Text: " \tactual output",
195+ require.NoError(t, err)
196+ require.True(t, done)
197+ require.Equal(t, "actual output", buf.String())
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) {
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"}},
215+ require.GreaterOrEqual(t, calls, 1, "spinner must stop once stdout has content")
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) {
228+ const sessionID = "S"
229+ const myRun = "run-mine"
230+ const otherRun = "run-other"
232+ buf := &bytes.Buffer{}
234+ sessionID: sessionID,
237+ read: map[string]int{},
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{
243+ SessionID: sessionID,
244+ Role: proto.Assistant,
245+ Parts: []proto.ContentPart{proto.TextContent{Text: "noise from another turn"}},
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")
253+ // The busy session's existing turn finishes first.
254+ done, err = s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
255+ SessionID: sessionID,
257+ MessageID: "other-msg",
258+ Text: "noise from another turn",
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")
266+ // Our own queued turn eventually finishes.
267+ done, err = s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
268+ SessionID: sessionID,
270+ MessageID: "my-msg",
273+ require.NoError(t, err)
274+ require.True(t, done, "matching RunID must terminate the stream")
275+ require.Equal(t, "OK", buf.String())
278+func TestRunStream_RunIDSuppressesLiveMessagesAndPrintsRunComplete(t *testing.T) {
281+ buf := &bytes.Buffer{}
286+ read: map[string]int{},
289+ done, err := s.handle(pubsub.Event[proto.Message]{Payload: proto.Message{
292+ Role: proto.Assistant,
293+ Parts: []proto.ContentPart{proto.TextContent{Text: "streamed prefix"}},
295+ require.NoError(t, err)
296+ require.False(t, done)
297+ require.Empty(t, buf.String())
299+ done, err = s.handle(pubsub.Event[proto.RunComplete]{Payload: proto.RunComplete{
302+ MessageID: "my-msg",
303+ Text: "streamed prefix final",
305+ require.NoError(t, err)
306+ require.True(t, done)
307+ require.Equal(t, "streamed prefix final", buf.String())
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) {
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{
326+ require.NoError(t, err)
327+ require.True(t, done)
328+ require.Equal(t, "DONE", buf.String())