Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions internal/agent/cancel_repair_behavior_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ func TestRunStreamCancelCurrentTurn(t *testing.T) {
store := NewInMemoryStore()
prov := &cancelThenSummaryProvider{}
a := NewAgent(prov, store, nil)
ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancelCause(context.Background())
events, err := a.RunStream(ctx, "s-cancel", "hi")
if err != nil {
t.Fatalf("run stream failed: %v", err)
}
time.AfterFunc(10*time.Millisecond, cancel)
time.AfterFunc(10*time.Millisecond, func() { cancel(NewUserInterrupt("esc")) })

seenCancelled := false
for ev := range events {
Expand All @@ -63,6 +63,18 @@ func TestRunStreamCancelCurrentTurn(t *testing.T) {
if last.Role != RoleUser || !last.Hidden || last.FinishReason != FinishReasonCanceled || !strings.Contains(last.Text, "<turn_aborted>") {
t.Fatalf("expected hidden interrupt marker, got: %+v", last)
}
if !strings.Contains(last.Text, "source: esc") {
t.Fatalf("expected interrupt source in marker, got: %+v", last)
}
var canceledAssistant *Message
for i := range msgs {
if msgs[i].Role == RoleAssistant && msgs[i].FinishReason == FinishReasonCanceled {
canceledAssistant = &msgs[i]
}
}
if canceledAssistant == nil || !strings.Contains(canceledAssistant.ErrorDetail, "user interrupt (source: esc)") {
t.Fatalf("expected persisted cancellation cause, got: %+v", canceledAssistant)
}
}

func TestRunStreamServiceShutdownDoesNotPersistUserInterruptMarker(t *testing.T) {
Expand Down
36 changes: 35 additions & 1 deletion internal/agent/cancellation.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,46 @@
package agent

import "errors"
import (
"errors"
"strings"
)

// ErrUserInterrupt identifies a cancellation requested for the active turn.
// Plain context.WithCancel callers remain supported and retain the historical
// interrupt behavior.
var ErrUserInterrupt = errors.New("user interrupt")

type userInterruptError struct {
source string
}

func (e userInterruptError) Error() string {
return "user interrupt (source: " + e.source + ")"
}

func (e userInterruptError) Unwrap() error {
return ErrUserInterrupt
}

// NewUserInterrupt records the UI or client path that requested cancellation
// while preserving errors.Is(err, ErrUserInterrupt) compatibility.
func NewUserInterrupt(source string) error {
source = strings.TrimSpace(source)
if source == "" {
return ErrUserInterrupt
}
return userInterruptError{source: source}
}

// UserInterruptSource extracts the source attached by NewUserInterrupt.
func UserInterruptSource(err error) string {
var tagged userInterruptError
if errors.As(err, &tagged) {
return tagged.source
}
return ""
}

// ErrServiceShutdown identifies cancellation caused by tearing down the
// service or its owning client. It must not be presented as a user interrupt.
var ErrServiceShutdown = errors.New("service shutdown")
43 changes: 43 additions & 0 deletions internal/agent/cancellation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package agent

import (
"context"
"errors"
"fmt"
"strings"
"testing"
)

func TestUserInterruptCarriesSource(t *testing.T) {
err := NewUserInterrupt(" ctrl+c ")
if !errors.Is(err, ErrUserInterrupt) {
t.Fatalf("expected user interrupt sentinel, got %v", err)
}
if got := UserInterruptSource(err); got != "ctrl+c" {
t.Fatalf("source = %q, want ctrl+c", got)
}
if got := UserInterruptSource(ErrUserInterrupt); got != "" {
t.Fatalf("sentinel source = %q, want empty", got)
}
}

func TestCancellationErrorDetailIncludesContextCause(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(NewUserInterrupt("esc"))
err := fmt.Errorf("request failed: %w", context.Canceled)

got := cancellationErrorDetail(ctx, err)
for _, want := range []string{"request failed: context canceled", "cancel cause: user interrupt (source: esc)"} {
if !strings.Contains(got, want) {
t.Fatalf("detail %q does not contain %q", got, want)
}
}
}

func TestCancellationErrorDetailLeavesPlainCancelStable(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if got := cancellationErrorDetail(ctx, context.Canceled); got != "context canceled" {
t.Fatalf("detail = %q, want context canceled", got)
}
}
13 changes: 11 additions & 2 deletions internal/agent/interrupt_marker.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,33 @@ package agent

import (
"context"
"strings"

"github.com/usewhale/whale/internal/core"
)

const interruptedTurnMarkerText = "<turn_aborted>\nThe user interrupted the previous turn on purpose. Any running tools or commands may have partially executed; verify current state before retrying.\n</turn_aborted>"

func interruptedTurnMarkerTextForContext(ctx context.Context) string {
source := strings.TrimSpace(UserInterruptSource(context.Cause(ctx)))
if source == "" {
return interruptedTurnMarkerText
}
return "<turn_aborted>\nsource: " + source + "\nThe user interrupted the previous turn on purpose. Any running tools or commands may have partially executed; verify current state before retrying.\n</turn_aborted>"
}

func approvalDeniedMarkerText(toolName string) string {
if toolName == "" {
toolName = "unknown"
}
return "<approval_denied>\nThe user denied a requested tool/action (tool: " + toolName + "). Treat the related task path as canceled. Do not retry, continue, or switch to another tool to bypass the denied action unless the user explicitly asks. If the user asks again, use the normal approval flow for the same capability instead of probing alternative tools that are known to be out of scope.\n</approval_denied>"
}

func (a *Agent) persistInterruptedTurnMarker(sessionID string) {
func (a *Agent) persistInterruptedTurnMarker(ctx context.Context, sessionID string) {
_, _ = a.store.Create(context.Background(), core.Message{
SessionID: sessionID,
Role: core.RoleUser,
Text: interruptedTurnMarkerText,
Text: interruptedTurnMarkerTextForContext(ctx),
Hidden: true,
FinishReason: core.FinishReasonCanceled,
})
Expand Down
7 changes: 7 additions & 0 deletions internal/agent/premature_end_turn.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ var prematureActionPrefixes = []string{
"now ",
"next ",
"then ",
"finally ",
"continue ",
"start ",
"retry ",
Expand All @@ -49,8 +50,14 @@ var prematureActionPrefixes = []string{
"先",
"现在",
"接下来",
"下一步",
"然后",
"再",
"最后是",
"最后一步",
"立即",
"马上",
"随后",
"继续",
"开始",
"重新",
Expand Down
5 changes: 5 additions & 0 deletions internal/agent/premature_end_turn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,14 @@ func TestShouldRecoverPrematureEndTurn(t *testing.T) {
{name: "observed workflow write lead-in", text: "项目已存在(mightty.pages.dev)。先写 workflow:", mode: session.ModeAgent, toolsAvailable: true, want: true},
{name: "observed rerun lead-in", text: "原始 formula 的 URL 不需要改。重新验证只改 version + sha256:", mode: session.ModeAgent, toolsAvailable: true, want: true},
{name: "observed retrigger lead-in", text: "权限已经修正。重新触发 release-please workflow 验证:", mode: session.ModeAgent, toolsAvailable: true, want: true},
{name: "observed final picker lead-in", text: "最后是 session picker 的循环:", mode: session.ModeAgent, toolsAvailable: true, want: true},
{name: "observed immediate merge lead-in", text: "CI 已通过,但 merge 没执行成功。立即用正确命令合并:", mode: session.ModeAgent, toolsAvailable: true, want: true},
{name: "chinese next step lead-in", text: "下一步检查 CI:", mode: session.ModeAgent, toolsAvailable: true, want: true},
{name: "english action lead-in", text: "The config is present. Now verify the workflow:", mode: session.ModeAgent, toolsAvailable: true, want: true},
{name: "english final action lead-in", text: "The checks passed. Finally merge the pull request:", mode: session.ModeAgent, toolsAvailable: true, want: true},
{name: "complete answer", text: "The workflow is valid.", mode: session.ModeAgent, toolsAvailable: true},
{name: "ordinary heading", text: "Details:", mode: session.ModeAgent, toolsAvailable: true},
{name: "ordinary final heading", text: "最后:", mode: session.ModeAgent, toolsAvailable: true},
{name: "user choice prompt", text: "请选择:", mode: session.ModeAgent, toolsAvailable: true},
{name: "plan reply", text: "接下来执行:", mode: session.ModePlan, toolsAvailable: true},
{name: "ask reply", text: "现在检查:", mode: session.ModeAsk, toolsAvailable: true},
Expand Down
17 changes: 16 additions & 1 deletion internal/agent/stream_ingest.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ func (a *Agent) collectAssistantStream(ctx context.Context, sessionID string, rt
} else {
assistant.FinishReason = core.FinishReasonError
}
assistant.ErrorDetail = ev.Err.Error()
assistant.ErrorDetail = cancellationErrorDetail(ctx, ev.Err)
assistant.ToolCalls = nil
a.bestEffortUpdateAssistant(assistant)
return core.Message{}, llm.Usage{}, "", nil, ev.Err
Expand Down Expand Up @@ -212,6 +212,21 @@ func (a *Agent) collectAssistantStream(ctx context.Context, sessionID string, rt
return assistant, lastUsage, lastModel, cacheShape, nil
}

func cancellationErrorDetail(ctx context.Context, err error) string {
if err == nil {
return ""
}
detail := err.Error()
if !errors.Is(err, context.Canceled) {
return detail
}
cause := context.Cause(ctx)
if cause == nil || cause == context.Canceled {
return detail
}
return detail + " (cancel cause: " + cause.Error() + ")"
}

// messageUsageFrom converts provider usage into the persisted form,
// returning nil when the provider reported nothing.
func messageUsageFrom(u llm.Usage) *core.MessageUsage {
Expand Down
4 changes: 2 additions & 2 deletions internal/agent/turn_loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ func (a *Agent) runStreamWithNewMessages(ctx context.Context, sessionID string,
if sErr != nil {
if errors.Is(sErr, context.Canceled) {
if !isServiceShutdown(ctx) {
a.persistInterruptedTurnMarker(sessionID)
a.persistInterruptedTurnMarker(ctx, sessionID)
}
emit(AgentEvent{Type: AgentEventTypeTurnCancelled, Content: "turn cancelled"})
return
Expand Down Expand Up @@ -249,7 +249,7 @@ func (a *Agent) runStreamWithNewMessages(ctx context.Context, sessionID string,
if ctx.Err() != nil {
if errors.Is(ctx.Err(), context.Canceled) {
if !isServiceShutdown(ctx) {
a.persistInterruptedTurnMarker(sessionID)
a.persistInterruptedTurnMarker(ctx, sessionID)
}
return
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/service/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func (s *Service) Dispatch(in Intent) {
case IntentShutdown:
s.cancelMu.Lock()
if s.cancelCause != nil {
s.cancelCause(agent.ErrUserInterrupt)
s.cancelCause(agent.NewUserInterrupt(in.InterruptSource))
} else if s.cancel != nil {
s.cancel()
}
Expand Down
27 changes: 27 additions & 0 deletions internal/app/service/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -1296,6 +1297,32 @@ func TestShutdownCancelsPendingInteractions(t *testing.T) {
}
}

func TestShutdownPreservesInterruptSource(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
turnCtx, turnCancel := context.WithCancelCause(ctx)
svc := &Service{
ctx: ctx,
events: make(chan Event, 1),
cancelCause: turnCancel,
}

svc.Dispatch(Intent{Kind: IntentShutdown, InterruptSource: "esc"})

select {
case <-turnCtx.Done():
case <-time.After(2 * time.Second):
t.Fatal("shutdown did not cancel active turn")
}
cause := context.Cause(turnCtx)
if !errors.Is(cause, agent.ErrUserInterrupt) {
t.Fatalf("cause = %v, want user interrupt", cause)
}
if got := agent.UserInterruptSource(cause); got != "esc" {
t.Fatalf("source = %q, want esc", got)
}
}

func TestShutdownRejectsLateInteractions(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
Expand Down
1 change: 1 addition & 0 deletions internal/app/service/protocol_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
func (s *Service) DispatchProtocol(in protocol.Intent) {
s.Dispatch(Intent{
Kind: serviceIntentKind(in.Kind),
InterruptSource: in.InterruptSource,
Input: in.Input,
ClientInputID: in.ClientInputID,
HiddenInput: in.HiddenInput,
Expand Down
1 change: 1 addition & 0 deletions internal/app/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const (

type Intent struct {
Kind IntentKind
InterruptSource string
Input string
ClientInputID string
HiddenInput bool
Expand Down
1 change: 1 addition & 0 deletions internal/runtime/protocol/intents.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const (

type Intent struct {
Kind IntentKind `json:"kind"`
InterruptSource string `json:"interrupt_source,omitempty"`
Input string `json:"input,omitempty"`
ClientInputID string `json:"client_input_id,omitempty"`
HiddenInput bool `json:"hidden_input,omitempty"`
Expand Down
6 changes: 3 additions & 3 deletions internal/tui/model_busy_queue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ func TestEscWithQueuedPromptSubmitsItAfterInterrupt(t *testing.T) {
if !strings.Contains(renderedAfterEsc, "Interrupted to submit queued follow-up") {
t.Fatalf("queued Esc interrupt should show queued follow-up notice:\n%s", renderedAfterEsc)
}
if len(*intents) != 1 || (*intents)[0].Kind != protocol.IntentShutdown {
if len(*intents) != 1 || (*intents)[0].Kind != protocol.IntentShutdown || (*intents)[0].InterruptSource != "esc" {
t.Fatalf("expected shutdown intent from Esc, got %+v", *intents)
}

Expand Down Expand Up @@ -612,7 +612,7 @@ func TestEscWithDraftQueuesAndSubmitsItAfterInterrupt(t *testing.T) {
if !m.submitQueuedPromptAfterInterrupt {
t.Fatal("expected Esc with draft to queue and request immediate submit")
}
if len(*intents) != 1 || (*intents)[0].Kind != protocol.IntentShutdown {
if len(*intents) != 1 || (*intents)[0].Kind != protocol.IntentShutdown || (*intents)[0].InterruptSource != "esc" {
t.Fatalf("expected shutdown intent from Esc, got %+v", *intents)
}

Expand Down Expand Up @@ -675,7 +675,7 @@ func TestCtrlCWhileBusyInterruptsWithoutArmingQuit(t *testing.T) {
if !m.quitArmedUntil.IsZero() {
t.Fatal("expected ctrl+c while busy not to arm quit")
}
if len(*intents) != 1 || (*intents)[0].Kind != protocol.IntentShutdown {
if len(*intents) != 1 || (*intents)[0].Kind != protocol.IntentShutdown || (*intents)[0].InterruptSource != "ctrl+c" {
t.Fatalf("expected ctrl+c while busy to dispatch shutdown intent, got %+v", *intents)
}

Expand Down
2 changes: 1 addition & 1 deletion internal/tui/model_event_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,5 +660,5 @@ func (m *model) handleSessionHydratedEvent(ev protocol.Event) tea.Cmd {

func (m *model) handleExitRequestedEvent() {
m.clearProviderRetryStatus()
m.dispatchIntent(protocol.Intent{Kind: protocol.IntentShutdown})
m.dispatchIntent(protocol.Intent{Kind: protocol.IntentShutdown, InterruptSource: "exit_requested"})
}
8 changes: 4 additions & 4 deletions internal/tui/model_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func (m *model) handleKeyMsg(msg tea.KeyMsg) (tea.Cmd, bool, bool) {
}
if m.mode == modeChat && m.page == pageDiff {
if msg.String() == "ctrl+c" && m.busy {
return m.interruptBusyTurn(), false, true
return m.interruptBusyTurn("ctrl+c"), false, true
}
if msg.String() == "ctrl+c" {
return m.handleGlobalKey(msg)
Expand Down Expand Up @@ -67,7 +67,7 @@ func (m *model) handleKeyMsg(msg tea.KeyMsg) (tea.Cmd, bool, bool) {
// textarea, so a Ctrl+C arriving inside that window would otherwise
// see an empty textarea and incorrectly interrupt the running turn.
if msg.String() == "ctrl+c" && m.busy && (m.mode != modeChat || (m.input.Value() == "" && !m.hasWindowsPasteBuffer())) {
return m.interruptBusyTurn(), false, true
return m.interruptBusyTurn("ctrl+c"), false, true
}
if m.mode == modeChat {
if cmd, handled := m.handleWindowsPasteFallbackKey(msg); handled {
Expand Down Expand Up @@ -141,7 +141,7 @@ func (m model) shouldRouteWindowsPasteFallbackBeforeLayout(msg tea.KeyMsg) bool
len(msg.Runes) > 0
}

func (m *model) interruptBusyTurn() tea.Cmd {
func (m *model) interruptBusyTurn(source string) tea.Cmd {
alreadyStopping := m.stopping
m.cancelBlockingModalForInterrupt(!alreadyStopping)
if alreadyStopping {
Expand Down Expand Up @@ -173,7 +173,7 @@ func (m *model) interruptBusyTurn() tea.Cmd {
m.stoppingInterruptCount = 0
m.quitArmedUntil = time.Time{}
if m.runtime != nil {
m.dispatchIntent(protocol.Intent{Kind: protocol.IntentShutdown})
m.dispatchIntent(protocol.Intent{Kind: protocol.IntentShutdown, InterruptSource: source})
}
m.status = "stopping"
m.stopping = true
Expand Down
Loading
Loading