@team-agent/installer 0.3.24 → 0.3.25
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/coordinator/tick.rs +55 -7
- package/crates/team-agent/src/lifecycle/launch.rs +217 -19
- package/crates/team-agent/src/lifecycle/restart/agent.rs +61 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +66 -1
- package/crates/team-agent/src/lifecycle/restart.rs +19 -2
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +994 -0
- package/crates/team-agent/src/messaging/delivery.rs +12 -0
- package/crates/team-agent/src/messaging/helpers.rs +64 -24
- package/crates/team-agent/src/messaging/tests/spine.rs +157 -4
- package/crates/team-agent/src/tmux_backend/tests.rs +251 -0
- package/crates/team-agent/src/tmux_backend.rs +128 -0
- package/crates/team-agent/src/transport.rs +26 -1
- package/npm/install.mjs +90 -25
- package/package.json +4 -4
|
@@ -469,7 +469,19 @@ fn inject_submit_verified(report: &InjectReport) -> bool {
|
|
|
469
469
|
SubmitVerification::PastedContentPromptStillPresentAfterSubmit => false,
|
|
470
470
|
SubmitVerification::PastedContentPromptAbsentAfterSubmit => true,
|
|
471
471
|
SubmitVerification::KeySentAfterVisibleToken { .. } => true,
|
|
472
|
+
// MUST-10 preserved: EnterSentWithoutPlaceholderCheck still ⇒ delivered
|
|
473
|
+
// (provider_submit_verification_red.rs:113-159 contract). E46 narrows
|
|
474
|
+
// when transport returns this variant: it is now emitted ONLY after
|
|
475
|
+
// post-Enter input-consumption confirmation succeeds. Fresh TUI
|
|
476
|
+
// (bracketed-paste-stuck) instead returns SubmitConsumptionUnverified
|
|
477
|
+
// so this branch keeps delivered semantics intact.
|
|
472
478
|
SubmitVerification::EnterSentWithoutPlaceholderCheck => true,
|
|
479
|
+
// E46 (0.3.24 bug#5): Enter was sent but post-Enter consumption was
|
|
480
|
+
// NOT observed within the bounded resend cap. Treat as not delivered
|
|
481
|
+
// (submitted_unverified / failed) — prevents the macmini假阳 where
|
|
482
|
+
// demo-director's stuck bracketed-paste swallowed the Enter and
|
|
483
|
+
// delivery still reported delivered.
|
|
484
|
+
SubmitVerification::SubmitConsumptionUnverified => false,
|
|
473
485
|
}
|
|
474
486
|
}
|
|
475
487
|
|
|
@@ -112,9 +112,20 @@ pub(crate) fn parse_scheduled_kind(kind: &str) -> Result<ScheduledKind, Messagin
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
pub(crate) fn working_seconds(scrollback: &str) -> Option<u64> {
|
|
115
|
-
|
|
115
|
+
// E47 ROOT-FIX#1 (0.3.24 P0, idle/busy 假阳): the pre-fix `lower.find(...)`
|
|
116
|
+
// scanned the WHOLE scrollback (Tail(40) lines) and matched historical
|
|
117
|
+
// working markers — e.g. an old `• Working (514s · esc to interrupt)`
|
|
118
|
+
// still in the 40-line window after the worker idled. That stale token
|
|
119
|
+
// flipped a truly-idle agent to Stuck whenever the historical seconds
|
|
120
|
+
// exceeded 300. Real fix: only consult the LIVE composer / status line
|
|
121
|
+
// (last non-empty line). If the current bottom line is something like
|
|
122
|
+
// `❯ Run /review`, `Worked for 8m 34s`, or an empty composer prompt,
|
|
123
|
+
// there is no LIVE working indicator and we return None — handing off
|
|
124
|
+
// to the structural latest_prompt_signal / IRON LAW Uncertain path.
|
|
125
|
+
let last_non_empty = scrollback.lines().rev().find(|line| !line.trim().is_empty())?;
|
|
126
|
+
let lower = last_non_empty.to_ascii_lowercase();
|
|
116
127
|
let start = lower.find("working (")?;
|
|
117
|
-
let rest =
|
|
128
|
+
let rest = last_non_empty.get(start + "Working (".len()..)?;
|
|
118
129
|
let seconds = rest.split_once('s')?.0;
|
|
119
130
|
seconds.parse::<u64>().ok()
|
|
120
131
|
}
|
|
@@ -129,32 +140,61 @@ pub(crate) fn non_provider_command(command: &str) -> Option<&str> {
|
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
pub(crate) fn latest_prompt_signal(scrollback: &str) -> Option<AgentActivity> {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
143
|
+
// E47 ROOT-FIX#2 (0.3.24 P0, idle/busy 假阳): limit the scan to the
|
|
144
|
+
// BOTTOM ACTIVE REGION (last 1-3 non-empty lines = composer / status
|
|
145
|
+
// line). The pre-fix `rfind` across the whole Tail(40) buffer let a
|
|
146
|
+
// historical spinner/`Working` token out-position the live `❯`/`›`
|
|
147
|
+
// composer (the rfind-recency family bug — same shape as the #320
|
|
148
|
+
// codex prompt residue). When the composer line is `❯ Run /review`
|
|
149
|
+
// (idle), a scrollback `Working (514s)` 20 lines up should NOT win.
|
|
150
|
+
//
|
|
151
|
+
// IRON LAW (activity.rs:3 / bug-071/077/085): no-signal in the bottom
|
|
152
|
+
// region must be Uncertain (caller treats None here as "no decisive
|
|
153
|
+
// signal" and surfaces Uncertain), NEVER silently flipped to Idle.
|
|
154
|
+
let active_region: Vec<&str> = scrollback
|
|
155
|
+
.lines()
|
|
156
|
+
.rev()
|
|
157
|
+
.filter(|line| !line.trim().is_empty())
|
|
158
|
+
.take(3)
|
|
159
|
+
.collect();
|
|
160
|
+
if active_region.is_empty() {
|
|
161
|
+
return None;
|
|
162
|
+
}
|
|
163
|
+
let mut has_idle_prompt = false;
|
|
164
|
+
let mut has_live_working_indicator = false;
|
|
165
|
+
for line in &active_region {
|
|
166
|
+
let lower = line.to_ascii_lowercase();
|
|
167
|
+
if line.contains('❯') || line.contains('›') {
|
|
168
|
+
has_idle_prompt = true;
|
|
169
|
+
}
|
|
170
|
+
// codex live spinner shapes (provider/adapter.rs:875-876 markers):
|
|
171
|
+
// braille spinner, `• Working (`, `Thinking`; claude `✶`/`✢` per
|
|
172
|
+
// adapter; we look for STRUCTURAL composer signals.
|
|
173
|
+
if [
|
|
174
|
+
"working (", "thinking", "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
|
|
175
|
+
"⠧", "⠇", "⠏", "✶", "✢",
|
|
176
|
+
]
|
|
177
|
+
.iter()
|
|
178
|
+
.any(|needle| lower.contains(needle))
|
|
179
|
+
{
|
|
180
|
+
has_live_working_indicator = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Working indicator IN the active region wins over a stale `❯`/`›`
|
|
184
|
+
// (composer may show a prompt char before the live spinner refreshes).
|
|
185
|
+
if has_live_working_indicator {
|
|
186
|
+
return Some(AgentActivity {
|
|
144
187
|
status: ActivityStatus::Working,
|
|
145
188
|
confidence: 0.9,
|
|
146
189
|
rationale: "working_indicator".to_string(),
|
|
147
|
-
})
|
|
148
|
-
(None, None) => None,
|
|
190
|
+
});
|
|
149
191
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.chain(scrollback.match_indices('›').map(|(idx, _)| idx))
|
|
157
|
-
.max()
|
|
192
|
+
if has_idle_prompt {
|
|
193
|
+
return Some(idle_activity());
|
|
194
|
+
}
|
|
195
|
+
// No structural signal in the bottom region → caller (activity.rs:184)
|
|
196
|
+
// gets None and treats as no-decisive-signal → Uncertain (IRON LAW).
|
|
197
|
+
None
|
|
158
198
|
}
|
|
159
199
|
|
|
160
200
|
fn idle_activity() -> AgentActivity {
|
|
@@ -71,7 +71,14 @@ fn p2_owner_bypass_denies_on_env_agent_id_mismatch() {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// P1 — classify_agent_activity must read current_command, stale last_output, multiline /
|
|
74
|
-
// Codex idle prompts, and Thinking/
|
|
74
|
+
// Codex idle prompts, and Thinking/codex-spinner working indicators
|
|
75
|
+
// (activity_detector.py:90-146). **E47 amendment (0.3.24 P0)**: the
|
|
76
|
+
// working-indicator probe is now bottom-active-region scoped (last 1-3
|
|
77
|
+
// non-empty lines) and looks for STRUCTURAL spinner markers (codex
|
|
78
|
+
// `• Working (`/`Thinking`/braille; claude `✶`/`✢`), not bare lowercase
|
|
79
|
+
// `working` anywhere in the scrollback. Pre-fix rfind across the whole
|
|
80
|
+
// Tail(40) buffer is the macmini假阳 root cause (historical Working tokens
|
|
81
|
+
// out-positioning a live idle composer).
|
|
75
82
|
#[test]
|
|
76
83
|
fn p2_classify_activity_reads_command_stale_and_prompts() {
|
|
77
84
|
let st = serde_json::json!({});
|
|
@@ -85,12 +92,28 @@ fn p2_classify_activity_reads_command_stale_and_prompts() {
|
|
|
85
92
|
// (3) embedded multiline idle prompt (Codex ❯ not on its own trimmed line) → idle 0.9.
|
|
86
93
|
let c = classify_agent_activity(&st, "some line\n❯\nmore", false, None, None);
|
|
87
94
|
assert_eq!((c.status, c.confidence), (ActivityStatus::Idle, 0.9));
|
|
88
|
-
// (4) 'Thinking' working indicator → working 0.9.
|
|
95
|
+
// (4) 'Thinking' working indicator in the bottom active region → working 0.9.
|
|
89
96
|
let d = classify_agent_activity(&st, "Thinking about it", false, None, None);
|
|
90
97
|
assert_eq!((d.status, d.confidence), (ActivityStatus::Working, 0.9));
|
|
91
|
-
// (5) lowercase 'working'
|
|
98
|
+
// (5) E47 amendment: bare lowercase 'working on it' is NOT a structural
|
|
99
|
+
// spinner marker; the live codex spinner is `• Working (Ns · esc to
|
|
100
|
+
// interrupt)`. Treat as no-decisive-signal → Uncertain. The pre-fix
|
|
101
|
+
// expectation here is exactly the macmini假阳 shape — a stale 'working'
|
|
102
|
+
// token (without the parenthesised seconds + esc-to-interrupt tail) is
|
|
103
|
+
// either scrollback residue or unrelated text, not a live working
|
|
104
|
+
// indicator. To assert the Working path use the structural codex shape.
|
|
92
105
|
let e = classify_agent_activity(&st, "working on it", false, None, None);
|
|
93
|
-
assert_eq!((e.status, e.confidence), (ActivityStatus::
|
|
106
|
+
assert_eq!((e.status, e.confidence), (ActivityStatus::Uncertain, 0.5));
|
|
107
|
+
// (5b) E47 structural Working: a real codex live spinner in the bottom
|
|
108
|
+
// active region must still classify Working 0.9.
|
|
109
|
+
let e_struct = classify_agent_activity(
|
|
110
|
+
&st,
|
|
111
|
+
"• Working (12s · esc to interrupt)",
|
|
112
|
+
false,
|
|
113
|
+
None,
|
|
114
|
+
None,
|
|
115
|
+
);
|
|
116
|
+
assert_eq!((e_struct.status, e_struct.confidence), (ActivityStatus::Working, 0.9));
|
|
94
117
|
}
|
|
95
118
|
|
|
96
119
|
// P1 — scheduler dispatch must be exhaustive: an unknown kind surfaces an error, not a
|
|
@@ -484,3 +507,133 @@ fn spine_delivery_injects_rendered_protocol_block_not_raw_content() {
|
|
|
484
507
|
worker builds a result_envelope; got {payload:?}"
|
|
485
508
|
);
|
|
486
509
|
}
|
|
510
|
+
|
|
511
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
512
|
+
// E47 (0.3.24 P0, idle/busy 假阳) — TUI keyword grep is structurally bounded.
|
|
513
|
+
//
|
|
514
|
+
// Real-machine repro (macmini): a Tail(40) capture of an idle codex worker
|
|
515
|
+
// still carries historical `• Working (514s · esc to interrupt)` plus a
|
|
516
|
+
// `─ Worked for 8m 34s ─` summary plus a fresh `❯ Run /review` prompt. The
|
|
517
|
+
// pre-fix `working_seconds` full-buffer find matched the 514s token and
|
|
518
|
+
// returned ≥300 → Stuck. The pre-fix `latest_prompt_signal` rfind let the
|
|
519
|
+
// historical spinner out-position the bottom `❯` → Working. E47 narrows
|
|
520
|
+
// both probes to the bottom active region (last 1-3 non-empty lines).
|
|
521
|
+
//
|
|
522
|
+
// The authoritative provider JSONL classify is wired in coordinator/tick.rs
|
|
523
|
+
// (jsonl_activity_for_agent); these unit tests pin the TUI fallback layer.
|
|
524
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
525
|
+
|
|
526
|
+
#[test]
|
|
527
|
+
fn e47_codex_idle_with_historical_working_in_scrollback_is_idle_not_stuck_or_working() {
|
|
528
|
+
// Exact macmini repro shape from the architect locate (E47).
|
|
529
|
+
let scrollback = "• Working (514s · esc to interrupt)\n\
|
|
530
|
+
tool call 1\n\
|
|
531
|
+
tool call 2\n\
|
|
532
|
+
─ Worked for 8m 34s ─\n\
|
|
533
|
+
› Run /review\n\
|
|
534
|
+
❯ ";
|
|
535
|
+
let st = serde_json::json!({});
|
|
536
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
537
|
+
assert_eq!(
|
|
538
|
+
a.status,
|
|
539
|
+
ActivityStatus::Idle,
|
|
540
|
+
"E47 (RED-1): codex idle worker whose scrollback still carries \
|
|
541
|
+
historical `• Working (514s · esc to interrupt)` must classify IDLE \
|
|
542
|
+
(bottom active region is `❯`+`› Run /review` = idle composer). \
|
|
543
|
+
pre-fix: Stuck/Working via rfind-recency. Got {a:?}"
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
#[test]
|
|
548
|
+
fn e47_past_tense_worked_for_is_not_stale_working_indicator() {
|
|
549
|
+
// RED-2: past-tense `Worked for` summary line must NOT trigger
|
|
550
|
+
// working_seconds Stuck. The pre-fix scanned the whole buffer and would
|
|
551
|
+
// match the embedded `8m 34s ─` against `working (` if formatted slightly
|
|
552
|
+
// differently — but more importantly this test pins the "past-tense
|
|
553
|
+
// summary is idle context" semantics.
|
|
554
|
+
let scrollback = "─ Worked for 8m 34s ─\n❯ ";
|
|
555
|
+
let st = serde_json::json!({});
|
|
556
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
557
|
+
assert_ne!(
|
|
558
|
+
a.status,
|
|
559
|
+
ActivityStatus::Stuck,
|
|
560
|
+
"E47 (RED-2): past-tense `Worked for 8m 34s` must NOT trigger \
|
|
561
|
+
stale_working_indicator Stuck. Got {a:?}"
|
|
562
|
+
);
|
|
563
|
+
assert_eq!(
|
|
564
|
+
a.status,
|
|
565
|
+
ActivityStatus::Idle,
|
|
566
|
+
"E47 (RED-2): bottom `❯` composer = idle. Got {a:?}"
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
#[test]
|
|
571
|
+
fn e47_claude_idle_with_historical_spinner_classifies_idle() {
|
|
572
|
+
// RED-3: claude idle worker; historical spinner shape (`✶` per
|
|
573
|
+
// adapter.rs:875-876) earlier in the buffer (>= 3 non-empty lines deep),
|
|
574
|
+
// bottom composer is idle (`›` glyph variant or `> ` claude prompt).
|
|
575
|
+
// The bottom active region (last 3 non-empty lines) has NO spinner.
|
|
576
|
+
let scrollback = "✶ Working on plan\n\
|
|
577
|
+
tool 1\n\
|
|
578
|
+
tool 2\n\
|
|
579
|
+
assistant reply text\n\
|
|
580
|
+
summary line A\n\
|
|
581
|
+
summary line B\n\
|
|
582
|
+
› Continue?";
|
|
583
|
+
let st = serde_json::json!({});
|
|
584
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
585
|
+
assert_eq!(
|
|
586
|
+
a.status,
|
|
587
|
+
ActivityStatus::Idle,
|
|
588
|
+
"E47 (RED-3): claude idle worker — historical ✶ above, idle `›` \
|
|
589
|
+
below — must classify IDLE. Got {a:?}"
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
#[test]
|
|
594
|
+
fn e47_codex_live_spinner_in_bottom_active_region_classifies_working() {
|
|
595
|
+
// RED-4 (defence vs over-tightening 假阴): a TRULY busy codex worker
|
|
596
|
+
// whose bottom active region carries the live spinner must still be
|
|
597
|
+
// Working. No idle composer present.
|
|
598
|
+
let scrollback = "tool call x\nassistant response so far\n• Working (12s · esc to interrupt)";
|
|
599
|
+
let st = serde_json::json!({});
|
|
600
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
601
|
+
assert_eq!(
|
|
602
|
+
a.status,
|
|
603
|
+
ActivityStatus::Working,
|
|
604
|
+
"E47 (RED-4): live codex spinner in bottom active region must \
|
|
605
|
+
classify Working. Got {a:?}"
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
#[test]
|
|
610
|
+
fn e47_claude_live_working_in_bottom_active_region_classifies_working() {
|
|
611
|
+
// RED-5: a TRULY busy claude worker showing `✶ Working` at the bottom
|
|
612
|
+
// active region must be Working — even without the `(Ns)` codex shape.
|
|
613
|
+
let scrollback = "earlier output\nmore output\n✶ Working through the request";
|
|
614
|
+
let st = serde_json::json!({});
|
|
615
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
616
|
+
assert_eq!(
|
|
617
|
+
a.status,
|
|
618
|
+
ActivityStatus::Working,
|
|
619
|
+
"E47 (RED-5): claude ✶ Working in bottom active region → Working. \
|
|
620
|
+
Got {a:?}"
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
#[test]
|
|
625
|
+
fn e47_iron_law_no_signal_in_bottom_region_stays_uncertain_not_idle() {
|
|
626
|
+
// Defence: IRON LAW (activity.rs:3 / bug-071/077/085). When the bottom
|
|
627
|
+
// active region has no spinner AND no `❯`/`›` AND no other decisive
|
|
628
|
+
// signal, classify_agent_activity falls through to no_decisive_signal →
|
|
629
|
+
// Uncertain — NEVER silently to Idle.
|
|
630
|
+
let scrollback = "some neutral text\nmore neutral text\nstill nothing decisive";
|
|
631
|
+
let st = serde_json::json!({});
|
|
632
|
+
let a = classify_agent_activity(&st, scrollback, false, None, None);
|
|
633
|
+
assert_eq!(
|
|
634
|
+
a.status,
|
|
635
|
+
ActivityStatus::Uncertain,
|
|
636
|
+
"E47 IRON LAW guard: bottom region carries no spinner and no idle \
|
|
637
|
+
composer → Uncertain, never silently Idle. Got {a:?}"
|
|
638
|
+
);
|
|
639
|
+
}
|
|
@@ -572,6 +572,257 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
|
|
|
572
572
|
);
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
576
|
+
// E46 0.3.24 task#327 P0 — submit verification false-positive / false-negative.
|
|
577
|
+
//
|
|
578
|
+
// Architect + Workflow C1: do NOT flip `EnterSentWithoutPlaceholderCheck =>
|
|
579
|
+
// false` (breaks MUST-10 in provider_submit_verification_red.rs:113-159).
|
|
580
|
+
// Instead introduce SubmitConsumptionUnverified and only emit it from the
|
|
581
|
+
// bracketed-paste / Enter path when post-Enter input consumption is NOT
|
|
582
|
+
// observed within a bounded resend cap.
|
|
583
|
+
//
|
|
584
|
+
// Real-machine truth source (macmini): a fresh claude TUI's bracketed
|
|
585
|
+
// paste swallows the framework's Enter as paste content; the framework
|
|
586
|
+
// must (a) send Escape first to exit the paste bracket and (b) verify
|
|
587
|
+
// post-Enter consumption by structural input-empty probe (token marker
|
|
588
|
+
// no longer in the bottom 5 lines of the pane = composer cleared).
|
|
589
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
590
|
+
|
|
591
|
+
/// **E46 RED-1 (核心假阳)**: token-bearing Text + Enter + bracketed paste.
|
|
592
|
+
/// Token is visible in the pane after submit AND remains in the bottom of
|
|
593
|
+
/// the pane (input region — consumption was NOT observed). Submit must
|
|
594
|
+
/// report `SubmitConsumptionUnverified`, NOT
|
|
595
|
+
/// `EnterSentWithoutPlaceholderCheck`. Otherwise delivery emits delivered
|
|
596
|
+
/// + message.delivered (假阳) even though the worker never consumed it.
|
|
597
|
+
#[test]
|
|
598
|
+
fn e46_inject_text_with_token_unconsumed_in_tail_reports_submit_consumption_unverified() {
|
|
599
|
+
// Mock always returns the token in the captured tail — simulates the
|
|
600
|
+
// macmini fresh claude TUI where the paste lands but the input never
|
|
601
|
+
// clears (Enter swallowed by bracketed-paste).
|
|
602
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red1]";
|
|
603
|
+
let (be, _rec) = backend_with(MockResp::Out(ok(token_text)), vec![]);
|
|
604
|
+
let report = be
|
|
605
|
+
.inject(
|
|
606
|
+
&Target::Pane(PaneId::new("%7")),
|
|
607
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
608
|
+
Key::Enter,
|
|
609
|
+
true,
|
|
610
|
+
)
|
|
611
|
+
.expect("inject runs");
|
|
612
|
+
assert_eq!(
|
|
613
|
+
report.submit_verification,
|
|
614
|
+
SubmitVerification::SubmitConsumptionUnverified,
|
|
615
|
+
"E46 RED-1: when token remains in the pane input region after \
|
|
616
|
+
Enter + resend cap, transport must report SubmitConsumptionUnverified \
|
|
617
|
+
(not EnterSentWithoutPlaceholderCheck which delivery treats as \
|
|
618
|
+
delivered). Got {:?}",
|
|
619
|
+
report.submit_verification
|
|
620
|
+
);
|
|
621
|
+
// turn_verification stays NotYetObserved (Gap42: metadata only).
|
|
622
|
+
assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/// **E46 RED-2 (正向消费确认 → delivered)**: token-bearing Text + Enter +
|
|
626
|
+
/// bracketed paste. Token VISIBLE on first capture (pre/post-submit token
|
|
627
|
+
/// readback) BUT then GONE from the post-submit input-consumption probe
|
|
628
|
+
/// (composer cleared after Enter). Submit must report
|
|
629
|
+
/// `EnterSentWithoutPlaceholderCheck` (MUST-10 path) — delivery proceeds
|
|
630
|
+
/// to delivered. Guards against regression on crd's existing-worker path.
|
|
631
|
+
#[test]
|
|
632
|
+
fn e46_inject_text_with_token_consumed_after_enter_keeps_enter_sent_without_placeholder_check() {
|
|
633
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red2]";
|
|
634
|
+
// First captures (token visibility probes pre/post-Enter) return text
|
|
635
|
+
// with token; the LAST capture (consumption probe) returns text WITHOUT
|
|
636
|
+
// the token in the tail (composer cleared). MockCommandRunner drains
|
|
637
|
+
// `queued` first then defaults — we queue the early "token visible"
|
|
638
|
+
// captures, then drop to the empty default for the consumption probe.
|
|
639
|
+
let visible = ok(token_text);
|
|
640
|
+
let queued = vec![
|
|
641
|
+
MockResp::Out(ok("")), // set-buffer
|
|
642
|
+
MockResp::Out(ok("")), // paste-buffer
|
|
643
|
+
MockResp::Out(ok("")), // delete-buffer
|
|
644
|
+
MockResp::Out(visible.clone()), // pasted-content prompt poll: none → continue
|
|
645
|
+
MockResp::Out(visible.clone()), // pre-submit token visibility: visible
|
|
646
|
+
MockResp::Out(ok("")), // Escape send-keys (no stdout needed)
|
|
647
|
+
MockResp::Out(ok("")), // Enter send-keys
|
|
648
|
+
// From here on, the default response is `ok("")` (empty pane tail) so
|
|
649
|
+
// post-Enter consumption probe sees no token in tail → consumed=true.
|
|
650
|
+
];
|
|
651
|
+
let (be, _rec) = backend_with(MockResp::Out(ok("")), queued);
|
|
652
|
+
let report = be
|
|
653
|
+
.inject(
|
|
654
|
+
&Target::Pane(PaneId::new("%7")),
|
|
655
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
656
|
+
Key::Enter,
|
|
657
|
+
true,
|
|
658
|
+
)
|
|
659
|
+
.expect("inject runs");
|
|
660
|
+
assert_eq!(
|
|
661
|
+
report.submit_verification,
|
|
662
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
663
|
+
"E46 RED-2: when post-Enter consumption is observed (token gone \
|
|
664
|
+
from input region tail), submit must report the canonical \
|
|
665
|
+
EnterSentWithoutPlaceholderCheck so MUST-10 delivery semantics \
|
|
666
|
+
hold (provider_submit_verification_red.rs:113-159). Got {:?}",
|
|
667
|
+
report.submit_verification
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/// **E46 RED-3 (resend-to-cap, no double-submit)**: when the FIRST Enter
|
|
672
|
+
/// landed on the composer but our readback was slow, the re-check BEFORE
|
|
673
|
+
/// resend must observe the now-empty input and STOP — must NOT fire a
|
|
674
|
+
/// second Enter into an empty composer (would open an empty turn).
|
|
675
|
+
#[test]
|
|
676
|
+
fn e46_inject_text_resend_rechecks_input_before_resending_to_avoid_double_submit() {
|
|
677
|
+
// Same mock as RED-2: post-Enter consumption probe sees empty tail.
|
|
678
|
+
// The implementation should issue only ONE Enter (no resend) once
|
|
679
|
+
// consumption is observed.
|
|
680
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_red3]";
|
|
681
|
+
let queued = vec![
|
|
682
|
+
MockResp::Out(ok("")), // set-buffer
|
|
683
|
+
MockResp::Out(ok("")), // paste-buffer
|
|
684
|
+
MockResp::Out(ok("")), // delete-buffer
|
|
685
|
+
MockResp::Out(ok(token_text)), // pasted-content prompt: not matched
|
|
686
|
+
MockResp::Out(ok(token_text)), // pre-submit token visibility
|
|
687
|
+
MockResp::Out(ok("")), // Escape
|
|
688
|
+
MockResp::Out(ok("")), // Enter
|
|
689
|
+
];
|
|
690
|
+
let (be, rec) = backend_with(MockResp::Out(ok("")), queued);
|
|
691
|
+
let _report = be
|
|
692
|
+
.inject(
|
|
693
|
+
&Target::Pane(PaneId::new("%7")),
|
|
694
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
695
|
+
Key::Enter,
|
|
696
|
+
true,
|
|
697
|
+
)
|
|
698
|
+
.expect("inject runs");
|
|
699
|
+
let calls = rec.lock().unwrap().clone();
|
|
700
|
+
// Count `send-keys ... Enter` invocations. There must be EXACTLY ONE
|
|
701
|
+
// (the canonical submit). A second one indicates the resend loop
|
|
702
|
+
// didn't honour C3 (re-check input before resend).
|
|
703
|
+
let enter_count = calls
|
|
704
|
+
.iter()
|
|
705
|
+
.filter(|argv| {
|
|
706
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
707
|
+
&& argv.contains(&"Enter".to_string())
|
|
708
|
+
})
|
|
709
|
+
.count();
|
|
710
|
+
assert_eq!(
|
|
711
|
+
enter_count, 1,
|
|
712
|
+
"E46 RED-3: when consumption is observed (or already happened \
|
|
713
|
+
between Enter and probe), the resend loop must STOP and NOT \
|
|
714
|
+
issue a second Enter. Got {enter_count} Enter sends. \
|
|
715
|
+
calls={calls:?}"
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/// **E46 RED-5 (provider-agnostic detector)**: the consumption probe must
|
|
720
|
+
/// NOT hard-code provider UI strings (claude `>`, codex `╰`, ...). It uses
|
|
721
|
+
/// the token marker absence in the bottom of the pane — works for any
|
|
722
|
+
/// provider. Here we run a non-claude-shaped pane (no claude markers) and
|
|
723
|
+
/// confirm the same delivered semantics when consumption happens.
|
|
724
|
+
#[test]
|
|
725
|
+
fn e46_consumption_detector_works_on_codex_shaped_pane_without_provider_strings() {
|
|
726
|
+
// Codex-shaped fake: prompt looks like `codex>` with no claude markers.
|
|
727
|
+
// After Enter, the composer clears (empty default returns ok("")).
|
|
728
|
+
let token_text =
|
|
729
|
+
"Team Agent message from leader:\n\ncodex msg\n\n[team-agent-token:msg_red5]";
|
|
730
|
+
let queued = vec![
|
|
731
|
+
MockResp::Out(ok("")), // set-buffer
|
|
732
|
+
MockResp::Out(ok("")), // paste-buffer
|
|
733
|
+
MockResp::Out(ok("")), // delete-buffer
|
|
734
|
+
MockResp::Out(ok("codex>\n")), // pasted-content prompt: no match
|
|
735
|
+
MockResp::Out(ok(token_text)), // pre-submit token visibility
|
|
736
|
+
MockResp::Out(ok("")), // Escape
|
|
737
|
+
MockResp::Out(ok("")), // Enter
|
|
738
|
+
// post-Enter: default returns ok("") (composer cleared) → token gone
|
|
739
|
+
];
|
|
740
|
+
let (be, _rec) = backend_with(MockResp::Out(ok("")), queued);
|
|
741
|
+
let report = be
|
|
742
|
+
.inject(
|
|
743
|
+
&Target::Pane(PaneId::new("%7")),
|
|
744
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
745
|
+
Key::Enter,
|
|
746
|
+
true,
|
|
747
|
+
)
|
|
748
|
+
.expect("inject runs");
|
|
749
|
+
assert_eq!(
|
|
750
|
+
report.submit_verification,
|
|
751
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck,
|
|
752
|
+
"E46 RED-5: provider-agnostic detector — codex-shaped pane (no \
|
|
753
|
+
claude UI string) where composer clears post-Enter must still \
|
|
754
|
+
report EnterSentWithoutPlaceholderCheck via structural \
|
|
755
|
+
input-empty check. Got {:?}",
|
|
756
|
+
report.submit_verification
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/// **E46 Escape pre-Enter**: when bracketed=true + Text payload + submit=Enter,
|
|
761
|
+
/// the inject must send Escape BEFORE the Enter (exits bracketed-paste
|
|
762
|
+
/// mode on stuck composer). Non-Enter submits (e.g. Key::Down for codex
|
|
763
|
+
/// menu) must NOT receive the Escape pre-step.
|
|
764
|
+
#[test]
|
|
765
|
+
fn e46_inject_text_enter_emits_escape_before_enter_on_bracketed_paste() {
|
|
766
|
+
let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_esc]";
|
|
767
|
+
let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
|
|
768
|
+
let _report = be
|
|
769
|
+
.inject(
|
|
770
|
+
&Target::Pane(PaneId::new("%7")),
|
|
771
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
772
|
+
Key::Enter,
|
|
773
|
+
true,
|
|
774
|
+
)
|
|
775
|
+
.expect("inject runs");
|
|
776
|
+
let calls = rec.lock().unwrap().clone();
|
|
777
|
+
let escape_idx = calls.iter().position(|argv| {
|
|
778
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
779
|
+
&& argv.contains(&"Escape".to_string())
|
|
780
|
+
});
|
|
781
|
+
let enter_idx = calls.iter().position(|argv| {
|
|
782
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
783
|
+
&& argv.contains(&"Enter".to_string())
|
|
784
|
+
});
|
|
785
|
+
let escape_idx = escape_idx.expect("E46: bracketed text + Enter must send Escape pre-step");
|
|
786
|
+
let enter_idx = enter_idx.expect("E46: must still send the final Enter");
|
|
787
|
+
assert!(
|
|
788
|
+
escape_idx < enter_idx,
|
|
789
|
+
"E46: Escape must be sent BEFORE Enter (closes bracketed-paste \
|
|
790
|
+
so Enter is interpreted as submit). escape_idx={escape_idx} \
|
|
791
|
+
enter_idx={enter_idx} calls={calls:?}"
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/// **E46 regression guard**: non-Enter submit (codex menu navigation,
|
|
796
|
+
/// `Key::Down`) does NOT trigger the Escape pre-step (would interfere
|
|
797
|
+
/// with the menu).
|
|
798
|
+
#[test]
|
|
799
|
+
fn e46_inject_text_non_enter_submit_does_not_emit_escape_prestep() {
|
|
800
|
+
let token_text = "menu interaction\n[team-agent-token:msg_down]";
|
|
801
|
+
let (be, rec) = backend_with(MockResp::Out(ok("")), vec![]);
|
|
802
|
+
let _report = be
|
|
803
|
+
.inject(
|
|
804
|
+
&Target::Pane(PaneId::new("%7")),
|
|
805
|
+
&InjectPayload::Text(token_text.to_string()),
|
|
806
|
+
Key::Down,
|
|
807
|
+
true,
|
|
808
|
+
)
|
|
809
|
+
.expect("inject runs");
|
|
810
|
+
let calls = rec.lock().unwrap().clone();
|
|
811
|
+
let escape_count = calls
|
|
812
|
+
.iter()
|
|
813
|
+
.filter(|argv| {
|
|
814
|
+
argv.get(1).map(String::as_str) == Some("send-keys")
|
|
815
|
+
&& argv.contains(&"Escape".to_string())
|
|
816
|
+
})
|
|
817
|
+
.count();
|
|
818
|
+
assert_eq!(
|
|
819
|
+
escape_count, 0,
|
|
820
|
+
"E46 regression guard: Key::Down submits (codex menu navigation) \
|
|
821
|
+
must NOT receive Escape pre-step. Got {escape_count}. \
|
|
822
|
+
calls={calls:?}"
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
575
826
|
#[test]
|
|
576
827
|
fn send_keys_cancel_mode_queries_mode_and_dispatches_cancel_argv() {
|
|
577
828
|
let (be, rec) = backend_with(
|