@team-agent/installer 0.2.10 → 0.3.0
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 +744 -0
- package/Cargo.toml +34 -0
- package/crates/team-agent/Cargo.toml +33 -0
- package/crates/team-agent/src/cli/adapters.rs +1343 -0
- package/crates/team-agent/src/cli/diagnose.rs +554 -0
- package/crates/team-agent/src/cli/emit.rs +1077 -0
- package/crates/team-agent/src/cli/helpers.rs +88 -0
- package/crates/team-agent/src/cli/leader.rs +216 -0
- package/crates/team-agent/src/cli/mod.rs +1141 -0
- package/crates/team-agent/src/cli/profile.rs +306 -0
- package/crates/team-agent/src/cli/send.rs +215 -0
- package/crates/team-agent/src/cli/status.rs +179 -0
- package/crates/team-agent/src/cli/status_port.rs +502 -0
- package/crates/team-agent/src/cli/tests/base.rs +616 -0
- package/crates/team-agent/src/cli/tests/compile.rs +96 -0
- package/crates/team-agent/src/cli/tests/divergence.rs +509 -0
- package/crates/team-agent/src/cli/tests/lane_c.rs +333 -0
- package/crates/team-agent/src/cli/tests/leader_watch.rs +395 -0
- package/crates/team-agent/src/cli/tests/main_preserved.rs +675 -0
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +390 -0
- package/crates/team-agent/src/cli/tests/mod.rs +97 -0
- package/crates/team-agent/src/cli/tests/peer_allow.rs +137 -0
- package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +302 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +305 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +385 -0
- package/crates/team-agent/src/cli/tests/verb_profile.rs +182 -0
- package/crates/team-agent/src/cli/tests/verb_settle.rs +236 -0
- package/crates/team-agent/src/cli/tests/verb_validate.rs +184 -0
- package/crates/team-agent/src/cli/types.rs +605 -0
- package/crates/team-agent/src/compiler/tests.rs +701 -0
- package/crates/team-agent/src/compiler.rs +489 -0
- package/crates/team-agent/src/coordinator/backoff.rs +153 -0
- package/crates/team-agent/src/coordinator/health.rs +436 -0
- package/crates/team-agent/src/coordinator/mod.rs +80 -0
- package/crates/team-agent/src/coordinator/orphan.rs +179 -0
- package/crates/team-agent/src/coordinator/tests/abnormal.rs +255 -0
- package/crates/team-agent/src/coordinator/tests/basics.rs +262 -0
- package/crates/team-agent/src/coordinator/tests/daemon.rs +323 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +263 -0
- package/crates/team-agent/src/coordinator/tests/main_preserved.rs +136 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +310 -0
- package/crates/team-agent/src/coordinator/tests/spine.rs +261 -0
- package/crates/team-agent/src/coordinator/tests/takeover.rs +227 -0
- package/crates/team-agent/src/coordinator/tests/tick_core.rs +256 -0
- package/crates/team-agent/src/coordinator/tests/watch.rs +167 -0
- package/crates/team-agent/src/coordinator/tick.rs +2032 -0
- package/crates/team-agent/src/coordinator/types.rs +584 -0
- package/crates/team-agent/src/db/migration.rs +716 -0
- package/crates/team-agent/src/db/mod.rs +23 -0
- package/crates/team-agent/src/db/schema.rs +378 -0
- package/crates/team-agent/src/event_log.rs +375 -0
- package/crates/team-agent/src/fake_worker.rs +253 -0
- package/crates/team-agent/src/leader/helpers.rs +190 -0
- package/crates/team-agent/src/leader/inject.rs +33 -0
- package/crates/team-agent/src/leader/lease.rs +1063 -0
- package/crates/team-agent/src/leader/mod.rs +99 -0
- package/crates/team-agent/src/leader/owner_bind.rs +292 -0
- package/crates/team-agent/src/leader/rediscover/tests.rs +525 -0
- package/crates/team-agent/src/leader/rediscover.rs +1099 -0
- package/crates/team-agent/src/leader/start.rs +273 -0
- package/crates/team-agent/src/leader/takeover.rs +235 -0
- package/crates/team-agent/src/leader/tests/basics.rs +183 -0
- package/crates/team-agent/src/leader/tests/byte_findings.rs +234 -0
- package/crates/team-agent/src/leader/tests/identity.rs +206 -0
- package/crates/team-agent/src/leader/tests/idle.rs +271 -0
- package/crates/team-agent/src/leader/tests/lease_api.rs +225 -0
- package/crates/team-agent/src/leader/tests/lease_claim.rs +253 -0
- package/crates/team-agent/src/leader/tests/mod.rs +125 -0
- package/crates/team-agent/src/leader/tests/rediscover.rs +351 -0
- package/crates/team-agent/src/leader/tests/wake_start_owner.rs +204 -0
- package/crates/team-agent/src/leader/types.rs +487 -0
- package/crates/team-agent/src/lib.rs +85 -0
- package/crates/team-agent/src/lifecycle/display.rs +228 -0
- package/crates/team-agent/src/lifecycle/helpers.rs +112 -0
- package/crates/team-agent/src/lifecycle/launch/plan.rs +227 -0
- package/crates/team-agent/src/lifecycle/launch.rs +1833 -0
- package/crates/team-agent/src/lifecycle/mod.rs +62 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +533 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +517 -0
- package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +41 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +268 -0
- package/crates/team-agent/src/lifecycle/restart/remove.rs +780 -0
- package/crates/team-agent/src/lifecycle/restart/selection.rs +208 -0
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +242 -0
- package/crates/team-agent/src/lifecycle/restart.rs +76 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +455 -0
- package/crates/team-agent/src/lifecycle/tests/core.rs +989 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +583 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +933 -0
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +265 -0
- package/crates/team-agent/src/lifecycle/tests.rs +27 -0
- package/crates/team-agent/src/lifecycle/types.rs +685 -0
- package/crates/team-agent/src/main.rs +41 -0
- package/crates/team-agent/src/mcp_server/helpers.rs +228 -0
- package/crates/team-agent/src/mcp_server/mod.rs +183 -0
- package/crates/team-agent/src/mcp_server/normalize.rs +312 -0
- package/crates/team-agent/src/mcp_server/tests/golden.rs +283 -0
- package/crates/team-agent/src/mcp_server/tests/normalize.rs +244 -0
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +189 -0
- package/crates/team-agent/src/mcp_server/tests/send.rs +222 -0
- package/crates/team-agent/src/mcp_server/tests/tools.rs +158 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +159 -0
- package/crates/team-agent/src/mcp_server/tests.rs +38 -0
- package/crates/team-agent/src/mcp_server/tools.rs +603 -0
- package/crates/team-agent/src/mcp_server/types.rs +421 -0
- package/crates/team-agent/src/mcp_server/wire.rs +388 -0
- package/crates/team-agent/src/message_store.rs +767 -0
- package/crates/team-agent/src/messaging/activity.rs +433 -0
- package/crates/team-agent/src/messaging/delivery.rs +542 -0
- package/crates/team-agent/src/messaging/helpers.rs +209 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +340 -0
- package/crates/team-agent/src/messaging/mod.rs +147 -0
- package/crates/team-agent/src/messaging/peers.rs +32 -0
- package/crates/team-agent/src/messaging/results.rs +537 -0
- package/crates/team-agent/src/messaging/scheduler.rs +344 -0
- package/crates/team-agent/src/messaging/selftest.rs +100 -0
- package/crates/team-agent/src/messaging/send.rs +582 -0
- package/crates/team-agent/src/messaging/tests/basic.rs +357 -0
- package/crates/team-agent/src/messaging/tests/main_preserved.rs +122 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +293 -0
- package/crates/team-agent/src/messaging/tests/runtime.rs +1422 -0
- package/crates/team-agent/src/messaging/tests/spine.rs +437 -0
- package/crates/team-agent/src/messaging/trust.rs +192 -0
- package/crates/team-agent/src/messaging/types.rs +355 -0
- package/crates/team-agent/src/messaging/watchers.rs +591 -0
- package/crates/team-agent/src/model/enums.rs +311 -0
- package/crates/team-agent/src/model/errors.rs +17 -0
- package/crates/team-agent/src/model/ids.rs +155 -0
- package/crates/team-agent/src/model/mod.rs +22 -0
- package/crates/team-agent/src/model/paths.rs +228 -0
- package/crates/team-agent/src/model/permissions.rs +567 -0
- package/crates/team-agent/src/model/routing.rs +340 -0
- package/crates/team-agent/src/model/spec.rs +680 -0
- package/crates/team-agent/src/model/task_graph.rs +380 -0
- package/crates/team-agent/src/model/testdata/fuzz.golden.yaml +43 -0
- package/crates/team-agent/src/model/testdata/fuzz.yaml +43 -0
- package/crates/team-agent/src/model/testdata/spec_invalid_a.yaml +207 -0
- package/crates/team-agent/src/model/testdata/team.spec.golden.yaml +206 -0
- package/crates/team-agent/src/model/testdata/team.spec.yaml +206 -0
- package/crates/team-agent/src/model/yaml/tests.rs +288 -0
- package/crates/team-agent/src/model/yaml.rs +800 -0
- package/crates/team-agent/src/packaging/install.rs +305 -0
- package/crates/team-agent/src/packaging/migrate.rs +30 -0
- package/crates/team-agent/src/packaging/mod.rs +82 -0
- package/crates/team-agent/src/packaging/repair.rs +24 -0
- package/crates/team-agent/src/packaging/tests.rs +829 -0
- package/crates/team-agent/src/packaging/types.rs +369 -0
- package/crates/team-agent/src/provider/adapter.rs +801 -0
- package/crates/team-agent/src/provider/approvals/mod.rs +2 -0
- package/crates/team-agent/src/provider/approvals/parsing.rs +452 -0
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +163 -0
- package/crates/team-agent/src/provider/classify.rs +456 -0
- package/crates/team-agent/src/provider/faults.rs +136 -0
- package/crates/team-agent/src/provider/helpers.rs +41 -0
- package/crates/team-agent/src/provider/mod.rs +53 -0
- package/crates/team-agent/src/provider/startup_prompt.rs +423 -0
- package/crates/team-agent/src/provider/tests/adapter.rs +239 -0
- package/crates/team-agent/src/provider/tests/classify.rs +240 -0
- package/crates/team-agent/src/provider/tests/faults.rs +120 -0
- package/crates/team-agent/src/provider/tests/idle.rs +208 -0
- package/crates/team-agent/src/provider/tests/wire.rs +213 -0
- package/crates/team-agent/src/provider/tests.rs +31 -0
- package/crates/team-agent/src/provider/types.rs +424 -0
- package/crates/team-agent/src/state/identity.rs +656 -0
- package/crates/team-agent/src/state/mod.rs +58 -0
- package/crates/team-agent/src/state/owner_gate.rs +423 -0
- package/crates/team-agent/src/state/persist.rs +712 -0
- package/crates/team-agent/src/state/projection.rs +657 -0
- package/crates/team-agent/src/state/selector.rs +105 -0
- package/crates/team-agent/src/state/testdata/state-rich.canonical.json +133 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +586 -0
- package/crates/team-agent/src/tmux_backend.rs +758 -0
- package/crates/team-agent/src/transport/test_support.rs +252 -0
- package/crates/team-agent/src/transport/tests/behavior.rs +327 -0
- package/crates/team-agent/src/transport/tests/mod.rs +199 -0
- package/crates/team-agent/src/transport/tests/wire.rs +527 -0
- package/crates/team-agent/src/transport.rs +774 -0
- package/npm/install.mjs +90 -106
- package/package.json +15 -13
- package/crates/team-agent-core/Cargo.toml +0 -12
- package/crates/team-agent-core/src/lib.rs +0 -332
- package/crates/team-agent-core/src/main.rs +0 -152
- package/pyproject.toml +0 -18
- package/scripts/install.py +0 -88
- package/scripts/run_regression_tests.py +0 -83
- package/src/team_agent/__init__.py +0 -3
- package/src/team_agent/__main__.py +0 -5
- package/src/team_agent/_legacy_pane_discovery.py +0 -186
- package/src/team_agent/abnormal_track.py +0 -253
- package/src/team_agent/approvals/__init__.py +0 -65
- package/src/team_agent/approvals/constants.py +0 -6
- package/src/team_agent/approvals/parsing.py +0 -176
- package/src/team_agent/approvals/runtime_prompts.py +0 -171
- package/src/team_agent/approvals/status.py +0 -176
- package/src/team_agent/cli/__init__.py +0 -137
- package/src/team_agent/cli/commands.py +0 -481
- package/src/team_agent/cli/e2e.py +0 -202
- package/src/team_agent/cli/helpers.py +0 -226
- package/src/team_agent/cli/parser.py +0 -540
- package/src/team_agent/compiler.py +0 -334
- package/src/team_agent/coordinator/__init__.py +0 -53
- package/src/team_agent/coordinator/__main__.py +0 -83
- package/src/team_agent/coordinator/lifecycle.py +0 -363
- package/src/team_agent/coordinator/metadata.py +0 -61
- package/src/team_agent/coordinator/paths.py +0 -17
- package/src/team_agent/diagnose/__init__.py +0 -48
- package/src/team_agent/diagnose/checks.py +0 -101
- package/src/team_agent/diagnose/comms.py +0 -213
- package/src/team_agent/diagnose/health.py +0 -241
- package/src/team_agent/diagnose/orphan_cleanup.py +0 -364
- package/src/team_agent/diagnose/preflight.py +0 -194
- package/src/team_agent/diagnose/quick_start.py +0 -324
- package/src/team_agent/display/__init__.py +0 -92
- package/src/team_agent/display/adaptive.py +0 -511
- package/src/team_agent/display/backend.py +0 -46
- package/src/team_agent/display/close.py +0 -154
- package/src/team_agent/display/ghostty.py +0 -77
- package/src/team_agent/display/rebuild.py +0 -102
- package/src/team_agent/display/tiling.py +0 -156
- package/src/team_agent/display/worker_window.py +0 -114
- package/src/team_agent/display/workspace.py +0 -382
- package/src/team_agent/errors.py +0 -10
- package/src/team_agent/events.py +0 -84
- package/src/team_agent/fake_worker.py +0 -80
- package/src/team_agent/idle_predicate.py +0 -200
- package/src/team_agent/idle_takeover.py +0 -59
- package/src/team_agent/idle_takeover_wiring.py +0 -111
- package/src/team_agent/launch/__init__.py +0 -41
- package/src/team_agent/launch/bootstrap.py +0 -85
- package/src/team_agent/launch/config.py +0 -106
- package/src/team_agent/launch/core.py +0 -301
- package/src/team_agent/launch/requirements.py +0 -57
- package/src/team_agent/leader/__init__.py +0 -926
- package/src/team_agent/leader_binding.py +0 -183
- package/src/team_agent/lifecycle/__init__.py +0 -5
- package/src/team_agent/lifecycle/agents.py +0 -278
- package/src/team_agent/lifecycle/operations.py +0 -411
- package/src/team_agent/lifecycle/paste_buffer_hygiene.py +0 -39
- package/src/team_agent/lifecycle/start.py +0 -363
- package/src/team_agent/mcp_server/__init__.py +0 -42
- package/src/team_agent/mcp_server/__main__.py +0 -7
- package/src/team_agent/mcp_server/contracts.py +0 -148
- package/src/team_agent/mcp_server/normalize.py +0 -257
- package/src/team_agent/mcp_server/server.py +0 -150
- package/src/team_agent/mcp_server/tools.py +0 -352
- package/src/team_agent/message_store/__init__.py +0 -23
- package/src/team_agent/message_store/agent_health.py +0 -113
- package/src/team_agent/message_store/core.py +0 -497
- package/src/team_agent/message_store/leader_notification_log.py +0 -198
- package/src/team_agent/message_store/result_watchers.py +0 -251
- package/src/team_agent/message_store/schema.py +0 -308
- package/src/team_agent/message_store/schema_migration.py +0 -448
- package/src/team_agent/messaging/__init__.py +0 -1
- package/src/team_agent/messaging/activity_detector.py +0 -254
- package/src/team_agent/messaging/delivery.py +0 -473
- package/src/team_agent/messaging/deps.py +0 -247
- package/src/team_agent/messaging/idle_alerts.py +0 -423
- package/src/team_agent/messaging/internal_delivery.py +0 -46
- package/src/team_agent/messaging/leader.py +0 -497
- package/src/team_agent/messaging/leader_api_errors.py +0 -216
- package/src/team_agent/messaging/leader_panes.py +0 -673
- package/src/team_agent/messaging/owner_bypass.py +0 -29
- package/src/team_agent/messaging/result_delivery.py +0 -539
- package/src/team_agent/messaging/results.py +0 -447
- package/src/team_agent/messaging/scheduler.py +0 -450
- package/src/team_agent/messaging/send.py +0 -532
- package/src/team_agent/messaging/session_drift.py +0 -94
- package/src/team_agent/messaging/tmux_io.py +0 -506
- package/src/team_agent/messaging/tmux_prompt.py +0 -338
- package/src/team_agent/messaging/trust_auto_answer.py +0 -52
- package/src/team_agent/orchestrator/__init__.py +0 -376
- package/src/team_agent/orchestrator/plan.py +0 -122
- package/src/team_agent/orchestrator/state.py +0 -128
- package/src/team_agent/paths.py +0 -45
- package/src/team_agent/permissions.py +0 -123
- package/src/team_agent/profiles/__init__.py +0 -82
- package/src/team_agent/profiles/constants.py +0 -19
- package/src/team_agent/profiles/core.py +0 -407
- package/src/team_agent/profiles/helpers.py +0 -69
- package/src/team_agent/profiles/provider_env.py +0 -188
- package/src/team_agent/profiles/smoke.py +0 -201
- package/src/team_agent/provider_cli/__init__.py +0 -43
- package/src/team_agent/provider_cli/adapter.py +0 -172
- package/src/team_agent/provider_cli/base.py +0 -48
- package/src/team_agent/provider_cli/claude.py +0 -457
- package/src/team_agent/provider_cli/codex.py +0 -336
- package/src/team_agent/provider_cli/copilot.py +0 -8
- package/src/team_agent/provider_cli/fake.py +0 -39
- package/src/team_agent/provider_cli/gemini.py +0 -95
- package/src/team_agent/provider_cli/opencode.py +0 -8
- package/src/team_agent/provider_cli/prompt.py +0 -62
- package/src/team_agent/provider_cli/registry.py +0 -18
- package/src/team_agent/provider_cli/unsupported.py +0 -32
- package/src/team_agent/provider_state/README.md +0 -78
- package/src/team_agent/provider_state/__init__.py +0 -86
- package/src/team_agent/provider_state/claude.py +0 -86
- package/src/team_agent/provider_state/codex.py +0 -84
- package/src/team_agent/provider_state/common.py +0 -207
- package/src/team_agent/provider_state/registry.py +0 -118
- package/src/team_agent/providers.py +0 -163
- package/src/team_agent/quality_gates.py +0 -104
- package/src/team_agent/restart/__init__.py +0 -34
- package/src/team_agent/restart/orchestration.py +0 -554
- package/src/team_agent/restart/selection.py +0 -89
- package/src/team_agent/restart/snapshot.py +0 -70
- package/src/team_agent/routing.py +0 -84
- package/src/team_agent/runtime.py +0 -1239
- package/src/team_agent/rust_core.py +0 -327
- package/src/team_agent/sessions/__init__.py +0 -25
- package/src/team_agent/sessions/capture.py +0 -143
- package/src/team_agent/sessions/inventory.py +0 -44
- package/src/team_agent/sessions/resume.py +0 -135
- package/src/team_agent/simple_yaml.py +0 -236
- package/src/team_agent/spec.py +0 -370
- package/src/team_agent/state.py +0 -602
- package/src/team_agent/status/__init__.py +0 -63
- package/src/team_agent/status/approvals.py +0 -52
- package/src/team_agent/status/compact.py +0 -158
- package/src/team_agent/status/constants.py +0 -18
- package/src/team_agent/status/inbox.py +0 -58
- package/src/team_agent/status/peek.py +0 -117
- package/src/team_agent/status/queries.py +0 -199
- package/src/team_agent/task_graph.py +0 -80
- package/src/team_agent/terminal.py +0 -57
- package/src/team_agent/wake.py +0 -58
- package/src/team_agent/watch/__init__.py +0 -145
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
fn claude_end_turn() -> String {
|
|
2
|
+
r#"{"type":"assistant","requestId":"r1","message":{"stop_reason":"end_turn"}}"#.to_string()
|
|
3
|
+
}
|
|
4
|
+
fn claude_open_turn() -> String {
|
|
5
|
+
r#"{"type":"assistant","requestId":"r1","message":{"stop_reason":"tool_use"}}"#.to_string()
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
#[test]
|
|
9
|
+
fn classify_empty_text_is_unknown_never_idle() {
|
|
10
|
+
// probe empty_text: state=unknown turn_id=None reason=unreadable_or_empty
|
|
11
|
+
// source=session_file. BLOOD-LINE: unknown is NEVER idle (bug-071/077/085).
|
|
12
|
+
let c = classify(Provider::ClaudeCode, "", ProcessLiveness::Unverifiable, 0.0)
|
|
13
|
+
.expect("classify ok");
|
|
14
|
+
assert_eq!(c.state, TurnState::Unknown);
|
|
15
|
+
assert_eq!(c.reason, "unreadable_or_empty");
|
|
16
|
+
assert_eq!(c.turn_id, None);
|
|
17
|
+
assert_eq!(c.source, ClassifySource::SessionFile);
|
|
18
|
+
// The pin the audit demands: classify(unreadable) MUST NOT be treatable as idle.
|
|
19
|
+
assert!(
|
|
20
|
+
!c.state.is_idle_for_takeover(),
|
|
21
|
+
"unreadable/empty input must never be idle (C5)"
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[test]
|
|
26
|
+
fn classify_whitespace_only_is_unknown() {
|
|
27
|
+
// probe whitespace_only → unknown / unreadable_or_empty.
|
|
28
|
+
let c = classify(Provider::Claude, " \n \t \n", ProcessLiveness::Unverifiable, 0.0)
|
|
29
|
+
.expect("classify ok");
|
|
30
|
+
assert_eq!(c.state, TurnState::Unknown);
|
|
31
|
+
assert_eq!(c.reason, "unreadable_or_empty");
|
|
32
|
+
assert!(!c.state.is_idle_for_takeover());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#[test]
|
|
36
|
+
fn classify_garbage_jsonl_is_unknown_not_idle() {
|
|
37
|
+
// probe garbage_jsonl ("not json\n{broken"): parse yields diagnostics but
|
|
38
|
+
// had_records=false → reason=unreadable_or_empty (the !had_records branch
|
|
39
|
+
// wins over diagnostics; common.py:72-75). NEVER idle.
|
|
40
|
+
let c = classify(Provider::Claude, "not json\n{broken", ProcessLiveness::Unverifiable, 0.0)
|
|
41
|
+
.expect("classify ok");
|
|
42
|
+
assert_eq!(c.state, TurnState::Unknown);
|
|
43
|
+
assert_eq!(c.reason, "unreadable_or_empty");
|
|
44
|
+
assert!(!c.state.is_idle_for_takeover());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[test]
|
|
48
|
+
fn classify_unrecognized_format_is_unknown() {
|
|
49
|
+
// probe '{"foo":"bar"}': had_records=true, no lifecycle fact, NO diagnostics
|
|
50
|
+
// → reason=no_turn_lifecycle_fact (NOT unrecognized_format — golden-confirmed).
|
|
51
|
+
let c = classify(Provider::Claude, r#"{"foo":"bar"}"#, ProcessLiveness::Unverifiable, 0.0)
|
|
52
|
+
.expect("classify ok");
|
|
53
|
+
assert_eq!(c.state, TurnState::Unknown);
|
|
54
|
+
assert_eq!(c.reason, "no_turn_lifecycle_fact");
|
|
55
|
+
assert!(!c.state.is_idle_for_takeover());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[test]
|
|
59
|
+
fn classify_claude_code_alias_normalizes_to_claude_reader() {
|
|
60
|
+
// 陷阱 #4: claude_code → claude reader (__init__.py:88). Both must classify an
|
|
61
|
+
// end_turn transcript identically to idle / end_turn — the alias never dies.
|
|
62
|
+
let txt = claude_end_turn();
|
|
63
|
+
let alias = classify(Provider::ClaudeCode, &txt, ProcessLiveness::Unverifiable, 0.0)
|
|
64
|
+
.expect("alias ok");
|
|
65
|
+
let canon = classify(Provider::Claude, &txt, ProcessLiveness::Unverifiable, 0.0)
|
|
66
|
+
.expect("canon ok");
|
|
67
|
+
assert_eq!(alias.state, TurnState::Idle);
|
|
68
|
+
assert_eq!(alias.reason, "end_turn");
|
|
69
|
+
assert_eq!(alias, canon, "claude_code must normalize to the claude reader");
|
|
70
|
+
}
|
|
71
|
+
|
|
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
|
+
#[test]
|
|
98
|
+
fn classify_open_turn_dead_is_abnormal_crashed_mid_turn() {
|
|
99
|
+
// probe open_turn_dead: open turn + Dead → abnormal / crashed_mid_turn /
|
|
100
|
+
// source=process_guard, annotations contains "crashed_mid_turn".
|
|
101
|
+
let c = classify(Provider::Claude, &claude_open_turn(), ProcessLiveness::Dead, 0.0)
|
|
102
|
+
.expect("classify ok");
|
|
103
|
+
assert_eq!(c.state, TurnState::Abnormal);
|
|
104
|
+
assert_eq!(c.reason, "crashed_mid_turn");
|
|
105
|
+
assert_eq!(c.source, ClassifySource::ProcessGuard);
|
|
106
|
+
assert!(c.annotations.contains(&"crashed_mid_turn".to_string()));
|
|
107
|
+
}
|
|
108
|
+
|
|
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
|
+
// ---- (b) idle predicate / evaluate_takeover_reminder (NoPingReason cases) ----
|
|
237
|
+
//
|
|
238
|
+
// Golden via /tmp/probe_idle.py (idle_predicate.evaluate_takeover_reminder).
|
|
239
|
+
// Nodes / monitor_state passed as serde_json::Value dicts (Python dict shape).
|
|
240
|
+
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
fn line(v: serde_json::Value) -> String {
|
|
2
|
+
v.to_string()
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
// P0 — interrupt marker must match EXACTLY (provider_state/claude.py:75 `== _INTERRUPT_TEXT`),
|
|
6
|
+
// not `.contains()`. A transcript that merely QUOTES the marker must stay Unknown
|
|
7
|
+
// (ping-blocked); only the exact text is idle-eligible IdleInterrupted. §11 wrong-direction.
|
|
8
|
+
#[test]
|
|
9
|
+
fn p2_claude_interrupt_requires_exact_marker_text() {
|
|
10
|
+
let quote = classify(
|
|
11
|
+
Provider::ClaudeCode,
|
|
12
|
+
&line(serde_json::json!({"type":"user","uuid":"u1","message":{"content":[
|
|
13
|
+
{"type":"text","text":"prefix [Request interrupted by user] suffix"}]}})),
|
|
14
|
+
ProcessLiveness::Alive,
|
|
15
|
+
0.0,
|
|
16
|
+
)
|
|
17
|
+
.unwrap();
|
|
18
|
+
assert_eq!(quote.state, TurnState::Unknown, "merely quoting the marker must NOT be idle-eligible");
|
|
19
|
+
assert_eq!(quote.reason, "no_turn_lifecycle_fact");
|
|
20
|
+
|
|
21
|
+
let exact = classify(
|
|
22
|
+
Provider::ClaudeCode,
|
|
23
|
+
&line(serde_json::json!({"type":"user","uuid":"u1","message":{"content":[
|
|
24
|
+
{"type":"text","text":"[Request interrupted by user]"}]}})),
|
|
25
|
+
ProcessLiveness::Alive,
|
|
26
|
+
0.0,
|
|
27
|
+
)
|
|
28
|
+
.unwrap();
|
|
29
|
+
assert_eq!(exact.state, TurnState::IdleInterrupted);
|
|
30
|
+
assert_eq!(exact.turn_id.as_ref().map(TurnId::as_str), Some("u1"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// P1 — claude api_error fault requires level=="error" (claude.py:54). Missing/other
|
|
34
|
+
// level → NO fault (Python count 0).
|
|
35
|
+
#[test]
|
|
36
|
+
fn p2_claude_api_error_fault_requires_level_error() {
|
|
37
|
+
let no_level = vec![serde_json::json!({"type":"system","subtype":"api_error","sessionId":"s-1"})];
|
|
38
|
+
assert!(read_fault_facts(&no_level, Provider::ClaudeCode).is_empty(), "api_error w/o level=error is not a fault");
|
|
39
|
+
let warn = vec![serde_json::json!({"type":"system","subtype":"api_error","level":"warning","sessionId":"s-1"})];
|
|
40
|
+
assert!(read_fault_facts(&warn, Provider::ClaudeCode).is_empty(), "level=warning is not a fault");
|
|
41
|
+
|
|
42
|
+
let err = vec![serde_json::json!({"type":"system","subtype":"api_error","level":"error","sessionId":"s-1"})];
|
|
43
|
+
let facts = read_fault_facts(&err, Provider::ClaudeCode);
|
|
44
|
+
assert_eq!(facts.len(), 1);
|
|
45
|
+
assert_eq!(facts[0].signature.as_str(), "api_error");
|
|
46
|
+
assert_eq!(facts[0].turn_id.as_ref().map(TurnId::as_str), Some("s-1"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// P1 — claude api_error turn_id fallback chain = sessionId -> parentUuid -> uuid
|
|
50
|
+
// (claude.py:58), NOT sessionId -> requestId. Collapsing to None breaks C8 dedup.
|
|
51
|
+
#[test]
|
|
52
|
+
fn p2_claude_api_error_turn_id_fallback_parentuuid_then_uuid() {
|
|
53
|
+
let pu = vec![serde_json::json!({"type":"system","subtype":"api_error","level":"error","parentUuid":"pu-1"})];
|
|
54
|
+
let f = read_fault_facts(&pu, Provider::ClaudeCode);
|
|
55
|
+
assert_eq!(f.len(), 1);
|
|
56
|
+
assert_eq!(f[0].turn_id.as_ref().map(TurnId::as_str), Some("pu-1"), "parentUuid is in the chain (requestId is not)");
|
|
57
|
+
|
|
58
|
+
let uu = vec![serde_json::json!({"type":"system","subtype":"api_error","level":"error","uuid":"uu-1"})];
|
|
59
|
+
let f2 = read_fault_facts(&uu, Provider::ClaudeCode);
|
|
60
|
+
assert_eq!(f2.len(), 1);
|
|
61
|
+
assert_eq!(f2[0].turn_id.as_ref().map(TurnId::as_str), Some("uu-1"));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// P1 — codex requestApproval turn_id = params.turnId OR params.turn_id (codex.py:79).
|
|
65
|
+
#[test]
|
|
66
|
+
fn p2_codex_approval_turn_id_accepts_snake_case() {
|
|
67
|
+
let snake = vec![serde_json::json!({"jsonrpc":"2.0","method":"session/requestApproval","params":{"turn_id":"snake1"}})];
|
|
68
|
+
let f = read_fault_facts(&snake, Provider::Codex);
|
|
69
|
+
assert_eq!(f.len(), 1);
|
|
70
|
+
assert_eq!(f[0].turn_id.as_ref().map(TurnId::as_str), Some("snake1"), "snake-case turn_id must be honored");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// P1 — codex app-server turn/completed status completed/interrupted/inProgress map to
|
|
74
|
+
// idle/idle_interrupted/working (codex.py:69-74), not Unknown.
|
|
75
|
+
#[test]
|
|
76
|
+
fn p2_codex_app_server_status_completed_interrupted_in_progress() {
|
|
77
|
+
let app = |status: &str| {
|
|
78
|
+
line(serde_json::json!({"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"t1","status":status}}}))
|
|
79
|
+
};
|
|
80
|
+
let c = classify(Provider::Codex, &app("completed"), ProcessLiveness::Alive, 0.0).unwrap();
|
|
81
|
+
assert_eq!((c.state, c.reason.as_str()), (TurnState::Idle, "completed"));
|
|
82
|
+
assert_eq!(c.turn_id.as_ref().map(TurnId::as_str), Some("t1"));
|
|
83
|
+
|
|
84
|
+
let i = classify(Provider::Codex, &app("interrupted"), ProcessLiveness::Alive, 0.0).unwrap();
|
|
85
|
+
assert_eq!((i.state, i.reason.as_str()), (TurnState::IdleInterrupted, "interrupted"));
|
|
86
|
+
|
|
87
|
+
let p = classify(Provider::Codex, &app("inProgress"), ProcessLiveness::Alive, 0.0).unwrap();
|
|
88
|
+
assert_eq!((p.state, p.reason.as_str()), (TurnState::Working, "open_turn"));
|
|
89
|
+
assert_eq!(p.turn_id.as_ref().map(TurnId::as_str), Some("t1"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// P1 — codex event_msg task_started → open turn → working(alive) (codex.py:30-31).
|
|
93
|
+
#[test]
|
|
94
|
+
fn p2_codex_event_msg_task_started_is_open_turn() {
|
|
95
|
+
let txt = line(serde_json::json!({"type":"event_msg","payload":{"type":"task_started","turn_id":"ts1"}}));
|
|
96
|
+
let r = classify(Provider::Codex, &txt, ProcessLiveness::Alive, 0.0).unwrap();
|
|
97
|
+
assert_eq!((r.state, r.reason.as_str()), (TurnState::Working, "open_turn"));
|
|
98
|
+
assert_eq!(r.turn_id.as_ref().map(TurnId::as_str), Some("ts1"));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// SPAWN+FAKE-WORKER RED — Provider::Fake::build_command must invoke the fake-worker backing program
|
|
102
|
+
// (the single-binary `fake-worker` subcommand), NOT the bare placeholder vec!["fake"] (no backing
|
|
103
|
+
// binary). This is what makes launch(dry_run=false)'s spawn path exercisable with NO subscription
|
|
104
|
+
// provider. Golden intent: fake_worker.py + provider_cli/fake.py — a subscription-free backing worker.
|
|
105
|
+
#[test]
|
|
106
|
+
fn fake_build_command_invokes_fake_worker_not_bare_fake() {
|
|
107
|
+
let adapter = get_adapter(Provider::Fake);
|
|
108
|
+
let argv = adapter
|
|
109
|
+
.build_command(AuthMode::Subscription, None, None, None)
|
|
110
|
+
.expect("fake build_command");
|
|
111
|
+
assert_ne!(
|
|
112
|
+
argv,
|
|
113
|
+
vec!["fake".to_string()],
|
|
114
|
+
"Provider::Fake::build_command must not be the bare placeholder vec![\"fake\"] (no backing binary)"
|
|
115
|
+
);
|
|
116
|
+
assert!(
|
|
117
|
+
argv.iter().any(|a| a == "fake-worker"),
|
|
118
|
+
"Provider::Fake::build_command must invoke the `fake-worker` subcommand so the spawn path runs the fake backing worker; got {argv:?}"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
fn node(id: &str, role: &str, state: &str) -> serde_json::Value {
|
|
2
|
+
serde_json::json!({"node_id": id, "role": role, "state": state})
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
#[test]
|
|
6
|
+
fn idle_any_non_idle_node_blocks_ping_with_node_reason() {
|
|
7
|
+
// probe: working → node_working, unknown → node_unknown,
|
|
8
|
+
// blocked_on_human → node_blocked_on_human, abnormal → node_abnormal,
|
|
9
|
+
// missing-state → node_unknown. should_ping=False every time.
|
|
10
|
+
// BLOOD-LINE: Unknown node blocks the ping via NoPingReason::Node(Unknown).
|
|
11
|
+
for (state, expect) in [
|
|
12
|
+
("working", NoPingReason::Node(TurnState::Working)),
|
|
13
|
+
("unknown", NoPingReason::Node(TurnState::Unknown)),
|
|
14
|
+
("blocked_on_human", NoPingReason::Node(TurnState::BlockedOnHuman)),
|
|
15
|
+
("abnormal", NoPingReason::Node(TurnState::Abnormal)),
|
|
16
|
+
] {
|
|
17
|
+
let r = evaluate_takeover_reminder(&[node("w1", "worker", state)], None, 100.0, 60.0)
|
|
18
|
+
.expect("evaluate ok");
|
|
19
|
+
assert!(!r.should_ping, "{state} node must block ping");
|
|
20
|
+
assert_eq!(r.reason, expect, "{state} → {}", expect.reason_str());
|
|
21
|
+
}
|
|
22
|
+
// The exact `node_<state>` wire strings the Python `_result` emits.
|
|
23
|
+
assert_eq!(NoPingReason::Node(TurnState::Working).reason_str(), "node_working");
|
|
24
|
+
assert_eq!(NoPingReason::Node(TurnState::Unknown).reason_str(), "node_unknown");
|
|
25
|
+
assert_eq!(
|
|
26
|
+
NoPingReason::Node(TurnState::BlockedOnHuman).reason_str(),
|
|
27
|
+
"node_blocked_on_human"
|
|
28
|
+
);
|
|
29
|
+
assert_eq!(NoPingReason::Node(TurnState::Abnormal).reason_str(), "node_abnormal");
|
|
30
|
+
// missing-state node → node_unknown (state defaults to unknown, idle_predicate.py:49).
|
|
31
|
+
let missing = serde_json::json!({"node_id": "w1", "role": "worker"});
|
|
32
|
+
let r = evaluate_takeover_reminder(&[missing], None, 100.0, 60.0).expect("evaluate ok");
|
|
33
|
+
assert!(!r.should_ping);
|
|
34
|
+
assert_eq!(r.reason, NoPingReason::Node(TurnState::Unknown));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[test]
|
|
38
|
+
fn idle_all_idle_but_not_armed_blocks() {
|
|
39
|
+
// probe all_idle_not_armed → should_ping=False, reason=not_armed_no_worker_turn.
|
|
40
|
+
// Worker idle alone never arms; only a DELEGATED state arms the watch (C1).
|
|
41
|
+
let r = evaluate_takeover_reminder(&[node("w1", "worker", "idle")], None, 100.0, 60.0)
|
|
42
|
+
.expect("evaluate ok");
|
|
43
|
+
assert!(!r.should_ping);
|
|
44
|
+
assert_eq!(r.reason, NoPingReason::NotArmedNoWorkerTurn);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[test]
|
|
48
|
+
fn idle_armed_debounce_active_then_ping() {
|
|
49
|
+
// probe armed_debounce_active (all_idle_since=100, now=130, debounce=60,
|
|
50
|
+
// elapsed=30 < 60) → should_ping=False, reason=debounce_active.
|
|
51
|
+
let ms = serde_json::json!({
|
|
52
|
+
"opened_worker_turn_since_ack": true,
|
|
53
|
+
"all_idle_since": 100.0,
|
|
54
|
+
"pinged_for_episode": serde_json::Value::Null
|
|
55
|
+
});
|
|
56
|
+
let active = evaluate_takeover_reminder(&[node("w1", "worker", "idle")], Some(&ms), 130.0, 60.0)
|
|
57
|
+
.expect("evaluate ok");
|
|
58
|
+
assert!(!active.should_ping);
|
|
59
|
+
assert_eq!(active.reason, NoPingReason::DebounceActive);
|
|
60
|
+
// probe armed_debounce_elapsed (now=160, elapsed=60 >= 60) →
|
|
61
|
+
// should_ping=True, reason=all_idle_debounce_elapsed.
|
|
62
|
+
let elapsed = evaluate_takeover_reminder(&[node("w1", "worker", "idle")], Some(&ms), 160.0, 60.0)
|
|
63
|
+
.expect("evaluate ok");
|
|
64
|
+
assert!(elapsed.should_ping, "ping must fire at/after debounce");
|
|
65
|
+
assert_eq!(elapsed.reason, NoPingReason::AllIdleDebounceElapsed);
|
|
66
|
+
assert!(elapsed.message.is_some(), "ping carries the stored neutral message");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#[test]
|
|
70
|
+
fn idle_suppressed_is_acknowledged_and_already_pinged_guard() {
|
|
71
|
+
// probe armed_suppressed_acknowledged → should_ping=False, reason=acknowledged.
|
|
72
|
+
let supp = serde_json::json!({
|
|
73
|
+
"opened_worker_turn_since_ack": true,
|
|
74
|
+
"suppressed": true,
|
|
75
|
+
"all_idle_since": 100.0
|
|
76
|
+
});
|
|
77
|
+
let r = evaluate_takeover_reminder(&[node("w1", "worker", "idle")], Some(&supp), 200.0, 60.0)
|
|
78
|
+
.expect("evaluate ok");
|
|
79
|
+
assert!(!r.should_ping);
|
|
80
|
+
assert_eq!(r.reason, NoPingReason::Acknowledged);
|
|
81
|
+
// probe already_pinged_this_episode (pinged_for_episode == all_idle_since) →
|
|
82
|
+
// should_ping=False, reason=already_pinged_this_episode.
|
|
83
|
+
let pinged = serde_json::json!({
|
|
84
|
+
"opened_worker_turn_since_ack": true,
|
|
85
|
+
"all_idle_since": 100.0,
|
|
86
|
+
"pinged_for_episode": 100.0
|
|
87
|
+
});
|
|
88
|
+
let r2 = evaluate_takeover_reminder(&[node("w1", "worker", "idle")], Some(&pinged), 200.0, 60.0)
|
|
89
|
+
.expect("evaluate ok");
|
|
90
|
+
assert!(!r2.should_ping);
|
|
91
|
+
assert_eq!(r2.reason, NoPingReason::AlreadyPingedThisEpisode);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[test]
|
|
95
|
+
fn idle_interrupted_counts_as_idle_and_appears_in_interrupted_nodes() {
|
|
96
|
+
// probe interrupted_counts_idle_ping → should_ping=True AND
|
|
97
|
+
// interrupted_nodes=["w1"] (C12: idle_interrupted is idle but annotated).
|
|
98
|
+
let armed = serde_json::json!({
|
|
99
|
+
"opened_worker_turn_since_ack": true,
|
|
100
|
+
"all_idle_since": 100.0,
|
|
101
|
+
"pinged_for_episode": serde_json::Value::Null
|
|
102
|
+
});
|
|
103
|
+
let r = evaluate_takeover_reminder(
|
|
104
|
+
&[node("w1", "worker", "idle_interrupted")],
|
|
105
|
+
Some(&armed),
|
|
106
|
+
200.0,
|
|
107
|
+
60.0,
|
|
108
|
+
)
|
|
109
|
+
.expect("evaluate ok");
|
|
110
|
+
assert!(r.should_ping);
|
|
111
|
+
assert_eq!(r.reason, NoPingReason::AllIdleDebounceElapsed);
|
|
112
|
+
assert_eq!(r.interrupted_nodes, vec!["w1".to_string()]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#[test]
|
|
116
|
+
fn idle_leader_activity_never_arms_but_leader_idle_allows_ping() {
|
|
117
|
+
// probe leader_working_does_not_arm: leader-only working never arms; the
|
|
118
|
+
// working node still BLOCKS the ping → reason=node_working, should_ping=False.
|
|
119
|
+
let r = evaluate_takeover_reminder(&[node("leader", "leader", "working")], None, 100.0, 60.0)
|
|
120
|
+
.expect("evaluate ok");
|
|
121
|
+
assert!(!r.should_ping);
|
|
122
|
+
assert_eq!(r.reason, NoPingReason::Node(TurnState::Working));
|
|
123
|
+
// probe leader_and_worker_idle_armed_ping: once a WORKER opened a turn the
|
|
124
|
+
// watch is armed; leader+worker both idle past debounce → should_ping=True.
|
|
125
|
+
let armed = serde_json::json!({
|
|
126
|
+
"opened_worker_turn_since_ack": true,
|
|
127
|
+
"all_idle_since": 100.0,
|
|
128
|
+
"pinged_for_episode": serde_json::Value::Null
|
|
129
|
+
});
|
|
130
|
+
let nodes = [node("leader", "leader", "idle"), node("w1", "worker", "idle")];
|
|
131
|
+
let r2 = evaluate_takeover_reminder(&nodes, Some(&armed), 200.0, 60.0).expect("evaluate ok");
|
|
132
|
+
assert!(r2.should_ping);
|
|
133
|
+
assert_eq!(r2.reason, NoPingReason::AllIdleDebounceElapsed);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- (c) trust-prompt recognizer (REAL fixtures, own-vs-foreign) ----
|
|
137
|
+
//
|
|
138
|
+
// NOTE: the own-vs-foreign trust recognizer lives in messaging/leader_panes.py
|
|
139
|
+
// (step 9/10 owns it per card §42). The provider.rs skeleton exposes only
|
|
140
|
+
// `status_patterns()` (idle/processing/trust regex set) — driven RED below.
|
|
141
|
+
// Full own-vs-foreign realpath judgement + truncated-workspace logic deferred.
|
|
142
|
+
|
|
143
|
+
// Real fixtures (mirrored into the rust workspace from team-agent-public).
|
|
144
|
+
const CLAUDE_IDLE_FIXTURE: &str = include_str!(concat!(
|
|
145
|
+
env!("CARGO_MANIFEST_DIR"),
|
|
146
|
+
"/../../snapshot/fixtures/idle_prompts/claude_code_idle.txt"
|
|
147
|
+
));
|
|
148
|
+
const CODEX_IDLE_FIXTURE: &str = include_str!(concat!(
|
|
149
|
+
env!("CARGO_MANIFEST_DIR"),
|
|
150
|
+
"/../../snapshot/fixtures/idle_prompts/codex_idle.txt"
|
|
151
|
+
));
|
|
152
|
+
const CODEX_WORKING_FIXTURE: &str = include_str!(concat!(
|
|
153
|
+
env!("CARGO_MANIFEST_DIR"),
|
|
154
|
+
"/../../snapshot/fixtures/idle_prompts/codex_working.txt"
|
|
155
|
+
));
|
|
156
|
+
|
|
157
|
+
fn fixture_matches(re: ®ex::Regex, fixture: &str) -> bool {
|
|
158
|
+
fixture.lines().any(|l| re.is_match(l))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#[test]
|
|
162
|
+
fn claude_status_patterns_compile() {
|
|
163
|
+
// provider_cli/claude.py:225 status_patterns idle=r"[>❯]\s".
|
|
164
|
+
// The real fixture has prompt lines like "❯ /compact" → idle MUST match.
|
|
165
|
+
// The processing pattern r"[✶✢✽✻✳·].*…" must NOT match those idle prompt lines.
|
|
166
|
+
let adapter = get_adapter(Provider::ClaudeCode);
|
|
167
|
+
let pats = adapter.status_patterns().expect("status_patterns ok");
|
|
168
|
+
assert!(
|
|
169
|
+
fixture_matches(&pats.idle, CLAUDE_IDLE_FIXTURE),
|
|
170
|
+
"claude idle pattern must match a '❯ ' prompt line in the idle fixture"
|
|
171
|
+
);
|
|
172
|
+
assert!(
|
|
173
|
+
pats.idle.is_match("❯ /compact"),
|
|
174
|
+
"claude idle pattern matches the canonical prompt line"
|
|
175
|
+
);
|
|
176
|
+
assert!(
|
|
177
|
+
!pats.processing.is_match("❯ /compact"),
|
|
178
|
+
"claude processing pattern must NOT match an idle prompt line"
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[test]
|
|
183
|
+
fn codex_status_patterns_compile() {
|
|
184
|
+
// provider_cli/codex.py:140 idle=r"(›|❯|codex>)" processing=r"•.*esc to interrupt".
|
|
185
|
+
// codex_idle.txt has a "› Find and fix a bug…" prompt → idle matches.
|
|
186
|
+
// codex_working.txt has a "• …esc to interrupt" spinner → processing matches.
|
|
187
|
+
let adapter = get_adapter(Provider::Codex);
|
|
188
|
+
let pats = adapter.status_patterns().expect("status_patterns ok");
|
|
189
|
+
assert!(
|
|
190
|
+
fixture_matches(&pats.idle, CODEX_IDLE_FIXTURE),
|
|
191
|
+
"codex idle pattern must match a '›' prompt line"
|
|
192
|
+
);
|
|
193
|
+
assert!(
|
|
194
|
+
CODEX_WORKING_FIXTURE.lines().any(|l| pats.processing.is_match(l)),
|
|
195
|
+
"codex processing pattern must match the 'esc to interrupt' spinner"
|
|
196
|
+
);
|
|
197
|
+
// The discriminator: a pure working-status footer line carries no prompt char.
|
|
198
|
+
assert!(
|
|
199
|
+
!pats.idle.is_match(" gpt-5.5 medium · /private/tmp/working"),
|
|
200
|
+
"codex idle pattern must NOT match a bare status footer"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---- (d) abnormal dedup key (signature, Option<TurnId>) ----
|
|
205
|
+
//
|
|
206
|
+
// NOTE: read_fault_facts lives in provider_state; not on the skeleton trait.
|
|
207
|
+
// Golden dedup keys recorded below; flagged deferred for the fault-facts entry.
|
|
208
|
+
|