Skip to main content

bobby_browser_client/
intents.rs

1//! Builders for intent [`RuntimeCommand`] and [`CommandEnvelope`] values —
2//! the Rust twin of the TypeScript SDK's `intents` module.
3//!
4//! Pass envelopes to [`BrowserRuntimeClient::submit`](crate::http::BrowserRuntimeClient::submit).
5//! Prefer these helpers over hand-rolling nested `{ kind, input }` shapes.
6//!
7//! Nested wire shape:
8//! `{ kind: "intent", input: { kind: "locate", input: { … } } }`.
9
10use crate::challenges::{DetectChallengeIntent, SolveChallengeIntent};
11use crate::commands::{
12    CommandEnvelope, CompleteFormIntent, ControlAction, DismissObstructionIntent, ExtractField,
13    ExtractIntent, FillIntent, FollowIntent, IntentCommand, IntentHints, LocateIntent,
14    RuntimeCommand, SubmitAndVerifyIntent, WaitCondition, WaitForCommand, WaitForStateIntent,
15    MAX_INTENT_PURPOSE_BYTES,
16};
17use crate::ids::{AttemptId, CommandId, PageId, SessionId, WorkflowId};
18use chrono::{DateTime, Utc};
19
20/// Meta that every intent envelope carries. Mirrors the TypeScript SDK's
21/// `IntentEnvelopeMeta`.
22#[derive(Debug, Clone)]
23pub struct IntentEnvelopeMeta {
24    pub command_id: CommandId,
25    pub workflow_id: WorkflowId,
26    pub attempt_id: AttemptId,
27    pub session_id: SessionId,
28    pub page_id: Option<PageId>,
29    /// RFC-3339 deadline stamped on the envelope.
30    pub deadline: DateTime<Utc>,
31}
32
33/// Throws the Rust equivalent of the TS SDK's `assertIntentPurpose`: an
34/// empty or over-budget purpose fails the builder instead of failing at the
35/// runtime after a round trip.
36fn assert_intent_purpose(purpose: &str) {
37    if purpose.len() > MAX_INTENT_PURPOSE_BYTES {
38        panic!(
39            "intent purpose exceeds {MAX_INTENT_PURPOSE_BYTES} bytes (got {})",
40            purpose.len()
41        );
42    }
43    if purpose.trim().is_empty() {
44        panic!("intent purpose must be a non-empty string");
45    }
46}
47
48/// `setText`'s `clear_first` is a plain `bool` with a serde default on the
49/// wire, so Rust callers get the TS SDK's normalization for free: an absent
50/// `clearFirst` decodes to `true` (replace). The builder is an identity
51/// function kept for parity with the TypeScript module.
52fn normalize_fill_value(value: ControlAction) -> ControlAction {
53    value
54}
55
56/// Wrap an intent [`RuntimeCommand`] in a [`CommandEnvelope`].
57pub fn intent_envelope(meta: IntentEnvelopeMeta, command: RuntimeCommand) -> CommandEnvelope {
58    debug_assert!(
59        matches!(command, RuntimeCommand::Intent(_)),
60        "intentEnvelope requires a RuntimeCommand with kind \"intent\""
61    );
62    CommandEnvelope {
63        schema_version: CommandEnvelope::SCHEMA_VERSION,
64        command_id: meta.command_id,
65        workflow_id: meta.workflow_id,
66        attempt_id: meta.attempt_id,
67        session_id: meta.session_id,
68        page_id: meta.page_id,
69        deadline: meta.deadline,
70        command,
71    }
72}
73
74/// Build a `locate` intent command.
75pub fn locate_runtime_command(input: LocateIntent) -> RuntimeCommand {
76    assert_intent_purpose(&input.purpose);
77    RuntimeCommand::Intent(IntentCommand::Locate(LocateIntent {
78        purpose: input.purpose,
79        hints: input.hints,
80    }))
81}
82
83/// Build a `fill` intent command.
84pub fn fill_runtime_command(input: FillIntent) -> RuntimeCommand {
85    assert_intent_purpose(&input.purpose);
86    RuntimeCommand::Intent(IntentCommand::Fill(FillIntent {
87        purpose: input.purpose,
88        hints: input.hints,
89        value: normalize_fill_value(input.value),
90    }))
91}
92
93/// Build a `completeForm` intent command (1–128 uniquely named fields).
94pub fn complete_form_runtime_command(input: CompleteFormIntent) -> RuntimeCommand {
95    assert_intent_purpose(&input.purpose);
96    assert!(
97        !input.fields.is_empty(),
98        "completeForm fields must not be empty"
99    );
100    assert!(
101        input.fields.len() <= 128,
102        "completeForm fields must not exceed 128 items"
103    );
104    let mut names = std::collections::HashSet::new();
105    let fields = input
106        .fields
107        .into_iter()
108        .map(|mut field| {
109            assert!(
110                !field.name.trim().is_empty(),
111                "completeForm field name must not be empty"
112            );
113            assert!(
114                names.insert(field.name.clone()),
115                "duplicate completeForm field name: {}",
116                field.name
117            );
118            assert_intent_purpose(&field.purpose);
119            field.value = normalize_fill_value(field.value);
120            field
121        })
122        .collect();
123    RuntimeCommand::Intent(IntentCommand::CompleteForm(CompleteFormIntent {
124        purpose: input.purpose,
125        fields,
126    }))
127}
128
129/// Build a `submitAndVerify` intent command.
130pub fn submit_and_verify_runtime_command(input: SubmitAndVerifyIntent) -> RuntimeCommand {
131    assert_intent_purpose(&input.purpose);
132    RuntimeCommand::Intent(IntentCommand::SubmitAndVerify(SubmitAndVerifyIntent {
133        purpose: input.purpose,
134        hints: input.hints,
135        expected_state: input.expected_state,
136    }))
137}
138
139/// Build a `waitForState` intent command.
140pub fn wait_for_state_runtime_command(input: WaitForStateIntent) -> RuntimeCommand {
141    RuntimeCommand::Intent(IntentCommand::WaitForState(WaitForStateIntent {
142        condition: input.condition,
143        timeout_ms: input.timeout_ms,
144    }))
145}
146
147/// Build a `follow` intent command.
148pub fn follow_runtime_command(input: FollowIntent) -> RuntimeCommand {
149    assert_intent_purpose(&input.purpose);
150    RuntimeCommand::Intent(IntentCommand::Follow(FollowIntent {
151        purpose: input.purpose,
152        hints: input.hints,
153        expected_destination: input.expected_destination,
154        boundary: input.boundary,
155    }))
156}
157
158/// Build a `dismissObstruction` intent command.
159pub fn dismiss_obstruction_runtime_command(input: DismissObstructionIntent) -> RuntimeCommand {
160    assert_intent_purpose(&input.purpose);
161    RuntimeCommand::Intent(IntentCommand::DismissObstruction(
162        DismissObstructionIntent {
163            purpose: input.purpose,
164            hints: input.hints,
165            timeout_ms: input.timeout_ms,
166        },
167    ))
168}
169
170/// Build an `extract` intent command (at least one uniquely named field).
171pub fn extract_runtime_command(input: ExtractIntent) -> RuntimeCommand {
172    assert_intent_purpose(&input.purpose);
173    assert!(
174        !input.fields.is_empty(),
175        "extract intent must include at least one field"
176    );
177    let mut seen = std::collections::HashSet::new();
178    for field in &input.fields {
179        assert_intent_purpose(&field.purpose);
180        assert!(
181            seen.insert(field.name.clone()),
182            "duplicate extract field name: {}",
183            field.name
184        );
185    }
186    RuntimeCommand::Intent(IntentCommand::Extract(ExtractIntent {
187        purpose: input.purpose,
188        fields: input.fields,
189    }))
190}
191
192/// Build a `detectChallenge` intent command (Replayable, read-only).
193pub fn detect_challenge_runtime_command(input: DetectChallengeIntent) -> RuntimeCommand {
194    assert_intent_purpose(&input.purpose);
195    RuntimeCommand::Intent(IntentCommand::DetectChallenge(DetectChallengeIntent {
196        purpose: input.purpose,
197        hints: input.hints,
198    }))
199}
200
201/// Build a `solveChallenge` intent command (Reconciliable vision solve loop).
202pub fn solve_challenge_runtime_command(input: SolveChallengeIntent) -> RuntimeCommand {
203    assert_intent_purpose(&input.purpose);
204    RuntimeCommand::Intent(IntentCommand::SolveChallenge(SolveChallengeIntent {
205        purpose: input.purpose,
206        hints: input.hints,
207    }))
208}
209
210/// Convenience: [`locate`] runtime command + [`intent_envelope`].
211pub fn locate_envelope(
212    meta: IntentEnvelopeMeta,
213    purpose: &str,
214    hints: Option<IntentHints>,
215) -> CommandEnvelope {
216    intent_envelope(
217        meta,
218        locate_runtime_command(LocateIntent {
219            purpose: purpose.into(),
220            hints: hints.unwrap_or_default(),
221        }),
222    )
223}
224
225/// Convenience: [`fill`] runtime command + [`intent_envelope`].
226pub fn fill_envelope(
227    meta: IntentEnvelopeMeta,
228    purpose: &str,
229    value: ControlAction,
230    hints: Option<IntentHints>,
231) -> CommandEnvelope {
232    intent_envelope(
233        meta,
234        fill_runtime_command(FillIntent {
235            purpose: purpose.into(),
236            value,
237            hints: hints.unwrap_or_default(),
238        }),
239    )
240}
241
242/// Convenience: [`submit_and_verify`] runtime command + [`intent_envelope`].
243pub fn submit_and_verify_envelope(
244    meta: IntentEnvelopeMeta,
245    purpose: &str,
246    expected_state: WaitForCommand,
247    hints: Option<IntentHints>,
248) -> CommandEnvelope {
249    intent_envelope(
250        meta,
251        submit_and_verify_runtime_command(SubmitAndVerifyIntent {
252            purpose: purpose.into(),
253            expected_state,
254            hints: hints.unwrap_or_default(),
255        }),
256    )
257}
258
259/// Convenience: [`wait_for_state`] runtime command + [`intent_envelope`].
260pub fn wait_for_state_envelope(
261    meta: IntentEnvelopeMeta,
262    condition: WaitCondition,
263    timeout_ms: u64,
264) -> CommandEnvelope {
265    intent_envelope(
266        meta,
267        wait_for_state_runtime_command(WaitForStateIntent {
268            condition,
269            timeout_ms,
270        }),
271    )
272}
273
274/// Convenience: [`follow`] runtime command + [`intent_envelope`].
275pub fn follow_envelope(
276    meta: IntentEnvelopeMeta,
277    purpose: &str,
278    expected_destination: WaitForCommand,
279    boundary: bool,
280    hints: Option<IntentHints>,
281) -> CommandEnvelope {
282    intent_envelope(
283        meta,
284        follow_runtime_command(FollowIntent {
285            purpose: purpose.into(),
286            expected_destination,
287            hints: hints.unwrap_or_default(),
288            boundary,
289        }),
290    )
291}
292
293/// Convenience: [`dismiss_obstruction`] runtime command + [`intent_envelope`].
294pub fn dismiss_obstruction_envelope(
295    meta: IntentEnvelopeMeta,
296    purpose: &str,
297    timeout_ms: Option<u64>,
298) -> CommandEnvelope {
299    intent_envelope(
300        meta,
301        dismiss_obstruction_runtime_command(DismissObstructionIntent {
302            purpose: purpose.into(),
303            hints: IntentHints::default(),
304            timeout_ms: timeout_ms
305                .unwrap_or(crate::commands::DEFAULT_DISMISS_OBSTRUCTION_TIMEOUT_MS),
306        }),
307    )
308}
309
310/// Convenience: [`extract`] runtime command + [`intent_envelope`].
311pub fn extract_envelope(
312    meta: IntentEnvelopeMeta,
313    purpose: &str,
314    fields: Vec<ExtractField>,
315) -> CommandEnvelope {
316    intent_envelope(
317        meta,
318        extract_runtime_command(ExtractIntent {
319            purpose: purpose.into(),
320            fields,
321        }),
322    )
323}
324
325/// Convenience: [`detect_challenge`] runtime command + [`intent_envelope`].
326pub fn detect_challenge_envelope(
327    meta: IntentEnvelopeMeta,
328    purpose: &str,
329    hints: Option<crate::challenges::DetectChallengeHints>,
330) -> CommandEnvelope {
331    intent_envelope(
332        meta,
333        detect_challenge_runtime_command(DetectChallengeIntent {
334            purpose: purpose.into(),
335            hints: hints.unwrap_or_default(),
336        }),
337    )
338}
339
340/// Convenience: [`solve_challenge`] runtime command + [`intent_envelope`].
341pub fn solve_challenge_envelope(
342    meta: IntentEnvelopeMeta,
343    purpose: &str,
344    hints: Option<crate::challenges::SolveChallengeHints>,
345) -> CommandEnvelope {
346    intent_envelope(
347        meta,
348        solve_challenge_runtime_command(SolveChallengeIntent {
349            purpose: purpose.into(),
350            hints: hints.unwrap_or_default(),
351        }),
352    )
353}
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::commands::{CompleteFormField, WaitUntil};
358    use crate::ids::PageId;
359
360    fn meta() -> IntentEnvelopeMeta {
361        IntentEnvelopeMeta {
362            command_id: CommandId::new(),
363            workflow_id: WorkflowId::new(),
364            attempt_id: AttemptId::new(),
365            session_id: SessionId::new(),
366            page_id: Some(PageId::new()),
367            deadline: Utc::now(),
368        }
369    }
370
371    /// Golden wire shape: the nested `{kind, input}` envelope matches the
372    /// TypeScript SDK's byte-for-byte (serde tag + camelCase rename).
373    #[test]
374    fn submit_envelope_wraps_the_nested_wire_shape() {
375        let envelope = submit_and_verify_envelope(
376            meta(),
377            "Submit the customer priority",
378            WaitForCommand {
379                condition: WaitCondition::Document {
380                    ready: WaitUntil::Interactive,
381                },
382                timeout_ms: 1_000,
383            },
384            None,
385        );
386        assert_eq!(envelope.schema_version, CommandEnvelope::SCHEMA_VERSION);
387        match &envelope.command {
388            RuntimeCommand::Intent(IntentCommand::SubmitAndVerify(submit)) => {
389                assert_eq!(submit.purpose, "Submit the customer priority");
390                assert_eq!(
391                    submit.expected_state.condition,
392                    WaitCondition::Document {
393                        ready: WaitUntil::Interactive
394                    }
395                );
396            }
397            other => panic!("expected intent submitAndVerify, got {other:?}"),
398        }
399    }
400
401    #[test]
402    fn complete_form_builds_unique_named_fields() {
403        let command = complete_form_runtime_command(CompleteFormIntent {
404            purpose: "fill the onboarding form".into(),
405            fields: vec![CompleteFormField {
406                name: "email".into(),
407                purpose: "work address".into(),
408                hints: IntentHints::default(),
409                value: ControlAction::SetText {
410                    value: "ada@example.test".into(),
411                    clear_first: true,
412                },
413                revealed_by: None,
414            }],
415        });
416        match command {
417            RuntimeCommand::Intent(IntentCommand::CompleteForm(form)) => {
418                assert_eq!(form.fields.len(), 1);
419            }
420            other => panic!("expected completeForm, got {other:?}"),
421        }
422    }
423
424    #[test]
425    fn duplicate_extract_field_names_are_refused() {
426        let field = ExtractField {
427            name: "email".into(),
428            purpose: "address".into(),
429            hints: IntentHints::default(),
430            value: crate::commands::ExtractValueKind::Text,
431        };
432        let result = std::panic::catch_unwind(|| {
433            extract_runtime_command(ExtractIntent {
434                purpose: "read the header".into(),
435                fields: vec![field.clone(), field],
436            })
437        });
438        assert!(result.is_err(), "duplicate field names must panic");
439    }
440
441    #[test]
442    fn challenge_envelopes_carry_their_hints() {
443        let envelope = solve_challenge_envelope(
444            meta(),
445            "clear the recaptcha",
446            Some(crate::challenges::SolveChallengeHints {
447                region: Some(crate::challenges::ChallengeRegion {
448                    x: 1.0,
449                    y: 2.0,
450                    width: 3.0,
451                    height: 4.0,
452                }),
453                timeout_ms: 45_000,
454            }),
455        );
456        match &envelope.command {
457            RuntimeCommand::Intent(IntentCommand::SolveChallenge(solve)) => {
458                assert_eq!(solve.hints.timeout_ms, 45_000);
459                assert!(solve.hints.region.is_some());
460            }
461            other => panic!("expected solveChallenge, got {other:?}"),
462        }
463    }
464}