cel-contracts 0.1.2

Shared Rust contracts for AI agent actions, planning views, runtime capabilities, and execution receipts.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
//! Action contract — what a planner can ask the runtime to do.
//!
//! Lives at the cortex/planner boundary so neither side has to depend on the
//! other to talk about actions.

use serde::{Deserialize, Deserializer, Serialize};

/// Deserialize a field that may be a string, object, array, or null.
/// LLMs sometimes return structured data where a plain string is expected.
fn deserialize_string_or_value<'de, D>(deserializer: D) -> Result<String, D::Error>
where
    D: Deserializer<'de>,
{
    let value = serde_json::Value::deserialize(deserializer)?;
    match value {
        serde_json::Value::String(s) => Ok(s),
        serde_json::Value::Null => Ok(String::new()),
        other => Ok(other.to_string()),
    }
}

/// What the runtime should observe after an action lands to confirm the
/// side-effect actually materialised.
///
/// Cortex polls the page (via CDP `Runtime.evaluate`) until the
/// expectation holds or the timeout fires. The result is reported back
/// in the action's `ActionResult` so the planner sees not just
/// "dispatched ok" but "dispatched ok AND observed the expected change".
///
/// Closes the "click reported ok but page didn't react" gap:
/// `e.preventDefault()` from a validation handler, a remounted DOM node
/// the click landed on but is no longer wired up, an animation that
/// swallowed the click — all previously reported `ok` and forced the
/// planner to verify state from screenshots after the fact. With
/// `expect_after` the runtime knows immediately the side-effect didn't
/// materialise and surfaces an `EffectMissing` error to the planner.
///
/// All variants carry a `timeout_ms` with a sensible default
/// (2_000 ms) — long enough for animations / debounced handlers, short
/// enough not to add real latency on the happy path (most expectations
/// resolve in one CDP round-trip when the action actually fired).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EffectExpectation {
    /// A new element matching `selector` becomes visible
    /// (`offsetParent !== null`). Use for "after submit, success
    /// message appears", "after open, modal appears" patterns.
    SelectorAppears {
        selector: String,
        #[serde(default = "default_effect_timeout_ms")]
        timeout_ms: u64,
    },
    /// An element matching `selector` becomes invisible (detached or
    /// `offsetParent === null`). Use for "after close, modal disappears",
    /// "after delete, row goes away" patterns.
    SelectorDisappears {
        selector: String,
        #[serde(default = "default_effect_timeout_ms")]
        timeout_ms: u64,
    },
    /// An element matching `selector` contains the given text. Use for
    /// state changes like "button label flips from 'Approve' to
    /// 'Approved ✓'", or "row gains an 'Approved' status cell".
    SelectorTextContains {
        selector: String,
        substring: String,
        #[serde(default = "default_effect_timeout_ms")]
        timeout_ms: u64,
    },
    /// The page DOM changed in any meaningful way after the action.
    /// Compares a "before" snapshot (captured at dispatch entry) to a
    /// "after" snapshot polled until either differs or the timeout
    /// fires. The snapshot is small + cheap: visible text length,
    /// interactive element count, and current URL.
    ///
    /// Use when the post-state isn't a single named selector but you
    /// know the action SHOULD cause SOME visible change — e.g.:
    ///   • a delete button that removes a row (count decreases),
    ///   • a tab switch that swaps the entire content panel,
    ///   • a submit that navigates to a thank-you page (URL changes),
    ///   • a "load more" that appends results (text length grows).
    ///
    /// Strictly weaker than `SelectorAppears`/`Disappears`/`TextContains`
    /// — those tell you EXACTLY what should change, this just tells
    /// you SOMETHING did. Use the selector-based variants when you
    /// have a verbatim `selector="..."` from perception; fall back
    /// to `DomChanged` when you don't.
    ///
    /// False-positive risk: pages with timestamp tickers / animated
    /// counters / live data feeds produce diffs every tick. The 2s
    /// default timeout is short enough that most non-action-triggered
    /// changes don't have time to land — but if you're on a chatty
    /// page, prefer a selector-based variant if you can name one.
    DomChanged {
        #[serde(default = "default_effect_timeout_ms")]
        timeout_ms: u64,
    },
}

fn default_effect_timeout_ms() -> u64 {
    2_000
}

