fix(ai-hook): keep the turn-done notification off background work

`Stop` fires at the end of every turn, not at task completion, so the guard
that suppresses the completion toast while work is still in flight is what
keeps that notification honest. It reads `background_tasks`, but the counter
only accepted entries whose type was `subagent` — and Claude Code reports a
backgrounded Bash command as `local_bash`. The most common shape (start a
long command, turn ends, command still running) therefore counted zero: the
guard stayed off and the toast claimed the turn was done while the command
was still running.

Count every entry the field carries. It is documented as in-flight work
("Empty array when nothing is in flight"), so the filter belongs on the
status, not on the type. Idle `in_process_teammate` entries stay excluded —
they keep reporting `status: "running"` after they finish
(anthropics/claude-code#85955) — matching Claude Code's own
`status === "running" && !isIdle` predicate.

Tests: three cases pin the counting (every in-flight type, an empty array
leaving the turn done, an idle teammate not holding it open). The first one
counted zero before this change.
This commit is contained in:
AnxForever committed 2026-09-14 22:54:29 +08:00
1 parent 546daac927
commit 2b4eadcdee
1 file changed
+151 -14
+151 -14
View File
@@ -867,6 +867,18 @@ fn background_task_summary(payload: &Value) -> Option<AiBackgroundTasks> {
Some(AiBackgroundTasks { active, total })
}
/// 数出这批后台活儿里有多少还在跑。
///
/// 旧实现只认 `type == "subagent"`,其余类型一律跳过。而 Claude Code 给后台 bash
/// 的类型名是 `local_bash`(同族还有 `monitor` / `workflow` / `mcp_task` /
/// `in_process_teammate` …),于是「跑着一条后台命令、回合先结束」这个最常见、也
/// 最容易被误报成「任务完成」的形状,永远数出 `active = 0`:`TurnDone` 的守卫不
/// 触发,命令还在跑就先弹了完成通知。
///
/// `background_tasks` 本身就是「在飞」的集合——字段说明写着 *In-flight background
/// work … Empty array when nothing is in flight*,它的存在就是为了让 hook 区分
/// 「真的收工」和「在等后台活儿把自己叫醒」。所以这里不再按类型过滤,只看每笔的
/// 状态。
fn count_background_tasks(value: &Value, active: &mut u32, total: &mut u32) {
match value {
Value::Array(values) => {
@@ -875,21 +887,14 @@ fn count_background_tasks(value: &Value, active: &mut u32, total: &mut u32) {
}
},
Value::Object(task) => {
if let Some(kind) = task.get("type").and_then(Value::as_str) {
if !kind.eq_ignore_ascii_case("subagent") {
return;
}
if is_task_entry(task) {
*total = total.saturating_add(1);
if task.get("status").and_then(Value::as_str).is_some_and(|status| {
matches!(
status.to_ascii_lowercase().as_str(),
"running" | "processing" | "in_progress" | "active"
)
}) {
if task_is_in_flight(task) {
*active = active.saturating_add(1);
}
return;
}
// 既没有类型也没有状态的中间层对象(旧 wire 形状的包装)继续往下找。
for value in task.values() {
count_background_tasks(value, active, total);
}
@@ -898,6 +903,79 @@ fn count_background_tasks(value: &Value, active: &mut u32, total: &mut u32) {
}
}
/// 任务条目的终态词表。
///
/// 取值不是猜的:本机 claude 2.1.270 二进制里出现过的 `status:"…"` 字面量是
/// `failed` / `success` / `pending` / `completed` / `running` / `killed` / `idle` /
/// `stopped` / `cancelled` / `aborted` / `exited`。这里取其中的终态,再补上同族词
/// (`done` / `finished` / `error` / `canceled` …)。
///
/// 判反的代价不对称:漏掉一个终态,`active` 会永远大于 0,pane 停在「工作中」、
/// 完成通知不再弹,`runtime_api` 那边连 `agent.delegate` 的完成回调都不会触发——
/// 比早弹一条通知更难发现。多留的词最多只是让一笔刚结束的活儿多算一拍。
const TERMINAL_TASK_STATUSES: &[&str] = &[
"completed",
"complete",
"done",
"success",
"succeeded",
"finished",
"exited",
"failed",
"failure",
"error",
"timeout",
"timed_out",
"expired",
"terminated",
"cancelled",
"canceled",
"stopped",
"killed",
"aborted",
// 任务自己说 idle,就是没在干活——它仍留在集合里只是注册表还没清扫。
"idle",
];
/// 这一笔是任务条目,还是需要继续往下找的中间层包装。
///
/// 只认**字符串**字段:`{"type": null}`、`{"type": {"name": …}}` 既不是任务也没有
/// 状态,放行给下一层递归(旧实现按 `as_str()` 判断,本次改动一度退化成
/// `is_some()`,会把一个 null 凭空当成一笔在飞任务)。
fn is_task_entry(task: &serde_json::Map<String, Value>) -> bool {
task.get("type").and_then(Value::as_str).is_some()
|| task.get("status").and_then(Value::as_str).is_some()
}
/// 这一笔后台活儿是否还在跑。
///
/// 读不到状态就按还在跑处理:这个集合本身就是「在飞」的任务。
fn task_is_in_flight(task: &serde_json::Map<String, Value>) -> bool {
if is_idle_teammate(task) {
return false;
}
match task.get("status").and_then(Value::as_str) {
Some(status) => {
!TERMINAL_TASK_STATUSES.contains(&status.to_ascii_lowercase().as_str())
},
None => true,
}
}
/// 闲置的 `in_process_teammate`:`status` 会一直挂着 `running`
/// (anthropics/claude-code#85955),只有它自己的 `isIdle` 说得准——这也正是
/// Claude Code 内部的判据(`type === "in_process_teammate" && status ===
/// "running" && !isIdle`)。
///
/// **只对 teammate 生效**:别的类型没有 `isIdle` 的约定,拿它压掉一笔在跑的
/// `local_bash` 就是谎报完成,正好是这次要修的那个毛病。
fn is_idle_teammate(task: &serde_json::Map<String, Value>) -> bool {
task.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| kind.eq_ignore_ascii_case("in_process_teammate"))
&& task.get("isIdle").and_then(Value::as_bool) == Some(true)
}
fn sanitized_raw_context(payload: &Value) -> Option<String> {
let sanitized = sanitize_json(payload, 0);
let raw = serde_json::to_string(&sanitized).ok()?;
@@ -1177,18 +1255,77 @@ mod remote_tests {
assert!(!summary.contains("selected source"));
}
/// `background_tasks` 里的**每一笔**在飞的活儿都算数,不只是 subagent。
///
/// 2026-09-14:旧实现只认 `type == "subagent"`,而 Claude Code 给后台 bash 的
/// 类型名是 `local_bash`——「跑着后台命令、回合先结束」于是永远数出 active=0,
/// `TurnDone` 的守卫不触发,用户在命令还在跑时就收到「回合完成」。
/// 类型名取自本机 claude 2.1.270 二进制里的集合:`local_bash` / `subagent` /
/// `monitor` / `workflow` / `mcp_task` / `in_process_teammate` / `local_agent` /
/// `remote_agent` / `dream` / `auto_mode_scan` / `cloud_session`。
#[test]
fn claude_stop_reports_running_background_subagents() {
fn claude_stop_counts_every_in_flight_background_task() {
let raw = br#"nebula-hook/1 source=claude pane=3
{"session_id":"s","hook_event_name":"Stop","background_tasks":[{"type":"subagent","status":"running"},{"type":"subagent","status":"completed"},{"type":"other","status":"running"}]}"#;
{"session_id":"s","hook_event_name":"Stop","background_tasks":[{"type":"local_bash","status":"running"},{"type":"subagent","status":"completed"},{"type":"monitor","status":"running"},{"type":"mcp_task","status":"cancelled"}]}"#;
let event = parse_remote_envelope(raw, Some(3)).unwrap();
assert_eq!(event.kind, AiHookKind::TurnDone);
assert_eq!(event.active_background_tasks(), 1);
assert_eq!(event.background_tasks.unwrap().total, 2);
assert_eq!(event.active_background_tasks(), 2, "local_bash 与 monitor 都还在跑");
assert_eq!(event.background_tasks.unwrap().total, 4);
assert!(capabilities_for("claude").background_tasks);
assert!(!capabilities_for("pi").attention_context);
}
/// 空数组 = 真的收工:守卫必须放行,否则完成通知永远不弹。
#[test]
fn empty_background_tasks_leave_the_turn_done() {
let raw = br#"nebula-hook/1 source=claude pane=3
{"session_id":"s","hook_event_name":"Stop","background_tasks":[]}"#;
let event = parse_remote_envelope(raw, Some(3)).unwrap();
assert_eq!(event.active_background_tasks(), 0);
assert_eq!(event.background_tasks.unwrap().total, 0);
}
/// 闲置的 `in_process_teammate` 会一直挂 `status: "running"`
/// (anthropics/claude-code#85955),只有它自己的 `isIdle` 能说明它没在干活。
/// 少了这道判据,pane 会永远停在「还在跑」,完成通知再也弹不出来。
#[test]
fn idle_in_process_teammate_does_not_hold_the_turn_open() {
let raw = br#"nebula-hook/1 source=claude pane=3
{"session_id":"s","hook_event_name":"Stop","background_tasks":[{"type":"in_process_teammate","status":"running","isIdle":true},{"type":"in_process_teammate","status":"running","isIdle":false}]}"#;
let event = parse_remote_envelope(raw, Some(3)).unwrap();
assert_eq!(event.active_background_tasks(), 1, "只有没闲置的那个算在跑");
}
/// 终态词表必须覆盖真实取值:`success` / `exited` 是从本机 claude 2.1.270 里
/// 读到的字面量,漏掉它们会让 pane 永远停在「工作中」——完成通知不再弹,
/// `agent.delegate` 的完成回调也不会触发,比早弹一条更难发现。
#[test]
fn finished_background_tasks_do_not_hold_the_turn_open() {
for status in ["completed", "success", "exited", "failed", "killed", "cancelled", "idle"]
{
let payload = serde_json::json!({
"session_id": "s",
"hook_event_name": "Stop",
"background_tasks": [{ "type": "local_bash", "status": status }],
});
let raw = format!("nebula-hook/1 source=claude pane=3\n{payload}");
let event = parse_remote_envelope(raw.as_bytes(), Some(3)).unwrap();
assert_eq!(event.active_background_tasks(), 0, "status={status} 是终态");
assert_eq!(event.background_tasks.unwrap().total, 1, "它仍然算一笔任务");
}
}
/// 两个形状陷阱:`{"type": null}` 不是任务(不能凭空造出一笔在飞的活儿),
/// `isIdle` 只对 teammate 有约定(别拿它压掉一笔在跑的 `local_bash`)。
#[test]
fn malformed_task_shapes_do_not_manufacture_in_flight_work() {
let raw = br#"nebula-hook/1 source=claude pane=3
{"session_id":"s","hook_event_name":"Stop","background_tasks":[{"type":null},{"type":"local_bash","status":"running","isIdle":true}]}"#;
let event = parse_remote_envelope(raw, Some(3)).unwrap();
assert_eq!(event.active_background_tasks(), 1, "isIdle 不适用于 local_bash");
assert_eq!(event.background_tasks.unwrap().total, 1, "null 型别不算任务");
}
#[test]
fn distinct_original_answers_are_not_swallowed_by_preview_deduplication() {
for provider in ["claude", "codex"] {