@@ -58,13 +58,15 @@ func TestSessionAgentRun_QueueStripsOnComplete(t *testing.T) {
5858 "RunComplete still correlates with the originating SendMessage")
5959}
6060
61−// TestDrainUncanceledQueue_FiltersUnderDispatchLock verifies that the
62−// queue drain evaluates the per-session cancel mark while holding the
63−// dispatch mutex (canceledBySeq's documented precondition). Queued calls
64−// at or below the cancel high-water mark are dropped, calls queued after
65−// the cancel (higher seq) survive, untracked enqueues (seq == 0) are
66−// dropped whenever any mark is present, and the queue is cleared.
67−func TestDrainUncanceledQueue_FiltersUnderDispatchLock(t *testing.T) {
61+// TestDrainQueueForStep_FiltersUnderDispatchLock verifies that the queue
62+// drain evaluates the per-session cancel mark while holding the dispatch
63+// mutex (canceledBySeq's documented precondition). Queued calls at or
64+// below the cancel high-water mark are dropped, calls queued after the
65+// cancel (higher seq) are folded, untracked enqueues (seq == 0) are
66+// dropped whenever any mark is present, and the queue is cleared. These
67+// calls carry no RunID, so all foldable survivors are returned for
68+// folding (the existing follow-up behavior).
69+func TestDrainQueueForStep_FiltersUnderDispatchLock(t *testing.T) {
6870 t.Parallel()
6971
7072 env := testEnv(t)
@@ -83,19 +85,21 @@ func TestDrainUncanceledQueue_FiltersUnderDispatchLock(t *testing.T) {
8385 // Cancel high-water mark at seq 2: seq <= 2 and seq == 0 are covered.
8486 a.cancelMark.Set(sessionID, 2)
8587
86− survivors := a.drainUncanceledQueue(sessionID)
88+ fold, canceledWithRunID := a.drainQueueForStep(sessionID)
8789
88− require.Len(t, survivors, 1,
89− "only the follow-up queued after the cancel (seq > mark) must survive")
90− require.Equal(t, "after", survivors[0].Prompt)
90+ require.Len(t, fold, 1,
91+ "only the follow-up queued after the cancel (seq > mark) must be folded")
92+ require.Equal(t, "after", fold[0].Prompt)
93+ require.Empty(t, canceledWithRunID,
94+ "no dropped call carried a RunID, so none need a terminal RunComplete")
9195
9296 _, ok := a.messageQueue.Get(sessionID)
93− require.False(t, ok, "drain must clear the session message queue")
97+ require.False(t, ok, "drain must clear the session message queue when nothing is kept")
9498}
9599
96−// TestDrainUncanceledQueue_NoMarkKeepsAll verifies that with no cancel
97−// mark recorded, every queued call survives the drain.
98−func TestDrainUncanceledQueue_NoMarkKeepsAll(t *testing.T) {
100+// TestDrainQueueForStep_NoMarkFoldsAllNonRunID verifies that with no
101+// cancel mark recorded, every queued call without a RunID is folded.
102+func TestDrainQueueForStep_NoMarkFoldsAllNonRunID(t *testing.T) {
99103 t.Parallel()
100104
101105 env := testEnv(t)
@@ -110,8 +114,80 @@ func TestDrainUncanceledQueue_NoMarkKeepsAll(t *testing.T) {
110114 {SessionID: sessionID, Prompt: "b", acceptSeq: 5},
111115 })
112116
113− survivors := a.drainUncanceledQueue(sessionID)
114− require.Len(t, survivors, 2, "no cancel mark means all queued calls survive")
117+ fold, canceledWithRunID := a.drainQueueForStep(sessionID)
118+ require.Len(t, fold, 2, "no cancel mark means all non-RunID queued calls are folded")
119+ require.Empty(t, canceledWithRunID)
120+}
121+
122+// TestDrainQueueForStep_KeepsRunIDPromptsQueued is the core of fix 2: a
123+// queued prompt that carries a RunID must NOT be folded into the active
124+// turn. Folding it would silently absorb it into another turn and never
125+// publish a RunComplete for its RunID, hanging a `crush run` caller that
126+// blocks on that event. Such prompts are left in the queue so the
127+// recursive run path gives each its own turn and its own RunComplete.
128+// Non-RunID prompts are still folded.
129+func TestDrainQueueForStep_KeepsRunIDPromptsQueued(t *testing.T) {
130+ t.Parallel()
131+
132+ env := testEnv(t)
133+ a := NewSessionAgent(SessionAgentOptions{
134+ Sessions: env.sessions,
135+ Messages: env.messages,
136+ }).(*sessionAgent)
137+
138+ const sessionID = "drain-runid"
139+ a.messageQueue.Set(sessionID, []SessionAgentCall{
140+ {SessionID: sessionID, Prompt: "fold-me", acceptSeq: 1},
141+ {SessionID: sessionID, RunID: "run-a", Prompt: "keep-me", acceptSeq: 2},
142+ {SessionID: sessionID, RunID: "run-b", Prompt: "keep-me-too", acceptSeq: 3},
143+ })
144+
145+ fold, canceledWithRunID := a.drainQueueForStep(sessionID)
146+
147+ require.Len(t, fold, 1, "only the non-RunID prompt is folded into the active turn")
148+ require.Equal(t, "fold-me", fold[0].Prompt)
149+ require.Empty(t, canceledWithRunID)
150+
151+ kept, ok := a.messageQueue.Get(sessionID)
152+ require.True(t, ok, "RunID-bearing prompts must remain queued for the recursive run path")
153+ require.Len(t, kept, 2)
154+ require.Equal(t, "run-a", kept[0].RunID)
155+ require.Equal(t, "run-b", kept[1].RunID)
156+}
157+
158+// TestDrainQueueForStep_ReportsCanceledRunIDDrops verifies that a queued
159+// prompt carrying a RunID that is dropped because a cancel covers it is
160+// reported in canceledWithRunID so the caller can publish its terminal
161+// cancelled RunComplete. A canceled prompt without a RunID is dropped
162+// silently as before.
163+func TestDrainQueueForStep_ReportsCanceledRunIDDrops(t *testing.T) {
164+ t.Parallel()
165+
166+ env := testEnv(t)
167+ a := NewSessionAgent(SessionAgentOptions{
168+ Sessions: env.sessions,
169+ Messages: env.messages,
170+ }).(*sessionAgent)
171+
172+ const sessionID = "drain-cancel-runid"
173+ a.messageQueue.Set(sessionID, []SessionAgentCall{
174+ {SessionID: sessionID, RunID: "run-canceled", Prompt: "canceled", acceptSeq: 1},
175+ {SessionID: sessionID, Prompt: "canceled-no-runid", acceptSeq: 1},
176+ {SessionID: sessionID, RunID: "run-survives", Prompt: "survives", acceptSeq: 5},
177+ })
178+ a.cancelMark.Set(sessionID, 2)
179+
180+ fold, canceledWithRunID := a.drainQueueForStep(sessionID)
181+
182+ require.Empty(t, fold, "no uncanceled non-RunID prompts to fold")
183+ require.Len(t, canceledWithRunID, 1,
184+ "only the dropped RunID-bearing prompt needs a terminal RunComplete")
185+ require.Equal(t, "run-canceled", canceledWithRunID[0].RunID)
186+
187+ kept, ok := a.messageQueue.Get(sessionID)
188+ require.True(t, ok)
189+ require.Len(t, kept, 1, "the uncanceled RunID prompt stays queued")
190+ require.Equal(t, "run-survives", kept[0].RunID)
115191}
116192
117193// TestRunCompletePublisher_MustDeliverOverTakesPublish exercises the
@@ -143,3 +219,104 @@ func TestRunCompletePublisher_MustDeliverOverTakesPublish(t *testing.T) {
143219 t.Fatal("PublishMustDeliver did not deliver event")
144220 }
145221}
222+
223+// requireSingleCancelledRunComplete reads exactly one RunComplete from ch,
224+// asserts it is the cancelled terminal event for runID, and verifies no
225+// second event arrives. This observes the published pubsub event rather
226+// than internal bookkeeping, which is the contract a `crush run` caller
227+// blocking on the broker actually relies on.
228+func requireSingleCancelledRunComplete(t *testing.T, ch <-chan pubsub.Event[notify.RunComplete], sessionID, runID string) {
229+ t.Helper()
230+ select {
231+ case ev := <-ch:
232+ require.Equal(t, runID, ev.Payload.RunID,
233+ "the published RunComplete must carry the dropped queued prompt's RunID")
234+ require.Equal(t, sessionID, ev.Payload.SessionID)
235+ require.True(t, ev.Payload.Cancelled,
236+ "a dropped queued prompt must publish a cancelled RunComplete")
237+ case <-time.After(5 * time.Second):
238+ t.Fatal("timed out waiting for the cancelled RunComplete")
239+ }
240+ select {
241+ case extra := <-ch:
242+ t.Fatalf("expected exactly one RunComplete, got a second: %+v", extra.Payload)
243+ case <-time.After(100 * time.Millisecond):
244+ }
245+}
246+
247+// TestCancel_QueuedRunIDPromptPublishesCancelledRunComplete proves the
248+// terminal-event behavior end-to-end: a RunID-bearing prompt sitting in
249+// the queue that is canceled while queued (via the public Cancel path,
250+// which routes through clearQueueAndNotify -> publishCanceledQueueDrops)
251+// must emit exactly one cancelled RunComplete on the broker for its
252+// RunID. A queued prompt without a RunID is dropped silently. This is the
253+// coverage the earlier drain test lacked: it asserted the returned
254+// bookkeeping slice, not the published event a `crush run` caller awaits.
255+func TestCancel_QueuedRunIDPromptPublishesCancelledRunComplete(t *testing.T) {
256+ t.Parallel()
257+
258+ env := testEnv(t)
259+ broker := pubsub.NewBroker[notify.RunComplete]()
260+ t.Cleanup(broker.Shutdown)
261+
262+ a := NewSessionAgent(SessionAgentOptions{
263+ Sessions: env.sessions,
264+ Messages: env.messages,
265+ RunComplete: broker,
266+ }).(*sessionAgent)
267+
268+ subCtx, subCancel := context.WithCancel(t.Context())
269+ defer subCancel()
270+ ch := broker.Subscribe(subCtx)
271+
272+ const sessionID = "cancel-queued-runid"
273+ a.messageQueue.Set(sessionID, []SessionAgentCall{
274+ {SessionID: sessionID, Prompt: "no-runid", acceptSeq: 1},
275+ {SessionID: sessionID, RunID: "run-queued", Prompt: "queued", acceptSeq: 2},
276+ })
277+
278+ a.Cancel(sessionID)
279+
280+ requireSingleCancelledRunComplete(t, ch, sessionID, "run-queued")
281+
282+ _, ok := a.messageQueue.Get(sessionID)
283+ require.False(t, ok, "Cancel must clear the queue")
284+}
285+
286+// TestDrainQueueForStep_DroppedRunIDPublishesCancelledRunComplete drives
287+// the production drain sequence (drainQueueForStep then
288+// publishCanceledQueueDrops, mirroring the PrepareStep handoff) and
289+// asserts the dropped RunID-bearing prompt actually publishes exactly one
290+// cancelled RunComplete on the broker. The companion bookkeeping test
291+// covers the returned slice; this one covers the observable terminal
292+// event.
293+func TestDrainQueueForStep_DroppedRunIDPublishesCancelledRunComplete(t *testing.T) {
294+ t.Parallel()
295+
296+ env := testEnv(t)
297+ broker := pubsub.NewBroker[notify.RunComplete]()
298+ t.Cleanup(broker.Shutdown)
299+
300+ a := NewSessionAgent(SessionAgentOptions{
301+ Sessions: env.sessions,
302+ Messages: env.messages,
303+ RunComplete: broker,
304+ }).(*sessionAgent)
305+
306+ subCtx, subCancel := context.WithCancel(t.Context())
307+ defer subCancel()
308+ ch := broker.Subscribe(subCtx)
309+
310+ const sessionID = "drain-drop-runid"
311+ a.messageQueue.Set(sessionID, []SessionAgentCall{
312+ {SessionID: sessionID, RunID: "run-dropped", Prompt: "dropped", acceptSeq: 1},
313+ {SessionID: sessionID, Prompt: "dropped-no-runid", acceptSeq: 1},
314+ })
315+ a.cancelMark.Set(sessionID, 2)
316+
317+ _, canceledWithRunID := a.drainQueueForStep(sessionID)
318+ require.Len(t, canceledWithRunID, 1)
319+ a.publishCanceledQueueDrops(canceledWithRunID)
320+
321+ requireSingleCancelledRunComplete(t, ch, sessionID, "run-dropped")
322+}