diff --git a/internal/agent/intent_classify.go b/internal/agent/intent_classify.go index 675a8ff7..2fd4e660 100644 --- a/internal/agent/intent_classify.go +++ b/internal/agent/intent_classify.go @@ -40,6 +40,23 @@ var cancelKeywords = []string{ "nevermind", "never mind", } +// exactCancelKeywords is a set of keywords that trigger immediate abort when +// sent as the entire message (exact match, case-insensitive, trimmed). +// Used by chat.send to auto-abort when user sends "stop" during an active run. +var exactCancelKeywords = map[string]bool{ + "stop": true, "cancel": true, "abort": true, + "thôi": true, "dừng": true, "hủy": true, + "取消": true, "停": true, + "nevermind": true, "never mind": true, +} + +// IsExactCancelKeyword returns true if the message is an exact cancel keyword +// (case-insensitive, whitespace-trimmed). Used by chat.send to auto-abort +// running agent loops when user explicitly sends a stop command. +func IsExactCancelKeyword(msg string) bool { + return exactCancelKeywords[strings.ToLower(strings.TrimSpace(msg))] +} + // quickClassify attempts keyword-based classification for ultra-short messages // before calling the LLM. Only messages ≤ 15 runes are fast-pathed; longer // messages always go to LLM for proper context understanding. diff --git a/internal/agent/intent_classify_test.go b/internal/agent/intent_classify_test.go index 7676827e..761e990f 100644 --- a/internal/agent/intent_classify_test.go +++ b/internal/agent/intent_classify_test.go @@ -227,3 +227,59 @@ func TestFormatStatusReply_WithStatus(t *testing.T) { t.Error("expected non-empty reply with status") } } + +// ─── IsExactCancelKeyword ───────────────────────────────────────────────── + +func TestIsExactCancelKeyword_ExactMatches(t *testing.T) { + cases := []string{"stop", "cancel", "abort", "thôi", "dừng", "hủy", "取消", "停", "nevermind", "never mind"} + for _, kw := range cases { + t.Run(kw, func(t *testing.T) { + if !IsExactCancelKeyword(kw) { + t.Errorf("%q should be recognized as cancel keyword", kw) + } + }) + } +} + +func TestIsExactCancelKeyword_CaseInsensitive(t *testing.T) { + cases := []string{"STOP", "Stop", "CANCEL", "Cancel", "ABORT", "Abort"} + for _, kw := range cases { + t.Run(kw, func(t *testing.T) { + if !IsExactCancelKeyword(kw) { + t.Errorf("%q should be recognized (case-insensitive)", kw) + } + }) + } +} + +func TestIsExactCancelKeyword_WithWhitespace(t *testing.T) { + cases := []string{" stop ", "\tstop\n", " cancel "} + for _, kw := range cases { + t.Run(kw, func(t *testing.T) { + if !IsExactCancelKeyword(kw) { + t.Errorf("%q should be recognized after trimming", kw) + } + }) + } +} + +func TestIsExactCancelKeyword_NonMatches(t *testing.T) { + cases := []string{ + "stop now", // not exact + "please stop", // not exact + "nonstop", // embedded + "stop it", // not exact + "cancel the order", // not exact + "don't stop", // not exact + "", // empty + "hello", // unrelated + "làm đơn giản thôi", // contains "thôi" but not exact + } + for _, msg := range cases { + t.Run(msg, func(t *testing.T) { + if IsExactCancelKeyword(msg) { + t.Errorf("%q should NOT be recognized as cancel keyword", msg) + } + }) + } +} diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go index e3146bc6..421d055b 100644 --- a/internal/gateway/methods/chat.go +++ b/internal/gateway/methods/chat.go @@ -195,6 +195,23 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re // Mid-run injection: if session already has an active run, inject the message // into the running loop instead of starting a new concurrent run. if m.agents.IsSessionBusy(sessionKey) { + // Exact cancel keyword detection: auto-abort when user sends "stop", "cancel", etc. + if agent.IsExactCancelKeyword(params.Message) { + results := m.agents.AbortRunsForSession(sessionKey) + aborted := false + for _, r := range results { + if r.Stopped || r.Forced { + aborted = true + break + } + } + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ + "cancelled": true, + "aborted": aborted, + })) + return + } + injected := m.agents.InjectMessage(sessionKey, agent.InjectedMessage{ Content: params.Message, UserID: userID,