/// How a native-input action delivers its event relative to app focus.
///
/// `Foreground` is the historical CEL behavior: the dispatcher brings the
/// target app to the front (`activate_app` / `ensure_target_app_focus`) and
/// then posts a session-wide CGEvent at whatever is frontmost. Robust, but
/// it steals the user's focus and is the root cause of the focus-sensitive
/// `hybrid`-suite constraint.
///
/// `Background` posts the event directly to the target process via
/// `CGEventPostToPid` without activating it (see `cel_input::background`),
/// so the user's active window keeps focus. Not every macOS app honors
/// background-delivered events, so the dispatcher falls back to `Foreground`
/// when no target PID resolves or the background post is rejected.
///
/// Defaults to `Foreground` so existing plans and serialized payloads are
/// unchanged. WS1 of the Peekaboo-parity plan.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[repr(u8)]
pub enum FocusMode {
    /// Activate the target app, then post input. Steals focus. Default.
    #[default]
    Foreground,
    /// Post input to the target PID without activating it. Non-focus-stealing.
    Background,
}

/// The action the planner wants to execute.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PlannedAction {
    Click {
        target_id: String,
        /// Optional post-state verification. When set, the runtime
        /// polls the page after the click and reports the action as
        /// failed if the expectation doesn't hold within `timeout_ms`.
        /// Closes the "click reported ok but DOM didn't react" gap.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        expect_after: Option<EffectExpectation>,
    },
    Type {
        /// Optional: if provided, clicks the element first then types.
        /// If omitted, types into the currently focused element.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        target_id: Option<String>,
        text: String,
    },
    Key {
        key: String,
    },
    KeyCombo {
        keys: Vec<String>,
    },
    /// Set a value directly via the accessibility API (bypasses mouse/keyboard).
    /// More reliable than Type for form fields where settable=true.
    SetValue {
        target_id: String,
        value: String,
        /// Optional post-state verification — see [`EffectExpectation`].
        /// Most useful for `<select>` where setting the value should
        /// flip an `aria-selected` attribute on the chosen option, or
        /// for `<input type="text">` in framework-controlled forms
        /// where the visible label should reflect the typed value.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        expect_after: Option<EffectExpectation>,
    },
    Scroll {
        dx: i32,
        dy: i32,
    },
    /// Drag from one element/position to another.
    Drag {
        from_target_id: String,
        to_target_id: String,
    },
    Wait {
        ms: u32,
    },
    Custom {
        adapter: String,
        action: String,
        #[serde(default)]
        params: serde_json::Value,
    },
    /// Extract: read data from the current page/screen without interaction.
    /// For read-only goals: "what are the prices?", "read the headlines", "what's on screen?"
    /// Returns the extracted data in the `data` field — no clicking or navigation needed.
    Extract {
        /// What data to extract (natural language description).
        #[serde(default, deserialize_with = "deserialize_string_or_value")]
        goal: String,
        /// The extracted data (filled by the planner from visible context).
        /// Optional: Gemini 2.5 Flash sometimes omits this field.
        #[serde(default, deserialize_with = "deserialize_string_or_value")]
        data: String,
    },
    /// Batch: execute multiple simple actions in sequence without re-planning.
    Batch {
        actions: Vec<PlannedAction>,
    },
    /// Natural language action — CEL resolves the instruction to the best matching element.
    Act {
        instruction: String,
    },
    /// Terminal: goal achieved. `evidence_ids` should cite element IDs
    /// from the current context that prove the goal was achieved.
    Done {
        #[serde(deserialize_with = "deserialize_string_or_value")]
        summary: String,
        #[serde(default)]
        evidence_ids: Vec<String>,
    },
    /// Terminal: cannot proceed.
    Fail {
        #[serde(deserialize_with = "deserialize_string_or_value")]
        reason: String,
    },
    /// Native accessibility action — more reliable than coordinate clicks for desktop apps.
    /// Uses macOS AXUIElementPerformAction under the hood.
    AxAction {
        /// AX hash id from the same perception snapshot that produced
        /// the plan. May be missing (empty / null) when the planner
        /// only knows the visible label — the cortex falls back to
        /// `label` + `role_hint` resolution in that case.
        ///
        /// Lenient deserialization accepts `null` from the LLM (some
        /// models emit `"target_id": null` when they want label-only
        /// dispatch) and treats it as an empty string. Without this,
        /// the whole turn parse-errors with
        /// `invalid type: null, expected a string` and the run dies
        /// before the runner gets a chance to fall back.
        #[serde(default, deserialize_with = "deserialize_string_or_value")]
        target_id: String,
        /// The action to perform: "click" (AXPress), "activate" (AXConfirm),
        /// "increment", "decrement", "show_menu"
        action: String,
        /// Label hint for fallback element resolution. AX IDs are hashes
        /// that include bounds + depth and therefore change whenever the
        /// UI mutates between plan time and dispatch time. If the cortex
        /// can't find `target_id` in the live AX tree (or `target_id`
        /// is missing entirely), it falls back to searching for the
        /// first visible element whose role matches `role_hint` (if
        /// provided) and whose label equals `label`. Planner must
        /// populate this from the same perception snapshot that
        /// produced the target_id.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        label: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        role_hint: Option<String>,
        /// Optional post-state verification — see [`EffectExpectation`].
        /// Same shape as the equivalent field on `Click`/`SetValue`.
        /// Mostly useful when the AX action lands on a browser DOM
        /// element (the runtime routes those through CDP), so the page
        /// state is observable via querySelector.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        expect_after: Option<EffectExpectation>,
    },
    /// Activate (bring to front) a macOS application by name.
    /// Uses `open -a` under the hood — the most reliable app switching method.
    ActivateApp {
        app_name: String,
    },
    /// Launch (start) a macOS application by name. Unlike `activate_app`, this
    /// is about *starting* the app; with `background` it launches without
    /// stealing focus (`open -g`). Verified by polling until the process
    /// appears.
    LaunchApp {
        app_name: String,
        /// Launch without bringing the app to the front. Defaults to false.
        #[serde(default)]
        background: bool,
    },
    /// Quit a macOS application by name, gracefully (AppleScript `quit`, like
    /// ⌘Q). Never force-kills — an app showing an unsaved-changes dialog stays
    /// running and the action reports failure. Verified by polling until the
    /// process is gone.
    QuitApp {
        app_name: String,
    },
    /// Select text by dragging from one coordinate to another.
    /// Used for text selection, highlighting, and marking tasks.
    Select {
        from_x: i32,
        from_y: i32,
        to_x: i32,
        to_y: i32,
    },
    /// Execute JavaScript in the focused browser tab via Chrome DevTools Protocol.
    /// Fastest way to interact with web pages: click elements, fill forms, extract data,
    /// dismiss cookie banners — all in a single action.
    CdpEval {
        /// JavaScript expression to evaluate in the page context.
        expression: String,
    },
    /// Canonical navigation action. The cortex routes this through the
    /// browser adapter (Playwright) when one is registered + active, and
    /// otherwise falls back to in-cortex `cel_cdp::Page.navigate` plus a
    /// `document.readyState` poll keyed by `wait_until`.
    ///
    /// All three control fields are optional with sensible defaults so
    /// the legacy `{"type":"navigate","url":"..."}` payload still parses
    /// unchanged. Aliases `href` / `to` on `url` survive too — LLMs
    /// gravitate toward both.
    Navigate {
        #[serde(alias = "href", alias = "to")]
        url: String,
        /// One of `"none"`, `"domcontentloaded"`, `"load"`, `"networkidle"`.
        /// Unknown values are treated as the default. Default: `"domcontentloaded"`.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        wait_until: Option<String>,
        /// Upper bound for the lifecycle wait. Default: 30_000 ms.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        timeout_ms: Option<u64>,
        /// When true (default), the cortex fallback runs a best-effort
        /// cookie-banner / overlay-dismiss script after the page loads.
        /// The TS browser adapter does this unconditionally inside its
        /// own navigate handler (process-driver.ts) so this flag only
        /// affects the in-cortex fallback path.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        dismiss_overlays: Option<bool>,
    },
    /// No-op: LLM sometimes puts notebook_writes inside the actions array.
    /// This variant absorbs that mistake gracefully instead of causing a parse error.
    /// The actual notebook_writes are processed from the top-level PlannedStep field.
    #[serde(alias = "notebook_write")]
    NotebookWrites {
        #[serde(default)]
        key: String,
        #[serde(default)]
        value: String,
        #[serde(default)]
        category: String,
    },
    /// Declarative extraction with selector fallbacks.
    ///
    /// Replaces the "LLM hand-writes `document.querySelector(...)` in a
    /// loop" failure mode. Runtime tries each selector in order via
    /// CDP `Runtime.evaluate`, parses according to `parse_as`, and on
    /// first success writes the value into `shared_memory[name]`. The
    /// planner supplies the scenario-specific selector knowledge as
    /// parameters; the retry/parse machinery is generic.
    ///
    /// Contract with the runner: consecutive failures for the same
    /// `name` (across turns) accumulate toward an auto-null cutoff
    /// (see the stall/retry budget in `canonical_runner.rs`). After
    /// the cutoff the runtime records `shared_memory[name] = null` so
    /// the LLM stops polishing a field that the page does not surface.
    #[serde(alias = "extract_with_fallbacks", alias = "extract_declarative")]
    ExtractWithFallback {
        /// Logical name for this extraction target (e.g. `"btc_price"`).
        /// Written into `shared_memory` on success, and used by the
        /// runner to group consecutive failures for retry budgeting.
        name: String,
        /// CSS selector or JS expression candidates, tried in order.
        /// An entry may be either a plain CSS selector (runtime wraps
        /// it into `document.querySelector(SEL)?.textContent`) or a
        /// full JS expression starting with `function` or a recognized
        /// prefix — the runtime auto-detects.
        selectors: Vec<String>,
        /// How to parse the raw string yielded by the first matching
        /// selector. One of: `"text"`, `"float"`, `"int"`, `"html"`.
        /// Unknown values fall back to `"text"`.
        #[serde(default = "default_parse_as", alias = "parse", alias = "as")]
        parse_as: String,
    },
    /// Deterministic spreadsheet cell writes via AppleScript (Numbers).
    ///
    /// Replaces the flaky keystroke recipe (`activate_app → key(arrows)
    /// → key(Delete) → type → key(Return)`) with one atomic operation
    /// against the document model. The keystroke recipe produced
    /// concatenated garbage values, duplicated headers, and values
    /// landing in the wrong cells whenever an intermediate step got
    /// perturbed by focus drift or AX tree lag. `WriteCells` sidesteps
    /// the entire UI event loop.
    ///
    /// Batch-shaped because the AppleScript spawn cost amortizes across
    /// many cells — single-cell callers pass a length-1 `writes` vector.
    #[serde(alias = "write_cell")]
    WriteCells {
        /// Target app. Currently only `"Numbers"` is implemented; other
        /// values produce a clean runtime error so the planner can pivot.
        #[serde(default = "default_spreadsheet_app")]
        app: String,
        /// Optional sheet name. `None` = first sheet of first document.
        #[serde(default)]
        sheet: Option<String>,
        /// Optional table name. `None` = first table of selected sheet.
        #[serde(default)]
        table: Option<String>,
        /// Writes to apply, in order.
        writes: Vec<CellWrite>,
        /// When true, the runtime reads each cell back after writing
        /// and includes the readback in the step result's `data` field.
        /// Recommended: keep `true` — verification is cheap (same
        /// AppleScript call) and catches Numbers' value coercions.
        #[serde(default = "default_true")]
        verify: bool,
    },
    /// Deterministic spreadsheet cell reads via AppleScript (Numbers).
    ///
    /// Use this when the agent needs spreadsheet truth from the app
    /// model instead of relying on the accessibility tree to expose
    /// the values. Particularly useful for Numbers, whose AX surface
    /// often only exposes the focused cell or formula-bar content.
    #[serde(alias = "read_cell")]
    ReadCells {
        /// Target app. Currently only `"Numbers"` is implemented.
        #[serde(default = "default_spreadsheet_app")]
        app: String,
        /// Optional sheet name. `None` = first sheet of first document.
        #[serde(default)]
        sheet: Option<String>,
        /// Optional table name. `None` = first table of selected sheet.
        #[serde(default)]
        table: Option<String>,
        /// Cells to read, in order.
        #[serde(alias = "refs", alias = "cells", alias = "addresses")]
        cell_refs: Vec<String>,
    },
    /// Native window management (WS2) — move/resize/minimize/maximize/focus a
    /// macOS window resolved by `app` + `window_index`. The cortex routes this
    /// to `cel_accessibility::perform_window_op` and reads geometry back into
    /// the receipt. `op` is one of: `"move"`, `"resize"`, `"set_bounds"`,
    /// `"minimize"`, `"unminimize"`, `"maximize"`, `"focus"`. A `preset`
    /// (WS2.3 tiling, e.g. `"left_half"`) overrides `op` + geometry when set.
    Window {
        op: String,
        /// Target app name. `None` = the frontmost app.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        app: Option<String>,
        /// Window index (0 = the app's frontmost window).
        #[serde(default)]
        window_index: usize,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        x: Option<f64>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        y: Option<f64>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        width: Option<f64>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        height: Option<f64>,
        /// Optional tiling preset (WS2.3). When set, overrides `op` + geometry.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        preset: Option<String>,
        /// Optional target display index (WS4). When set, presets/placement
        /// target that display; otherwise the window's current display.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        display: Option<usize>,
    },
    /// Native dialog / sheet driver (WS5) — list, click a button by title, set
    /// a text field, or dismiss the frontmost macOS Open/Save/Print sheet or
    /// alert. The cortex resolves the dialog's controls via the accessibility
    /// tree. `op` is one of: `"list"`, `"click"`, `"set_field"`, `"dismiss"`.
    Dialog {
        op: String,
        /// Button title to click (for `op = "click"`); case-insensitive substring.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        button: Option<String>,
        /// Value to set (for `op = "set_field"`).
        #[serde(default, skip_serializing_if = "Option::is_none")]
        value: Option<String>,
        /// Which visible text field to set (0-based; default 0).
        #[serde(default)]
        field_index: usize,
    },
    /// Native Dock control (WS6) — list items, launch / right-click an item by
    /// title, or toggle auto-hide. `op` is one of: `"list"`, `"launch"`,
    /// `"right_click"`, `"hide"`, `"show"`.
    Dock {
        op: String,
        /// Dock item title (for `launch` / `right_click`).
        #[serde(default, skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },
    /// Native menu-bar extras (WS7) — list system status items (Wi-Fi,
    /// Bluetooth, Control Center, …) or click one by title. `op` is `"list"`
    /// or `"click"`.
    MenuExtra {
        op: String,
        /// Status-item title to click (for `op = "click"`), case-insensitive.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        name: Option<String>,
    },
}

