@team-agent/installer 0.3.13 → 0.3.15
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 -0
- package/crates/team-agent/src/cli/emit.rs +92 -11
- package/crates/team-agent/src/cli/mod.rs +228 -85
- package/crates/team-agent/src/cli/send.rs +252 -0
- package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +159 -2
- package/crates/team-agent/src/cli/types.rs +30 -1
- package/crates/team-agent/src/compiler/tests.rs +2 -2
- package/crates/team-agent/src/compiler.rs +1 -1
- package/crates/team-agent/src/fake_worker.rs +32 -145
- package/crates/team-agent/src/leader/start.rs +279 -4
- package/crates/team-agent/src/lifecycle/display.rs +3 -3
- package/crates/team-agent/src/lifecycle/launch.rs +692 -92
- package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
- package/crates/team-agent/src/lifecycle/restart/common.rs +88 -42
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +197 -33
- package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
- package/crates/team-agent/src/lifecycle/tests/core.rs +195 -50
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +934 -72
- package/crates/team-agent/src/lifecycle/types.rs +5 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
- package/crates/team-agent/src/mcp_server/wire.rs +153 -3
- package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
- package/crates/team-agent/src/messaging/mod.rs +6 -3
- package/crates/team-agent/src/messaging/results.rs +240 -158
- package/crates/team-agent/src/messaging/send.rs +3 -2
- package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
- package/crates/team-agent/src/os_probe.rs +11 -0
- package/crates/team-agent/src/state/persist.rs +6 -0
- package/crates/team-agent/src/tmux_backend.rs +90 -0
- package/crates/team-agent/src/transport/test_support.rs +46 -1
- package/crates/team-agent/src/transport.rs +31 -0
- package/package.json +4 -4
- package/skills/team-agent/references/recovery-runbook.md +277 -0
|
@@ -107,7 +107,10 @@ fn parse_rendered_header(line: &str) -> Option<RenderedBlock> {
|
|
|
107
107
|
.split_once(" for ")
|
|
108
108
|
.map(|(_, task)| task.trim().to_string())
|
|
109
109
|
.filter(|task| !task.is_empty());
|
|
110
|
-
Some(RenderedBlock {
|
|
110
|
+
Some(RenderedBlock {
|
|
111
|
+
task_id,
|
|
112
|
+
lines: Vec::new(),
|
|
113
|
+
})
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
fn parse_token(line: &str) -> Option<String> {
|
|
@@ -135,149 +138,13 @@ fn report_fake_result(
|
|
|
135
138
|
.ok()
|
|
136
139
|
.map(|value| value.trim().to_string())
|
|
137
140
|
.filter(|value| !value.is_empty());
|
|
138
|
-
|
|
141
|
+
let _ = source_content;
|
|
139
142
|
crate::messaging::report_result_for_owner_team(workspace, &envelope, owner_team.as_deref())
|
|
140
143
|
.map_err(|e| FakeWorkerError::Report(e.to_string()))?;
|
|
141
|
-
mirror_fake_result_to_leader(workspace, owner_team.as_deref(), &envelope, source_content);
|
|
142
144
|
writeln!(output, "{}", serde_json::to_string(&envelope)?)?;
|
|
143
145
|
Ok(())
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
fn mirror_fake_result_to_leader(
|
|
147
|
-
workspace: &Path,
|
|
148
|
-
owner_team: Option<&str>,
|
|
149
|
-
envelope: &serde_json::Value,
|
|
150
|
-
source_content: Option<&str>,
|
|
151
|
-
) {
|
|
152
|
-
let Ok(raw_state) = crate::state::persist::load_runtime_state(workspace) else {
|
|
153
|
-
return;
|
|
154
|
-
};
|
|
155
|
-
let state = owner_team
|
|
156
|
-
.and_then(|team| crate::state::projection::resolve_owner_team_id(&raw_state, team).canonical_key().map(str::to_string))
|
|
157
|
-
.map(|team| crate::state::projection::project_top_level_view(&raw_state, &team))
|
|
158
|
-
.unwrap_or(raw_state);
|
|
159
|
-
let attached = state
|
|
160
|
-
.get("leader_receiver")
|
|
161
|
-
.and_then(|receiver| receiver.get("status"))
|
|
162
|
-
.and_then(serde_json::Value::as_str)
|
|
163
|
-
== Some("attached");
|
|
164
|
-
let pane_id = state
|
|
165
|
-
.get("leader_receiver")
|
|
166
|
-
.and_then(|receiver| receiver.get("pane_id"))
|
|
167
|
-
.and_then(serde_json::Value::as_str)
|
|
168
|
-
.filter(|pane| !pane.is_empty())
|
|
169
|
-
.map(str::to_string)
|
|
170
|
-
.or_else(|| owner_team.and_then(|team| claimed_pane_from_events(workspace, team)));
|
|
171
|
-
let Some(pane_id) = pane_id.filter(|_| attached || owner_team.is_some()) else {
|
|
172
|
-
return;
|
|
173
|
-
};
|
|
174
|
-
let summary = envelope
|
|
175
|
-
.get("summary")
|
|
176
|
-
.and_then(serde_json::Value::as_str)
|
|
177
|
-
.unwrap_or("Fake worker completed");
|
|
178
|
-
let result_id = envelope
|
|
179
|
-
.get("result_id")
|
|
180
|
-
.and_then(serde_json::Value::as_str)
|
|
181
|
-
.unwrap_or("");
|
|
182
|
-
let text = match source_content.filter(|content| !content.trim().is_empty()) {
|
|
183
|
-
Some(content) => format!(
|
|
184
|
-
"Fake worker result: {summary}\nMessage content: {}\nResult id: {result_id}",
|
|
185
|
-
content.trim()
|
|
186
|
-
),
|
|
187
|
-
None => format!("Fake worker result: {summary}\nResult id: {result_id}"),
|
|
188
|
-
};
|
|
189
|
-
let target = crate::transport::Target::Pane(crate::transport::PaneId::new(&pane_id));
|
|
190
|
-
let payload = crate::transport::InjectPayload::Text(text.clone());
|
|
191
|
-
let key = crate::transport::Key::Enter;
|
|
192
|
-
if let Some(socket) = leader_receiver_full_socket(&state) {
|
|
193
|
-
let endpoint_backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket);
|
|
194
|
-
if crate::transport::Transport::inject(&endpoint_backend, &target, &payload, key, true).is_ok() {
|
|
195
|
-
mirror_to_team_session_panes(&state, workspace, agent_id_from_envelope(envelope), &text);
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
let workspace_backend = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
|
|
200
|
-
let _ = crate::transport::Transport::inject(&workspace_backend, &target, &payload, key, true);
|
|
201
|
-
mirror_to_team_session_panes(&state, workspace, agent_id_from_envelope(envelope), &text);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
fn leader_receiver_full_socket(state: &serde_json::Value) -> Option<&str> {
|
|
205
|
-
state
|
|
206
|
-
.get("leader_receiver")
|
|
207
|
-
.and_then(|receiver| receiver.get("tmux_socket"))
|
|
208
|
-
.and_then(serde_json::Value::as_str)
|
|
209
|
-
.filter(|socket| !socket.is_empty() && Path::new(socket).is_absolute())
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
fn claimed_pane_from_events(workspace: &Path, owner_team: &str) -> Option<String> {
|
|
213
|
-
crate::event_log::EventLog::new(workspace)
|
|
214
|
-
.tail(50)
|
|
215
|
-
.ok()?
|
|
216
|
-
.into_iter()
|
|
217
|
-
.rev()
|
|
218
|
-
.find_map(|event| {
|
|
219
|
-
if event.get("event").and_then(serde_json::Value::as_str)
|
|
220
|
-
!= Some("leader_receiver.rebind_applied")
|
|
221
|
-
{
|
|
222
|
-
return None;
|
|
223
|
-
}
|
|
224
|
-
if event.get("team_id").and_then(serde_json::Value::as_str) != Some(owner_team) {
|
|
225
|
-
return None;
|
|
226
|
-
}
|
|
227
|
-
event
|
|
228
|
-
.get("new_pane_id")
|
|
229
|
-
.and_then(serde_json::Value::as_str)
|
|
230
|
-
.filter(|pane| !pane.is_empty())
|
|
231
|
-
.map(str::to_string)
|
|
232
|
-
})
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
fn agent_id_from_envelope(envelope: &serde_json::Value) -> Option<&str> {
|
|
236
|
-
envelope.get("agent_id").and_then(serde_json::Value::as_str)
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
fn mirror_to_team_session_panes(
|
|
240
|
-
state: &serde_json::Value,
|
|
241
|
-
workspace: &Path,
|
|
242
|
-
agent_id: Option<&str>,
|
|
243
|
-
text: &str,
|
|
244
|
-
) {
|
|
245
|
-
let Some(session) = state
|
|
246
|
-
.get("session_name")
|
|
247
|
-
.and_then(serde_json::Value::as_str)
|
|
248
|
-
.filter(|session| !session.is_empty())
|
|
249
|
-
else {
|
|
250
|
-
return;
|
|
251
|
-
};
|
|
252
|
-
let worker_pane = agent_id.and_then(|agent_id| {
|
|
253
|
-
state
|
|
254
|
-
.get("agents")
|
|
255
|
-
.and_then(|agents| agents.get(agent_id))
|
|
256
|
-
.and_then(|agent| agent.get("pane_id"))
|
|
257
|
-
.and_then(serde_json::Value::as_str)
|
|
258
|
-
});
|
|
259
|
-
if let Some(socket) = leader_receiver_full_socket(state) {
|
|
260
|
-
let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket);
|
|
261
|
-
for pane in crate::transport::Transport::list_targets(&backend).unwrap_or_default() {
|
|
262
|
-
if pane.session.as_str() != session || Some(pane.pane_id.as_str()) == worker_pane {
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
let target = crate::transport::Target::Pane(pane.pane_id);
|
|
266
|
-
let payload = crate::transport::InjectPayload::Text(text.to_string());
|
|
267
|
-
let _ = crate::transport::Transport::inject(&backend, &target, &payload, crate::transport::Key::Enter, true);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
let backend = crate::tmux_backend::TmuxBackend::for_workspace(workspace);
|
|
271
|
-
for pane in crate::transport::Transport::list_targets(&backend).unwrap_or_default() {
|
|
272
|
-
if pane.session.as_str() != session || Some(pane.pane_id.as_str()) == worker_pane {
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
let target = crate::transport::Target::Pane(pane.pane_id);
|
|
276
|
-
let payload = crate::transport::InjectPayload::Text(text.to_string());
|
|
277
|
-
let _ = crate::transport::Transport::inject(&backend, &target, &payload, crate::transport::Key::Enter, true);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
148
|
fn fake_envelope(
|
|
282
149
|
workspace: &Path,
|
|
283
150
|
agent_id: &str,
|
|
@@ -331,7 +198,9 @@ mod tests {
|
|
|
331
198
|
fn stored_result(ws: &std::path::Path) -> (String, String, String, serde_json::Value) {
|
|
332
199
|
let store = MessageStore::open(ws).unwrap();
|
|
333
200
|
let conn = open_db(store.db_path()).unwrap();
|
|
334
|
-
let mut stmt = conn
|
|
201
|
+
let mut stmt = conn
|
|
202
|
+
.prepare("select task_id, agent_id, status, envelope from results")
|
|
203
|
+
.unwrap();
|
|
335
204
|
let row = stmt
|
|
336
205
|
.query_row([], |r| {
|
|
337
206
|
Ok((
|
|
@@ -354,9 +223,18 @@ mod tests {
|
|
|
354
223
|
("t1", "w1", "success"),
|
|
355
224
|
"stored result row must be task_id=t1 agent_id=w1 status=success"
|
|
356
225
|
);
|
|
357
|
-
assert_eq!(
|
|
358
|
-
|
|
359
|
-
|
|
226
|
+
assert_eq!(
|
|
227
|
+
env["schema_version"],
|
|
228
|
+
serde_json::json!("result_envelope_v1")
|
|
229
|
+
);
|
|
230
|
+
assert_eq!(
|
|
231
|
+
env["summary"],
|
|
232
|
+
serde_json::json!("Fake worker handled message m1")
|
|
233
|
+
);
|
|
234
|
+
assert_eq!(
|
|
235
|
+
env["tests"],
|
|
236
|
+
serde_json::json!([{"command": "fake-provider", "status": "passed"}])
|
|
237
|
+
);
|
|
360
238
|
let artifact_path = env["artifacts"][0]["path"].as_str().unwrap_or_default();
|
|
361
239
|
assert!(
|
|
362
240
|
artifact_path.ends_with(".team/logs/w1.scrollback"),
|
|
@@ -375,8 +253,14 @@ mod tests {
|
|
|
375
253
|
|
|
376
254
|
assert_golden_envelope(&ws);
|
|
377
255
|
let printed = String::from_utf8(out).unwrap();
|
|
378
|
-
assert!(
|
|
379
|
-
|
|
256
|
+
assert!(
|
|
257
|
+
printed.contains("TEAM_AGENT_FAKE_READY agent=w1"),
|
|
258
|
+
"must print the READY idle marker; got {printed}"
|
|
259
|
+
);
|
|
260
|
+
assert!(
|
|
261
|
+
printed.contains("TEAM_AGENT_FAKE_WORKING agent=w1"),
|
|
262
|
+
"must print the WORKING marker on a non-empty line; got {printed}"
|
|
263
|
+
);
|
|
380
264
|
}
|
|
381
265
|
|
|
382
266
|
// Rendered-message form: a `Team Agent message from leader for t1:` block ending `[team-agent-token:m1]`
|
|
@@ -391,6 +275,9 @@ mod tests {
|
|
|
391
275
|
|
|
392
276
|
assert_golden_envelope(&ws);
|
|
393
277
|
let printed = String::from_utf8(out).unwrap();
|
|
394
|
-
assert!(
|
|
278
|
+
assert!(
|
|
279
|
+
printed.contains("TEAM_AGENT_FAKE_WORKING agent=w1"),
|
|
280
|
+
"rendered form prints WORKING per line; got {printed}"
|
|
281
|
+
);
|
|
395
282
|
}
|
|
396
283
|
}
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
use std::collections::BTreeMap;
|
|
4
4
|
use std::io::IsTerminal;
|
|
5
|
-
use std::path::Path;
|
|
5
|
+
use std::path::{Path, PathBuf};
|
|
6
6
|
use std::process::{Command, Stdio};
|
|
7
7
|
|
|
8
8
|
use crate::provider::{get_adapter, Provider};
|
|
9
9
|
use crate::tmux_backend::TmuxBackend;
|
|
10
|
-
use crate::transport::{SessionName, Transport};
|
|
10
|
+
use crate::transport::{PaneId, SessionName, Target, Transport};
|
|
11
11
|
|
|
12
12
|
use super::helpers::{
|
|
13
13
|
provider_wire, resolve_workspace_for_hash, sanitize_session_folder, sha1_hex_prefix,
|
|
@@ -125,7 +125,10 @@ pub(crate) fn leader_env_for_identity(
|
|
|
125
125
|
identity.team_id.as_str().to_string(),
|
|
126
126
|
);
|
|
127
127
|
if provider == Provider::Copilot {
|
|
128
|
-
leader_env.insert(
|
|
128
|
+
leader_env.insert(
|
|
129
|
+
"COPILOT_DISABLE_TERMINAL_TITLE".to_string(),
|
|
130
|
+
"1".to_string(),
|
|
131
|
+
);
|
|
129
132
|
}
|
|
130
133
|
leader_env
|
|
131
134
|
}
|
|
@@ -171,7 +174,7 @@ pub fn execute_leader_plan(
|
|
|
171
174
|
let detached = plan.mode == LeaderStartMode::NewTmuxSession
|
|
172
175
|
&& !std::io::stdin().is_terminal()
|
|
173
176
|
&& insert_detach_flag(&mut argv);
|
|
174
|
-
let status = run_leader_argv(&argv, &plan.leader_env)?;
|
|
177
|
+
let status = run_leader_argv(&argv, &plan.leader_env, plan, workspace)?;
|
|
175
178
|
let code = status.code();
|
|
176
179
|
if !status.success() {
|
|
177
180
|
return Err(LeaderError::Start(format!(
|
|
@@ -294,6 +297,8 @@ fn insert_detach_flag(argv: &mut Vec<String>) -> bool {
|
|
|
294
297
|
fn run_leader_argv(
|
|
295
298
|
argv: &[String],
|
|
296
299
|
env: &BTreeMap<String, String>,
|
|
300
|
+
plan: &LeaderStartPlan,
|
|
301
|
+
workspace: &Path,
|
|
297
302
|
) -> Result<std::process::ExitStatus, LeaderError> {
|
|
298
303
|
let Some(program) = argv.first() else {
|
|
299
304
|
return Err(LeaderError::Start(
|
|
@@ -307,9 +312,84 @@ fn run_leader_argv(
|
|
|
307
312
|
.stdout(Stdio::inherit())
|
|
308
313
|
.stderr(Stdio::inherit())
|
|
309
314
|
.spawn()?;
|
|
315
|
+
if plan.mode == LeaderStartMode::ExecProvider {
|
|
316
|
+
spawn_exec_provider_startup_prompt_handler(plan.provider, workspace.to_path_buf());
|
|
317
|
+
}
|
|
310
318
|
child.wait().map_err(LeaderError::Io)
|
|
311
319
|
}
|
|
312
320
|
|
|
321
|
+
fn spawn_exec_provider_startup_prompt_handler(provider: Provider, workspace: PathBuf) {
|
|
322
|
+
let Some(pane_id) = std::env::var("TMUX_PANE")
|
|
323
|
+
.ok()
|
|
324
|
+
.filter(|value| !value.trim().is_empty())
|
|
325
|
+
else {
|
|
326
|
+
write_leader_startup_prompt_event(
|
|
327
|
+
&workspace,
|
|
328
|
+
"leader.startup_prompt_skipped",
|
|
329
|
+
serde_json::json!({
|
|
330
|
+
"provider": provider_wire(provider),
|
|
331
|
+
"reason": "tmux_pane_missing",
|
|
332
|
+
"action": "skip",
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
return;
|
|
336
|
+
};
|
|
337
|
+
std::thread::spawn(move || {
|
|
338
|
+
let transport = tmux_transport_for_current_pane();
|
|
339
|
+
let _ = handle_exec_provider_startup_prompts(
|
|
340
|
+
provider, &workspace, &pane_id, &transport, 30, 0.5,
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
fn tmux_transport_for_current_pane() -> TmuxBackend {
|
|
346
|
+
crate::tmux_backend::socket_name_from_tmux_env()
|
|
347
|
+
.map(|endpoint| TmuxBackend::for_tmux_endpoint(&endpoint))
|
|
348
|
+
.unwrap_or_else(TmuxBackend::new)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
pub fn handle_exec_provider_startup_prompts(
|
|
352
|
+
provider: Provider,
|
|
353
|
+
workspace: &Path,
|
|
354
|
+
pane_id: &str,
|
|
355
|
+
transport: &dyn Transport,
|
|
356
|
+
checks: usize,
|
|
357
|
+
sleep_s: f64,
|
|
358
|
+
) -> crate::provider::StartupPromptOutcome {
|
|
359
|
+
let target = Target::Pane(PaneId::new(pane_id.to_string()));
|
|
360
|
+
let outcome =
|
|
361
|
+
get_adapter(provider).handle_startup_prompts_outcome(transport, &target, checks, sleep_s);
|
|
362
|
+
for handled in &outcome.handled {
|
|
363
|
+
write_leader_startup_prompt_event(
|
|
364
|
+
workspace,
|
|
365
|
+
"leader.startup_prompt_handled",
|
|
366
|
+
serde_json::json!({
|
|
367
|
+
"provider": provider_wire(provider),
|
|
368
|
+
"pane_id": pane_id,
|
|
369
|
+
"prompt": handled.prompt,
|
|
370
|
+
"action": handled.action,
|
|
371
|
+
}),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if let Some(error) = &outcome.capture_error {
|
|
375
|
+
write_leader_startup_prompt_event(
|
|
376
|
+
workspace,
|
|
377
|
+
"leader.startup_prompt_capture_failed",
|
|
378
|
+
serde_json::json!({
|
|
379
|
+
"provider": provider_wire(provider),
|
|
380
|
+
"pane_id": pane_id,
|
|
381
|
+
"action": "capture",
|
|
382
|
+
"error": error,
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
outcome
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
fn write_leader_startup_prompt_event(workspace: &Path, event: &str, fields: serde_json::Value) {
|
|
390
|
+
let _ = crate::event_log::EventLog::new(workspace).write(event, fields);
|
|
391
|
+
}
|
|
392
|
+
|
|
313
393
|
fn ensure_tmux_installed() -> Result<(), LeaderError> {
|
|
314
394
|
match Command::new("tmux").arg("-V").output() {
|
|
315
395
|
Ok(output) if output.status.success() => Ok(()),
|
|
@@ -388,3 +468,198 @@ fn shlex_quote(raw: &str) -> String {
|
|
|
388
468
|
format!("'{}'", raw.replace('\'', "'\"'\"'"))
|
|
389
469
|
}
|
|
390
470
|
}
|
|
471
|
+
|
|
472
|
+
#[cfg(test)]
|
|
473
|
+
mod tests {
|
|
474
|
+
use std::collections::BTreeMap;
|
|
475
|
+
use std::path::Path;
|
|
476
|
+
use std::sync::Mutex;
|
|
477
|
+
|
|
478
|
+
use crate::model::enums::PaneLiveness;
|
|
479
|
+
use crate::provider::{Provider, COPILOT_READY_MARKER, COPILOT_TRUST_PROMPT_MARKER};
|
|
480
|
+
use crate::transport::{
|
|
481
|
+
AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
|
|
482
|
+
InjectStage, InjectVerification, Key, PaneField, PaneId, PaneInfo, SessionName,
|
|
483
|
+
SetEnvOutcome, SpawnResult, SubmitVerification, Target, Transport, TransportError,
|
|
484
|
+
TurnVerification, WindowName,
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
use super::handle_exec_provider_startup_prompts;
|
|
488
|
+
|
|
489
|
+
struct ScriptedTransport {
|
|
490
|
+
screens: Mutex<Vec<String>>,
|
|
491
|
+
sent: Mutex<Vec<(Target, Vec<Key>)>>,
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
impl ScriptedTransport {
|
|
495
|
+
fn new(screens: Vec<String>) -> Self {
|
|
496
|
+
Self {
|
|
497
|
+
screens: Mutex::new(screens),
|
|
498
|
+
sent: Mutex::new(Vec::new()),
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
fn sent(&self) -> Vec<(Target, Vec<Key>)> {
|
|
503
|
+
match self.sent.lock() {
|
|
504
|
+
Ok(guard) => guard.clone(),
|
|
505
|
+
Err(poisoned) => poisoned.into_inner().clone(),
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
impl Transport for ScriptedTransport {
|
|
511
|
+
fn kind(&self) -> BackendKind {
|
|
512
|
+
BackendKind::Tmux
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
fn spawn_first(
|
|
516
|
+
&self,
|
|
517
|
+
_session: &SessionName,
|
|
518
|
+
_window: &WindowName,
|
|
519
|
+
_argv: &[String],
|
|
520
|
+
_cwd: &Path,
|
|
521
|
+
_env: &BTreeMap<String, String>,
|
|
522
|
+
) -> Result<SpawnResult, TransportError> {
|
|
523
|
+
Err(TransportError::Io(std::io::Error::other(
|
|
524
|
+
"spawn_first not used by startup-prompt test",
|
|
525
|
+
)))
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
fn spawn_into(
|
|
529
|
+
&self,
|
|
530
|
+
_session: &SessionName,
|
|
531
|
+
_window: &WindowName,
|
|
532
|
+
_argv: &[String],
|
|
533
|
+
_cwd: &Path,
|
|
534
|
+
_env: &BTreeMap<String, String>,
|
|
535
|
+
) -> Result<SpawnResult, TransportError> {
|
|
536
|
+
Err(TransportError::Io(std::io::Error::other(
|
|
537
|
+
"spawn_into not used by startup-prompt test",
|
|
538
|
+
)))
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
fn inject(
|
|
542
|
+
&self,
|
|
543
|
+
_target: &Target,
|
|
544
|
+
_payload: &InjectPayload,
|
|
545
|
+
_submit: Key,
|
|
546
|
+
_bracketed: bool,
|
|
547
|
+
) -> Result<InjectReport, TransportError> {
|
|
548
|
+
Ok(InjectReport {
|
|
549
|
+
stage_reached: InjectStage::Submit,
|
|
550
|
+
inject_verification: InjectVerification::CaptureContainsToken,
|
|
551
|
+
submit_verification: SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
552
|
+
turn_verification: TurnVerification::NotRequired,
|
|
553
|
+
attempts: 1,
|
|
554
|
+
})
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
fn send_keys(&self, target: &Target, keys: &[Key]) -> Result<(), TransportError> {
|
|
558
|
+
match self.sent.lock() {
|
|
559
|
+
Ok(mut guard) => guard.push((target.clone(), keys.to_vec())),
|
|
560
|
+
Err(poisoned) => poisoned.into_inner().push((target.clone(), keys.to_vec())),
|
|
561
|
+
}
|
|
562
|
+
Ok(())
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
fn capture(
|
|
566
|
+
&self,
|
|
567
|
+
_target: &Target,
|
|
568
|
+
range: CaptureRange,
|
|
569
|
+
) -> Result<CapturedText, TransportError> {
|
|
570
|
+
let text = match self.screens.lock() {
|
|
571
|
+
Ok(mut guard) => {
|
|
572
|
+
if guard.is_empty() {
|
|
573
|
+
String::new()
|
|
574
|
+
} else {
|
|
575
|
+
guard.remove(0)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
Err(poisoned) => {
|
|
579
|
+
let mut guard = poisoned.into_inner();
|
|
580
|
+
if guard.is_empty() {
|
|
581
|
+
String::new()
|
|
582
|
+
} else {
|
|
583
|
+
guard.remove(0)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
Ok(CapturedText { text, range })
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
fn query(
|
|
591
|
+
&self,
|
|
592
|
+
_target: &Target,
|
|
593
|
+
_field: PaneField,
|
|
594
|
+
) -> Result<Option<String>, TransportError> {
|
|
595
|
+
Ok(None)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
fn liveness(&self, _pane: &PaneId) -> Result<PaneLiveness, TransportError> {
|
|
599
|
+
Ok(PaneLiveness::Unknown)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
|
|
603
|
+
Ok(Vec::new())
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
|
|
607
|
+
Ok(false)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
|
|
611
|
+
Ok(Vec::new())
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
fn set_session_env(
|
|
615
|
+
&self,
|
|
616
|
+
_session: &SessionName,
|
|
617
|
+
_key: &str,
|
|
618
|
+
_value: &str,
|
|
619
|
+
) -> Result<SetEnvOutcome, TransportError> {
|
|
620
|
+
Ok(SetEnvOutcome::Applied)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
|
|
624
|
+
Ok(())
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
|
|
628
|
+
Ok(())
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
|
|
632
|
+
Ok(AttachOutcome::Unsupported {
|
|
633
|
+
reason: "not used by startup-prompt test".to_string(),
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
#[test]
|
|
639
|
+
fn exec_provider_leader_startup_prompt_handler_reuses_copilot_adapter() {
|
|
640
|
+
let transport = ScriptedTransport::new(vec![
|
|
641
|
+
COPILOT_TRUST_PROMPT_MARKER.to_string(),
|
|
642
|
+
COPILOT_READY_MARKER.to_string(),
|
|
643
|
+
]);
|
|
644
|
+
let workspace =
|
|
645
|
+
std::env::temp_dir().join(format!("ta_rs_red2_leader_startup_{}", std::process::id()));
|
|
646
|
+
let _ = std::fs::create_dir_all(&workspace);
|
|
647
|
+
|
|
648
|
+
let outcome = handle_exec_provider_startup_prompts(
|
|
649
|
+
Provider::Copilot,
|
|
650
|
+
&workspace,
|
|
651
|
+
"%0",
|
|
652
|
+
&transport,
|
|
653
|
+
5,
|
|
654
|
+
0.0,
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
assert_eq!(outcome.handled.len(), 1);
|
|
658
|
+
assert_eq!(outcome.handled[0].prompt, "copilot_workspace_trust");
|
|
659
|
+
assert_eq!(outcome.handled[0].action, "sent_enter_yes_session");
|
|
660
|
+
let sent = transport.sent();
|
|
661
|
+
assert_eq!(sent.len(), 1);
|
|
662
|
+
assert_eq!(sent[0].0, Target::Pane(PaneId::new("%0")));
|
|
663
|
+
assert_eq!(sent[0].1, vec![Key::Enter]);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
@@ -11,15 +11,15 @@ use super::*;
|
|
|
11
11
|
// ── lifecycle::display —— 能力门 / 后端解析 / 开关 / rebind 后重建 ────────────
|
|
12
12
|
|
|
13
13
|
/// `resolve_display_backend(requested, recorded, source)`(`display/backend.py`)。默认
|
|
14
|
-
/// none
|
|
14
|
+
/// adaptive;显式 none 是逃生口。
|
|
15
15
|
pub fn resolve_display_backend(
|
|
16
16
|
requested: Option<DisplayBackend>,
|
|
17
17
|
recorded: Option<DisplayBackend>,
|
|
18
18
|
) -> ResolvedBackend {
|
|
19
|
-
let backend = requested.or(recorded).unwrap_or(DisplayBackend::
|
|
19
|
+
let backend = requested.or(recorded).unwrap_or(DisplayBackend::Adaptive);
|
|
20
20
|
ResolvedBackend {
|
|
21
21
|
backend,
|
|
22
|
-
non_default: backend != DisplayBackend::
|
|
22
|
+
non_default: backend != DisplayBackend::Adaptive,
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|