From dabae7f72bac70526fe94c43de571377f1ffab20 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Sun, 26 Apr 2026 13:36:43 +0200 Subject: [PATCH] agent: preserve session on interrupt only when state is clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep session state after an interrupt if the last message is a clean assistant text response (no dangling tool calls). Roll back to pre-turn snapshot if the history is inconsistent — mid-tool-call interrupts would send malformed history to the backend on the next turn. Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/core.go | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/pkg/agent/core.go b/pkg/agent/core.go index 55cc892..712a684 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -855,16 +855,19 @@ func (s *agent) executeTurn(ctx context.Context, input string, handler EventHand if err != nil { interrupted := errors.Is(err, context.Canceled) || errors.Is(err, ErrInterrupted) + if interrupted && s.session != nil && sessionClean(s.session.messages) { + // Interrupt with clean state: keep whatever was built during the turn. + return "" + } + // Real error or dirty session (dangling tool calls): restore pre-turn state. + if snapSession == nil { + s.session = nil + } else { + s.session.messages = snapMessages + } if !interrupted { - // Real error: restore pre-turn state so the session is clean for retry. - if snapSession == nil { - s.session = nil - } else { - s.session.messages = snapMessages - } handler(Event{Role: "error", Content: err.Error()}) } - // Interrupt: keep whatever session state was built during the turn. return "" } @@ -900,6 +903,18 @@ func (s *agent) executeTurn(ctx context.Context, input string, handler EventHand return "" } +// sessionClean reports whether the message history is in a consistent state +// that is safe to resume from. An interrupted turn may leave dangling tool +// calls (assistant message with ToolCalls but no following tool results) or +// end on a tool result with no subsequent assistant message — both are unsafe. +func sessionClean(msgs []backend.Message) bool { + if len(msgs) == 0 { + return true + } + last := msgs[len(msgs)-1] + return last.Role == "assistant" && len(last.ToolCalls) == 0 +} + func toolInfosToBackend(infos []tools.ToolInfo) []backend.Tool { out := make([]backend.Tool, len(infos)) for i, t := range infos {