@@ -3,13 +3,15 @@ package control
33import (
44 "context"
55 "encoding/json"
6+ "errors"
67 "os"
78 "path/filepath"
89 "strings"
910 "testing"
1011
1112 "reasonix/internal/agent"
1213 "reasonix/internal/event"
14+ "reasonix/internal/evidence"
1315 "reasonix/internal/hook"
1416 "reasonix/internal/provider"
1517 "reasonix/internal/tool"
@@ -301,3 +303,137 @@ func TestTurnOrchestratorStopHookCancelledContext(t *testing.T) {
301303 t .Fatalf ("Stop hooks called = %d; want 1" , stopCalls )
302304 }
303305}
306+
307+ // TestTurnOrchestratorCancelStripsIncompleteTurn verifies that when the user
308+ // explicitly cancels a turn (Ctrl+C), the incomplete turn's messages are
309+ // stripped from the session so the next turn starts clean. Without this, the
310+ // model sees leftover in-progress todo items and partial tool calls and may
311+ // re-execute the interrupted work. See #5286.
312+ func TestTurnOrchestratorCancelStripsIncompleteTurn (t * testing.T ) {
313+ sess := agent .NewSession ("you are a helpful agent" )
314+ // Pre-populate with a few messages from an earlier turn.
315+ sess .Add (provider.Message {Role : provider .RoleUser , Content : "previous work" })
316+ sess .Add (provider.Message {Role : provider .RoleAssistant , Content : "done" })
317+ preCount := len (sess .Messages )
318+
319+ // runner that simulates a cancelled turn: it adds the user message plus
320+ // some tool-call garbage the real agent would leave behind, then returns
321+ // context.Canceled.
322+ runner := & cancelStrippingRunner {
323+ session : sess ,
324+ add : []provider.Message {
325+ {Role : provider .RoleAssistant , Content : "let me do that" , ToolCalls : []provider.ToolCall {
326+ {ID : "c1" , Name : "todo_write" , Arguments : `{"todos":[{"content":"add abc","status":"in_progress"}]}` },
327+ }},
328+ {Role : provider .RoleTool , Content : "Todos updated: 1 total — 0 completed, 1 in_progress, 0 pending." , ToolCallID : "c1" , Name : "todo_write" },
329+ },
330+ err : context .Canceled ,
331+ }
332+
333+ ex := agent .New (nil , nil , sess , agent.Options {}, event .Discard )
334+ c := New (Options {Runner : runner , Executor : ex })
335+ // Simulate a user-initiated cancel: set the cancelling flag.
336+ c .mu .Lock ()
337+ c .canceling = true
338+ c .mu .Unlock ()
339+
340+ // Pre-seed todoState as if a successful todo_write from the cancelled turn
341+ // had already updated it — this is the state the runner leaves behind before
342+ // returning context.Canceled, and what RebuildTodoState must clear.
343+ ex .ReplaceTodoState ([]evidence.TodoItem {{Content : "add abc" , Status : "in_progress" }})
344+
345+ o := newTurnOrchestrator (c )
346+ err := o .runTurnWithRawDisplay (context .Background (), "add config file abc" , "add config file abc" , "" )
347+ if ! errors .Is (err , context .Canceled ) {
348+ t .Fatalf ("expected context.Canceled, got %v" , err )
349+ }
350+
351+ // The incomplete turn (user prompt + assistant + tool result) must be
352+ // stripped; the session must only contain the pre-turn messages.
353+ msgs := sess .Messages
354+ if len (msgs ) != preCount {
355+ t .Fatalf ("session messages after cancel = %d, want pre-turn count %d: %+v" , len (msgs ), preCount , msgs )
356+ }
357+
358+ // todoState must also be reset: the in_progress todo written by the
359+ // cancelled turn must not survive the strip.
360+ if todos := c .Todos (); len (todos ) != 0 {
361+ t .Fatalf ("Todos() after cancel = %v, want empty — cancelled todo_write leaked into canonical state" , todos )
362+ }
363+ }
364+
365+ // TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk verifies that after a
366+ // user-cancel strip the cleaned transcript is written to disk, so a restart or
367+ // session resume does not reload the partial turn from a stale mid-turn
368+ // autosave. See #5286.
369+ func TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk (t * testing.T ) {
370+ sess := agent .NewSession ("system" )
371+ sess .Add (provider.Message {Role : provider .RoleUser , Content : "earlier turn" })
372+ sess .Add (provider.Message {Role : provider .RoleAssistant , Content : "done" })
373+ // Count only non-system messages; the system prompt is not written to the
374+ // .jsonl by Session.Save (it is reconstructed from the session options).
375+ wantNonSystem := 0
376+ for _ , m := range sess .Messages {
377+ if m .Role != provider .RoleSystem {
378+ wantNonSystem ++
379+ }
380+ }
381+
382+ runner := & cancelStrippingRunner {
383+ session : sess ,
384+ add : []provider.Message {
385+ {Role : provider .RoleAssistant , Content : "working…" , ToolCalls : []provider.ToolCall {
386+ {ID : "d1" , Name : "todo_write" , Arguments : `{"todos":[{"content":"task","status":"in_progress"}]}` },
387+ }},
388+ {Role : provider .RoleTool , Content : "Todos updated." , ToolCallID : "d1" , Name : "todo_write" },
389+ },
390+ err : context .Canceled ,
391+ }
392+
393+ sessionPath := agent .NewSessionPath (t .TempDir (), "test-model" )
394+ c := New (Options {
395+ Runner : runner ,
396+ Executor : agent .New (nil , nil , sess , agent.Options {}, event .Discard ),
397+ SessionPath : sessionPath ,
398+ })
399+ c .mu .Lock ()
400+ c .canceling = true
401+ c .mu .Unlock ()
402+
403+ o := newTurnOrchestrator (c )
404+ if err := o .runTurnWithRawDisplay (context .Background (), "do something" , "do something" , "" ); ! errors .Is (err , context .Canceled ) {
405+ t .Fatalf ("expected context.Canceled, got %v" , err )
406+ }
407+
408+ // Load the session file written after the strip and verify it contains only
409+ // the pre-cancel messages — not the partial assistant/tool messages.
410+ loaded , err := agent .LoadSession (sessionPath )
411+ if err != nil {
412+ t .Fatalf ("LoadSession: %v" , err )
413+ }
414+ nonSystem := 0
415+ for _ , m := range loaded .Messages {
416+ if m .Role != provider .RoleSystem {
417+ nonSystem ++
418+ }
419+ }
420+ if nonSystem != wantNonSystem {
421+ t .Fatalf ("on-disk message count (non-system) = %d, want %d — stale partial turn still on disk" , nonSystem , wantNonSystem )
422+ }
423+ }
424+
425+ // cancelStrippingRunner adds messages to a session then returns a fixed error,
426+ // simulating an agent that was interrupted mid-turn.
427+ type cancelStrippingRunner struct {
428+ session * agent.Session
429+ add []provider.Message
430+ err error
431+ }
432+
433+ func (r * cancelStrippingRunner ) Run (ctx context.Context , input string ) error {
434+ r .session .Add (provider.Message {Role : provider .RoleUser , Content : input })
435+ for _ , m := range r .add {
436+ r .session .Add (m )
437+ }
438+ return r .err
439+ }
0 commit comments