Skip to content

Commit e282a7e

Browse files
authored
feat(ding): support OpenCode composer delivery (#313)
* feat(ding): support OpenCode composer delivery * fix(ding): require exact OpenCode composer notices * fix(ding): preserve OpenCode composer token boundaries * fix(ding): require positive OpenCode idle state * fix(ding): fail closed on ambiguous OpenCode rows
1 parent e848f1d commit e282a7e

2 files changed

Lines changed: 344 additions & 2 deletions

File tree

src/ding/harness/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
1212
pub(super) mod claude;
1313
pub(super) mod codex;
14+
pub(super) mod opencode;
1415

1516
use super::composer::ComposerState;
1617

@@ -99,6 +100,6 @@ pub(super) fn screen_has_accepted_notice(
99100

100101
/// Every registered harness. Claude is last so that an exact row tie resolves to Claude, which is
101102
/// how the positional comparison behaved before the harnesses were split apart.
102-
pub(super) fn all() -> [&'static dyn Harness; 2] {
103-
[&codex::Codex, &claude::Claude]
103+
pub(super) fn all() -> [&'static dyn Harness; 3] {
104+
[&codex::Codex, &claude::Claude, &opencode::OpenCode]
104105
}

src/ding/harness/opencode.rs

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
//! The OpenCode full-screen composer: bordered input rows immediately above its mode footer.
2+
3+
use super::{Harness, Located, ReceiptState, Screen};
4+
use crate::ding::composer::ComposerState;
5+
6+
pub(super) struct OpenCode;
7+
8+
impl Harness for OpenCode {
9+
fn locate(&self, screen: &Screen<'_>) -> Option<Located> {
10+
locate_composer(screen.plain).map(|composer| Located {
11+
row: composer.start,
12+
})
13+
}
14+
15+
fn classify(&self, screen: &Screen<'_>, expected: &str) -> ComposerState {
16+
let Some(composer) = locate_composer(screen.plain) else {
17+
return ComposerState::Ambiguous;
18+
};
19+
let exact = equivalent(&composer.input, expected);
20+
if exact {
21+
if composer.idle {
22+
ComposerState::ExactSafe
23+
} else {
24+
ComposerState::ExactBlocked
25+
}
26+
} else if composer.input.is_empty() && composer.idle {
27+
ComposerState::EmptySafe
28+
} else if composer.input.is_empty() {
29+
ComposerState::Ambiguous
30+
} else {
31+
ComposerState::Changed
32+
}
33+
}
34+
35+
fn receipt(&self, screen: &Screen<'_>, expected: &str) -> ReceiptState {
36+
let Some(composer) = locate_composer(screen.plain) else {
37+
return ReceiptState::Unproven;
38+
};
39+
if equivalent(&composer.input, expected) {
40+
return if composer.idle {
41+
ReceiptState::RetainedSafe
42+
} else {
43+
ReceiptState::RetainedBlocked
44+
};
45+
}
46+
if !composer.input.is_empty() {
47+
return ReceiptState::NotRetained;
48+
}
49+
if composer.idle && preceding_submitted_box(&screen.plain[..composer.start_byte], expected)
50+
{
51+
ReceiptState::Accepted
52+
} else if composer.idle {
53+
ReceiptState::NotRetained
54+
} else {
55+
ReceiptState::Unproven
56+
}
57+
}
58+
}
59+
60+
struct Composer {
61+
start: usize,
62+
start_byte: usize,
63+
input: String,
64+
idle: bool,
65+
}
66+
67+
fn locate_composer(plain: &str) -> Option<Composer> {
68+
let lines = plain.split_inclusive('\n').collect::<Vec<_>>();
69+
let closure = lines.iter().rposition(|line| {
70+
let line = line.trim();
71+
line.strip_prefix('╹').is_some_and(|rule| {
72+
rule.chars().filter(|ch| *ch == '▀').count() >= 40
73+
&& rule.chars().all(|ch| ch == '▀' || ch.is_whitespace())
74+
})
75+
})?;
76+
let footer = (0..closure).rev().find(|index| {
77+
let line = lines[*index].trim();
78+
line.starts_with('┃') && line.contains(" · ")
79+
})?;
80+
let start = (0..=footer)
81+
.rev()
82+
.take_while(|index| lines[*index].trim_start().starts_with('┃'))
83+
.last()?;
84+
let input = lines[start..footer]
85+
.iter()
86+
.map(|line| line.trim_start().strip_prefix('┃').unwrap_or(line).trim())
87+
.filter(|line| !line.is_empty())
88+
.collect::<Vec<_>>()
89+
.join("\n");
90+
let start_byte = lines[..start].iter().map(|line| line.len()).sum();
91+
let tail = lines[footer..].concat();
92+
Some(Composer {
93+
start,
94+
start_byte,
95+
input,
96+
// Return is only safe when the pinned OpenCode idle shortcut chrome is
97+
// positively present. Unknown modal/active states fail closed even if
98+
// they happen not to render one of the known interrupt hints.
99+
idle: tail.contains("ctrl+p commands"),
100+
})
101+
}
102+
103+
fn equivalent(observed: &str, expected: &str) -> bool {
104+
if observed.contains('\n') || expected.contains('\n') {
105+
return observed == expected;
106+
}
107+
let normalized = |value: &str| value.split_whitespace().collect::<Vec<_>>().join(" ");
108+
normalized(observed) == normalized(expected)
109+
}
110+
111+
fn preceding_submitted_box(before_composer: &str, expected: &str) -> bool {
112+
let mut lines = before_composer.lines().rev();
113+
let first = lines.find(|line| !line.trim().is_empty());
114+
let Some(first) = first else {
115+
return false;
116+
};
117+
let Some(content) = first.trim_start().strip_prefix('┃') else {
118+
return false;
119+
};
120+
let mut box_lines = vec![content.trim()];
121+
for line in lines {
122+
let Some(content) = line.trim_start().strip_prefix('┃') else {
123+
break;
124+
};
125+
box_lines.push(content.trim());
126+
}
127+
box_lines.reverse();
128+
equivalent(&box_lines.join("\n"), expected)
129+
}
130+
131+
#[cfg(test)]
132+
mod tests {
133+
use super::*;
134+
135+
const EXPECTED: &str = "[DING] id=abc from=dev3.cos subject=liveness";
136+
137+
fn screen(composer: &str, history: &str, status: &str) -> String {
138+
format!(
139+
"{history}\n ┃ {composer}\n\n\n ┃ Build auto · MiMo V2.5 Free OpenCode Zen\n ╹{}\n /work 39K {status}\n",
140+
"▀".repeat(60)
141+
)
142+
}
143+
144+
#[test]
145+
fn recognizes_idle_empty_and_exact_composers() {
146+
let empty = screen("", "answer", "ctrl+p commands");
147+
let exact = screen(EXPECTED, "answer", "ctrl+p commands");
148+
assert_eq!(
149+
OpenCode.classify(
150+
&Screen {
151+
raw: &empty,
152+
plain: &empty
153+
},
154+
EXPECTED
155+
),
156+
ComposerState::EmptySafe
157+
);
158+
assert_eq!(
159+
OpenCode.classify(
160+
&Screen {
161+
raw: &exact,
162+
plain: &exact
163+
},
164+
EXPECTED
165+
),
166+
ComposerState::ExactSafe
167+
);
168+
}
169+
170+
#[test]
171+
fn active_turn_blocks_submission() {
172+
let active = screen(EXPECTED, "answer", "esc interrupt");
173+
assert_eq!(
174+
OpenCode.classify(
175+
&Screen {
176+
raw: &active,
177+
plain: &active
178+
},
179+
EXPECTED
180+
),
181+
ComposerState::ExactBlocked
182+
);
183+
}
184+
185+
#[test]
186+
fn unfamiliar_or_modal_chrome_never_enables_return() {
187+
for status in ["select an option", "unknown footer"] {
188+
let exact = screen(EXPECTED, "answer", status);
189+
let empty = screen("", "answer", status);
190+
assert_eq!(
191+
OpenCode.classify(
192+
&Screen {
193+
raw: &exact,
194+
plain: &exact
195+
},
196+
EXPECTED
197+
),
198+
ComposerState::ExactBlocked
199+
);
200+
assert_eq!(
201+
OpenCode.classify(
202+
&Screen {
203+
raw: &empty,
204+
plain: &empty
205+
},
206+
EXPECTED
207+
),
208+
ComposerState::Ambiguous
209+
);
210+
}
211+
}
212+
213+
#[test]
214+
fn human_modified_composer_is_never_exact() {
215+
for draft in [
216+
format!("human prefix {EXPECTED}"),
217+
format!("{EXPECTED} human suffix"),
218+
EXPECTED.replace(" from=", "from="),
219+
] {
220+
let modified = screen(&draft, "answer", "ctrl+p commands");
221+
assert_eq!(
222+
OpenCode.classify(
223+
&Screen {
224+
raw: &modified,
225+
plain: &modified
226+
},
227+
EXPECTED
228+
),
229+
ComposerState::Changed
230+
);
231+
}
232+
}
233+
234+
#[test]
235+
fn provider_name_does_not_identify_the_opencode_composer() {
236+
let anthropic = screen(EXPECTED, "answer", "ctrl+p commands")
237+
.replace("MiMo V2.5 Free OpenCode Zen", "Claude Opus 4.1 Anthropic");
238+
assert_eq!(
239+
OpenCode.classify(
240+
&Screen {
241+
raw: &anthropic,
242+
plain: &anthropic
243+
},
244+
EXPECTED
245+
),
246+
ComposerState::ExactSafe
247+
);
248+
}
249+
250+
#[test]
251+
fn accepted_receipt_requires_history_and_an_empty_idle_composer() {
252+
let accepted = screen("", &format!(" ┃ {EXPECTED}\n\n"), "ctrl+p commands");
253+
assert_eq!(
254+
OpenCode.receipt(
255+
&Screen {
256+
raw: &accepted,
257+
plain: &accepted
258+
},
259+
EXPECTED
260+
),
261+
ReceiptState::Accepted
262+
);
263+
}
264+
265+
#[test]
266+
fn ordinary_transcript_text_is_not_an_acceptance_receipt() {
267+
let transcript = screen(
268+
"",
269+
&format!("assistant repeated {EXPECTED}\n\n"),
270+
"ctrl+p commands",
271+
);
272+
assert_eq!(
273+
OpenCode.receipt(
274+
&Screen {
275+
raw: &transcript,
276+
plain: &transcript
277+
},
278+
EXPECTED
279+
),
280+
ReceiptState::NotRetained
281+
);
282+
}
283+
284+
#[test]
285+
fn hard_line_break_in_composer_is_not_exact() {
286+
let multiline = screen(
287+
&EXPECTED.replace(" from=", "\n ┃ from="),
288+
"answer",
289+
"ctrl+p commands",
290+
);
291+
assert_eq!(
292+
OpenCode.classify(
293+
&Screen {
294+
raw: &multiline,
295+
plain: &multiline
296+
},
297+
EXPECTED
298+
),
299+
ComposerState::Changed
300+
);
301+
}
302+
303+
#[test]
304+
fn old_matching_box_separated_by_output_is_not_a_receipt() {
305+
let old = screen(
306+
"",
307+
&format!(" ┃ {EXPECTED}\n\nassistant output\n"),
308+
"ctrl+p commands",
309+
);
310+
assert_eq!(
311+
OpenCode.receipt(
312+
&Screen {
313+
raw: &old,
314+
plain: &old
315+
},
316+
EXPECTED
317+
),
318+
ReceiptState::NotRetained
319+
);
320+
}
321+
322+
#[test]
323+
fn modified_submitted_box_is_not_an_acceptance_receipt() {
324+
for submitted in [
325+
format!("human prefix {EXPECTED}"),
326+
format!("{EXPECTED} human suffix"),
327+
] {
328+
let modified = screen("", &format!(" ┃ {submitted}\n\n"), "ctrl+p commands");
329+
assert_eq!(
330+
OpenCode.receipt(
331+
&Screen {
332+
raw: &modified,
333+
plain: &modified
334+
},
335+
EXPECTED
336+
),
337+
ReceiptState::NotRetained
338+
);
339+
}
340+
}
341+
}

0 commit comments

Comments
 (0)