Skip to content

Commit 7056884

Browse files
authored
Merge pull request #5303 from myipanta/fix/5286-interrupted-task-re-execution
fix(control): strip incomplete turn messages after user cancel (#5286)
2 parents 91395b7 + 65f18ea commit 7056884

4 files changed

Lines changed: 192 additions & 0 deletions

File tree

internal/agent/agent.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,6 +1086,13 @@ func (a *Agent) emitTodoState(todos []evidence.TodoItem, itemIndex int) {
10861086
a.sink.Emit(event.Event{Kind: event.ToolResult, Tool: t})
10871087
}
10881088

1089+
// RebuildTodoState re-derives canonical task state from the current session
1090+
// transcript. Call after externally truncating the session (e.g. after a
1091+
// user-cancel strip) so Agent.todoState stays consistent with the messages.
1092+
func (a *Agent) RebuildTodoState() {
1093+
a.rebuildTodoState(a.Session().Snapshot())
1094+
}
1095+
10891096
// rebuildTodoState reconstructs the canonical task list from a transcript: the
10901097
// latest successful todo_write is the base, then every complete_step after it
10911098
// advances an item. Deterministic from persisted messages, so it survives a

internal/control/controller.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2116,6 +2116,41 @@ 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+
// Rebuild canonical todo state from the truncated transcript so
2135+
// Controller.Todos(), goal readiness, and the task panel no longer see
2136+
// the in_progress items written by the cancelled turn.
2137+
c.executor.RebuildTodoState()
2138+
// The mid-turn autosave may have already written a partial transcript to
2139+
// disk. snapshotActivityIfChanged skips the write when messageCount()
2140+
// returns to startMessages, so force a flush here to overwrite the stale
2141+
// file. We call Session.Save directly to cover the edge case where the
2142+
// strip leaves only a system message (HasContent() == false), which would
2143+
// cause snapshot() to return early without writing.
2144+
c.mu.Lock()
2145+
path := c.sessionPath
2146+
c.mu.Unlock()
2147+
if path != "" {
2148+
if err := c.executor.Session().Save(path); err != nil {
2149+
slog.Warn("controller: post-cancel transcript flush", "err", err)
2150+
}
2151+
}
2152+
}
2153+
21192154
func (c *Controller) snapshotActivityIfChanged(startMessages int) {
21202155
if c.messageCount() <= startMessages {
21212156
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: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ package control
33
import (
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

Comments
 (0)