@team-agent/installer 0.5.106 → 0.5.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/tests/base.rs +112 -225
- package/crates/team-agent/src/compiler/tests.rs +46 -59
- package/crates/team-agent/src/leader/tests/byte_findings.rs +1 -14
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +57 -173
- package/crates/team-agent/src/lifecycle/launch/pi_mcp_extension.js +226 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +0 -37
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +0 -9
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_native_mcp_host.mjs +71 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -66
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +0 -392
- package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +0 -5
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +2 -361
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +1 -16
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +20 -112
- package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1 -12
- package/crates/team-agent/src/lifecycle/tests/pi_native_mcp_capability_red.rs +546 -0
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +11 -54
- package/crates/team-agent/src/messaging/tests/wave2.rs +2 -302
- package/crates/team-agent/src/model/name_similarity.rs +52 -67
- package/crates/team-agent/src/model/task_graph.rs +37 -124
- package/crates/team-agent/src/model/yaml/tests.rs +46 -165
- package/crates/team-agent/src/provider/tests/adapter.rs +0 -20
- package/crates/team-agent/src/provider/tests/classify.rs +106 -198
- package/crates/team-agent/src/tmux_backend/tests.rs +178 -501
- package/crates/team-agent/src/transport/tests/wire.rs +0 -55
- package/package.json +4 -4
|
@@ -5,9 +5,11 @@ use super::*;
|
|
|
5
5
|
/// 把一段 YAML 文本经 `dumps(loads(t))` 往返,断言**逐字节**等于 Python golden。
|
|
6
6
|
/// 这是 §4.2 双跑对该方言的字节锁:golden 由
|
|
7
7
|
/// `PYTHONPATH=src python3 -c "from team_agent.simple_yaml import loads,dumps; ..."` 取得。
|
|
8
|
-
fn roundtrip_eq(input: &str, golden: &str) {
|
|
9
|
-
let
|
|
10
|
-
|
|
8
|
+
fn roundtrip_eq(id: &str, input: &str, golden: &str, note: &str) {
|
|
9
|
+
let parsed = loads(input);
|
|
10
|
+
assert!(parsed.is_ok(), "case={id}; {note}; parse failed: {parsed:?}");
|
|
11
|
+
let v = parsed.unwrap();
|
|
12
|
+
assert_eq!(dumps(&v), golden, "case={id}; {note}; roundtrip mismatch for input:\n{input}");
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
// === 真实语料:examples/team.spec.yaml 的 dumps(loads(t)) golden(逐字节) ===
|
|
@@ -41,168 +43,47 @@ fn rich_edge_fixture_roundtrip_matches_python_golden() {
|
|
|
41
43
|
assert_eq!(dumps(&v), golden);
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
// === SKILL
|
|
45
|
-
#[test]
|
|
46
|
-
fn
|
|
47
|
-
//
|
|
48
|
-
let
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
#
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
#[test]
|
|
87
|
-
fn int_plus_and_underscore() {
|
|
88
|
-
roundtrip_eq("a: +3\nb: 1_000\n", "a: 3\nb: 1000\n");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
#[test]
|
|
92
|
-
fn non_ints_stay_strings() {
|
|
93
|
-
roundtrip_eq(
|
|
94
|
-
"a: 1.5\nb: 0x1f\nc: 1e3\nd: _1\n",
|
|
95
|
-
"a: \"1.5\"\nb: \"0x1f\"\nc: \"1e3\"\nd: \"_1\"\n",
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
#[test]
|
|
100
|
-
fn bools_and_nulls() {
|
|
101
|
-
roundtrip_eq(
|
|
102
|
-
"a: true\nb: False\nc: NULL\nd: ~\n",
|
|
103
|
-
"a: true\nb: false\nc: null\nd: null\n",
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
#[test]
|
|
108
|
-
fn empty_collections() {
|
|
109
|
-
roundtrip_eq("a: {}\nb: []\n", "a: {}\nb: []\n");
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
#[test]
|
|
113
|
-
fn quoted_strings_unwrap() {
|
|
114
|
-
roundtrip_eq("a: \"hello\"\nb: 'world'\n", "a: \"hello\"\nb: \"world\"\n");
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
#[test]
|
|
118
|
-
fn nested_map() {
|
|
119
|
-
roundtrip_eq("a:\n b:\n c: 1\n", "a:\n b:\n c: 1\n");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
#[test]
|
|
123
|
-
fn list_of_maps() {
|
|
124
|
-
roundtrip_eq(
|
|
125
|
-
"items:\n - id: x\n val: 1\n - id: y\n val: 2\n",
|
|
126
|
-
"items:\n - id: \"x\"\n val: 1\n - id: \"y\"\n val: 2\n",
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
#[test]
|
|
131
|
-
fn scalar_list_mixed() {
|
|
132
|
-
roundtrip_eq(
|
|
133
|
-
"a:\n - 1\n - two\n - true\n",
|
|
134
|
-
"a:\n - 1\n - \"two\"\n - true\n",
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
#[test]
|
|
139
|
-
fn empty_value_is_null() {
|
|
140
|
-
roundtrip_eq("a:\nb: 2\n", "a: null\nb: 2\n");
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
#[test]
|
|
144
|
-
fn inline_comment_not_stripped() {
|
|
145
|
-
// 整行注释被跳过;行内 `#` 不被当注释,留在值里。
|
|
146
|
-
roundtrip_eq(
|
|
147
|
-
"# top\na: 1 # inline?\nb: 2\n",
|
|
148
|
-
"a: \"1 # inline?\"\nb: 2\n",
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
#[test]
|
|
153
|
-
fn key_no_space_after_colon() {
|
|
154
|
-
roundtrip_eq("a:1\n", "a: 1\n");
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
#[test]
|
|
158
|
-
fn special_chars_json_escaped() {
|
|
159
|
-
roundtrip_eq("a: he said \"hi\"\n", "a: \"he said \\\"hi\\\"\"\n");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
#[test]
|
|
163
|
-
fn unicode_preserved() {
|
|
164
|
-
roundtrip_eq("name: café ☕\n", "name: \"café ☕\"\n");
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
#[test]
|
|
168
|
-
fn list_item_value_has_colon_and_space() {
|
|
169
|
-
// foo: bar baz —— item_text 看起来是 key-value(key=foo 合法),value = "bar baz"。
|
|
170
|
-
roundtrip_eq("a:\n - foo: bar baz\n", "a:\n - foo: \"bar baz\"\n");
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// === JSON 顶层路径(`{`/`[` 开头 → json.loads,保留键序)===
|
|
174
|
-
|
|
175
|
-
#[test]
|
|
176
|
-
fn json_object_path_preserves_key_order() {
|
|
177
|
-
roundtrip_eq("{\"a\": 1, \"b\": [1,2]}", "a: 1\nb:\n - 1\n - 2\n");
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
#[test]
|
|
181
|
-
fn json_array_path() {
|
|
182
|
-
roundtrip_eq("[1, 2, 3]", "- 1\n- 2\n- 3\n");
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
#[test]
|
|
186
|
-
fn json_leading_whitespace() {
|
|
187
|
-
roundtrip_eq(" {\"a\": 1}", "a: 1\n");
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
#[test]
|
|
191
|
-
fn json_float_becomes_quoted_string() {
|
|
192
|
-
// json.loads -> float 1.5; dumps -> json.dumps(str(1.5)) = "1.5"。
|
|
193
|
-
roundtrip_eq("{\"a\": 1.5}", "a: \"1.5\"\n");
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
#[test]
|
|
197
|
-
fn json_string_with_newline_becomes_block_scalar() {
|
|
198
|
-
roundtrip_eq("{\"x\": \"with\\nnewline\"}", "x: |\n with\n newline\n");
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
#[test]
|
|
202
|
-
fn json_empty_object_and_array_top_level() {
|
|
203
|
-
// 顶层 {} / [] → _dump 返回空 → "\n"。
|
|
204
|
-
roundtrip_eq("{}", "\n");
|
|
205
|
-
roundtrip_eq("[]", "\n");
|
|
46
|
+
// === SKILL front matter、方言怪癖与 JSON 顶层路径:逐字节锁定 Python golden ===
|
|
47
|
+
#[test]
|
|
48
|
+
fn python_golden_roundtrip_cases() {
|
|
49
|
+
// (旧函数 id, input, golden, Python 语义说明)。26 个旧测试包含 27 次往返。
|
|
50
|
+
let cases = [
|
|
51
|
+
(
|
|
52
|
+
"skill_front_matter_roundtrip",
|
|
53
|
+
"name: team-agent\ndescription: Use only when the user explicitly asks to start, operate, inspect, shutdown, or restart a Team Agent team. Treat the team-agent CLI as a sealed appliance.\n",
|
|
54
|
+
"name: \"team-agent\"\ndescription: \"Use only when the user explicitly asks to start, operate, inspect, shutdown, or restart a Team Agent team. Treat the team-agent CLI as a sealed appliance.\"\n",
|
|
55
|
+
"取自 skills/team-agent/SKILL.md 的 front matter。SKILL.md front-matter 块(`---` 之间那段)。",
|
|
56
|
+
),
|
|
57
|
+
("block_scalar_basic", "msg: |\n line one\n line two\n", "msg: |\n line one\n line two\n", ""),
|
|
58
|
+
("block_scalar_blank_line_becomes_padded", "msg: |\n a\n\n b\n", "msg: |\n a\n \n b\n", "空行在 dumps 时产生仅含 pad 的行(尾随空格)—— Python 行为,必须复刻。"),
|
|
59
|
+
("inline_unquoted_list_stays_string", "tools: [a, b, c]\n", "tools: \"[a, b, c]\"\n", "ast.literal_eval('[a, b, c]') 失败 → 原样字符串。"),
|
|
60
|
+
("inline_quoted_list_becomes_list", "x: ['a', 'b']\n", "x:\n - \"a\"\n - \"b\"\n", ""),
|
|
61
|
+
("ints_canonicalize", "a: 1\nb: -5\nc: 007\n", "a: 1\nb: -5\nc: 7\n", "007 -> 7,-5 保号。"),
|
|
62
|
+
("int_plus_and_underscore", "a: +3\nb: 1_000\n", "a: 3\nb: 1000\n", ""),
|
|
63
|
+
("non_ints_stay_strings", "a: 1.5\nb: 0x1f\nc: 1e3\nd: _1\n", "a: \"1.5\"\nb: \"0x1f\"\nc: \"1e3\"\nd: \"_1\"\n", ""),
|
|
64
|
+
("bools_and_nulls", "a: true\nb: False\nc: NULL\nd: ~\n", "a: true\nb: false\nc: null\nd: null\n", ""),
|
|
65
|
+
("empty_collections", "a: {}\nb: []\n", "a: {}\nb: []\n", ""),
|
|
66
|
+
("quoted_strings_unwrap", "a: \"hello\"\nb: 'world'\n", "a: \"hello\"\nb: \"world\"\n", ""),
|
|
67
|
+
("nested_map", "a:\n b:\n c: 1\n", "a:\n b:\n c: 1\n", ""),
|
|
68
|
+
("list_of_maps", "items:\n - id: x\n val: 1\n - id: y\n val: 2\n", "items:\n - id: \"x\"\n val: 1\n - id: \"y\"\n val: 2\n", ""),
|
|
69
|
+
("scalar_list_mixed", "a:\n - 1\n - two\n - true\n", "a:\n - 1\n - \"two\"\n - true\n", ""),
|
|
70
|
+
("empty_value_is_null", "a:\nb: 2\n", "a: null\nb: 2\n", ""),
|
|
71
|
+
("inline_comment_not_stripped", "# top\na: 1 # inline?\nb: 2\n", "a: \"1 # inline?\"\nb: 2\n", "整行注释被跳过;行内 `#` 不被当注释,留在值里。"),
|
|
72
|
+
("key_no_space_after_colon", "a:1\n", "a: 1\n", ""),
|
|
73
|
+
("special_chars_json_escaped", "a: he said \"hi\"\n", "a: \"he said \\\"hi\\\"\"\n", ""),
|
|
74
|
+
("unicode_preserved", "name: café ☕\n", "name: \"café ☕\"\n", ""),
|
|
75
|
+
("list_item_value_has_colon_and_space", "a:\n - foo: bar baz\n", "a:\n - foo: \"bar baz\"\n", "foo: bar baz —— item_text 看起来是 key-value(key=foo 合法),value = \"bar baz\"。"),
|
|
76
|
+
("json_object_path_preserves_key_order", "{\"a\": 1, \"b\": [1,2]}", "a: 1\nb:\n - 1\n - 2\n", "JSON 顶层路径保留键序。"),
|
|
77
|
+
("json_array_path", "[1, 2, 3]", "- 1\n- 2\n- 3\n", ""),
|
|
78
|
+
("json_leading_whitespace", " {\"a\": 1}", "a: 1\n", ""),
|
|
79
|
+
("json_float_becomes_quoted_string", "{\"a\": 1.5}", "a: \"1.5\"\n", "json.loads -> float 1.5; dumps -> json.dumps(str(1.5)) = \"1.5\"。"),
|
|
80
|
+
("json_string_with_newline_becomes_block_scalar", "{\"x\": \"with\\nnewline\"}", "x: |\n with\n newline\n", ""),
|
|
81
|
+
("json_empty_object_and_array_top_level", "{}", "\n", "顶层 {} / [] → _dump 返回空 → \"\\n\"。"),
|
|
82
|
+
("json_empty_object_and_array_top_level", "[]", "\n", "顶层 {} / [] → _dump 返回空 → \"\\n\"。"),
|
|
83
|
+
];
|
|
84
|
+
for (id, input, golden, note) in cases {
|
|
85
|
+
roundtrip_eq(id, input, golden, note);
|
|
86
|
+
}
|
|
206
87
|
}
|
|
207
88
|
|
|
208
89
|
// === dumps-only 结构(直接构造 Value,golden 来自 Python dumps)===
|
|
@@ -403,26 +403,6 @@ fn test_cursor_agent_accepts_mcp_config_without_putting_it_on_argv() {
|
|
|
403
403
|
);
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
-
#[test]
|
|
407
|
-
fn test_cursor_agent_refuses_mcp_config_loudly() {
|
|
408
|
-
// 已废除的行为:旧实现收到 mcp_config 就返回 CapabilityUnsupported
|
|
409
|
-
// (「MCP is not implemented」),此断言证明它确实没了。
|
|
410
|
-
let adapter = get_adapter(Provider::CursorAgent);
|
|
411
|
-
let cfg = McpConfig {
|
|
412
|
-
raw: serde_json::json!({
|
|
413
|
-
"team_orchestrator": {
|
|
414
|
-
"command": "/bin/team-agent",
|
|
415
|
-
"args": ["mcp-server"]
|
|
416
|
-
}
|
|
417
|
-
}),
|
|
418
|
-
};
|
|
419
|
-
let result = adapter.build_command(AuthMode::Subscription, Some(&cfg), None, None);
|
|
420
|
-
assert!(
|
|
421
|
-
!matches!(result, Err(ProviderError::CapabilityUnsupported(_))),
|
|
422
|
-
"feeding mcp_config must not return CapabilityUnsupported; got {result:?}"
|
|
423
|
-
);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
406
|
#[test]
|
|
427
407
|
fn test_grok_build_command_plan_binds_expected_session_id_to_argv() {
|
|
428
408
|
let adapter = get_adapter(Provider::Grok);
|
|
@@ -6,53 +6,113 @@ fn claude_open_turn() -> String {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
#[test]
|
|
9
|
-
fn
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"unreadable/empty input must never be idle (C5)"
|
|
9
|
+
fn classify_state_table() {
|
|
10
|
+
use ClassifySource::{ProcessGuard, SessionFile};
|
|
11
|
+
use ProcessLiveness::{Alive, Unverifiable};
|
|
12
|
+
use Provider::{Claude, ClaudeCode, Codex};
|
|
13
|
+
use TurnState::{Abnormal, BlockedOnHuman, Idle, IdleInterrupted, Unknown, Working};
|
|
14
|
+
|
|
15
|
+
type Input<'a> = (Provider, &'a str, ProcessLiveness, f64);
|
|
16
|
+
// State, reason, turn ID, source, exact annotations, idle-for-takeover.
|
|
17
|
+
// None skips an originally absent assertion; Some(None) asserts no turn ID.
|
|
18
|
+
type Expected<'a> = (
|
|
19
|
+
TurnState, &'a str, Option<Option<&'a str>>, Option<ClassifySource>,
|
|
20
|
+
Option<&'a [&'a str]>, Option<bool>,
|
|
22
21
|
);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
22
|
+
let open = claude_open_turn();
|
|
23
|
+
let two = format!(
|
|
24
|
+
"{}\n{}",
|
|
25
|
+
r#"{"type":"assistant","requestId":"a","message":{"stop_reason":"tool_use"}}"#,
|
|
26
|
+
r#"{"type":"assistant","requestId":"b","message":{"stop_reason":"end_turn"}}"#,
|
|
27
|
+
);
|
|
28
|
+
let c14 = format!(
|
|
29
|
+
"{}\n{}",
|
|
30
|
+
r#"{"type":"assistant","requestId":"a","message":{"stop_reason":"end_turn"}}"#,
|
|
31
|
+
r#"{"type":"assistant","requestId":"b","message":{"stop_reason":"tool_use"}}"#,
|
|
32
|
+
);
|
|
33
|
+
let cases: &[(&str, Input<'_>, Expected<'_>)] = &[
|
|
34
|
+
// Unknown is NEVER idle (bug-071/077/085, C5).
|
|
35
|
+
("empty",
|
|
36
|
+
(ClaudeCode, "", Unverifiable, 0.0),
|
|
37
|
+
(Unknown, "unreadable_or_empty", Some(None), Some(SessionFile), None, Some(false))),
|
|
38
|
+
("whitespace",
|
|
39
|
+
(Claude, " \n \t \n", Unverifiable, 0.0),
|
|
40
|
+
(Unknown, "unreadable_or_empty", None, None, None, Some(false))),
|
|
41
|
+
// !had_records wins over parse diagnostics (common.py:72-75).
|
|
42
|
+
("garbage_jsonl",
|
|
43
|
+
(Claude, "not json\n{broken", Unverifiable, 0.0),
|
|
44
|
+
(Unknown, "unreadable_or_empty", None, None, None, Some(false))),
|
|
45
|
+
// Valid record without lifecycle facts: NOT unrecognized_format.
|
|
46
|
+
("unrecognized_record",
|
|
47
|
+
(Claude, r#"{"foo":"bar"}"#, Unverifiable, 0.0),
|
|
48
|
+
(Unknown, "no_turn_lifecycle_fact", None, None, None, Some(false))),
|
|
49
|
+
// C4: missing process identity is NEVER optimistically working.
|
|
50
|
+
("open_unverifiable_identity",
|
|
51
|
+
(Claude, &open, Unverifiable, 0.0),
|
|
52
|
+
(Unknown, "process_identity_unverified", Some(Some("r1")), Some(ProcessGuard), None, Some(false))),
|
|
53
|
+
("open_alive",
|
|
54
|
+
(Claude, &open, Alive, 0.0),
|
|
55
|
+
(Working, "open_turn", Some(Some("r1")), Some(SessionFile), None, None)),
|
|
56
|
+
("open_unverifiable_process",
|
|
57
|
+
(Claude, &open, Unverifiable, 0.0),
|
|
58
|
+
(Unknown, "process_identity_unverified", None, None, None, Some(false))),
|
|
59
|
+
("last_fact_wins",
|
|
60
|
+
(Claude, &two, Unverifiable, 0.0),
|
|
61
|
+
(Idle, "end_turn", Some(Some("b")), None, None, None)),
|
|
62
|
+
// C14: silence is discarded; only a dead process can demote an open turn.
|
|
63
|
+
("open_beats_silence",
|
|
64
|
+
(Claude, &c14, Alive, 9999.0),
|
|
65
|
+
(Working, "open_turn", Some(Some("b")), None, None, None)),
|
|
66
|
+
// C12: idle_interrupted IS idle for take-over, with exact annotations.
|
|
67
|
+
("claude_interrupted",
|
|
68
|
+
(Claude, r#"{"type":"user","uuid":"u1","message":{"content":[{"type":"text","text":"[Request interrupted by user]"}]}}"#, Unverifiable, 0.0),
|
|
69
|
+
(IdleInterrupted, "user_interrupt", Some(Some("u1")), None, Some(&["interrupted"]), Some(true))),
|
|
70
|
+
("claude_stop_sequence",
|
|
71
|
+
(Claude, r#"{"type":"assistant","requestId":"r1","message":{"stop_reason":"stop_sequence"}}"#, Unverifiable, 0.0),
|
|
72
|
+
(Idle, "stop_sequence", None, None, None, None)),
|
|
73
|
+
("codex_task_complete",
|
|
74
|
+
(Codex, r#"{"type":"event_msg","payload":{"type":"task_complete","turn_id":"ct1"}}"#, Unverifiable, 0.0),
|
|
75
|
+
(Idle, "task_complete", Some(Some("ct1")), None, None, None)),
|
|
76
|
+
("codex_abort_interrupted",
|
|
77
|
+
(Codex, r#"{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"ct2","reason":"interrupted"}}"#, Unverifiable, 0.0),
|
|
78
|
+
(IdleInterrupted, "interrupted", None, None, None, None)),
|
|
79
|
+
// Raw abort reason must pass through, including non-interrupted errors.
|
|
80
|
+
("codex_abort_error",
|
|
81
|
+
(Codex, r#"{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"ct3","reason":"error"}}"#, Unverifiable, 0.0),
|
|
82
|
+
(IdleInterrupted, "error", Some(Some("ct3")), None, None, None)),
|
|
83
|
+
("codex_turn_failed",
|
|
84
|
+
(Codex, r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"ct4","status":"failed"}}}"#, Unverifiable, 0.0),
|
|
85
|
+
(Abnormal, "turn_failed", Some(Some("ct4")), None, Some(&["turn_failed"]), Some(false))),
|
|
86
|
+
// blocked_on_human is NOT idle for take-over.
|
|
87
|
+
("codex_approval",
|
|
88
|
+
(Codex, r#"{"jsonrpc":"2.0","method":"session/requestApproval","params":{"turnId":"ct5"}}"#, Unverifiable, 0.0),
|
|
89
|
+
(BlockedOnHuman, "approval_required", Some(Some("ct5")), None, Some(&["awaiting_approval"]), Some(false))),
|
|
90
|
+
];
|
|
91
|
+
for &(id, (provider, text, liveness, silence), expected) in cases {
|
|
92
|
+
let c = classify(provider, text, liveness, silence)
|
|
93
|
+
.unwrap_or_else(|err| panic!("{id}: classify failed: {err:?}"));
|
|
94
|
+
let (state, reason, turn_id, source, annotations, idle) = expected;
|
|
95
|
+
let note = match id {
|
|
96
|
+
"empty" => "unreadable/empty input must never be idle (C5)",
|
|
97
|
+
"codex_abort_error" => "raw abort reason must pass through",
|
|
98
|
+
_ => "",
|
|
99
|
+
};
|
|
100
|
+
assert_eq!(c.state, state, "{id}: state");
|
|
101
|
+
assert_eq!(c.reason, reason, "{id}: reason; {note}");
|
|
102
|
+
if let Some(turn_id) = turn_id {
|
|
103
|
+
assert_eq!(c.turn_id.as_ref().map(TurnId::as_str), turn_id, "{id}: turn ID");
|
|
104
|
+
}
|
|
105
|
+
if let Some(source) = source {
|
|
106
|
+
assert_eq!(c.source, source, "{id}: source");
|
|
107
|
+
}
|
|
108
|
+
if let Some(annotations) = annotations {
|
|
109
|
+
let actual: Vec<_> = c.annotations.iter().map(String::as_str).collect();
|
|
110
|
+
assert_eq!(actual.as_slice(), annotations, "{id}: exact annotations");
|
|
111
|
+
}
|
|
112
|
+
if let Some(idle) = idle {
|
|
113
|
+
assert_eq!(c.state.is_idle_for_takeover(), idle, "{id}: idle-for-takeover; {note}");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
56
116
|
}
|
|
57
117
|
|
|
58
118
|
#[test]
|
|
@@ -69,31 +129,6 @@ fn classify_claude_code_alias_normalizes_to_claude_reader() {
|
|
|
69
129
|
assert_eq!(alias, canon, "claude_code must normalize to the claude reader");
|
|
70
130
|
}
|
|
71
131
|
|
|
72
|
-
#[test]
|
|
73
|
-
fn classify_open_turn_with_no_process_is_unknown_not_working() {
|
|
74
|
-
// probe open_turn_process_none: open turn + Unverifiable (None process) →
|
|
75
|
-
// unknown / process_identity_unverified / source=process_guard, turn_id=r1.
|
|
76
|
-
// C4: missing identity is NEVER optimistically working.
|
|
77
|
-
let c = classify(Provider::Claude, &claude_open_turn(), ProcessLiveness::Unverifiable, 0.0)
|
|
78
|
-
.expect("classify ok");
|
|
79
|
-
assert_eq!(c.state, TurnState::Unknown);
|
|
80
|
-
assert_eq!(c.reason, "process_identity_unverified");
|
|
81
|
-
assert_eq!(c.source, ClassifySource::ProcessGuard);
|
|
82
|
-
assert_eq!(c.turn_id, Some(TurnId::new("r1")));
|
|
83
|
-
assert!(!c.state.is_idle_for_takeover());
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
#[test]
|
|
87
|
-
fn classify_open_turn_alive_is_working() {
|
|
88
|
-
// probe open_turn_alive: open turn + Alive → working / open_turn / source=session_file.
|
|
89
|
-
let c = classify(Provider::Claude, &claude_open_turn(), ProcessLiveness::Alive, 0.0)
|
|
90
|
-
.expect("classify ok");
|
|
91
|
-
assert_eq!(c.state, TurnState::Working);
|
|
92
|
-
assert_eq!(c.reason, "open_turn");
|
|
93
|
-
assert_eq!(c.source, ClassifySource::SessionFile);
|
|
94
|
-
assert_eq!(c.turn_id, Some(TurnId::new("r1")));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
132
|
#[test]
|
|
98
133
|
fn classify_open_turn_dead_is_abnormal_crashed_mid_turn() {
|
|
99
134
|
// probe open_turn_dead: open turn + Dead → abnormal / crashed_mid_turn /
|
|
@@ -106,133 +141,6 @@ fn classify_open_turn_dead_is_abnormal_crashed_mid_turn() {
|
|
|
106
141
|
assert!(c.annotations.contains(&"crashed_mid_turn".to_string()));
|
|
107
142
|
}
|
|
108
143
|
|
|
109
|
-
#[test]
|
|
110
|
-
fn classify_open_turn_unverifiable_process_is_unknown() {
|
|
111
|
-
// probe open_turn_unverifiable: open turn + Unverifiable →
|
|
112
|
-
// unknown / process_identity_unverified (NOT working). NEVER idle.
|
|
113
|
-
let c = classify(Provider::Claude, &claude_open_turn(), ProcessLiveness::Unverifiable, 0.0)
|
|
114
|
-
.expect("classify ok");
|
|
115
|
-
assert_eq!(c.state, TurnState::Unknown);
|
|
116
|
-
assert_eq!(c.reason, "process_identity_unverified");
|
|
117
|
-
assert!(!c.state.is_idle_for_takeover());
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
#[test]
|
|
121
|
-
fn classify_last_lifecycle_fact_wins() {
|
|
122
|
-
// probe last_fact_wins_complete (tool_use 'a' then end_turn 'b') →
|
|
123
|
-
// idle / end_turn, turn_id = the LAST lifecycle fact's id ("b").
|
|
124
|
-
let two = format!(
|
|
125
|
-
"{}\n{}",
|
|
126
|
-
r#"{"type":"assistant","requestId":"a","message":{"stop_reason":"tool_use"}}"#,
|
|
127
|
-
r#"{"type":"assistant","requestId":"b","message":{"stop_reason":"end_turn"}}"#,
|
|
128
|
-
);
|
|
129
|
-
let c = classify(Provider::Claude, &two, ProcessLiveness::Unverifiable, 0.0)
|
|
130
|
-
.expect("classify ok");
|
|
131
|
-
assert_eq!(c.state, TurnState::Idle);
|
|
132
|
-
assert_eq!(c.reason, "end_turn");
|
|
133
|
-
assert_eq!(c.turn_id, Some(TurnId::new("b")));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
#[test]
|
|
137
|
-
fn classify_c14_open_turn_beats_silence() {
|
|
138
|
-
// probe c14_open_after_complete_alive (end_turn 'a' then tool_use 'b', alive,
|
|
139
|
-
// file_silence=9999) → working / open_turn, turn_id="b". Silence is DISCARDED:
|
|
140
|
-
// only a dead process guard can demote an open turn (common.py:93-103, C14).
|
|
141
|
-
let c14 = format!(
|
|
142
|
-
"{}\n{}",
|
|
143
|
-
r#"{"type":"assistant","requestId":"a","message":{"stop_reason":"end_turn"}}"#,
|
|
144
|
-
r#"{"type":"assistant","requestId":"b","message":{"stop_reason":"tool_use"}}"#,
|
|
145
|
-
);
|
|
146
|
-
let c = classify(Provider::Claude, &c14, ProcessLiveness::Alive, 9999.0)
|
|
147
|
-
.expect("classify ok");
|
|
148
|
-
assert_eq!(c.state, TurnState::Working);
|
|
149
|
-
assert_eq!(c.reason, "open_turn");
|
|
150
|
-
assert_eq!(c.turn_id, Some(TurnId::new("b")));
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
#[test]
|
|
154
|
-
fn classify_claude_interrupted_is_idle_interrupted_annotated() {
|
|
155
|
-
// probe claude_interrupted ("[Request interrupted by user]") →
|
|
156
|
-
// idle_interrupted / user_interrupt / annotations=["interrupted"], turn_id=u1.
|
|
157
|
-
let txt = r#"{"type":"user","uuid":"u1","message":{"content":[{"type":"text","text":"[Request interrupted by user]"}]}}"#;
|
|
158
|
-
let c = classify(Provider::Claude, txt, ProcessLiveness::Unverifiable, 0.0)
|
|
159
|
-
.expect("classify ok");
|
|
160
|
-
assert_eq!(c.state, TurnState::IdleInterrupted);
|
|
161
|
-
assert_eq!(c.reason, "user_interrupt");
|
|
162
|
-
assert_eq!(c.annotations, vec!["interrupted".to_string()]);
|
|
163
|
-
assert_eq!(c.turn_id, Some(TurnId::new("u1")));
|
|
164
|
-
// C12: idle_interrupted IS idle for take-over (annotated).
|
|
165
|
-
assert!(c.state.is_idle_for_takeover());
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
#[test]
|
|
169
|
-
fn classify_claude_stop_sequence_is_idle() {
|
|
170
|
-
// probe claude_stop_sequence → idle / stop_sequence.
|
|
171
|
-
let txt = r#"{"type":"assistant","requestId":"r1","message":{"stop_reason":"stop_sequence"}}"#;
|
|
172
|
-
let c = classify(Provider::Claude, txt, ProcessLiveness::Unverifiable, 0.0)
|
|
173
|
-
.expect("classify ok");
|
|
174
|
-
assert_eq!(c.state, TurnState::Idle);
|
|
175
|
-
assert_eq!(c.reason, "stop_sequence");
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
#[test]
|
|
179
|
-
fn classify_codex_task_complete_is_idle() {
|
|
180
|
-
// probe codex_task_complete → idle / task_complete, turn_id from payload (ct1).
|
|
181
|
-
let txt = r#"{"type":"event_msg","payload":{"type":"task_complete","turn_id":"ct1"}}"#;
|
|
182
|
-
let c = classify(Provider::Codex, txt, ProcessLiveness::Unverifiable, 0.0)
|
|
183
|
-
.expect("classify ok");
|
|
184
|
-
assert_eq!(c.state, TurnState::Idle);
|
|
185
|
-
assert_eq!(c.reason, "task_complete");
|
|
186
|
-
assert_eq!(c.turn_id, Some(TurnId::new("ct1")));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
#[test]
|
|
190
|
-
fn classify_codex_turn_aborted_interrupted_is_idle_interrupted() {
|
|
191
|
-
// probe codex_turn_aborted_interrupted → idle_interrupted / interrupted.
|
|
192
|
-
let interrupted = r#"{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"ct2","reason":"interrupted"}}"#;
|
|
193
|
-
let c = classify(Provider::Codex, interrupted, ProcessLiveness::Unverifiable, 0.0)
|
|
194
|
-
.expect("classify ok");
|
|
195
|
-
assert_eq!(c.state, TurnState::IdleInterrupted);
|
|
196
|
-
assert_eq!(c.reason, "interrupted");
|
|
197
|
-
// probe codex_turn_aborted_other (reason="error") → idle_interrupted with the
|
|
198
|
-
// RAW reason string passed through ("error"), turn_id=ct3.
|
|
199
|
-
let other = r#"{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"ct3","reason":"error"}}"#;
|
|
200
|
-
let c2 = classify(Provider::Codex, other, ProcessLiveness::Unverifiable, 0.0)
|
|
201
|
-
.expect("classify ok");
|
|
202
|
-
assert_eq!(c2.state, TurnState::IdleInterrupted);
|
|
203
|
-
assert_eq!(c2.reason, "error", "raw abort reason must pass through");
|
|
204
|
-
assert_eq!(c2.turn_id, Some(TurnId::new("ct3")));
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
#[test]
|
|
208
|
-
fn classify_codex_appserver_failed_is_abnormal() {
|
|
209
|
-
// probe codex_appserver_failed (turn.status==failed) → abnormal / turn_failed /
|
|
210
|
-
// annotations=["turn_failed"], source=session_file, turn_id=ct4.
|
|
211
|
-
let txt = r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"ct4","status":"failed"}}}"#;
|
|
212
|
-
let c = classify(Provider::Codex, txt, ProcessLiveness::Unverifiable, 0.0)
|
|
213
|
-
.expect("classify ok");
|
|
214
|
-
assert_eq!(c.state, TurnState::Abnormal);
|
|
215
|
-
assert_eq!(c.reason, "turn_failed");
|
|
216
|
-
assert_eq!(c.annotations, vec!["turn_failed".to_string()]);
|
|
217
|
-
assert_eq!(c.turn_id, Some(TurnId::new("ct4")));
|
|
218
|
-
assert!(!c.state.is_idle_for_takeover());
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
#[test]
|
|
222
|
-
fn classify_codex_appserver_approval_is_blocked_on_human() {
|
|
223
|
-
// probe codex_appserver_approval (method endswith requestApproval) →
|
|
224
|
-
// blocked_on_human / approval_required / annotations=["awaiting_approval"], turn_id=ct5.
|
|
225
|
-
let txt = r#"{"jsonrpc":"2.0","method":"session/requestApproval","params":{"turnId":"ct5"}}"#;
|
|
226
|
-
let c = classify(Provider::Codex, txt, ProcessLiveness::Unverifiable, 0.0)
|
|
227
|
-
.expect("classify ok");
|
|
228
|
-
assert_eq!(c.state, TurnState::BlockedOnHuman);
|
|
229
|
-
assert_eq!(c.reason, "approval_required");
|
|
230
|
-
assert_eq!(c.annotations, vec!["awaiting_approval".to_string()]);
|
|
231
|
-
assert_eq!(c.turn_id, Some(TurnId::new("ct5")));
|
|
232
|
-
// blocked_on_human is NOT idle for take-over.
|
|
233
|
-
assert!(!c.state.is_idle_for_takeover());
|
|
234
|
-
}
|
|
235
|
-
|
|
236
144
|
// ---- (b) idle predicate / evaluate_takeover_reminder (NoPingReason cases) ----
|
|
237
145
|
//
|
|
238
146
|
// Golden via /tmp/probe_idle.py (idle_predicate.evaluate_takeover_reminder).
|