Skip to content

Commit 66dadfc

Browse files
committed
fix(control): strip incomplete turn messages after user cancel (#5286)
When the user presses Ctrl+C to cancel a turn, the agent's incomplete assistant messages and tool results are already saved to the session. On the next turn, the model sees leftover in-progress todo items and partial tool calls and may re-execute the interrupted work. Fix: in turn_orchestrator.runTurnWithRawDisplay, when Run() returns context.Canceled AND the cancel was user-initiated (CancelRequested() == true), strip the session back to the pre-turn message count so the next prompt starts clean. The guard (CancelRequested) ensures internal context cancellations (stream recovery, timeouts) are unaffected — only explicit user cancels trigger the strip.
1 parent fb364be commit 66dadfc

3 files changed

Lines changed: 94 additions & 0 deletions

File tree

internal/control/controller.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2116,6 +2116,23 @@ func (c *Controller) messageCount() int {
21162116
return len(c.executor.Session().Snapshot())
21172117
}
21182118

2119+
// stripTurnMessagesAfter truncates the executor's session to keep only messages
2120+
// before the given index, discarding an incomplete turn (the user prompt plus
2121+
// every assistant / tool message that followed). It is called when the user
2122+
// explicitly cancels a turn so the next prompt starts clean — the model won't
2123+
// see leftover in-progress todo items or partial tool calls and re-execute
2124+
// interrupted work.
2125+
func (c *Controller) stripTurnMessagesAfter(idx int) {
2126+
if c.executor == nil {
2127+
return
2128+
}
2129+
msgs := c.executor.Session().Snapshot()
2130+
if len(msgs) <= idx {
2131+
return
2132+
}
2133+
c.executor.Session().Replace(msgs[:idx])
2134+
}
2135+
21192136
func (c *Controller) snapshotActivityIfChanged(startMessages int) {
21202137
if c.messageCount() <= startMessages {
21212138
return

internal/control/turn_orchestrator.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package control
22

33
import (
44
"context"
5+
"errors"
56

67
"reasonix/internal/agent"
78
"reasonix/internal/jobs"
@@ -50,6 +51,16 @@ func (o *turnOrchestrator) runTurnWithRawDisplay(ctx context.Context, input, raw
5051
defer func() { c.hooks.Stop(context.Background(), lastAssistantText(c.History()), turn) }()
5152
}
5253
if err := c.runner.Run(ctx, input); err != nil {
54+
// When the user explicitly cancels (Ctrl+C), the incomplete turn's
55+
// assistant messages and tool results are already saved to the
56+
// session. If they stay, the next turn's model sees leftover
57+
// in-progress todo items and partial tool calls and may re-execute
58+
// the interrupted work (the next user message looks like a
59+
// continuation instead of a fresh task). Strip the turn so the
60+
// next prompt starts clean.
61+
if errors.Is(err, context.Canceled) && c.CancelRequested() {
62+
c.stripTurnMessagesAfter(startMessages)
63+
}
5364
return err
5465
}
5566
c.mu.Lock()
@@ -80,6 +91,9 @@ func (o *turnOrchestrator) runTurnWithRawDisplay(ctx context.Context, input, raw
8091
c.approval.setPlanAutoApprove(true)
8192
defer c.approval.setPlanAutoApprove(false)
8293
if err := c.runner.Run(ctx, c.ComposeSynthetic(planApprovedMessage)); err != nil {
94+
if errors.Is(err, context.Canceled) && c.CancelRequested() {
95+
c.stripTurnMessagesAfter(execStart)
96+
}
8397
return err
8498
}
8599
if todoArgs != "" && !c.hasTodoUpdateSince(execStart) {

internal/control/turn_orchestrator_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package control
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"os"
78
"path/filepath"
89
"strings"
@@ -301,3 +302,65 @@ func TestTurnOrchestratorStopHookCancelledContext(t *testing.T) {
301302
t.Fatalf("Stop hooks called = %d; want 1", stopCalls)
302303
}
303304
}
305+
306+
// TestTurnOrchestratorCancelStripsIncompleteTurn verifies that when the user
307+
// explicitly cancels a turn (Ctrl+C), the incomplete turn's messages are
308+
// stripped from the session so the next turn starts clean. Without this, the
309+
// model sees leftover in-progress todo items and partial tool calls and may
310+
// re-execute the interrupted work. See #5286.
311+
func TestTurnOrchestratorCancelStripsIncompleteTurn(t *testing.T) {
312+
sess := agent.NewSession("you are a helpful agent")
313+
// Pre-populate with a few messages from an earlier turn.
314+
sess.Add(provider.Message{Role: provider.RoleUser, Content: "previous work"})
315+
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
316+
preCount := len(sess.Messages)
317+
318+
// runner that simulates a cancelled turn: it adds the user message plus
319+
// some tool-call garbage the real agent would leave behind, then returns
320+
// context.Canceled.
321+
runner := &cancelStrippingRunner{
322+
session: sess,
323+
add: []provider.Message{
324+
{Role: provider.RoleAssistant, Content: "let me do that", ToolCalls: []provider.ToolCall{
325+
{ID: "c1", Name: "todo_write", Arguments: `{"todos":[{"content":"add abc","status":"in_progress"}]}`},
326+
}},
327+
{Role: provider.RoleTool, Content: "Todos updated: 1 total — 0 completed, 1 in_progress, 0 pending.", ToolCallID: "c1", Name: "todo_write"},
328+
},
329+
err: context.Canceled,
330+
}
331+
332+
c := New(Options{Runner: runner, Executor: agent.New(nil, nil, sess, agent.Options{}, event.Discard)})
333+
// Simulate a user-initiated cancel: set the cancelling flag.
334+
c.mu.Lock()
335+
c.canceling = true
336+
c.mu.Unlock()
337+
338+
o := newTurnOrchestrator(c)
339+
err := o.runTurnWithRawDisplay(context.Background(), "add config file abc", "add config file abc", "")
340+
if !errors.Is(err, context.Canceled) {
341+
t.Fatalf("expected context.Canceled, got %v", err)
342+
}
343+
344+
// The incomplete turn (user prompt + assistant + tool result) must be
345+
// stripped; the session must only contain the pre-turn messages.
346+
msgs := sess.Messages
347+
if len(msgs) != preCount {
348+
t.Fatalf("session messages after cancel = %d, want pre-turn count %d: %+v", len(msgs), preCount, msgs)
349+
}
350+
}
351+
352+
// cancelStrippingRunner adds messages to a session then returns a fixed error,
353+
// simulating an agent that was interrupted mid-turn.
354+
type cancelStrippingRunner struct {
355+
session *agent.Session
356+
add []provider.Message
357+
err error
358+
}
359+
360+
func (r *cancelStrippingRunner) Run(ctx context.Context, input string) error {
361+
r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
362+
for _, m := range r.add {
363+
r.session.Add(m)
364+
}
365+
return r.err
366+
}

0 commit comments

Comments
 (0)