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。
This commit is contained in:
Autumn-27 committed 2026-08-19 09:20:10 -04:00
1 parent 8e9b126972
commit a6bd18050c
16 files changed
+624 -60

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
package agent
import (
"context"
"errors"
"fmt"
)
// AbortCause names WHY a planner/worker run's context was cancelled.
//
// Background: when a run's ctx is cancelled mid-flight the harness finishes with
// ReasonAbortedStreaming / ReasonAbortedTools and Terminal.Err = context.Canceled.
// ARTEX cancels that ctx from a dozen different places (pause, delete, kill_work,
// goal-met, settle drain backstop, chat stop, process shutdown, hard run timeout,
// …) but by the time it reaches captureRun they all collapse into one bare
// context.Canceled — so the trace could only print a guess-list, not a diagnosis.
//
// Fix: every cancel site uses context.WithCancelCause / WithTimeoutCause and
// attaches one of the causes below; captureRun recovers it via context.Cause and
// prints exactly who cancelled the run, why, and what happens to the intent.
//
// When you add a new cancel site, add its cause HERE — a site that cancels
// without one shows up in the trace as "canceled_no_cause", by design.
type AbortCause struct {
Code string // stable machine-readable id (logs / future filtering)
Short string // 一句话,进折叠摘要行
Text string // 完整说明:谁取消的、为什么、这条意图接下来会怎样
}
func (c *AbortCause) Error() string { return c.Text }
func cause(code, short, text string) *AbortCause {
return &AbortCause{Code: code, Short: short, Text: text}
}
// Causef builds an ad-hoc cause with runtime detail (e.g. which intent, which
// deadline). Use the predeclared vars below when there is nothing dynamic to add.
func Causef(code, short, format string, args ...any) *AbortCause {
return &AbortCause{Code: code, Short: short, Text: fmt.Sprintf(format, args...)}
}
// The full enumeration of cancel sites, grouped by which context gets cancelled.
var (
// ---- 任务级 exec ctx(Engine.cancelExec:取消该任务在跑的 planner + 全部 worker)----
AbortPausedByUser = cause("paused_by_user", "用户暂停了任务",
"用户暂停了任务(前端「暂停」按钮 / POST /api/tasks/{id}/state action=pause)。本次运行被主动取消,该意图已退回 frontier(open),恢复任务后会被重新领取、从头重跑")
AbortPausedByOrchestrator = cause("paused_by_orchestrator", "编排 agent 调用 pause_task 暂停了任务",
"编排 agent 调用了 pause_task 工具暂停本任务。本次运行被主动取消,该意图已退回 frontier(open),恢复后重跑")
AbortTaskDeleted = cause("task_deleted", "任务被删除",
"任务被删除(DELETE /api/tasks/{id})——删除前先取消在跑的 planner/worker。本次运行的结果不会再被使用")
AbortPausedOnReload = cause("paused_on_reload", "后端启动时恢复了「已暂停」状态",
"后端启动时按库里持久化的「已暂停」状态恢复暂停。本次运行被取消(正常情况下此时并没有在跑的 run)")
AbortGoalMet = cause("goal_met", "规划者判定目标已达成,任务落终态 done",
"规划者判定任务目标已达成、任务落终态 done,随即取消所有在跑的 worker——它们手头的意图跑完也没意义了。该意图标记为 stopped,不是失败")
AbortSettleDrainTimeout = cause("settle_drain_timeout", "任务级超时收尾:drain 宽限用尽后强制取消",
"任务级超时收尾的硬兜底:到达任务 timeout 后等在跑 worker 优雅收尾,超过 90s drain 宽限仍未结束 → 强制取消。该意图标记为 exhausted,收尾阶段通常已把事实/资产写回")
// ---- 单个 work 的 ctx(Engine.workCancel[intentID])----
AbortKilledByPlanner = cause("killed_by_planner", "规划者用 kill_work 终止了这条意图",
"规划者调用 kill_work 主动终止了这条意图(判定方向跑偏/无意义,止损)。该意图标记为 stopped,不会被自动重领")
AbortWorkFinished = cause("work_finished", "work 已正常结束,引擎释放其 context",
"work 已正常结束,引擎在 unregisterWork 里释放其 context 资源。这不是中断——若它出现在中断消息里,说明取消与收场事件存在竞态")
AbortPausedRaceGuard = cause("paused_race_guard", "任务处于暂停态,引擎拒绝启动新 run",
"任务处于暂停态时请求执行上下文,引擎直接返回一个已取消的 context(守卫 claim→Execute 的竞态,避免暂停后仍启动新 run)。该意图会退回 frontier")
// ---- 对话 / 主 Agent 的单轮 ctx(Server.chatCancel[busyKey])----
AbortChatStoppedByUser = cause("chat_stopped_by_user", "用户点了「停止」中止本轮对话",
"用户点了「停止」,主动中止本轮对话运行(POST .../chat/stop 或 .../conversations/{id}/stop)。已产生的步骤都保留,可以直接发下一条消息")
AbortChatTurnFinished = cause("chat_turn_finished", "本轮对话已正常结束,服务端释放其 context",
"本轮对话已正常结束,服务端在 defer 里释放其 context 资源。这不是中断——若它出现在中断消息里,说明取消与收场事件存在竞态")
// ---- 进程级 ----
AbortShutdown = cause("shutdown", "后端进程正在关停(重启 / 更新 / Ctrl-C)",
"后端进程收到 SIGINT/SIGTERM 正在关停(重启 / 更新 / 手动 Ctrl-C)。所有在跑的 planner/worker 一并取消;重启后残留的 running 意图会被重置为 open 重跑")
// ---- 单次 run 级(worker.Execute / planner.Plan 的硬兜底)----
AbortRunHardTimeout = cause("run_hard_timeout", "单次运行的硬超时兜底触发:某一步卡死了",
"单次运行的硬超时兜底被触发:正常情况下软墙钟预算(MaxDuration)会在回合边界优雅收尾,只有当某一步(模型请求或某个工具)自身卡死、久久不返回、导致回合边界检查根本跑不到时,才会走到「软预算 + 90s 宽限」的硬 cancel。请重点看中断前最后一个 tool_use——多半是它挂住了")
)
// AbortReason resolves the cause attached to a cancelled run context. ok=false
// means the ctx carries no cause at all (nothing was cancelled).
func AbortReason(ctx context.Context) (code, short, text string, ok bool) {
c := context.Cause(ctx)
if c == nil {
return "", "", "", false
}
if ac, ok := errors.AsType[*AbortCause](c); ok {
return ac.Code, ac.Short, ac.Text, true
}
switch {
case errors.Is(c, context.DeadlineExceeded):
return "deadline_exceeded", "上游 context 到达 deadline(未挂具名原因)",
"上游 context 到达 deadline,但设置方没有用 WithTimeoutCause 挂具名原因: " + c.Error(), true
case errors.Is(c, context.Canceled):
// a plain Canceled means some cancel site still lacks a cause — say so
// plainly instead of inventing one.
return "canceled_no_cause", "取消方未挂具名原因(漏网的取消点)",
"上游 context 被取消,但取消方没有用 context.WithCancelCause 挂上具名原因。" +
"这属于漏网的取消点——请在 agent/cancelcause.go 补一条枚举并接到该取消处", true
}
return "other", firstLine(c.Error(), 80), c.Error(), true
}
+20 -21
View File
@@ -3,6 +3,8 @@ package agent
import (
"context"
"strings"
"time"
"unicode/utf8"
"github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/harness"
@@ -64,13 +66,18 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
tbuf.WriteString(text)
}
// breadcrumbs for the terminal message: when a run is aborted or times out,
// "which tool was still running, for how long" is the whole diagnosis.
lastTool := &runTrace{startedAt: time.Now()}
var finalText string
var rerr error
for ev, err := range s.Prompt(ctx, input) {
if err != nil {
flush()
if ctx.Err() != nil { // ctx cancelled = manual stop, not a failure
rec(db.Activity{Kind: "result", Summary: "(已手动停止本次运行)"})
if ctx.Err() != nil { // ctx cancelled = stopped by the engine, not a failure
sum, detail := terminalText(ctx, &harness.Terminal{Reason: reason, Err: ctx.Err()}, lastTool)
rec(db.Activity{Kind: "result", Summary: firstLine(sum, 400), Detail: detail})
return finalText, reason, ctx.Err()
}
rec(db.Activity{Kind: "result", IsError: true, Summary: "执行出错: " + err.Error(), Detail: err.Error()})
@@ -84,6 +91,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
flush()
toolNames[ev.ToolUse.ID] = ev.ToolUse.Name
in := string(ev.ToolUse.Input)
lastTool.start(ev.ToolUse.ID, ev.ToolUse.Name, in)
rec(db.Activity{Kind: "tool_use", Tool: ev.ToolUse.Name, ToolUseID: ev.ToolUse.ID,
Summary: ev.ToolUse.Name + " " + firstLine(in, 200), Detail: in})
case harness.KindToolResult:
@@ -92,6 +100,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
}
flush()
out := blocksText(ev.ToolResult.Content)
lastTool.done(ev.ToolResult.ToolUseID)
rec(db.Activity{Kind: "tool_result", Tool: toolNames[ev.ToolResult.ToolUseID], ToolUseID: ev.ToolResult.ToolUseID,
IsError: ev.ToolResult.IsError, Summary: firstLine(out, 200), Detail: out})
case harness.KindText:
@@ -120,25 +129,13 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
tkind = ""
}
flush() // flush any trailing thinking / non-final text
sum := ev.Terminal.Text
sum, detail := ev.Terminal.Text, ev.Terminal.Text
if sum == "" {
switch ev.Terminal.Reason {
case harness.ReasonAbortedTools, harness.ReasonAbortedStreaming:
// interruption (task paused / worker killed / backend restart),
// not a real completion — the intent is re-queued (open) or
// stopped, so the session icon reflects that, not this run.
sum = "(运行被中断:任务暂停 / 终止 / 重启所致;未完成)"
case harness.ReasonTimeout, harness.ReasonMaxTurns:
// budget hit → settlement ran (facts/assets were written back);
// only the trailing text summary is missing, not a failure.
sum = "(达运行预算上限,已收尾写回事实;本次无文字总结,终态: " + string(ev.Terminal.Reason) + ")"
default:
sum = "(无总结,终态: " + string(ev.Terminal.Reason) + ")"
}
sum, detail = terminalText(ctx, ev.Terminal, lastTool)
}
u := ev.Terminal.Usage // cumulative token usage for this session
rec(db.Activity{Kind: "result", IsError: ev.Terminal.Err != nil,
Summary: firstLine(sum, 400), Detail: sum,
Summary: firstLine(sum, 400), Detail: detail,
InputTokens: &u.InputTokens, OutputTokens: &u.OutputTokens,
CacheReadTokens: &u.CacheReadTokens, CacheWriteTokens: &u.CacheWriteTokens})
if ev.Terminal.Err != nil {
@@ -166,13 +163,15 @@ func blocksText(blocks []llm.ContentBlock) string {
}
// firstLine returns a single-line, length-capped preview for the summary column.
// max counts RUNES, not bytes: these previews are mostly 中文, and byte-slicing
// used to cut a multi-byte rune in half and render as "恢�…".
func firstLine(s string, max int) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[:i]
if before, _, found := strings.Cut(s, "\n"); found {
s = before
}
if len(s) > max {
s = s[:max] + "…"
if utf8.RuneCountInString(s) > max {
s = string([]rune(s)[:max]) + "…"
}
return s
}
+1 -1
View File
@@ -334,7 +334,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
runCtx := ctx
if maxDur > 0 {
var cancel context.CancelFunc
runCtx, cancel = context.WithTimeout(ctx, maxDur+settleHardGrace)
runCtx, cancel = context.WithTimeoutCause(ctx, maxDur+settleHardGrace, AbortRunHardTimeout)
defer cancel()
}
_, _, err = captureRun(runCtx, opts, input,
+160
View File
@@ -0,0 +1,160 @@
package agent
import (
"context"
"fmt"
"strings"
"time"
"github.com/Autumn-27/norma/harness"
)
// This file turns a run's terminal state into something a human can act on.
//
// Before: a cancelled run produced one fixed sentence — "(运行被中断:任务暂停 /
// 终止 / 重启所致;未完成)" — a guess-list, not a diagnosis. The information to do
// better was already there (the cancel cause, the terminal reason, the turn
// count, the tool that never returned); it was just being thrown away.
//
// terminalText produces a self-contained ONE-LINE summary (the transcript's
// collapsed row, cut at the first newline by firstLine) plus a multi-line detail
// block rendered in full when the row is expanded.
// runTrace records which tool call is in flight, so an aborted / timed-out run can
// name the step it died on — the single most useful fact when a run hangs.
type runTrace struct {
startedAt time.Time
id string // tool_use id of the most recent call
name string // its tool name
input string // its raw input JSON
at time.Time
pending bool // true = no tool_result arrived for it yet
}
func (t *runTrace) start(id, name, input string) {
t.id, t.name, t.input, t.at, t.pending = id, name, input, time.Now(), true
}
func (t *runTrace) done(id string) {
if id == t.id {
t.pending = false
}
}
// reasonHint explains each harness terminal reason in plain 中文: what it means,
// whether results were still written back, and what the engine does next. Covers
// every TerminalReason the harness can emit.
var reasonHint = map[harness.TerminalReason]string{
harness.ReasonCompleted: "模型主动结束了本轮(正常收场),但没有留下文字总结。事实/资产以工具调用为准,看上面的 tool_use 记录",
harness.ReasonMaxTurns: "撞到步数上限(MaxTurns):SDK 已自动跑了一轮收尾(settlement)把事实/资产写回,只是缺一段文字总结。" +
"意图标记为 exhausted——表示「这个方向试过但没做完」,规划者会据此换角度,不是失败",
harness.ReasonTimeout: "撞到单次运行的软墙钟预算(MaxDuration):SDK 在回合边界优雅收尾并写回事实/资产,只是缺文字总结。" +
"意图标记为 exhausted,不是失败",
harness.ReasonModelError: "模型/API 调用失败(网络、鉴权、限流、供应商 5xx 等)。引擎会退避后自动重跑几次;重试用尽仍失败则意图标记为 blocked",
harness.ReasonBlockingLimit: "上下文长度撞硬上限,请求在发出前就被拦下。通常是单次运行塞进了过多工具输出——考虑收窄意图粒度或压缩工具返回",
harness.ReasonPromptTooLong: "提示词过长且压缩(compaction)重试已用尽,无法继续。同样指向单次运行累积的上下文过大",
harness.ReasonImageError: "多模态内容不被当前模型支持(截图/图片块)。换支持视觉的模型,或让工具别回图",
harness.ReasonStopHookPrevented: "Stop 钩子阻止了本轮结束(守卫判定还不能收场),且随后没能继续下去。" +
"检查任务的 Guard 规则是否过严",
harness.ReasonHookStopped: "某个工具/钩子主动叫停了继续执行(守卫拦截,如越界目标、禁用命令)。看上面最后一条 tool_result 里的拦截说明",
harness.ReasonAbortedStreaming: "运行在【模型输出流式生成阶段】被取消",
harness.ReasonAbortedTools: "运行在【工具执行阶段】被取消",
}
// terminalText renders a terminal event with no final text into (summary, detail).
// summary is one self-contained line; detail is the expandable breakdown.
func terminalText(ctx context.Context, term *harness.Terminal, tr *runTrace) (string, string) {
reason := term.Reason
aborted := reason == harness.ReasonAbortedStreaming || reason == harness.ReasonAbortedTools
// ---- one-line summary: cause first (that's what the reader is after), then
// just enough run shape to judge it without expanding. ----
var sum string
switch {
case aborted:
_, short, _, ok := AbortReason(ctx)
if !ok {
short = "取不到取消原因(run 的 context 上没有挂 cause)"
}
stage := "工具执行阶段"
if reason == harness.ReasonAbortedStreaming {
stage = "模型输出阶段"
}
sum = "(运行被中断:" + short + ";停在" + stage + progressSuffix(term, tr) + ",未完成)"
case reason == harness.ReasonMaxTurns || reason == harness.ReasonTimeout:
sum = "(达运行预算上限(" + string(reason) + "),已收尾写回事实" + progressSuffix(term, tr) + ";本次无文字总结)"
default:
hint := reasonHint[reason]
if hint == "" {
hint = "未知终态(harness 新增了终态但这里还没登记,请补 reasonHint)"
}
sum = "(无文字总结,终态 " + string(reason) + ":" + firstLine(hint, 80) + ")"
}
// ---- expandable detail ----
var b strings.Builder
b.WriteString(sum)
b.WriteString("\n\n")
fmt.Fprintf(&b, "- **终态**: `%s` — %s\n", reason, reasonHint[reason])
if aborted {
code, _, why, ok := AbortReason(ctx)
if ok {
fmt.Fprintf(&b, "- **中断原因** (`%s`): %s\n", code, why)
} else {
b.WriteString("- **中断原因**: 取不到——取消方没有用 context.WithCancelCause 挂上具名原因。" +
"这属于漏网的取消点,请补 agent/cancelcause.go 里的枚举\n")
}
}
if term.Err != nil {
fmt.Fprintf(&b, "- **底层错误**: `%v`\n", term.Err)
}
if term.Turns > 0 {
fmt.Fprintf(&b, "- **已执行**: %d 轮模型回合\n", term.Turns)
}
if !tr.startedAt.IsZero() {
fmt.Fprintf(&b, "- **本次运行耗时**: %s\n", roundDur(time.Since(tr.startedAt)))
}
if u := term.Usage; u.InputTokens+u.OutputTokens+u.CacheReadTokens+u.CacheWriteTokens > 0 {
fmt.Fprintf(&b, "- **累计 token**: 输入 %d / 输出 %d / 缓存读 %d / 缓存写 %d\n",
u.InputTokens, u.OutputTokens, u.CacheReadTokens, u.CacheWriteTokens)
}
if tr.name != "" {
if tr.pending {
fmt.Fprintf(&b, "- **中断时正在执行的工具**: `%s`(已跑 %s,**未返回结果**) — 若是硬超时/长时间无进展,问题多半就在这一步\n ```\n %s\n ```\n",
tr.name, roundDur(time.Since(tr.at)), firstLine(tr.input, 300))
} else {
fmt.Fprintf(&b, "- **中断前最后一个工具**: `%s`(已正常返回)\n", tr.name)
}
} else {
b.WriteString("- **工具调用**: 本次运行还没发出任何工具调用就结束了\n")
}
return sum, b.String()
}
// progressSuffix adds "已跑 N 轮 / 12m30s" to a summary line — how far the run got
// before it died, which decides whether an abort cost anything worth re-running.
func progressSuffix(term *harness.Terminal, tr *runTrace) string {
var parts []string
if term.Turns > 0 {
parts = append(parts, fmt.Sprintf("%d 轮", term.Turns))
}
if !tr.startedAt.IsZero() {
parts = append(parts, roundDur(time.Since(tr.startedAt)))
}
if len(parts) == 0 {
return ""
}
return ",已跑 " + strings.Join(parts, " / ")
}
// roundDur formats a duration at a readable granularity (no 3.400213s noise).
func roundDur(d time.Duration) string {
switch {
case d < time.Minute:
return d.Round(100 * time.Millisecond).String()
case d < time.Hour:
return d.Round(time.Second).String()
default:
return d.Round(time.Minute).String()
}
}
+172
View File
@@ -0,0 +1,172 @@
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)
}
}
+1 -1
View File
@@ -361,7 +361,7 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
runCtx := ctx
if maxDur > 0 {
var runCancel context.CancelFunc
runCtx, runCancel = context.WithTimeout(ctx, maxDur+settleHardGrace)
runCtx, runCancel = context.WithTimeoutCause(ctx, maxDur+settleHardGrace, AbortRunHardTimeout)
defer runCancel()
}
_, reason, err := captureRunSession(runCtx, s, input, emitWrap)