/// One cell write inside a [`PlannedAction::WriteCells`] batch.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CellWrite {
    /// A1-notation cell reference, e.g. `"B2"`, `"AA17"`.
    #[serde(alias = "ref", alias = "cell", alias = "address")]
    pub cell_ref: String,
    /// Value to write. Pass raw numeric strings (`"108432.50"`, not
    /// `"$108,432.50"`); Numbers formats per the cell's display
    /// format. Text values pass through unchanged.
    pub value: String,
}

fn default_spreadsheet_app() -> String {
    "Numbers".into()
}

fn default_true() -> bool {
    true
}

fn default_parse_as() -> String {
    "text".into()
}

impl PlannedAction {
    /// Target element IDs this action depends on, if any. Used by the runner
    /// to pre-validate that planned elements still exist in the fresh Cortex
    /// context before dispatch — missing targets trigger a replan instead of
    /// silent misfire.
    ///
    /// Returns an empty slice for actions with no element target (coordinate
    /// scrolls, key events, `CdpEval`, terminal actions). `Drag` returns two
    /// IDs (from + to). `Batch` recurses — every sub-action's targets are
    /// collected flat, so if any one is missing the whole batch is aborted
    /// before any side-effect lands. Sub-batches inside batches collapse the
    /// same way.
    pub fn target_ids(&self) -> Vec<&str> {
        match self {
            Self::Click { target_id, .. }
            | Self::SetValue { target_id, .. }
            | Self::AxAction { target_id, .. } => vec![target_id.as_str()],
            Self::Type {
                target_id: Some(id),
                ..
            } => vec![id.as_str()],
            Self::Drag {
                from_target_id,
                to_target_id,
            } => vec![from_target_id.as_str(), to_target_id.as_str()],
            Self::Batch { actions } => actions.iter().flat_map(|a| a.target_ids()).collect(),
            // No element-level targets:
            Self::Type {
                target_id: None, ..
            }
            | Self::Key { .. }
            | Self::KeyCombo { .. }
            | Self::Scroll { .. }
            | Self::Wait { .. }
            | Self::Custom { .. }
            | Self::Extract { .. }
            | Self::Act { .. }
            | Self::Done { .. }
            | Self::Fail { .. }
            | Self::ActivateApp { .. }
            | Self::LaunchApp { .. }
            | Self::QuitApp { .. }
            | Self::Select { .. }
            | Self::CdpEval { .. }
            | Self::Navigate { .. }
            | Self::NotebookWrites { .. }
            | Self::WriteCells { .. }
            | Self::ReadCells { .. }
            | Self::ExtractWithFallback { .. }
            // Window ops target by app + window index, not an element id.
            | Self::Window { .. }
            // Dialog ops resolve their controls internally, not by element id.
            | Self::Dialog { .. }
            // Dock ops target by item title, not an element id.
            | Self::Dock { .. }
            // Menu extras resolve by title, not by element id.
            | Self::MenuExtra { .. } => vec![],
        }
    }
}

