@team-agent/installer 0.5.106 → 0.5.108
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/adapters.rs +1 -1
- package/crates/team-agent/src/cli/emit.rs +3 -3
- package/crates/team-agent/src/cli/mod.rs +87 -4
- package/crates/team-agent/src/cli/models.rs +252 -3
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/status_port/brief.rs +155 -37
- package/crates/team-agent/src/cli/tests/base.rs +112 -225
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +184 -0
- package/crates/team-agent/src/cli/tests/status_brief.rs +624 -0
- 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/model_catalog.rs +166 -3
- package/crates/team-agent/src/provider/testdata/cursor-list-models-native.stdout +250 -0
- package/crates/team-agent/src/provider/testdata/grok-models-authenticated.stdout +9 -0
- package/crates/team-agent/src/provider/testdata/grok-models-unauthenticated.stdout +7 -0
- 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
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
|
2
|
+
|
|
3
|
+
#[path = "../../../tests/support/hermetic.rs"]
|
|
4
|
+
mod hermetic;
|
|
5
|
+
#[path = "../../../tests/support/mcp_sim_harness.rs"]
|
|
6
|
+
mod sim;
|
|
7
|
+
|
|
8
|
+
use hermetic::HermeticTestEnv;
|
|
9
|
+
use serde_json::{json, Value};
|
|
10
|
+
use std::collections::BTreeSet;
|
|
11
|
+
use std::io::{BufRead, BufReader, Write};
|
|
12
|
+
use std::os::unix::fs::PermissionsExt;
|
|
13
|
+
use std::path::{Path, PathBuf};
|
|
14
|
+
use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio};
|
|
15
|
+
use std::sync::mpsc::{self, Receiver};
|
|
16
|
+
use std::thread;
|
|
17
|
+
use std::time::{Duration, Instant};
|
|
18
|
+
|
|
19
|
+
use crate::event_log::EventLog;
|
|
20
|
+
use crate::lifecycle::launch::pi_mcp::{
|
|
21
|
+
materialize_pi_plan, pi_model_candidates, pi_seat_paths, PiMaterializeRequest, PiSessionScope,
|
|
22
|
+
};
|
|
23
|
+
use crate::lifecycle::launch::run_pi_catalog_preflight;
|
|
24
|
+
use crate::lifecycle::tests::test_binary_path;
|
|
25
|
+
use crate::message_store::MessageStore;
|
|
26
|
+
use crate::provider::McpConfig;
|
|
27
|
+
|
|
28
|
+
const CHILD_MARKER: &str = "TEAM_AGENT_TEST_PI_NATIVE_MCP_CHILD";
|
|
29
|
+
const TEST_PATH: &str = concat!(
|
|
30
|
+
"lifecycle::tests::pi_native_mcp_capability_red::",
|
|
31
|
+
"native_pi_without_adapter_registers_and_serves_all_team_tools"
|
|
32
|
+
);
|
|
33
|
+
const REQUIRED_TOOLS: [&str; 3] = ["send_message", "report_result", "get_team_status"];
|
|
34
|
+
|
|
35
|
+
#[test]
|
|
36
|
+
fn native_pi_without_adapter_registers_and_serves_all_team_tools() {
|
|
37
|
+
if std::env::var_os(CHILD_MARKER).is_some() {
|
|
38
|
+
native_pi_without_adapter_registers_and_serves_all_team_tools_body();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let output = Command::new(std::env::current_exe().expect("lib-test executable"))
|
|
43
|
+
.args(["--exact", TEST_PATH, "--nocapture", "--test-threads=1"])
|
|
44
|
+
.env(CHILD_MARKER, "1")
|
|
45
|
+
.output()
|
|
46
|
+
.expect("run N1 native Pi fixture in an isolated test process");
|
|
47
|
+
assert!(
|
|
48
|
+
output.status.success(),
|
|
49
|
+
"isolated N1 child failed: status={:?}\nstdout:\n{}\nstderr:\n{}",
|
|
50
|
+
output.status.code(),
|
|
51
|
+
String::from_utf8_lossy(&output.stdout),
|
|
52
|
+
String::from_utf8_lossy(&output.stderr),
|
|
53
|
+
);
|
|
54
|
+
print!("{}", String::from_utf8_lossy(&output.stdout));
|
|
55
|
+
eprint!("{}", String::from_utf8_lossy(&output.stderr));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fn native_pi_without_adapter_registers_and_serves_all_team_tools_body() {
|
|
59
|
+
let hermetic = HermeticTestEnv::enter("pi-native-mcp-n1");
|
|
60
|
+
let system_path = std::env::var_os("PATH").expect("test PATH");
|
|
61
|
+
let runner = executable_on_path("bun", &system_path)
|
|
62
|
+
.or_else(|| executable_on_path("node", &system_path))
|
|
63
|
+
.expect("N1 fixture requires Bun or Node.js (>=22) runtime");
|
|
64
|
+
let root = hermetic.workspace("native-pi");
|
|
65
|
+
let bin = root.join("bin");
|
|
66
|
+
std::fs::create_dir_all(&bin).expect("create fake Pi bin");
|
|
67
|
+
let call_log = root.join("pi-calls.txt");
|
|
68
|
+
let pi_real = root.join("pi-real");
|
|
69
|
+
let script = r##"#!/bin/sh
|
|
70
|
+
printf '%s\n' "${1-}" >> "$TEAM_AGENT_TEST_PI_CALL_LOG"
|
|
71
|
+
case "${1-}" in
|
|
72
|
+
--version) printf '1.0.0\n' ;;
|
|
73
|
+
--list-models) printf 'provider model\nteam-agent qwen3.8-27b\n' ;;
|
|
74
|
+
list) exit 0 ;;
|
|
75
|
+
*) exit 64 ;;
|
|
76
|
+
esac
|
|
77
|
+
"##;
|
|
78
|
+
std::fs::write(&pi_real, script).expect("write standard Pi fixture");
|
|
79
|
+
std::fs::set_permissions(&pi_real, std::fs::Permissions::from_mode(0o755))
|
|
80
|
+
.expect("make fake Pi executable");
|
|
81
|
+
std::os::unix::fs::symlink(&pi_real, bin.join("pi")).expect("create npm-style Pi symlink");
|
|
82
|
+
let mut fixture_path_entries = vec![bin.clone()];
|
|
83
|
+
fixture_path_entries.extend(std::env::split_paths(&system_path));
|
|
84
|
+
let fixture_path = std::env::join_paths(fixture_path_entries).expect("join fake Pi PATH");
|
|
85
|
+
let _path = hermetic.with_env(
|
|
86
|
+
"PATH",
|
|
87
|
+
fixture_path.to_str().expect("fixture PATH must be UTF-8"),
|
|
88
|
+
);
|
|
89
|
+
let _call_log = hermetic.with_env(
|
|
90
|
+
"TEAM_AGENT_TEST_PI_CALL_LOG",
|
|
91
|
+
call_log.to_str().expect("call log path must be UTF-8"),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
let roles = hermetic.workspace("roles");
|
|
95
|
+
let agents = roles.join("agents");
|
|
96
|
+
std::fs::create_dir_all(&agents).expect("create Pi role directory");
|
|
97
|
+
std::fs::write(
|
|
98
|
+
agents.join("n1.md"),
|
|
99
|
+
"---\nprovider: pi\nmodel: team-agent/qwen3.8-27b\n---\nN1 native Pi fixture\n",
|
|
100
|
+
)
|
|
101
|
+
.expect("write exact-model Pi role");
|
|
102
|
+
assert_eq!(
|
|
103
|
+
pi_model_candidates("qwen3.8-27b").expect("fake Pi model catalog"),
|
|
104
|
+
["team-agent/qwen3.8-27b"],
|
|
105
|
+
"catalog discovery must succeed without consulting the package list"
|
|
106
|
+
);
|
|
107
|
+
let mut discover = |requested: &str| pi_model_candidates(requested).map_err(|_| ());
|
|
108
|
+
run_pi_catalog_preflight(&roles, &mut discover)
|
|
109
|
+
.expect("exact-model catalog preflight must pass on native-only Pi");
|
|
110
|
+
|
|
111
|
+
let harness = sim::McpSimHarness::new();
|
|
112
|
+
harness.bind_leader_receiver_to_team("teamA");
|
|
113
|
+
let workspace = harness.workspace_path();
|
|
114
|
+
let candidate = PathBuf::from(test_binary_path());
|
|
115
|
+
assert!(
|
|
116
|
+
candidate.is_absolute() && candidate.is_file(),
|
|
117
|
+
"MCP candidate must be an absolute built binary: {}",
|
|
118
|
+
candidate.display()
|
|
119
|
+
);
|
|
120
|
+
let args = vec![
|
|
121
|
+
"mcp-server".to_string(),
|
|
122
|
+
"--workspace".to_string(),
|
|
123
|
+
workspace.to_string_lossy().into_owned(),
|
|
124
|
+
];
|
|
125
|
+
let env = json!({
|
|
126
|
+
"TEAM_AGENT_WORKSPACE": workspace,
|
|
127
|
+
"TEAM_AGENT_ID": "worker_a",
|
|
128
|
+
"TEAM_AGENT_AGENT_ID": "worker_a",
|
|
129
|
+
"TEAM_AGENT_OWNER_TEAM_ID": "teamA",
|
|
130
|
+
"TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE": "leader-session-team-a"
|
|
131
|
+
});
|
|
132
|
+
let config = McpConfig {
|
|
133
|
+
raw: json!({
|
|
134
|
+
"team_orchestrator": {
|
|
135
|
+
"command": candidate,
|
|
136
|
+
"args": args,
|
|
137
|
+
"cwd": workspace,
|
|
138
|
+
"env": env
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
let plan = materialize_pi_plan(PiMaterializeRequest {
|
|
143
|
+
workspace,
|
|
144
|
+
team_id: "teamA",
|
|
145
|
+
agent_id: "worker_a",
|
|
146
|
+
model: Some("team-agent/qwen3.8-27b"),
|
|
147
|
+
effort: None,
|
|
148
|
+
system_prompt: "N1 native Pi MCP capability fixture",
|
|
149
|
+
team_mcp_tools: &REQUIRED_TOOLS,
|
|
150
|
+
mcp_config: &config,
|
|
151
|
+
session_scope: PiSessionScope::Isolated,
|
|
152
|
+
});
|
|
153
|
+
let pi_calls = std::fs::read_to_string(&call_log).unwrap_or_default();
|
|
154
|
+
assert!(
|
|
155
|
+
plan.is_ok(),
|
|
156
|
+
"N1 must materialize on native-capable Pi when `pi list` is empty; got={plan:?}; observed_pi_commands={pi_calls:?}"
|
|
157
|
+
);
|
|
158
|
+
let plan = plan.expect("N1 materialization after the assertion above");
|
|
159
|
+
|
|
160
|
+
let commands = pi_calls.lines().collect::<Vec<_>>();
|
|
161
|
+
assert!(
|
|
162
|
+
!commands.iter().any(|command| *command == "list"),
|
|
163
|
+
"capability resolution must not call `pi list` or inspect package presence: {commands:?}"
|
|
164
|
+
);
|
|
165
|
+
let seat = pi_seat_paths(workspace, "teamA", "worker_a");
|
|
166
|
+
assert!(
|
|
167
|
+
seat.wrapper.is_file(),
|
|
168
|
+
"successful materialization must publish the seat wrapper"
|
|
169
|
+
);
|
|
170
|
+
assert!(
|
|
171
|
+
plan.argv
|
|
172
|
+
.iter()
|
|
173
|
+
.any(|arg| arg == seat.wrapper.to_string_lossy().as_ref()),
|
|
174
|
+
"Pi command plan must load the generated native extension: {:?}",
|
|
175
|
+
plan.argv
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
let receipt_path = workspace.join(".team/test-evidence/native-registration.json");
|
|
179
|
+
std::fs::create_dir_all(receipt_path.parent().expect("registration receipt parent"))
|
|
180
|
+
.expect("create registration receipt directory");
|
|
181
|
+
let host_fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
182
|
+
.join("src/lifecycle/tests/fixtures/pi_native_mcp_host.mjs");
|
|
183
|
+
let host = Command::new(&runner)
|
|
184
|
+
.arg(host_fixture)
|
|
185
|
+
.arg(&seat.wrapper)
|
|
186
|
+
.arg(&receipt_path)
|
|
187
|
+
.arg(workspace)
|
|
188
|
+
.current_dir(workspace)
|
|
189
|
+
.output()
|
|
190
|
+
.expect("execute generated extension in the Pi native API fixture");
|
|
191
|
+
assert!(
|
|
192
|
+
host.status.success(),
|
|
193
|
+
"Pi native host fixture failed: status={:?}\nstdout:\n{}\nstderr:\n{}",
|
|
194
|
+
host.status.code(),
|
|
195
|
+
String::from_utf8_lossy(&host.stdout),
|
|
196
|
+
String::from_utf8_lossy(&host.stderr),
|
|
197
|
+
);
|
|
198
|
+
let native_receipt: Value = serde_json::from_slice(
|
|
199
|
+
&std::fs::read(&receipt_path).expect("native registerMcpServer receipt"),
|
|
200
|
+
)
|
|
201
|
+
.expect("parse native registration receipt");
|
|
202
|
+
let registrations = native_receipt["registrations"]
|
|
203
|
+
.as_array()
|
|
204
|
+
.expect("host recorded registerMcpServer calls");
|
|
205
|
+
assert_eq!(
|
|
206
|
+
registrations.len(),
|
|
207
|
+
1,
|
|
208
|
+
"one owned native server registration is expected"
|
|
209
|
+
);
|
|
210
|
+
let expected_runtime_tools = BTreeSet::from(REQUIRED_TOOLS.map(|tool| {
|
|
211
|
+
format!(
|
|
212
|
+
"mcp__{}__{tool}",
|
|
213
|
+
registrations[0]["name"].as_str().unwrap()
|
|
214
|
+
)
|
|
215
|
+
}));
|
|
216
|
+
let runtime_tools = native_receipt["activeTools"]
|
|
217
|
+
.as_array()
|
|
218
|
+
.expect("native Pi consumer exposes active tools")
|
|
219
|
+
.iter()
|
|
220
|
+
.filter_map(Value::as_str)
|
|
221
|
+
.map(str::to_string)
|
|
222
|
+
.collect::<BTreeSet<_>>();
|
|
223
|
+
assert_eq!(runtime_tools, expected_runtime_tools);
|
|
224
|
+
assert_eq!(
|
|
225
|
+
native_receipt["expectedTools"]
|
|
226
|
+
.as_array()
|
|
227
|
+
.expect("host confirms native binding tools")
|
|
228
|
+
.iter()
|
|
229
|
+
.filter_map(Value::as_str)
|
|
230
|
+
.map(str::to_string)
|
|
231
|
+
.collect::<BTreeSet<_>>(),
|
|
232
|
+
expected_runtime_tools,
|
|
233
|
+
);
|
|
234
|
+
let wire_prompt = native_receipt["wirePrompt"]
|
|
235
|
+
.as_str()
|
|
236
|
+
.expect("Pi wrapper installs an actual tool wire binding");
|
|
237
|
+
assert!(REQUIRED_TOOLS.iter().all(|tool| wire_prompt.contains(tool)));
|
|
238
|
+
let registration = ®istrations[0];
|
|
239
|
+
let alias = registration["name"]
|
|
240
|
+
.as_str()
|
|
241
|
+
.expect("native registration name");
|
|
242
|
+
let alias_id = alias
|
|
243
|
+
.strip_prefix("team_")
|
|
244
|
+
.expect("native server alias must be Team-owned");
|
|
245
|
+
assert_eq!(
|
|
246
|
+
alias_id.len(),
|
|
247
|
+
32,
|
|
248
|
+
"Team alias retains UUID entropy: {alias:?}"
|
|
249
|
+
);
|
|
250
|
+
assert!(alias_id.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
|
251
|
+
assert!(!alias.contains('-') && !alias.contains('/'));
|
|
252
|
+
let native_config = ®istration["config"];
|
|
253
|
+
assert_eq!(native_config["command"], json!(candidate.to_string_lossy()));
|
|
254
|
+
assert_eq!(native_config["args"], json!(args));
|
|
255
|
+
assert_eq!(native_config["cwd"], json!(workspace.to_string_lossy()));
|
|
256
|
+
assert_eq!(native_config["exposure"], json!("hidden"));
|
|
257
|
+
let exposure = native_config["toolExposure"]
|
|
258
|
+
.as_object()
|
|
259
|
+
.expect("native per-tool exposure map");
|
|
260
|
+
assert_eq!(
|
|
261
|
+
exposure.keys().cloned().collect::<BTreeSet<_>>(),
|
|
262
|
+
BTreeSet::from(REQUIRED_TOOLS.map(str::to_string)),
|
|
263
|
+
"only the three Team operations are directly exposed"
|
|
264
|
+
);
|
|
265
|
+
assert!(exposure.values().all(|value| value == "direct"));
|
|
266
|
+
for (key, expected) in [
|
|
267
|
+
("TEAM_AGENT_WORKSPACE", workspace.to_string_lossy().as_ref()),
|
|
268
|
+
("TEAM_AGENT_ID", "worker_a"),
|
|
269
|
+
("TEAM_AGENT_AGENT_ID", "worker_a"),
|
|
270
|
+
("TEAM_AGENT_OWNER_TEAM_ID", "teamA"),
|
|
271
|
+
] {
|
|
272
|
+
assert_eq!(
|
|
273
|
+
native_config["env"][key].as_str(),
|
|
274
|
+
Some(expected),
|
|
275
|
+
"native server must retain captured seat identity for {key}"
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let store = MessageStore::open(workspace).expect("open isolated MCP store");
|
|
280
|
+
store
|
|
281
|
+
.create_message(
|
|
282
|
+
Some("task_mcp"),
|
|
283
|
+
"leader",
|
|
284
|
+
"worker_a",
|
|
285
|
+
"N1_NATIVE_MCP_INPUT",
|
|
286
|
+
None,
|
|
287
|
+
false,
|
|
288
|
+
Some("teamA"),
|
|
289
|
+
)
|
|
290
|
+
.expect("seed attributable task input");
|
|
291
|
+
let mut client = RawMcpClient::spawn(native_config, &system_path, hermetic.home());
|
|
292
|
+
let initialize = client.rpc("initialize", json!({"protocolVersion":"2024-11-05"}));
|
|
293
|
+
assert_eq!(
|
|
294
|
+
initialize["result"]["serverInfo"]["name"],
|
|
295
|
+
json!("team_orchestrator")
|
|
296
|
+
);
|
|
297
|
+
client.notification("notifications/initialized", json!({}));
|
|
298
|
+
let listed = client.rpc("tools/list", json!({}));
|
|
299
|
+
let tool_names = listed["result"]["tools"]
|
|
300
|
+
.as_array()
|
|
301
|
+
.expect("raw tools/list response")
|
|
302
|
+
.iter()
|
|
303
|
+
.filter_map(|tool| tool["name"].as_str())
|
|
304
|
+
.map(str::to_string)
|
|
305
|
+
.collect::<BTreeSet<_>>();
|
|
306
|
+
assert_eq!(
|
|
307
|
+
tool_names,
|
|
308
|
+
BTreeSet::from(REQUIRED_TOOLS.map(str::to_string)),
|
|
309
|
+
"native config must reach the actual stdio MCP server with exactly the three Team tools"
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
let status = client.call_tool("get_team_status", json!({}));
|
|
313
|
+
assert!(
|
|
314
|
+
!status.is_error && status.body["ok"] == json!(true),
|
|
315
|
+
"get_team_status stdio call: {}",
|
|
316
|
+
status.body
|
|
317
|
+
);
|
|
318
|
+
assert!(status.body["teams"].get("teamA").is_some());
|
|
319
|
+
let marker = format!("N1_NATIVE_MCP_SEND_{}", std::process::id());
|
|
320
|
+
let send = client.call_tool("send_message", json!({"to":"leader","content":marker}));
|
|
321
|
+
assert!(
|
|
322
|
+
!send.is_error && send.body["ok"] == json!(true),
|
|
323
|
+
"send_message stdio call: {}",
|
|
324
|
+
send.body
|
|
325
|
+
);
|
|
326
|
+
let result_marker = format!("N1_NATIVE_MCP_RESULT_{}", std::process::id());
|
|
327
|
+
let report = client.call_tool(
|
|
328
|
+
"report_result",
|
|
329
|
+
json!({"task_id":"task_mcp","summary":result_marker}),
|
|
330
|
+
);
|
|
331
|
+
assert!(
|
|
332
|
+
!report.is_error,
|
|
333
|
+
"report_result stdio call: {}",
|
|
334
|
+
report.body
|
|
335
|
+
);
|
|
336
|
+
let result_id = report.body["result_id"]
|
|
337
|
+
.as_str()
|
|
338
|
+
.expect("durable result id");
|
|
339
|
+
let result_row = harness
|
|
340
|
+
.result_row(result_id)
|
|
341
|
+
.expect("report_result persists result");
|
|
342
|
+
assert_eq!(result_row.owner_team_id.as_deref(), Some("teamA"));
|
|
343
|
+
assert_eq!(result_row.agent_id, "worker_a");
|
|
344
|
+
assert_eq!(result_row.task_id, "task_mcp");
|
|
345
|
+
harness.drive_delivery_twice();
|
|
346
|
+
assert!(harness.pane_text("leader").contains(&marker));
|
|
347
|
+
assert!(harness.pane_text("leader").contains(&result_marker));
|
|
348
|
+
|
|
349
|
+
let trace_path = workspace.join(".team/test-evidence/native-mcp-stdio.jsonl");
|
|
350
|
+
client.write_trace(&trace_path);
|
|
351
|
+
let raw_trace = std::fs::read_to_string(&trace_path).expect("raw MCP stdio trace");
|
|
352
|
+
let server_pid = client.pid();
|
|
353
|
+
let exit = client.close_stdin_and_wait();
|
|
354
|
+
assert!(
|
|
355
|
+
exit.success(),
|
|
356
|
+
"native candidate stdio server exit must be successful: {exit:?}"
|
|
357
|
+
);
|
|
358
|
+
let events = EventLog::new(workspace)
|
|
359
|
+
.tail(0)
|
|
360
|
+
.expect("MCP server lifecycle receipt");
|
|
361
|
+
assert!(
|
|
362
|
+
events.iter().any(|event| {
|
|
363
|
+
event["event"] == "mcp.server_started" && event["pid"] == json!(server_pid)
|
|
364
|
+
}),
|
|
365
|
+
"actual stdio server start receipt must match child pid {server_pid}"
|
|
366
|
+
);
|
|
367
|
+
assert!(
|
|
368
|
+
events.iter().any(|event| {
|
|
369
|
+
event["event"] == "mcp.server_exit"
|
|
370
|
+
&& event["pid"] == json!(server_pid)
|
|
371
|
+
&& event["reason"] == json!("stdin_eof")
|
|
372
|
+
}),
|
|
373
|
+
"actual stdio server exit receipt must match child pid and clean EOF"
|
|
374
|
+
);
|
|
375
|
+
println!(
|
|
376
|
+
"N1_NATIVE_MCP_RECEIPT={}",
|
|
377
|
+
json!({"alias":alias,"candidate":candidate,"native_config":native_config,"native_host":native_receipt,"server_pid":server_pid,"exit_code":exit.code(),"tool_names":tool_names,"pi_commands":commands,"server_events":events,"stdio_trace":trace_path})
|
|
378
|
+
);
|
|
379
|
+
println!("N1_NATIVE_MCP_TRACE_BEGIN");
|
|
380
|
+
print!("{raw_trace}");
|
|
381
|
+
println!("N1_NATIVE_MCP_TRACE_END");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
fn executable_on_path(name: &str, path: &std::ffi::OsStr) -> Option<PathBuf> {
|
|
385
|
+
std::env::split_paths(path)
|
|
386
|
+
.map(|entry| entry.join(name))
|
|
387
|
+
.find(|candidate| candidate.is_file())
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
struct RawMcpClient {
|
|
391
|
+
child: Child,
|
|
392
|
+
stdin: Option<ChildStdin>,
|
|
393
|
+
responses: Receiver<String>,
|
|
394
|
+
next_id: u64,
|
|
395
|
+
trace: Vec<Value>,
|
|
396
|
+
waited: bool,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
struct ToolCall {
|
|
400
|
+
body: Value,
|
|
401
|
+
is_error: bool,
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
impl RawMcpClient {
|
|
405
|
+
fn spawn(config: &Value, path: &std::ffi::OsStr, home: &Path) -> Self {
|
|
406
|
+
let program = config["command"].as_str().expect("registered MCP command");
|
|
407
|
+
let args = config["args"]
|
|
408
|
+
.as_array()
|
|
409
|
+
.expect("registered MCP args")
|
|
410
|
+
.iter()
|
|
411
|
+
.map(|arg| arg.as_str().expect("string MCP arg").to_string())
|
|
412
|
+
.collect::<Vec<_>>();
|
|
413
|
+
let cwd = config["cwd"].as_str().expect("registered MCP cwd");
|
|
414
|
+
let env = config["env"].as_object().expect("registered MCP env");
|
|
415
|
+
let mut command = Command::new(program);
|
|
416
|
+
command
|
|
417
|
+
.args(&args)
|
|
418
|
+
.env_clear()
|
|
419
|
+
.env("PATH", path)
|
|
420
|
+
.env("HOME", home)
|
|
421
|
+
.env("LANG", "C.UTF-8")
|
|
422
|
+
.current_dir(cwd)
|
|
423
|
+
.stdin(Stdio::piped())
|
|
424
|
+
.stdout(Stdio::piped())
|
|
425
|
+
.stderr(Stdio::null());
|
|
426
|
+
for (key, value) in env {
|
|
427
|
+
command.env(key, value.as_str().expect("string MCP env value"));
|
|
428
|
+
}
|
|
429
|
+
let mut child = command
|
|
430
|
+
.spawn()
|
|
431
|
+
.expect("spawn registered native stdio MCP command");
|
|
432
|
+
let stdin = child.stdin.take().expect("MCP child stdin");
|
|
433
|
+
let stdout = child.stdout.take().expect("MCP child stdout");
|
|
434
|
+
let (tx, responses) = mpsc::channel();
|
|
435
|
+
thread::spawn(move || {
|
|
436
|
+
for line in BufReader::new(stdout).lines() {
|
|
437
|
+
let Ok(line) = line else { break };
|
|
438
|
+
if tx.send(line).is_err() {
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
Self {
|
|
444
|
+
child,
|
|
445
|
+
stdin: Some(stdin),
|
|
446
|
+
responses,
|
|
447
|
+
next_id: 1,
|
|
448
|
+
trace: Vec::new(),
|
|
449
|
+
waited: false,
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
fn pid(&self) -> u32 {
|
|
454
|
+
self.child.id()
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
|
458
|
+
let id = self.next_id;
|
|
459
|
+
self.next_id += 1;
|
|
460
|
+
let request = json!({"jsonrpc":"2.0","id":id,"method":method,"params":params});
|
|
461
|
+
let line = serde_json::to_string(&request).expect("serialize MCP request");
|
|
462
|
+
let stdin = self.stdin.as_mut().expect("MCP stdin is open");
|
|
463
|
+
writeln!(stdin, "{line}").expect("write MCP request");
|
|
464
|
+
stdin.flush().expect("flush MCP request");
|
|
465
|
+
let response_line = self
|
|
466
|
+
.responses
|
|
467
|
+
.recv_timeout(Duration::from_secs(75))
|
|
468
|
+
.unwrap_or_else(|_| panic!("timed out waiting for stdio MCP {method} response"));
|
|
469
|
+
let response: Value = serde_json::from_str(&response_line).unwrap_or_else(|error| {
|
|
470
|
+
panic!("invalid MCP response for {method}: {error}; {response_line}")
|
|
471
|
+
});
|
|
472
|
+
assert_eq!(response["id"], json!(id), "MCP response id for {method}");
|
|
473
|
+
assert!(
|
|
474
|
+
response.get("error").is_none(),
|
|
475
|
+
"MCP protocol error for {method}: {response}"
|
|
476
|
+
);
|
|
477
|
+
self.trace
|
|
478
|
+
.push(json!({"request":request,"response":response}));
|
|
479
|
+
response
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
fn notification(&mut self, method: &str, params: Value) {
|
|
483
|
+
let request = json!({"jsonrpc":"2.0","method":method,"params":params});
|
|
484
|
+
let line = serde_json::to_string(&request).expect("serialize MCP notification");
|
|
485
|
+
let stdin = self.stdin.as_mut().expect("MCP stdin is open");
|
|
486
|
+
writeln!(stdin, "{line}").expect("write MCP notification");
|
|
487
|
+
stdin.flush().expect("flush MCP notification");
|
|
488
|
+
self.trace.push(json!({"request":request,"response":null}));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
fn call_tool(&mut self, name: &str, arguments: Value) -> ToolCall {
|
|
492
|
+
let response = self.rpc("tools/call", json!({"name":name,"arguments":arguments}));
|
|
493
|
+
let result = response
|
|
494
|
+
.get("result")
|
|
495
|
+
.unwrap_or_else(|| panic!("tools/call missing result: {response}"));
|
|
496
|
+
let text = result["content"][0]["text"]
|
|
497
|
+
.as_str()
|
|
498
|
+
.expect("tool response text");
|
|
499
|
+
ToolCall {
|
|
500
|
+
body: serde_json::from_str(text).unwrap_or_else(|_| json!({"raw_text":text})),
|
|
501
|
+
is_error: result["isError"].as_bool().unwrap_or(false),
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
fn write_trace(&self, path: &Path) {
|
|
506
|
+
let parent = path.parent().expect("MCP trace parent");
|
|
507
|
+
std::fs::create_dir_all(parent).expect("create MCP trace directory");
|
|
508
|
+
let mut file = std::fs::File::create(path).expect("create raw MCP trace");
|
|
509
|
+
for row in &self.trace {
|
|
510
|
+
writeln!(
|
|
511
|
+
file,
|
|
512
|
+
"{}",
|
|
513
|
+
serde_json::to_string(row).expect("serialize trace row")
|
|
514
|
+
)
|
|
515
|
+
.expect("write raw MCP trace");
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
fn close_stdin_and_wait(&mut self) -> ExitStatus {
|
|
520
|
+
self.stdin.take();
|
|
521
|
+
let deadline = Instant::now() + Duration::from_secs(15);
|
|
522
|
+
loop {
|
|
523
|
+
if let Some(status) = self.child.try_wait().expect("poll MCP server exit") {
|
|
524
|
+
self.waited = true;
|
|
525
|
+
return status;
|
|
526
|
+
}
|
|
527
|
+
if Instant::now() >= deadline {
|
|
528
|
+
let _ = self.child.kill();
|
|
529
|
+
let status = self.child.wait().expect("reap timed-out MCP server");
|
|
530
|
+
self.waited = true;
|
|
531
|
+
panic!("native stdio MCP server did not exit after EOF: {status:?}");
|
|
532
|
+
}
|
|
533
|
+
thread::sleep(Duration::from_millis(10));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
impl Drop for RawMcpClient {
|
|
539
|
+
fn drop(&mut self) {
|
|
540
|
+
if !self.waited {
|
|
541
|
+
let _ = self.child.kill();
|
|
542
|
+
let _ = self.child.wait();
|
|
543
|
+
self.waited = true;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
@@ -84,6 +84,7 @@ mod pi_dual_entry_red;
|
|
|
84
84
|
mod pi_executable_mcp_red;
|
|
85
85
|
mod pi_fork_session_inheritance_red;
|
|
86
86
|
mod pi_lifecycle_doctor_red;
|
|
87
|
+
mod pi_native_mcp_capability_red;
|
|
87
88
|
mod pi_session_delivery_red;
|
|
88
89
|
mod quick_start_worker_readiness_red;
|
|
89
90
|
mod realmachine_clusters_1_6_red;
|
|
@@ -185,26 +185,13 @@ pub(crate) fn compile_worker_system_prompt(
|
|
|
185
185
|
// C-1 cr verdict / B2 灵魂件 — identity 必须 FIRST(MUST-4 行为层守:空白上下文问
|
|
186
186
|
// "你是谁"必须先答 Team Agent worker 身份)。runtime contract 跟后。
|
|
187
187
|
let send_message = mcp_tool_name(agent.provider, "team_orchestrator", "send_message");
|
|
188
|
-
let get_team_status =
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
let
|
|
194
|
-
|
|
195
|
-
} else {
|
|
196
|
-
mcp_tool_name(agent.provider, "team_orchestrator", "report_result")
|
|
197
|
-
};
|
|
198
|
-
let runtime_contract = if agent.provider == Provider::Pi {
|
|
199
|
-
pi_runtime_contract_section(&get_team_status)
|
|
200
|
-
} else {
|
|
201
|
-
runtime_contract_section(&send_message, &report_result, &get_team_status)
|
|
202
|
-
};
|
|
203
|
-
let communication_contract = if agent.provider == Provider::Pi {
|
|
204
|
-
pi_communication_contract(agent.communication_mode)
|
|
205
|
-
} else {
|
|
206
|
-
agent.communication_mode.runtime_contract(&send_message)
|
|
207
|
-
};
|
|
188
|
+
let get_team_status = format!(
|
|
189
|
+
"{}()",
|
|
190
|
+
mcp_tool_name(agent.provider, "team_orchestrator", "get_team_status")
|
|
191
|
+
);
|
|
192
|
+
let report_result = mcp_tool_name(agent.provider, "team_orchestrator", "report_result");
|
|
193
|
+
let runtime_contract = runtime_contract_section(&send_message, &report_result, &get_team_status);
|
|
194
|
+
let communication_contract = agent.communication_mode.runtime_contract(&send_message);
|
|
208
195
|
let mut chunks = vec![
|
|
209
196
|
identity_section(agent),
|
|
210
197
|
runtime_contract,
|
|
@@ -224,11 +211,9 @@ pub(crate) fn compile_worker_system_prompt(
|
|
|
224
211
|
pub(crate) fn compile_pi_leader_system_prompt() -> String {
|
|
225
212
|
r#"You are the Team Agent leader for this workspace. Coordinate work through the existing Team Agent MCP server.
|
|
226
213
|
|
|
227
|
-
|
|
228
|
-
mcp({tool:"team_orchestrator_get_team_status", args:{}})
|
|
229
|
-
mcp({tool:"team_orchestrator_send_message", args:{to:"<agent_id>", content:"..."}})
|
|
214
|
+
The logical operations are send_message, report_result and get_team_status. The Team-owned Pi extension supplies the actual native/proxy tool names in the MCP wire binding before a run. Use that binding, not guessed tool names.
|
|
230
215
|
|
|
231
|
-
Do not invent a second team protocol or assume that
|
|
216
|
+
Do not pass sender, task_id or schema_version. Do not invent a second team protocol or assume that MCP registration proves a call has returned."#
|
|
232
217
|
.to_string()
|
|
233
218
|
}
|
|
234
219
|
|
|
@@ -238,7 +223,8 @@ fn mcp_tool_name(provider: Provider, server: &str, tool: &str) -> String {
|
|
|
238
223
|
match provider {
|
|
239
224
|
Provider::Claude | Provider::ClaudeCode => format!("mcp__{server}__{tool}"),
|
|
240
225
|
Provider::Grok => format!("{server}__{tool}"),
|
|
241
|
-
|
|
226
|
+
// Pi's owned extension binds these logical operations to the observed MCP call surface.
|
|
227
|
+
Provider::Pi => tool.to_string(),
|
|
242
228
|
// CursorAgent / Codex / Copilot / GeminiCli / Fake: 未验证,沿用现状点号。
|
|
243
229
|
// CursorAgent 不可与 grok 同臂:仓库里没有活转录,`{server}__{tool}` 是推断。
|
|
244
230
|
Provider::CursorAgent
|
|
@@ -260,35 +246,6 @@ fn runtime_contract_section(
|
|
|
260
246
|
.replace("{get_team_status}", get_team_status)
|
|
261
247
|
}
|
|
262
248
|
|
|
263
|
-
fn pi_runtime_contract_section(get_team_status: &str) -> String {
|
|
264
|
-
RUNTIME_CONTRACT_SECTION
|
|
265
|
-
.replace(
|
|
266
|
-
"{send_message}(to='<agent_id>', content='...')",
|
|
267
|
-
r#"mcp({tool:"team_orchestrator_send_message", args:{to:"<agent_id>", content:"..."}})"#,
|
|
268
|
-
)
|
|
269
|
-
.replace(
|
|
270
|
-
"{send_message}(to='*', content='...')",
|
|
271
|
-
r#"mcp({tool:"team_orchestrator_send_message", args:{to:"*", content:"..."}})"#,
|
|
272
|
-
)
|
|
273
|
-
.replace(
|
|
274
|
-
"{report_result}(summary='...')",
|
|
275
|
-
r#"mcp({tool:"team_orchestrator_report_result", args:{summary:"..."}})"#,
|
|
276
|
-
)
|
|
277
|
-
.replace("{get_team_status}", get_team_status)
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
fn pi_communication_contract(mode: CommunicationMode) -> String {
|
|
281
|
-
match mode {
|
|
282
|
-
CommunicationMode::LeaderCentric => r#"# Team Agent communication contract: leader_centric
|
|
283
|
-
|
|
284
|
-
- Progress, blockers, questions: mcp({tool:"team_orchestrator_send_message", args:{to:"leader", content:"..."}})
|
|
285
|
-
|
|
286
|
-
Respond through Team Agent MCP tools only to actionable requests or questions; writing in your terminal does not deliver it."#
|
|
287
|
-
.to_string(),
|
|
288
|
-
CommunicationMode::Orchestrated => mode.runtime_contract("mcp"),
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
249
|
fn communication_mode(value: Option<&str>) -> Result<CommunicationMode, LifecycleError> {
|
|
293
250
|
let Some(value) = value else {
|
|
294
251
|
return Ok(CommunicationMode::default());
|