Files
ARTEX/agent/terminalreason_test.go
T
Autumn-27 a6bd18050c feat(agent): 诊断 run 取消/终止原因,取代笼统的中断提示
- 新增 AbortCause(cancelcause.go):各取消点用 WithCancelCause/WithTimeoutCause
  附上原因(暂停/删除/kill_work/目标达成/drain 兜底/聊天停止/进程关闭/硬超时…),
  captureRun 用 context.Cause 还原出到底是谁、为何取消。
- 新增 terminalreason.go:把终端状态转成可读的一行摘要 + 多行详情(取消原因、
  终止 reason、轮数、卡住的工具),替换原来固定那句猜测式提示。
- engine/worker/planner/capture 及 conversations/orchestration/server 接入;
  cmd/artex/main.go、前端 llm/main-content 相应调整。
- 配套测试 terminalreason_test.go / engine_cancelcause_test.go。
2026-08-19 09:20:10 -04:00

173 lines
6.1 KiB
Go

package agent
import (
"context"
"errors"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/Autumn-27/norma/harness"
"github.com/Autumn-27/norma/llm"
)
// every TerminalReason the harness can emit must have an explanation — otherwise
// a run silently falls back to "未知终态", which is the bug this file exists to fix.
func TestReasonHintCoversEveryTerminalReason(t *testing.T) {
all := []harness.TerminalReason{
harness.ReasonCompleted, harness.ReasonBlockingLimit, harness.ReasonImageError,
harness.ReasonModelError, harness.ReasonAbortedStreaming, harness.ReasonAbortedTools,
harness.ReasonPromptTooLong, harness.ReasonStopHookPrevented, harness.ReasonHookStopped,
harness.ReasonMaxTurns, harness.ReasonTimeout,
}
for _, r := range all {
if strings.TrimSpace(reasonHint[r]) == "" {
t.Errorf("terminal reason %q 没有中文说明", r)
}
}
}
// the cause attached at cancel time must survive the ctx chain the engine builds:
// task exec ctx -> per-work child -> value ctx -> the run's hard-timeout backstop.
func TestAbortCausePropagatesThroughRunContextChain(t *testing.T) {
type key struct{}
exec, cancelExec := context.WithCancelCause(context.Background())
work, cancelWork := context.WithCancelCause(exec)
defer cancelWork(nil)
valued := context.WithValue(work, key{}, "task-1")
run, cancelRun := context.WithTimeoutCause(valued, time.Hour, AbortRunHardTimeout)
defer cancelRun()
cancelExec(AbortPausedByUser)
code, _, text, ok := AbortReason(run)
if !ok || code != "paused_by_user" {
t.Fatalf("code=%q ok=%v,期望 paused_by_user", code, ok)
}
if !strings.Contains(text, "退回 frontier") {
t.Errorf("原因文案没传下来: %q", text)
}
}
func TestAbortReasonFallbacks(t *testing.T) {
if _, _, _, ok := AbortReason(context.Background()); ok {
t.Error("未取消的 ctx 不应报出中断原因")
}
// a cancel site that forgot to attach a cause must be called out, not guessed at
plain, cancel := context.WithCancel(context.Background())
cancel()
code, _, _, ok := AbortReason(plain)
if !ok || code != "canceled_no_cause" {
t.Errorf("code=%q ok=%v,期望 canceled_no_cause", code, ok)
}
timed, cancelT := context.WithTimeout(context.Background(), time.Nanosecond)
defer cancelT()
<-timed.Done()
if code, _, _, _ := AbortReason(timed); code != "deadline_exceeded" {
t.Errorf("code=%q,期望 deadline_exceeded", code)
}
}
// an aborted run's one-line summary must name the actual cause, and the detail
// must pin down the tool that never came back.
func TestTerminalTextAbortedNamesCauseAndHangingTool(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(AbortKilledByPlanner)
tr := &runTrace{startedAt: time.Now().Add(-90 * time.Second)}
tr.start("tu_1", "Bash", `{"command":"nmap -p- 10.0.0.1"}`)
term := &harness.Terminal{
Reason: harness.ReasonAbortedTools,
Err: context.Canceled,
Turns: 7,
Usage: llm.Usage{InputTokens: 1200, OutputTokens: 340},
}
sum, detail := terminalText(ctx, term, tr)
if !strings.Contains(sum, "kill_work") {
t.Errorf("摘要没点出取消方: %q", sum)
}
if strings.Contains(sum, "\n") {
t.Errorf("摘要必须是单行(前端按首行截断): %q", sum)
}
for _, want := range []string{"killed_by_planner", "aborted_tools", "7 轮", "Bash", "未返回结果", "1200"} {
if !strings.Contains(detail, want) {
t.Errorf("详情缺少 %q:\n%s", want, detail)
}
}
}
// a tool that DID return must not be reported as the hang site.
func TestTerminalTextCompletedToolNotBlamed(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(AbortShutdown)
tr := &runTrace{startedAt: time.Now()}
tr.start("tu_1", "Read", `{"path":"/etc/hosts"}`)
tr.done("tu_1")
_, detail := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonAbortedStreaming}, tr)
if strings.Contains(detail, "未返回结果") {
t.Errorf("已返回的工具被误报为卡住:\n%s", detail)
}
if !strings.Contains(detail, "已正常返回") {
t.Errorf("详情没记录最后一个工具:\n%s", detail)
}
}
// non-abort terminals keep their own explanation instead of the interruption text.
func TestTerminalTextNonAbortReasons(t *testing.T) {
ctx := context.Background()
tr := &runTrace{startedAt: time.Now()}
sum, _ := terminalText(ctx, &harness.Terminal{Reason: harness.ReasonMaxTurns}, tr)
if !strings.Contains(sum, "达运行预算上限") || strings.Contains(sum, "中断") {
t.Errorf("max_turns 摘要不对: %q", sum)
}
sum, detail := terminalText(ctx, &harness.Terminal{
Reason: harness.ReasonModelError, Err: errors.New("429 rate limited")}, tr)
if !strings.Contains(sum, "model_error") {
t.Errorf("model_error 摘要不对: %q", sum)
}
if !strings.Contains(detail, "429 rate limited") {
t.Errorf("详情没带上底层错误:\n%s", detail)
}
}
// every enumerated cause must carry both a compact headline (for the collapsed
// row) and a full explanation (for the expanded detail).
func TestAbortCausesAreWellFormed(t *testing.T) {
all := []*AbortCause{
AbortPausedByUser, AbortPausedByOrchestrator, AbortTaskDeleted, AbortPausedOnReload,
AbortGoalMet, AbortSettleDrainTimeout, AbortKilledByPlanner, AbortWorkFinished,
AbortPausedRaceGuard, AbortChatStoppedByUser, AbortChatTurnFinished,
AbortShutdown, AbortRunHardTimeout,
}
seen := map[string]bool{}
for _, c := range all {
switch {
case c.Code == "" || seen[c.Code]:
t.Errorf("code 缺失或重复: %q", c.Code)
case c.Short == "" || utf8.RuneCountInString(c.Short) > 40:
t.Errorf("%s: Short 必须存在且 ≤40 字(进折叠摘要行): %q", c.Code, c.Short)
case utf8.RuneCountInString(c.Text) <= utf8.RuneCountInString(c.Short):
t.Errorf("%s: Text 应比 Short 更完整", c.Code)
}
seen[c.Code] = true
}
}
// summaries are mostly 中文; capping by bytes used to slice a rune in half.
func TestFirstLineCapsByRuneNotByte(t *testing.T) {
got := firstLine(strings.Repeat("恢复", 10), 5)
if got != "恢复恢复恢…" {
t.Errorf("firstLine 按字节截断了: %q", got)
}
if got := firstLine("头\n尾", 100); got != "头" {
t.Errorf("多行没截到首行: %q", got)
}
}