#[cfg(test)]
mod target_ids_tests {
    use super::*;

    #[test]
    fn click_returns_its_target() {
        let a = PlannedAction::Click {
            target_id: "a11y:42".into(),
            expect_after: None,
        };
        assert_eq!(a.target_ids(), vec!["a11y:42"]);
    }

    #[test]
    fn click_without_expect_after_round_trips_omitting_field() {
        // Back-compat: planners that don't know about `expect_after`
        // still round-trip through the same JSON shape they always
        // emitted (no field added on serialize, no field required on
        // deserialize).
        let raw = r#"{"type":"click","target_id":"dom:button:submit"}"#;
        let a: PlannedAction = serde_json::from_str(raw).unwrap();
        match a {
            PlannedAction::Click {
                target_id,
                expect_after,
            } => {
                assert_eq!(target_id, "dom:button:submit");
                assert!(expect_after.is_none());
            }
            _ => panic!("expected Click"),
        }
        let serialised = serde_json::to_string(&PlannedAction::Click {
            target_id: "dom:button:submit".into(),
            expect_after: None,
        })
        .unwrap();
        // `skip_serializing_if = "Option::is_none"` keeps the JSON
        // identical to the pre-`expect_after` shape.
        assert!(!serialised.contains("expect_after"));
    }

