diff --git a/nebula_app/src/ai_hook/native_tests.rs b/nebula_app/src/ai_hook/native_tests.rs index 694b6fe..4795a53 100644 --- a/nebula_app/src/ai_hook/native_tests.rs +++ b/nebula_app/src/ai_hook/native_tests.rs @@ -2,7 +2,7 @@ use super::*; #[test] fn non_successful_turns_do_not_emit_a_completion_notification() { - for reason in ["error", "aborted", "length", "toolUse"] { + for reason in ["error", "aborted", "length"] { let payload = serde_json::json!({"kind":"done", "stop_reason":reason}); let event = parse_remote_envelope( format!("nebula-hook/1 source=pi\n{payload}").as_bytes(), @@ -25,6 +25,17 @@ fn non_successful_turns_do_not_emit_a_completion_notification() { ) .unwrap(); assert!(crate::notify::Notification::from_ai_hook(&event, None, false,).is_some()); + // Host-finished turns (plan menu, completed goal) end on a tool call; they + // must notify like an ordinary completion, not as a failure. + let event = parse_remote_envelope( + b"nebula-hook/1 source=pi +{\"kind\":\"done\",\"stop_reason\":\"toolUse\"}", + Some(1), + ) + .unwrap(); + let notification = crate::notify::Notification::from_ai_hook(&event, None, false) + .expect("host-finished turns notify like ordinary completions"); + assert!(!notification.is_failure(), "a toolUse finish is not a failure"); } #[test] fn pi_result_metadata_distinguishes_legacy_unknown_and_background_work() { @@ -36,7 +47,7 @@ fn pi_result_metadata_distinguishes_legacy_unknown_and_background_work() { (",\"stop_reason\":\"error\"", AiTurnOutcome::Failed, AgentStatus::Idle, true), (",\"stop_reason\":\"aborted\"", AiTurnOutcome::Cancelled, AgentStatus::Idle, false), (",\"stop_reason\":\"length\"", AiTurnOutcome::Incomplete, AgentStatus::Idle, true), - (",\"stop_reason\":\"toolUse\"", AiTurnOutcome::Incomplete, AgentStatus::Idle, true), + (",\"stop_reason\":\"toolUse\"", AiTurnOutcome::Succeeded, AgentStatus::Done, true), (",\"stop_reason\":\"future\"", AiTurnOutcome::Unknown, AgentStatus::Idle, true), (",\"stop_reason\":null", AiTurnOutcome::Unknown, AgentStatus::Idle, true), (",\"stop_reason\":42", AiTurnOutcome::Unknown, AgentStatus::Idle, true), diff --git a/nebula_app/src/ai_hook/protocol.rs b/nebula_app/src/ai_hook/protocol.rs index a743964..ecf5453 100644 --- a/nebula_app/src/ai_hook/protocol.rs +++ b/nebula_app/src/ai_hook/protocol.rs @@ -235,7 +235,11 @@ pub(super) fn parse_envelope(bytes: &[u8]) -> Option { Some("stop") => AiTurnOutcome::Succeeded, Some("error") => AiTurnOutcome::Failed, Some("aborted") => AiTurnOutcome::Cancelled, - Some("length" | "toolUse") => AiTurnOutcome::Incomplete, + Some("length") => AiTurnOutcome::Incomplete, + // pi ends a turn with `toolUse` when the host or an extension closes it + // right after a tool result (the plan menu, a completed goal). That is a + // finished turn, not a truncated one, so it must not raise a failure. + Some("toolUse") => AiTurnOutcome::Succeeded, None if !payload.as_object()?.contains_key("stop_reason") => AiTurnOutcome::Unspecified, _ => AiTurnOutcome::Unknown, } diff --git a/nebula_app/src/notify.rs b/nebula_app/src/notify.rs index efb4154..5b0d4bf 100644 --- a/nebula_app/src/notify.rs +++ b/nebula_app/src/notify.rs @@ -696,7 +696,6 @@ mod delivery_tests { ("unknown", Some(AiTurnOutcome::Unknown)), ("future", Some(AiTurnOutcome::Unknown)), ("length", Some(AiTurnOutcome::Incomplete)), - ("toolUse", Some(AiTurnOutcome::Incomplete)), ] { let event = pi_result(Some(reason), None); let notification = Notification::from_ai_hook(&event, None, false); @@ -708,7 +707,7 @@ mod delivery_tests { result => panic!("unexpected {reason} result: {result:?}"), } } - for reason in [None, Some("stop")] { + for reason in [None, Some("stop"), Some("toolUse")] { let mut payload = serde_json::json!({"kind": "done"}); if let Some(reason) = reason { payload["stop_reason"] = reason.into(); @@ -716,7 +715,10 @@ mod delivery_tests { let wire = format!("nebula-hook/1 source=pi\n{payload}"); let event = crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(1)).unwrap(); let note = Notification::from_ai_hook(&event, None, false).unwrap(); - assert_eq!(matches!(note, Notification::AiTurn { .. }), reason == Some("stop")); + assert_eq!( + matches!(note, Notification::AiTurn { .. }), + matches!(reason, Some("stop") | Some("toolUse")) + ); } }