@team-agent/installer 0.5.116 → 0.5.118
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/mod.rs +20 -0
- package/crates/team-agent/src/leader/lease.rs +1 -18
- package/crates/team-agent/src/leader/start.rs +27 -47
- package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +2 -7
- package/crates/team-agent/src/messaging/delivery.rs +102 -69
- package/crates/team-agent/src/messaging/enter_rescue.rs +334 -0
- package/crates/team-agent/src/messaging/enter_rescue_tests.rs +566 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/mod.rs +1 -0
- package/crates/team-agent/src/messaging/scheduler.rs +8 -0
- package/crates/team-agent/src/messaging/tests/basic.rs +2 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +6 -5
- package/crates/team-agent/src/messaging/tests/spine.rs +27 -28
- package/crates/team-agent/src/messaging/types.rs +1 -0
- package/crates/team-agent/src/state/repository.rs +47 -0
- package/crates/team-agent/src/tmux_backend/native_input.rs +494 -0
- package/crates/team-agent/src/tmux_backend/native_input_tests.rs +918 -0
- package/crates/team-agent/src/tmux_backend.rs +49 -6
- package/crates/team-agent/src/transport/native_input.rs +716 -0
- package/crates/team-agent/src/transport.rs +19 -0
- package/package.json +4 -4
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
//! Owned legacy-tmux composer evidence. No keys, persistence, or provider admission.
|
|
2
|
+
|
|
3
|
+
use std::cell::RefCell;
|
|
4
|
+
|
|
5
|
+
use serde::{Deserialize, Serialize};
|
|
6
|
+
|
|
7
|
+
use crate::model::enums::Provider;
|
|
8
|
+
|
|
9
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
10
|
+
pub struct NativeInputRequest {
|
|
11
|
+
pub message_id: String,
|
|
12
|
+
pub owner_team_id: String,
|
|
13
|
+
pub recipient: String,
|
|
14
|
+
pub provider: Provider,
|
|
15
|
+
pub pane_id: String,
|
|
16
|
+
pub pane_pid: u32,
|
|
17
|
+
pub session: String,
|
|
18
|
+
pub window: String,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
impl NativeInputRequest {
|
|
22
|
+
pub fn marker(&self) -> String {
|
|
23
|
+
format!("[team-agent-token:{}]", self.message_id)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pub fn supported(&self) -> bool {
|
|
27
|
+
matches!(self.provider, Provider::Pi | Provider::CursorAgent)
|
|
28
|
+
&& self.message_id.starts_with("msg_")
|
|
29
|
+
&& self
|
|
30
|
+
.message_id
|
|
31
|
+
.bytes()
|
|
32
|
+
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
|
33
|
+
&& !self.owner_team_id.is_empty()
|
|
34
|
+
&& !self.recipient.is_empty()
|
|
35
|
+
&& self.recipient != "leader"
|
|
36
|
+
&& self.pane_id.starts_with('%')
|
|
37
|
+
&& self.pane_pid > 1
|
|
38
|
+
&& !self.session.is_empty()
|
|
39
|
+
&& !self.window.is_empty()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// Carrier birth and framework pane nonce, not a claim of native executable admission.
|
|
44
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
45
|
+
pub struct NativeInputBinding {
|
|
46
|
+
pub endpoint: String,
|
|
47
|
+
pub pane_id: String,
|
|
48
|
+
pub pane_pid: u32,
|
|
49
|
+
pub pane_height: u32,
|
|
50
|
+
pub carrier_birth: String,
|
|
51
|
+
pub binding_nonce: String,
|
|
52
|
+
pub session: String,
|
|
53
|
+
pub window: String,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
57
|
+
#[serde(rename_all = "snake_case")]
|
|
58
|
+
pub enum NativeInputPhase {
|
|
59
|
+
Ready,
|
|
60
|
+
MayHavePasted,
|
|
61
|
+
Pasted,
|
|
62
|
+
Seen,
|
|
63
|
+
MayHaveSubmitted,
|
|
64
|
+
Submitted,
|
|
65
|
+
Accepted,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
69
|
+
pub struct NativeInputEvidence {
|
|
70
|
+
pub request: NativeInputRequest,
|
|
71
|
+
pub binding: NativeInputBinding,
|
|
72
|
+
pub payload_sha256: String,
|
|
73
|
+
pub ui_identity: String,
|
|
74
|
+
pub footer_sha256: String,
|
|
75
|
+
pub baseline_sha256: String,
|
|
76
|
+
pub baseline_capsules: Vec<String>,
|
|
77
|
+
pub phase: NativeInputPhase,
|
|
78
|
+
pub capsule: Option<String>,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
82
|
+
pub enum EnterRescueOutcome {
|
|
83
|
+
Consumed,
|
|
84
|
+
NoEffect {
|
|
85
|
+
reason: &'static str,
|
|
86
|
+
reobserve: bool,
|
|
87
|
+
},
|
|
88
|
+
Entered {
|
|
89
|
+
accepted: bool,
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type Scope = (Option<NativeInputRequest>, Option<NativeInputEvidence>);
|
|
94
|
+
thread_local! {
|
|
95
|
+
static INPUT_SCOPE: RefCell<Scope> = const { RefCell::new((None, None)) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
struct Restore(Option<Scope>);
|
|
99
|
+
impl Drop for Restore {
|
|
100
|
+
fn drop(&mut self) {
|
|
101
|
+
if let Some(previous) = self.0.take() {
|
|
102
|
+
INPUT_SCOPE.with(|slot| *slot.borrow_mut() = previous);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// Existing synchronous delivery owns this scope; no borrowed observer escapes a thread.
|
|
108
|
+
pub fn with_native_input<R>(
|
|
109
|
+
request: Option<NativeInputRequest>,
|
|
110
|
+
f: impl FnOnce() -> R,
|
|
111
|
+
) -> (R, Option<NativeInputEvidence>) {
|
|
112
|
+
let previous = INPUT_SCOPE.with(|slot| slot.replace((request, None)));
|
|
113
|
+
let restore = Restore(Some(previous));
|
|
114
|
+
let result = f();
|
|
115
|
+
let evidence = INPUT_SCOPE.with(|slot| slot.borrow_mut().1.take());
|
|
116
|
+
drop(restore);
|
|
117
|
+
(result, evidence)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
pub(crate) fn current_request() -> Option<NativeInputRequest> {
|
|
121
|
+
INPUT_SCOPE.with(|slot| slot.borrow().0.clone())
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
pub(crate) fn record_evidence(evidence: &NativeInputEvidence) {
|
|
125
|
+
INPUT_SCOPE.with(|slot| {
|
|
126
|
+
let mut scope = slot.borrow_mut();
|
|
127
|
+
if scope.0.as_ref() == Some(&evidence.request) {
|
|
128
|
+
scope.1 = Some(evidence.clone());
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
134
|
+
pub(crate) struct InputFrame {
|
|
135
|
+
pub composer: String,
|
|
136
|
+
pub transcript: String,
|
|
137
|
+
pub identity: String,
|
|
138
|
+
pub footer: String,
|
|
139
|
+
pub busy: bool,
|
|
140
|
+
pub capsule: Option<String>,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
impl InputFrame {
|
|
144
|
+
pub fn empty(&self) -> bool {
|
|
145
|
+
self.composer.trim().is_empty()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
pub fn has_marker(&self, marker: &str) -> bool {
|
|
149
|
+
self.composer
|
|
150
|
+
.lines()
|
|
151
|
+
.map(str::trim)
|
|
152
|
+
.collect::<String>()
|
|
153
|
+
.matches(marker)
|
|
154
|
+
.count()
|
|
155
|
+
== 1
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
pub fn pending(&self, evidence: &NativeInputEvidence) -> bool {
|
|
159
|
+
!self.busy
|
|
160
|
+
&& self.identity == evidence.ui_identity
|
|
161
|
+
&& input_digest(&self.footer) == evidence.footer_sha256
|
|
162
|
+
&& (self.has_marker(&evidence.request.marker())
|
|
163
|
+
|| evidence.capsule.as_ref().is_some_and(|capsule| {
|
|
164
|
+
self.capsule.as_ref() == Some(capsule)
|
|
165
|
+
&& !evidence.baseline_capsules.contains(capsule)
|
|
166
|
+
}))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
pub fn accepted(&self, evidence: &NativeInputEvidence) -> bool {
|
|
170
|
+
if !self.empty() || self.identity != evidence.ui_identity {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if evidence.phase < NativeInputPhase::Seen {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
let marker = evidence.request.marker();
|
|
177
|
+
let joined: String = self.transcript.lines().map(str::trim).collect();
|
|
178
|
+
if joined.matches(&marker).count() == 1 {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
evidence.capsule.as_ref().is_some_and(|capsule| {
|
|
182
|
+
!evidence.baseline_capsules.contains(capsule)
|
|
183
|
+
&& self
|
|
184
|
+
.transcript
|
|
185
|
+
.lines()
|
|
186
|
+
.filter(|line| line.trim() == capsule)
|
|
187
|
+
.count()
|
|
188
|
+
== 1
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
fn input_digest(text: &str) -> String {
|
|
194
|
+
use sha2::{Digest, Sha256};
|
|
195
|
+
format!("{:x}", Sha256::digest(text.as_bytes()))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
pub(crate) fn inspect_input(
|
|
199
|
+
provider: Provider,
|
|
200
|
+
text: &str,
|
|
201
|
+
pane_height: u32,
|
|
202
|
+
) -> Option<InputFrame> {
|
|
203
|
+
match provider {
|
|
204
|
+
Provider::Pi => pi_input(text, pane_height),
|
|
205
|
+
Provider::CursorAgent => cursor_input(text, pane_height),
|
|
206
|
+
_ => None,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fn cursor_input(text: &str, pane_height: u32) -> Option<InputFrame> {
|
|
211
|
+
use team_agent_contract::contract::delivery::InputSurface;
|
|
212
|
+
if pane_height == 0 {
|
|
213
|
+
return None;
|
|
214
|
+
}
|
|
215
|
+
let text_rows: Vec<_> = text.lines().collect();
|
|
216
|
+
// Only the current viewport authorizes input; retain full history below
|
|
217
|
+
// solely for same-message transcript correlation.
|
|
218
|
+
let viewport_start = text_rows.len().saturating_sub(pane_height as usize);
|
|
219
|
+
let viewport = text_rows[viewport_start..].join("\n");
|
|
220
|
+
let (surface, rows, capsule, busy) =
|
|
221
|
+
team_agent_contract::providers::cursor::interaction::inspect_legacy_composer(&viewport)?;
|
|
222
|
+
let first = match *rows.first()? {
|
|
223
|
+
"→" => "",
|
|
224
|
+
row => row.strip_prefix("→ ")?,
|
|
225
|
+
};
|
|
226
|
+
let footer = rows.iter().rposition(|row| {
|
|
227
|
+
row.strip_suffix("Run Everything").is_some_and(|prefix| {
|
|
228
|
+
!prefix.trim().is_empty() && !prefix.trim_start().starts_with("→ ")
|
|
229
|
+
})
|
|
230
|
+
})?;
|
|
231
|
+
if footer == 0 {
|
|
232
|
+
return None;
|
|
233
|
+
}
|
|
234
|
+
let tail: Vec<_> = rows[footer + 1..]
|
|
235
|
+
.iter()
|
|
236
|
+
.filter(|row| !row.is_empty())
|
|
237
|
+
.copied()
|
|
238
|
+
.collect();
|
|
239
|
+
if tail.is_empty() {
|
|
240
|
+
return None;
|
|
241
|
+
}
|
|
242
|
+
// Startup history can leave Tail(200) between Ready/Pasted/Accepted. The
|
|
243
|
+
// immutable carrier birth/nonce remains in NativeInputBinding; this UI
|
|
244
|
+
// context is the current native cwd/footer tail, not a version attestation.
|
|
245
|
+
let identity = format!(
|
|
246
|
+
"cursor-current-composer-v1:{}",
|
|
247
|
+
input_digest(&tail.join("\n"))
|
|
248
|
+
);
|
|
249
|
+
let footer_row = rows[footer]
|
|
250
|
+
.split_whitespace()
|
|
251
|
+
.collect::<Vec<_>>()
|
|
252
|
+
.join(" ");
|
|
253
|
+
let ready = matches!(surface, InputSurface::ComposerReady | InputSurface::Busy);
|
|
254
|
+
if (ready || capsule.is_some()) && rows[1..footer].iter().any(|row| !row.trim().is_empty()) {
|
|
255
|
+
return None;
|
|
256
|
+
}
|
|
257
|
+
let composer = if ready {
|
|
258
|
+
String::new()
|
|
259
|
+
} else {
|
|
260
|
+
std::iter::once(first)
|
|
261
|
+
.chain(rows[1..footer].iter().copied())
|
|
262
|
+
.collect::<Vec<_>>()
|
|
263
|
+
.join("\n")
|
|
264
|
+
};
|
|
265
|
+
let arrow = text_rows
|
|
266
|
+
.iter()
|
|
267
|
+
.rposition(|line| line.trim() == "→" || line.trim().starts_with("→ "))?;
|
|
268
|
+
Some(InputFrame {
|
|
269
|
+
composer,
|
|
270
|
+
transcript: text.lines().take(arrow).collect::<Vec<_>>().join("\n"),
|
|
271
|
+
identity,
|
|
272
|
+
footer: footer_row,
|
|
273
|
+
busy,
|
|
274
|
+
capsule: capsule.map(str::to_owned),
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
fn border(line: &str) -> bool {
|
|
279
|
+
line.chars().filter(|character| *character == '─').count() >= 8
|
|
280
|
+
&& line.trim_start().starts_with('─')
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// A complete native update notice can end inside the editor search window,
|
|
284
|
+
// but its opening must precede every possible editor row. Never discard a
|
|
285
|
+
// ruler belonging to the editable region or an incomplete/unknown notice.
|
|
286
|
+
fn pi_closed_update_notice(rows: &[&str], closing: usize, editor_start: usize, top: usize) -> bool {
|
|
287
|
+
let rule = rows[closing].trim();
|
|
288
|
+
if !rule.chars().all(|character| character == '─')
|
|
289
|
+
|| !rows[closing + 1..top]
|
|
290
|
+
.iter()
|
|
291
|
+
.all(|row| row.trim().is_empty())
|
|
292
|
+
{
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
let Some(opening) = rows[..editor_start]
|
|
296
|
+
.iter()
|
|
297
|
+
.rposition(|row| row.trim() == rule)
|
|
298
|
+
else {
|
|
299
|
+
return false;
|
|
300
|
+
};
|
|
301
|
+
let body = &rows[opening + 1..closing];
|
|
302
|
+
body.len() >= 4
|
|
303
|
+
&& body[0].trim() == "Package Updates Available"
|
|
304
|
+
&& body[1].trim() == "Package updates are available. Run pi update --extensions"
|
|
305
|
+
&& body[2].trim() == "Packages:"
|
|
306
|
+
&& body[3..].iter().all(|row| {
|
|
307
|
+
row.trim()
|
|
308
|
+
.strip_prefix("- ")
|
|
309
|
+
.is_some_and(|package| !package.trim().is_empty())
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
pub(crate) fn pi_paste_capsule(text: &str) -> Option<&str> {
|
|
314
|
+
fn positive(value: &str) -> bool {
|
|
315
|
+
!value.starts_with('0')
|
|
316
|
+
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
|
317
|
+
&& value.parse::<u64>().is_ok()
|
|
318
|
+
}
|
|
319
|
+
let label = text.trim();
|
|
320
|
+
let body = label.strip_prefix("[paste #")?.strip_suffix(']')?;
|
|
321
|
+
let (id, size) = body.split_once(' ')?;
|
|
322
|
+
let count = if let Some(lines) = size.strip_prefix('+') {
|
|
323
|
+
lines.strip_suffix(" lines")?
|
|
324
|
+
} else {
|
|
325
|
+
size.strip_suffix(" chars")?
|
|
326
|
+
};
|
|
327
|
+
(positive(id) && positive(count)).then_some(label)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
fn pi_input(text: &str, pane_height: u32) -> Option<InputFrame> {
|
|
331
|
+
if pane_height < 10 {
|
|
332
|
+
return None;
|
|
333
|
+
}
|
|
334
|
+
let rows: Vec<_> = text.lines().collect();
|
|
335
|
+
let footer = rows
|
|
336
|
+
.iter()
|
|
337
|
+
.rposition(|row| row.contains(" · ") && row.contains("%/"))?;
|
|
338
|
+
if rows[footer + 1..].iter().any(|row| !row.trim().is_empty()) {
|
|
339
|
+
return None;
|
|
340
|
+
}
|
|
341
|
+
let bottom = (0..footer)
|
|
342
|
+
.rev()
|
|
343
|
+
.find(|index| !rows[*index].trim().is_empty())?;
|
|
344
|
+
if !border(rows[bottom]) {
|
|
345
|
+
return None;
|
|
346
|
+
}
|
|
347
|
+
// Current Pi renderer shows at most max(5, floor(pane_rows * .3))
|
|
348
|
+
// editor lines. A task's Unicode ruler is not a native top boundary:
|
|
349
|
+
// ambiguous borders inside that whole possible editor extent refuse.
|
|
350
|
+
let extent = (pane_height as usize * 3 / 10).max(5) + 1;
|
|
351
|
+
let editor_start = bottom.saturating_sub(extent);
|
|
352
|
+
let candidates: Vec<_> = (editor_start..bottom)
|
|
353
|
+
.filter(|index| border(rows[*index]))
|
|
354
|
+
.collect();
|
|
355
|
+
let top = *candidates.last()?;
|
|
356
|
+
if candidates[..candidates.len() - 1]
|
|
357
|
+
.iter()
|
|
358
|
+
.any(|index| !pi_closed_update_notice(&rows, *index, editor_start, top))
|
|
359
|
+
{
|
|
360
|
+
return None;
|
|
361
|
+
}
|
|
362
|
+
let composer = rows[top + 1..bottom].join("\n");
|
|
363
|
+
let transcript = rows[..top].join("\n");
|
|
364
|
+
// Inspect native status adjacent to the editor, never the task body.
|
|
365
|
+
let status = rows[..top]
|
|
366
|
+
.iter()
|
|
367
|
+
.rev()
|
|
368
|
+
.filter(|row| !row.trim().is_empty())
|
|
369
|
+
.take(3)
|
|
370
|
+
.copied()
|
|
371
|
+
.collect::<Vec<_>>()
|
|
372
|
+
.join("\n");
|
|
373
|
+
// Pi embeds its live status/spinner after a 1–3-cell border prefix. Idle
|
|
374
|
+
// (including the observed overflow/title border) has a solid leading run.
|
|
375
|
+
let busy = rows[top]
|
|
376
|
+
.trim_start()
|
|
377
|
+
.chars()
|
|
378
|
+
.take_while(|character| *character == '─')
|
|
379
|
+
.count()
|
|
380
|
+
< 8
|
|
381
|
+
|| status.to_ascii_lowercase().contains("esc to interrupt")
|
|
382
|
+
|| status.to_ascii_lowercase().contains("ctrl+c to stop");
|
|
383
|
+
let capsule = pi_paste_capsule(&composer).map(str::to_owned);
|
|
384
|
+
Some(InputFrame {
|
|
385
|
+
composer,
|
|
386
|
+
transcript,
|
|
387
|
+
identity: rows[footer].split(" · ").next()?.trim().to_string(),
|
|
388
|
+
footer: String::new(),
|
|
389
|
+
busy,
|
|
390
|
+
capsule,
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#[cfg(test)]
|
|
395
|
+
mod tests {
|
|
396
|
+
#![allow(clippy::unwrap_used, clippy::panic)]
|
|
397
|
+
use super::*;
|
|
398
|
+
|
|
399
|
+
fn cursor(composer: &str, transcript: &str) -> String {
|
|
400
|
+
format!("Cursor Agent\nv2026.10.01-e373342\n{transcript}\n→ {composer}\nGrok 4.7 High Run Everything\n/owned/project\n")
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
fn pi(composer: &str, transcript: &str) -> String {
|
|
404
|
+
format!("{transcript}\n──────────────── ↑ 20 more ─ runner-luna ─\n{composer}\n────────────────────────\ngpt-6-luna · max · 0%/272k · 0% · $0.00\n")
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
fn inspect_input(provider: Provider, text: &str) -> Option<InputFrame> {
|
|
408
|
+
super::inspect_input(provider, text, 120)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
#[test]
|
|
412
|
+
fn native_input_scope_restores_nested_and_panicking_calls() {
|
|
413
|
+
let request = NativeInputRequest {
|
|
414
|
+
message_id: "msg_one".into(),
|
|
415
|
+
owner_team_id: "team".into(),
|
|
416
|
+
recipient: "worker".into(),
|
|
417
|
+
provider: Provider::Pi,
|
|
418
|
+
pane_id: "%1".into(),
|
|
419
|
+
pane_pid: 2,
|
|
420
|
+
session: "team".into(),
|
|
421
|
+
window: "worker".into(),
|
|
422
|
+
};
|
|
423
|
+
with_native_input(Some(request.clone()), || {
|
|
424
|
+
assert_eq!(current_request(), Some(request.clone()));
|
|
425
|
+
with_native_input(None, || assert!(current_request().is_none()));
|
|
426
|
+
assert_eq!(current_request(), Some(request));
|
|
427
|
+
let _ = std::panic::catch_unwind(|| with_native_input(None, || panic!("fixture")));
|
|
428
|
+
assert!(current_request().is_some());
|
|
429
|
+
});
|
|
430
|
+
assert!(current_request().is_none());
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#[test]
|
|
434
|
+
fn native_input_pi_requires_editor_and_footer_not_startup_echo() {
|
|
435
|
+
let marker = "[team-agent-token:msg_one]";
|
|
436
|
+
let draft = pi(
|
|
437
|
+
&format!("{}\n{marker}", "line\n".repeat(26)),
|
|
438
|
+
"Warning: startup",
|
|
439
|
+
);
|
|
440
|
+
let frame = inspect_input(Provider::Pi, &draft).unwrap();
|
|
441
|
+
assert!(!frame.empty());
|
|
442
|
+
assert!(frame.has_marker(marker));
|
|
443
|
+
assert!(!frame.busy);
|
|
444
|
+
assert!(inspect_input(Provider::Pi, &format!("Warning: startup\n{marker}")).is_none());
|
|
445
|
+
assert!(inspect_input(Provider::Pi, &pi("", "old history"))
|
|
446
|
+
.unwrap()
|
|
447
|
+
.empty());
|
|
448
|
+
let busy = pi("draft", "history").replace(
|
|
449
|
+
"──────────────── ↑ 20 more ─ runner-luna ─",
|
|
450
|
+
"── ⠋ Working ─────────────────────",
|
|
451
|
+
);
|
|
452
|
+
assert!(inspect_input(Provider::Pi, &busy).unwrap().busy);
|
|
453
|
+
assert!(inspect_input(
|
|
454
|
+
Provider::Pi,
|
|
455
|
+
&pi("foreign draft\n────────\n", "old history")
|
|
456
|
+
)
|
|
457
|
+
.is_none());
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const PI_R6_IDLE: &str = include_str!("../../tests/fixtures/pi-r6-idle-24.txt");
|
|
461
|
+
|
|
462
|
+
#[test]
|
|
463
|
+
fn native_input_pi_r6_idle_update_notice_preserves_empty_editor() {
|
|
464
|
+
assert_eq!(
|
|
465
|
+
input_digest(PI_R6_IDLE),
|
|
466
|
+
"40f31e7476f628a471113c89b76a1ace0246e69f53fb7238999205f62b5d20fa"
|
|
467
|
+
);
|
|
468
|
+
let frame = super::inspect_input(Provider::Pi, PI_R6_IDLE, 24).unwrap();
|
|
469
|
+
assert!(frame.empty());
|
|
470
|
+
assert!(!frame.busy);
|
|
471
|
+
assert_eq!(frame.identity, "gpt-6-luna");
|
|
472
|
+
assert!(frame.transcript.contains("Package Updates Available"));
|
|
473
|
+
assert!(!frame.composer.contains("Package Updates Available"));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
#[test]
|
|
477
|
+
fn native_input_pi_r6_notice_does_not_hide_drafts_or_busy_state() {
|
|
478
|
+
let top = " pi-target ─\n\n";
|
|
479
|
+
let marker = "[team-agent-token:msg_one]";
|
|
480
|
+
let draft = PI_R6_IDLE.replace(top, &format!(" pi-target ─\nforeign draft\n{marker}\n"));
|
|
481
|
+
let frame = super::inspect_input(Provider::Pi, &draft, 24).unwrap();
|
|
482
|
+
assert!(!frame.empty());
|
|
483
|
+
assert!(frame.has_marker(marker));
|
|
484
|
+
let busy = PI_R6_IDLE.replace(
|
|
485
|
+
"──────────────────────────────────────────────────────────────────── pi-target ─",
|
|
486
|
+
"── ⠋ Working ──────────────────────────────────────────────────────────────────",
|
|
487
|
+
);
|
|
488
|
+
let frame = super::inspect_input(Provider::Pi, &busy, 24).unwrap();
|
|
489
|
+
assert!(frame.empty());
|
|
490
|
+
assert!(frame.busy);
|
|
491
|
+
for composer in ["foreign draft\n────────\n", "\n────────\n"]
|
|
492
|
+
{
|
|
493
|
+
let foreign = PI_R6_IDLE.replace(top, &format!(" pi-target ─\n{composer}\n"));
|
|
494
|
+
assert!(super::inspect_input(Provider::Pi, &foreign, 24).is_none());
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
#[test]
|
|
499
|
+
fn native_input_pi_r6_incomplete_or_ambiguous_notice_refuses() {
|
|
500
|
+
for frame in [
|
|
501
|
+
PI_R6_IDLE.replace("Package Updates Available", "Unknown notification"),
|
|
502
|
+
PI_R6_IDLE.replace(" - pi-mcp-adapter", "not a native package row"),
|
|
503
|
+
PI_R6_IDLE.replace("\n\n──────────────────────────────────────────────────────────────────── pi-target", "\nforeign draft\n──────────────────────────────────────────────────────────────────── pi-target"),
|
|
504
|
+
] {
|
|
505
|
+
assert!(super::inspect_input(Provider::Pi, &frame, 24).is_none());
|
|
506
|
+
}
|
|
507
|
+
// If the complete box itself could be editor content, geometry cannot
|
|
508
|
+
// distinguish it from a pasted imitation: keep the old refusal.
|
|
509
|
+
assert!(super::inspect_input(Provider::Pi, PI_R6_IDLE, 120).is_none());
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const PI_R7_FOLDED: &str = include_str!("../../tests/fixtures/pi-r7-folded-24.txt");
|
|
513
|
+
|
|
514
|
+
#[test]
|
|
515
|
+
fn native_input_pi_r7_capsule_is_current_composer_not_history() {
|
|
516
|
+
assert_eq!(
|
|
517
|
+
input_digest(PI_R7_FOLDED),
|
|
518
|
+
"b7ea6992c3303743b50714fb55ffc8b91ac5ad168e0acd015428ca5af538ed0e"
|
|
519
|
+
);
|
|
520
|
+
let frame = super::inspect_input(Provider::Pi, PI_R7_FOLDED, 24).unwrap();
|
|
521
|
+
assert!(!frame.empty());
|
|
522
|
+
assert!(!frame.busy);
|
|
523
|
+
assert_eq!(frame.capsule.as_deref(), Some("[paste #1 +30 lines]"));
|
|
524
|
+
assert!(!frame.has_marker("[team-agent-token:msg_one]"));
|
|
525
|
+
let history = format!("[paste #1 +30 lines]\n{PI_R6_IDLE}");
|
|
526
|
+
let frame = super::inspect_input(Provider::Pi, &history, 24).unwrap();
|
|
527
|
+
assert!(frame.empty());
|
|
528
|
+
assert!(frame.capsule.is_none());
|
|
529
|
+
let extra = PI_R7_FOLDED.replace(
|
|
530
|
+
"[paste #1 +30 lines]",
|
|
531
|
+
"[paste #1 +30 lines]\nforeign draft",
|
|
532
|
+
);
|
|
533
|
+
let frame = super::inspect_input(Provider::Pi, &extra, 24).unwrap();
|
|
534
|
+
assert!(!frame.empty());
|
|
535
|
+
assert!(frame.capsule.is_none());
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
#[test]
|
|
539
|
+
fn native_input_pi_capsule_grammar_is_canonical_and_provider_specific() {
|
|
540
|
+
for label in ["[paste #1 +30 lines]", "[paste #2 1234 chars]"] {
|
|
541
|
+
assert_eq!(pi_paste_capsule(label), Some(label));
|
|
542
|
+
}
|
|
543
|
+
for label in [
|
|
544
|
+
"[paste #0 +30 lines]",
|
|
545
|
+
"[paste #01 +30 lines]",
|
|
546
|
+
"[paste #1 +0 lines]",
|
|
547
|
+
"[paste #1 +030 lines]",
|
|
548
|
+
"[paste #18446744073709551616 +30 lines]",
|
|
549
|
+
"[paste #1 +18446744073709551616 lines]",
|
|
550
|
+
"[paste #1 -30 lines]",
|
|
551
|
+
"[paste #1 +30 line]",
|
|
552
|
+
"[paste #1 +30 lines]suffix",
|
|
553
|
+
"prefix[paste #1 +30 lines]",
|
|
554
|
+
"[paste #1 +30 lines]\n[paste #2 +40 lines]",
|
|
555
|
+
"[Pasted text #1 +30 lines]",
|
|
556
|
+
] {
|
|
557
|
+
assert_eq!(pi_paste_capsule(label), None, "{label}");
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Native Issue #328 current-composer rows; transcript removed and private
|
|
562
|
+
// cwd replaced with /owned/project. Startup banner is genuinely absent
|
|
563
|
+
// from the owned pane's Tail(200), not a fabricated invalid version.
|
|
564
|
+
const CURSOR_FOLLOWUP: &str =
|
|
565
|
+
include_str!("../../tests/fixtures/cursor-followup-headerless.txt");
|
|
566
|
+
|
|
567
|
+
#[test]
|
|
568
|
+
fn native_input_cursor_clean_followup_does_not_require_scrolled_startup_banner() {
|
|
569
|
+
let ready = inspect_input(Provider::CursorAgent, CURSOR_FOLLOWUP).unwrap();
|
|
570
|
+
assert!(ready.empty());
|
|
571
|
+
assert!(!ready.busy);
|
|
572
|
+
assert!(ready.capsule.is_none());
|
|
573
|
+
let header = format!("Cursor Agent\nv2026.10.14-0f9edde\n{CURSOR_FOLLOWUP}");
|
|
574
|
+
let with_header = inspect_input(Provider::CursorAgent, &header).unwrap();
|
|
575
|
+
assert!(with_header.empty() && !with_header.busy);
|
|
576
|
+
assert_eq!(ready.footer, with_header.footer);
|
|
577
|
+
assert_eq!(
|
|
578
|
+
ready.identity, with_header.identity,
|
|
579
|
+
"startup history is not a new UI identity"
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
#[test]
|
|
584
|
+
fn native_input_cursor_headerless_followup_retains_draft_busy_and_footer_fences() {
|
|
585
|
+
let label = "[Pasted text #2 +25 lines]";
|
|
586
|
+
let folded = CURSOR_FOLLOWUP.replace("Add a follow-up", label);
|
|
587
|
+
let draft = inspect_input(Provider::CursorAgent, &folded).unwrap();
|
|
588
|
+
assert!(!draft.empty());
|
|
589
|
+
assert_eq!(draft.capsule.as_deref(), Some(label));
|
|
590
|
+
let dirty = CURSOR_FOLLOWUP.replace("Add a follow-up", "Add a follow-up draft");
|
|
591
|
+
assert!(!inspect_input(Provider::CursorAgent, &dirty)
|
|
592
|
+
.unwrap()
|
|
593
|
+
.empty());
|
|
594
|
+
for dirty in [
|
|
595
|
+
CURSOR_FOLLOWUP.replace("Add a follow-up", "Add a follow-up\nforeign draft"),
|
|
596
|
+
CURSOR_FOLLOWUP.replace("Add a follow-up", &format!("{label}\nforeign draft")),
|
|
597
|
+
] {
|
|
598
|
+
assert!(inspect_input(Provider::CursorAgent, &dirty).is_none());
|
|
599
|
+
}
|
|
600
|
+
let busy = CURSOR_FOLLOWUP.replace("Add a follow-up", "Add a follow-up ctrl+c to stop");
|
|
601
|
+
assert!(inspect_input(Provider::CursorAgent, &busy).unwrap().busy);
|
|
602
|
+
let working = format!("⠘⠆ Working\n{CURSOR_FOLLOWUP}");
|
|
603
|
+
assert!(inspect_input(Provider::CursorAgent, &working).unwrap().busy);
|
|
604
|
+
for unknown in [
|
|
605
|
+
"→ Add a follow-up\n".to_string(),
|
|
606
|
+
CURSOR_FOLLOWUP.replace("Run Everything", "unknown footer"),
|
|
607
|
+
CURSOR_FOLLOWUP.replace(" /owned/project\n", ""),
|
|
608
|
+
] {
|
|
609
|
+
assert!(inspect_input(Provider::CursorAgent, &unknown).is_none());
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
#[test]
|
|
614
|
+
fn native_input_cursor_real_expanded_followup_is_nonempty_current_marker_not_capsule() {
|
|
615
|
+
let expanded = include_str!("../../tests/fixtures/cursor-followup-expanded.txt");
|
|
616
|
+
let marker = "[team-agent-token:msg_one]";
|
|
617
|
+
for prefix in [
|
|
618
|
+
"".to_string(),
|
|
619
|
+
format!("Cursor Agent\nv2026.10.14-0f9edde\n{CURSOR_FOLLOWUP}"),
|
|
620
|
+
] {
|
|
621
|
+
let text = format!("{prefix}{expanded}");
|
|
622
|
+
let frame = inspect_input(Provider::CursorAgent, &text).unwrap();
|
|
623
|
+
assert!(!frame.empty() && !frame.busy);
|
|
624
|
+
assert!(frame.has_marker(marker));
|
|
625
|
+
assert!(frame.capsule.is_none());
|
|
626
|
+
assert!(!frame.transcript.contains(marker));
|
|
627
|
+
let changed = text.replace("/owned/project", "/different/project");
|
|
628
|
+
assert_ne!(
|
|
629
|
+
frame.identity,
|
|
630
|
+
inspect_input(Provider::CursorAgent, &changed)
|
|
631
|
+
.unwrap()
|
|
632
|
+
.identity
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
let duplicate = expanded.replace(marker, &format!("{marker}\n{marker}"));
|
|
636
|
+
assert!(!inspect_input(Provider::CursorAgent, &duplicate)
|
|
637
|
+
.unwrap()
|
|
638
|
+
.has_marker(marker));
|
|
639
|
+
let busy = expanded.replace(" →", "⠘⠆ Working\n →");
|
|
640
|
+
assert!(inspect_input(Provider::CursorAgent, &busy).unwrap().busy);
|
|
641
|
+
for unknown in [
|
|
642
|
+
"→\nGrok 4.7 High Run Everything\n/owned/project\n".to_string(),
|
|
643
|
+
"→\nforeign draft\n→ Add a follow-up\nGrok 4.7 High Run Everything\n/owned/project\n"
|
|
644
|
+
.to_string(),
|
|
645
|
+
expanded.replace("Run Everything", "unknown footer"),
|
|
646
|
+
expanded.replace(" /owned/project\n", ""),
|
|
647
|
+
format!("{expanded}{}", "unknown current row\n".repeat(24)),
|
|
648
|
+
] {
|
|
649
|
+
assert!(super::inspect_input(Provider::CursorAgent, &unknown, 24).is_none());
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
#[test]
|
|
654
|
+
fn native_input_cursor_requires_composer_in_current_viewport_not_old_history() {
|
|
655
|
+
for header in ["", "Cursor Agent\nv2026.10.14-0f9edde\n"] {
|
|
656
|
+
let hidden = format!(
|
|
657
|
+
"{header}{CURSOR_FOLLOWUP}{}",
|
|
658
|
+
"unknown current row\n".repeat(24)
|
|
659
|
+
);
|
|
660
|
+
assert!(super::inspect_input(Provider::CursorAgent, &hidden, 24).is_none());
|
|
661
|
+
}
|
|
662
|
+
assert!(super::inspect_input(Provider::CursorAgent, CURSOR_FOLLOWUP, 0).is_none());
|
|
663
|
+
let current = super::inspect_input(Provider::CursorAgent, CURSOR_FOLLOWUP, 24).unwrap();
|
|
664
|
+
let changed = CURSOR_FOLLOWUP.replace("/owned/project", "/different/project");
|
|
665
|
+
let changed = super::inspect_input(Provider::CursorAgent, &changed, 24).unwrap();
|
|
666
|
+
assert_ne!(current.identity, changed.identity);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
#[test]
|
|
670
|
+
fn native_input_cursor_separates_folded_draft_from_history_and_busy() {
|
|
671
|
+
let capsule = "[Pasted text #1 +25 lines]";
|
|
672
|
+
let draft = inspect_input(Provider::CursorAgent, &cursor(capsule, "")).unwrap();
|
|
673
|
+
assert!(!draft.empty());
|
|
674
|
+
assert_eq!(draft.capsule.as_deref(), Some(capsule));
|
|
675
|
+
let ready = inspect_input(
|
|
676
|
+
Provider::CursorAgent,
|
|
677
|
+
&cursor("Plan, search, build anything", capsule),
|
|
678
|
+
)
|
|
679
|
+
.unwrap();
|
|
680
|
+
assert!(ready.empty());
|
|
681
|
+
assert!(ready.capsule.is_none());
|
|
682
|
+
let busy = inspect_input(
|
|
683
|
+
Provider::CursorAgent,
|
|
684
|
+
&cursor("Add a follow-up ctrl+c to stop", capsule),
|
|
685
|
+
)
|
|
686
|
+
.unwrap();
|
|
687
|
+
assert!(busy.busy);
|
|
688
|
+
assert!(
|
|
689
|
+
inspect_input(Provider::CursorAgent, &cursor(capsule, "⠘⠆ Working"))
|
|
690
|
+
.unwrap()
|
|
691
|
+
.busy
|
|
692
|
+
);
|
|
693
|
+
assert!(inspect_input(Provider::CursorAgent, "→ [Pasted text #1 +25 lines]").is_none());
|
|
694
|
+
assert!(inspect_input(
|
|
695
|
+
Provider::CursorAgent,
|
|
696
|
+
&cursor("Plan, search, build anything\nextra editable text", "")
|
|
697
|
+
)
|
|
698
|
+
.is_none());
|
|
699
|
+
assert!(inspect_input(
|
|
700
|
+
Provider::CursorAgent,
|
|
701
|
+
&cursor("[Pasted text #1 +25 lines]\nsecond block", "")
|
|
702
|
+
)
|
|
703
|
+
.is_none());
|
|
704
|
+
assert!(inspect_input(
|
|
705
|
+
Provider::CursorAgent,
|
|
706
|
+
"Cursor Agent\nv2026.10.01-e373342\n→ Run Everything\n"
|
|
707
|
+
)
|
|
708
|
+
.is_none());
|
|
709
|
+
let malformed = inspect_input(
|
|
710
|
+
Provider::CursorAgent,
|
|
711
|
+
&cursor("[Pasted text #0 +25 lines]", ""),
|
|
712
|
+
)
|
|
713
|
+
.unwrap();
|
|
714
|
+
assert!(malformed.capsule.is_none());
|
|
715
|
+
}
|
|
716
|
+
}
|