    #[test]
    fn click_with_selector_appears_expectation_round_trips() {
        let raw = r##"{
            "type": "click",
            "target_id": "dom:button:submit",
            "expect_after": {
                "kind": "selector_appears",
                "selector": "#success-message",
                "timeout_ms": 3000
            }
        }"##;
        let a: PlannedAction = serde_json::from_str(raw).unwrap();
        match a {
            PlannedAction::Click {
                target_id,
                expect_after:
                    Some(EffectExpectation::SelectorAppears {
                        selector,
                        timeout_ms,
                    }),
            } => {
                assert_eq!(target_id, "dom:button:submit");
                assert_eq!(selector, "#success-message");
                assert_eq!(timeout_ms, 3000);
            }
            other => panic!("expected Click with SelectorAppears, got {other:?}"),
        }
    }

    #[test]
    fn effect_expectation_default_timeout_when_omitted() {
        // `timeout_ms` has a serde default of 2000ms — the planner can
        // omit it for the common case and only override when the page
        // is known to be slow (heavy SPA render, lazy-loaded modal).
        let raw = r##"{"kind": "selector_appears", "selector": "#x"}"##;
        let e: EffectExpectation = serde_json::from_str(raw).unwrap();
        match e {
            EffectExpectation::SelectorAppears {
                selector,
                timeout_ms,
            } => {
                assert_eq!(selector, "#x");
                assert_eq!(timeout_ms, 2_000);
            }
            other => panic!("expected SelectorAppears, got {other:?}"),
        }
    }

    #[test]
    fn effect_expectation_all_four_variants_parse() {
        let appears: EffectExpectation =
            serde_json::from_str(r#"{"kind":"selector_appears","selector":".success"}"#).unwrap();
        assert!(matches!(appears, EffectExpectation::SelectorAppears { .. }));
        let disappears: EffectExpectation =
            serde_json::from_str(r#"{"kind":"selector_disappears","selector":".modal.open"}"#)
                .unwrap();
        assert!(matches!(
            disappears,
            EffectExpectation::SelectorDisappears { .. }
        ));
        let text: EffectExpectation = serde_json::from_str(
            r##"{"kind":"selector_text_contains","selector":"#status","substring":"Approved"}"##,
        )
        .unwrap();
        assert!(matches!(
            text,
            EffectExpectation::SelectorTextContains { .. }
        ));
        // `dom_changed` is the diff-based fallback for actions whose
        // post-state isn't a single named selector. No `selector`
        // field — just an optional timeout.
        let changed: EffectExpectation = serde_json::from_str(r#"{"kind":"dom_changed"}"#).unwrap();
        match changed {
            EffectExpectation::DomChanged { timeout_ms } => {
                // Default timeout when omitted.
                assert_eq!(timeout_ms, 2_000);
            }
            other => panic!("expected DomChanged, got {other:?}"),
        }
        let changed_custom: EffectExpectation =
            serde_json::from_str(r#"{"kind":"dom_changed","timeout_ms":5000}"#).unwrap();
        match changed_custom {
            EffectExpectation::DomChanged { timeout_ms } => {
                assert_eq!(timeout_ms, 5_000);
            }
            other => panic!("expected DomChanged with custom timeout, got {other:?}"),
        }
    }

    #[test]
    fn type_without_target_returns_empty() {
        let a = PlannedAction::Type {
            target_id: None,
            text: "hi".into(),
        };
        assert!(a.target_ids().is_empty());
    }

    #[test]
    fn drag_returns_both_endpoints() {
        let a = PlannedAction::Drag {
            from_target_id: "a11y:1".into(),
            to_target_id: "a11y:2".into(),
        };
        assert_eq!(a.target_ids(), vec!["a11y:1", "a11y:2"]);
    }

    #[test]
    fn batch_flattens_sub_action_targets() {
        let a = PlannedAction::Batch {
            actions: vec![
                PlannedAction::CdpEval {
                    expression: "1".into(),
                },
                PlannedAction::Click {
                    target_id: "a11y:ghost".into(),
                    expect_after: None,
                },
                PlannedAction::SetValue {
                    target_id: "a11y:input".into(),
                    value: "v".into(),
                    expect_after: None,
                },
            ],
        };
        assert_eq!(a.target_ids(), vec!["a11y:ghost", "a11y:input"]);
    }

    #[test]
    fn nested_batches_flatten() {
        let a = PlannedAction::Batch {
            actions: vec![PlannedAction::Batch {
                actions: vec![PlannedAction::Click {
                    target_id: "a11y:deep".into(),
                    expect_after: None,
                }],
            }],
        };
        assert_eq!(a.target_ids(), vec!["a11y:deep"]);
    }

    #[test]
    fn cdp_eval_and_terminals_have_no_targets() {
        assert!(PlannedAction::CdpEval {
            expression: "x".into()
        }
        .target_ids()
        .is_empty());
        assert!(PlannedAction::Done {
            summary: "ok".into(),
            evidence_ids: vec![]
        }
        .target_ids()
        .is_empty());
        assert!(PlannedAction::Wait { ms: 100 }.target_ids().is_empty());
    }

    #[test]
    fn extract_with_fallback_round_trips() {
        let raw = r#"{"type":"extract_with_fallback","name":"btc_price",
            "selectors":["fin-streamer[data-field='regularMarketPrice']",
                         "[data-test='qsp-price']"],
            "parse_as":"float"}"#;
        let a: PlannedAction = serde_json::from_str(raw).unwrap();
        match &a {
            PlannedAction::ExtractWithFallback {
                name,
                selectors,
                parse_as,
            } => {
                assert_eq!(name, "btc_price");
                assert_eq!(selectors.len(), 2);
                assert_eq!(parse_as, "float");
            }
            _ => panic!("expected ExtractWithFallback"),
        }
        // Has no element-level targets.
        assert!(a.target_ids().is_empty());
    }

    #[test]
    fn extract_with_fallback_defaults_parse_as_to_text() {
        let raw = r#"{"type":"extract_with_fallback","name":"title",
            "selectors":["h1"]}"#;
        let a: PlannedAction = serde_json::from_str(raw).unwrap();
        match a {
            PlannedAction::ExtractWithFallback { parse_as, .. } => {
                assert_eq!(parse_as, "text");
            }
            _ => panic!("expected ExtractWithFallback"),
        }
    }

    #[test]
    fn extract_with_fallback_accepts_parse_alias() {
        let raw = r#"{"type":"extract_with_fallback","name":"n",
            "selectors":["a"],"parse":"int"}"#;
        let a: PlannedAction = serde_json::from_str(raw).unwrap();
        match a {
            PlannedAction::ExtractWithFallback { parse_as, .. } => {
                assert_eq!(parse_as, "int");
            }
            _ => panic!("expected ExtractWithFallback"),
        }
    }

    #[test]
    fn read_cells_accepts_cells_alias_and_defaults_app() {
        let raw = r#"{"type":"read_cells","cells":["A1","B2"]}"#;
        let a: PlannedAction = serde_json::from_str(raw).unwrap();
        match a {
            PlannedAction::ReadCells { app, cell_refs, .. } => {
                assert_eq!(app, "Numbers");
                assert_eq!(cell_refs, vec!["A1", "B2"]);
            }
            _ => panic!("expected ReadCells"),
        }
    }

    #[test]
    fn ax_action_accepts_null_target_id_as_empty_string() {
        // Some models emit `"target_id": null` when they only know
        // the visible label and want the runtime to resolve. The
        // previous contract treated this as a fatal parse error
        // (`invalid type: null, expected a string`), killing the
        // entire turn before the label-fallback dispatcher could
        // run. Lenient deserialization converts null to an empty
        // string; the cortex dispatcher already handles empty
        // target_id by going straight to label resolution.
        let raw = r#"{
            "type": "ax_action",
            "target_id": null,
            "action": "click",
            "label": "Export to Notes",
            "role_hint": "button"
        }"#;
        let a: PlannedAction = serde_json::from_str(raw).unwrap();
        match a {
            PlannedAction::AxAction {
                target_id,
                action,
                label,
                role_hint,
                ..
            } => {
                assert_eq!(target_id, "");
                assert_eq!(action, "click");
                assert_eq!(label.as_deref(), Some("Export to Notes"));
                assert_eq!(role_hint.as_deref(), Some("button"));
            }
            _ => panic!("expected AxAction"),
        }
    }

    #[test]
    fn ax_action_accepts_missing_target_id_field_entirely() {
        // Defensive: if the planner omits the field rather than
        // explicitly sending null, the `#[serde(default)]` falls
        // back to `String::default()` (empty string), same
        // dispatch path.
        let raw = r#"{
            "type": "ax_action",
            "action": "click",
            "label": "Submit"
        }"#;
        let a: PlannedAction = serde_json::from_str(raw).unwrap();
        match a {
            PlannedAction::AxAction {
                target_id, label, ..
            } => {
                assert_eq!(target_id, "");
                assert_eq!(label.as_deref(), Some("Submit"));
            }
            _ => panic!("expected AxAction"),
        }
    }

    #[test]
    fn ax_action_still_accepts_explicit_target_id_string() {
        // Regression guard: the lenient path must not break the
        // normal case where the planner emits a real id.
        let raw = r#"{
            "type": "ax_action",
            "target_id": "ax:AXButton/0x1234",
            "action": "click"
        }"#;
        let a: PlannedAction = serde_json::from_str(raw).unwrap();
        match a {
            PlannedAction::AxAction { target_id, .. } => {
                assert_eq!(target_id, "ax:AXButton/0x1234");
            }
            _ => panic!("expected AxAction"),
        }
    }

    #[test]
    fn navigate_legacy_payload_still_parses() {
        // The pre-canonical wire shape — no wait/timeout/dismiss
        // fields — must keep working unchanged. Every existing
        // planner (LangGraph, internal tests, MCP clients on older
        // SDKs) emits this; flipping to required fields would silently
        // brick navigation.
        let raw = r#"{"type":"navigate","url":"https://example.com"}"#;
        let a: PlannedAction = serde_json::from_str(raw).unwrap();
        match a {
            PlannedAction::Navigate {
                url,
                wait_until,
                timeout_ms,
                dismiss_overlays,
            } => {
                assert_eq!(url, "https://example.com");
                assert!(wait_until.is_none());
                assert!(timeout_ms.is_none());
                assert!(dismiss_overlays.is_none());
            }
            _ => panic!("expected Navigate"),
        }
    }

    #[test]
    fn navigate_extended_payload_parses_all_fields() {
        let raw = r#"{
            "type":"navigate",
            "url":"https://example.com",
            "wait_until":"load",
            "timeout_ms":10000,
            "dismiss_overlays":false
        }"#;
        let a: PlannedAction = serde_json::from_str(raw).unwrap();
        match a {
            PlannedAction::Navigate {
                url,
                wait_until,
                timeout_ms,
                dismiss_overlays,
            } => {
                assert_eq!(url, "https://example.com");
                assert_eq!(wait_until.as_deref(), Some("load"));
                assert_eq!(timeout_ms, Some(10_000));
                assert_eq!(dismiss_overlays, Some(false));
            }
            _ => panic!("expected Navigate"),
        }
    }

    #[test]
    fn navigate_url_aliases_href_and_to_still_work() {
        // The original `Navigate` variant accepted `href` and `to`
        // aliases on the URL field for LLM ergonomics. The extended
        // variant inherits those aliases — pin both forms to catch
        // an accidental drop during a future refactor.
        for raw in [
            r#"{"type":"navigate","href":"https://example.com"}"#,
            r#"{"type":"navigate","to":"https://example.com"}"#,
        ] {
            let a: PlannedAction = serde_json::from_str(raw).expect(raw);
            match a {
                PlannedAction::Navigate { url, .. } => {
                    assert_eq!(url, "https://example.com")
                }
                _ => panic!("expected Navigate for {raw}"),
            }
        }
    }
}