Make coder-lite the coder TUI, over the capable runtime

da5c85f9f8b4 · AtlantisPleb · · parent 0f40e431ea89

Make coder-lite the coder TUI, over the capable runtime

There were two coder TUIs. coder-lite drew the right frame and had no
tools; `oa coder` had the runtime and a different frame. This makes
coder-lite the front and takes the runtime as a library rather than
copying it, so there is one implementation of each piece.

coder-lite keeps its identity exactly: the amber #FFB000 on #080600, the
braille spinner, the `Entry`/`CoderUi` frame, the terse system prompt
carried verbatim, and `--dev`. No status bar, no panes, no new chrome.

What it gained:

- Tools. All five of `openagents-cli`'s — `shell`, `skill`, `openagents`,
  `capability`, `delegate` — with the refusal list and the 30k ceiling,
  drawn in the existing tool box. `HarnessToolRegistry` grew a host-tool
  hook so a front end can declare a capability of its own.
- `acp`, coder-lite's own tool, over coder-lite's harness. Declared only
  where `find_agents` found something installed, and its `agent` enum is
  exactly that list. The harness gained `session/set_mode` and
  `stop_tree`, so a delegated agent no longer orphans what it started.
- The grok-derived composer: multi-line, readline chords, word motions, a
  caret that is where the caret is. Plus history and Tab completion.
- One session for the process rather than one per Enter, so the
  conversation is kept, one thread is opened, and `close()` is awaited on
  the way out and prints what the server billed.
- Lanes, the live model catalog, and per-turn usage lines. `--lane`,
  `--reasoning`, and a `--help` that names every flag it reads and says
  plainly that `oa coder` still exists and that this is not `openagents`.
- `/help`, `/clear`, `/diff`, `/run`, `/export`, `/resume`.

Nothing claims what it does not do. Every key in `/help` is wired and a
test holds the list to the dispatch. The model field is empty until a
model answers. A failure settles the stream and goes beside it, never
into it. A refused thread, a refused proxy, and an unserved lane each
refuse out loud with what the server said.

Carried across the rebase, since each landed in code this replaces:

- `afea5551fa`'s one delegation per user turn. The `tool_choice: none`
  clamp does not fit a turn loop that sends no `tool_choice`, so the
  `acp` tool refuses its own second call in a turn and says why — which
  also tells the model what the limit is instead of silently withdrawing
  the tool.
- `c06badb472`'s composer cursor. Trailing spaces survive by
  construction now, because the composer slices byte ranges rather than
  rejoining `split_whitespace`; the `REVERSED` block cursor is kept and
  sits at the real caret rather than at the end of the line.
- `c48fa5b138` / `28704f72ff`'s char-boundary rule. Both bounded cuts
  here go through `tracker::floor_char_boundary`, including the one in
  `acp_harness` that would have panicked the whole session on an agent's
  refusal carrying a multi-byte character across byte 200.
- `4f3a557fd8`'s `BUILTIN_TOOL_NAMES`, which `add_host_tool` now reads
  instead of a second copy of the list. It also refuses a host tool whose
  name a catalogued plugin claims — the collision `validate_manifest`
  cannot see, because it validates a manifest on disk long before a front
  end decides what to declare.

Tests: `cargo test -p coder-lite` 674 passed, `-p openagents-cli` 610.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified Cargo.lock
  • modified crates/coder-lite/Cargo.toml
  • modified crates/coder-lite/src/acp_harness.rs
  • added crates/coder-lite/src/acp_tool.rs
  • added crates/coder-lite/src/commands.rs
  • modified crates/coder-lite/src/export.rs
  • modified crates/coder-lite/src/interactive.rs
  • modified crates/coder-lite/src/lib.rs
  • modified crates/coder-lite/src/main.rs
  • modified crates/coder-lite/src/runtime.rs
  • modified crates/coder-lite/src/tui.rs
  • added crates/coder-lite/tests/frame.rs
  • deleted crates/coder-lite/tests/smoke.rs
  • added crates/coder-lite/tests/turn.rs
  • modified crates/openagents-cli/src/runtime.rs
  • modified crates/openagents-cli/src/tools.rs

Diff

16 files changed, +2975 -857

Cargo.lock modified +10 -320

@@ -166,29 +166,6 @@ version = "1.5.1"

166 166
source = "registry+https://github.com/rust-lang/crates.io-index"
167 167
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
168 168
169
[[package]]
170
name = "aws-lc-rs"
171
version = "1.18.0"
172
source = "registry+https://github.com/rust-lang/crates.io-index"
173
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
174
dependencies = [
175
 "aws-lc-sys",
176
 "zeroize",
177
]
178
179
[[package]]
180
name = "aws-lc-sys"
181
version = "0.44.0"
182
source = "registry+https://github.com/rust-lang/crates.io-index"
183
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
184
dependencies = [
185
 "cc",
186
 "cmake",
187
 "dunce",
188
 "fs_extra",
189
 "pkg-config",
190
]
191
192 169
[[package]]
193 170
name = "base16ct"
194 171
version = "0.2.0"

@@ -456,15 +433,6 @@ version = "1.1.0"

456 433
source = "registry+https://github.com/rust-lang/crates.io-index"
457 434
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
458 435
459
[[package]]
460
name = "cmake"
461
version = "0.1.58"
462
source = "registry+https://github.com/rust-lang/crates.io-index"
463
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
464
dependencies = [
465
 "cc",
466
]
467
468 436
[[package]]
469 437
name = "cobs"
470 438
version = "0.3.0"

@@ -485,7 +453,7 @@ dependencies = [

485 453
 "futures",
486 454
 "html-escape",
487 455
 "linkify",
488
 "openresponses-rust",
456
 "openagents-cli",
489 457
 "pretty_assertions",
490 458
 "pulldown-cmark",
491 459
 "ratatui",

@@ -537,16 +505,6 @@ version = "0.9.6"

537 505
source = "registry+https://github.com/rust-lang/crates.io-index"
538 506
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
539 507
540
[[package]]
541
name = "core-foundation"
542
version = "0.9.4"
543
source = "registry+https://github.com/rust-lang/crates.io-index"
544
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
545
dependencies = [
546
 "core-foundation-sys",
547
 "libc",
548
]
549
550 508
[[package]]
551 509
name = "core-foundation"
552 510
version = "0.10.1"

@@ -593,7 +551,7 @@ dependencies = [

593 551
 "core-foundation-sys",
594 552
 "coreaudio-rs",
595 553
 "dasp_sample",
596
 "jni 0.21.1",
554
 "jni",
597 555
 "js-sys",
598 556
 "libc",
599 557
 "mach2",

@@ -941,12 +899,6 @@ version = "1.2.1"

941 899
source = "registry+https://github.com/rust-lang/crates.io-index"
942 900
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
943 901
944
[[package]]
945
name = "dunce"
946
version = "1.0.5"
947
source = "registry+https://github.com/rust-lang/crates.io-index"
948
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
949
950 902
[[package]]
951 903
name = "ecdsa"
952 904
version = "0.16.9"

@@ -996,15 +948,6 @@ version = "0.6.1"

996 948
source = "registry+https://github.com/rust-lang/crates.io-index"
997 949
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
998 950
999
[[package]]
1000
name = "encoding_rs"
1001
version = "0.8.35"
1002
source = "registry+https://github.com/rust-lang/crates.io-index"
1003
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
1004
dependencies = [
1005
 "cfg-if",
1006
]
1007
1008 951
[[package]]
1009 952
name = "equivalent"
1010 953
version = "1.0.2"

@@ -1113,12 +1056,6 @@ dependencies = [

1113 1056
 "percent-encoding",
1114 1057
]
1115 1058
1116
[[package]]
1117
name = "fs_extra"
1118
version = "1.3.0"
1119
source = "registry+https://github.com/rust-lang/crates.io-index"
1120
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
1121
1122 1059
[[package]]
1123 1060
name = "futures"
1124 1061
version = "0.3.34"

@@ -1284,25 +1221,6 @@ dependencies = [

1284 1221
 "subtle",
1285 1222
]
1286 1223
1287
[[package]]
1288
name = "h2"
1289
version = "0.4.19"
1290
source = "registry+https://github.com/rust-lang/crates.io-index"
1291
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
1292
dependencies = [
1293
 "atomic-waker",
1294
 "bytes",
1295
 "fnv",
1296
 "futures-core",
1297
 "futures-sink",
1298
 "http",
1299
 "indexmap",
1300
 "slab",
1301
 "tokio",
1302
 "tokio-util",
1303
 "tracing",
1304
]
1305
1306 1224
[[package]]
1307 1225
name = "hashbrown"
1308 1226
version = "0.15.5"

@@ -1400,7 +1318,6 @@ dependencies = [

1400 1318
 "bytes",
1401 1319
 "futures-channel",
1402 1320
 "futures-core",
1403
 "h2",
1404 1321
 "http",
1405 1322
 "http-body",
1406 1323
 "httparse",

@@ -1445,11 +1362,9 @@ dependencies = [

1445 1362
 "percent-encoding",
1446 1363
 "pin-project-lite",
1447 1364
 "socket2",
1448
 "system-configuration",
1449 1365
 "tokio",
1450 1366
 "tower-service",
1451 1367
 "tracing",
1452
 "windows-registry",
1453 1368
]
1454 1369
1455 1370
[[package]]

@@ -1653,36 +1568,6 @@ dependencies = [

1653 1568
 "windows-sys 0.45.0",
1654 1569
]
1655 1570
1656
[[package]]
1657
name = "jni"
1658
version = "0.22.4"
1659
source = "registry+https://github.com/rust-lang/crates.io-index"
1660
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
1661
dependencies = [
1662
 "cfg-if",
1663
 "combine",
1664
 "jni-macros",
1665
 "jni-sys 0.4.1",
1666
 "log",
1667
 "simd_cesu8",
1668
 "thiserror 2.0.18",
1669
 "walkdir",
1670
 "windows-link 0.2.1",
1671
]
1672
1673
[[package]]
1674
name = "jni-macros"
1675
version = "0.22.4"
1676
source = "registry+https://github.com/rust-lang/crates.io-index"
1677
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
1678
dependencies = [
1679
 "proc-macro2",
1680
 "quote",
1681
 "rustc_version",
1682
 "simd_cesu8",
1683
 "syn 2.0.117",
1684
]
1685
1686 1571
[[package]]
1687 1572
name = "jni-sys"
1688 1573
version = "0.3.1"

@@ -1875,12 +1760,6 @@ dependencies = [

1875 1760
 "rustix 1.1.4",
1876 1761
]
1877 1762
1878
[[package]]
1879
name = "mime"
1880
version = "0.3.17"
1881
source = "registry+https://github.com/rust-lang/crates.io-index"
1882
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1883
1884 1763
[[package]]
1885 1764
name = "minimal-lexical"
1886 1765
version = "0.2.1"

@@ -2030,7 +1909,7 @@ dependencies = [

2030 1909
name = "oa-cloud-run-bridge"
2031 1910
version = "0.1.0"
2032 1911
dependencies = [
2033
 "reqwest 0.12.28",
1912
 "reqwest",
2034 1913
 "serde",
2035 1914
 "serde_json",
2036 1915
]

@@ -2042,7 +1921,7 @@ dependencies = [

2042 1921
 "base64",
2043 1922
 "libc",
2044 1923
 "openagents-cloud-contract",
2045
 "reqwest 0.12.28",
1924
 "reqwest",
2046 1925
 "serde",
2047 1926
 "serde_json",
2048 1927
 "sha2",

@@ -2066,7 +1945,7 @@ name = "oa-node"

2066 1945
version = "0.1.0"
2067 1946
dependencies = [
2068 1947
 "openagents-cloud-contract",
2069
 "reqwest 0.12.28",
1948
 "reqwest",
2070 1949
 "serde",
2071 1950
 "serde_json",
2072 1951
 "sha2",

@@ -2121,7 +2000,7 @@ version = "0.6.1"

2121 2000
source = "registry+https://github.com/rust-lang/crates.io-index"
2122 2001
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
2123 2002
dependencies = [
2124
 "jni 0.21.1",
2003
 "jni",
2125 2004
 "ndk",
2126 2005
 "ndk-context",
2127 2006
 "num-derive",

@@ -2200,7 +2079,7 @@ dependencies = [

2200 2079
 "portable-pty",
2201 2080
 "ratatui",
2202 2081
 "regex",
2203
 "reqwest 0.12.28",
2082
 "reqwest",
2204 2083
 "ring",
2205 2084
 "ripemd",
2206 2085
 "rustls",

@@ -2229,24 +2108,6 @@ dependencies = [

2229 2108
 "serde_json",
2230 2109
]
2231 2110
2232
[[package]]
2233
name = "openresponses-rust"
2234
version = "2026.7.26"
2235
source = "registry+https://github.com/rust-lang/crates.io-index"
2236
checksum = "a7484a3517821742462fdd2f5e4bf5c13a4d2fd57ddf23be70e6576809bd899f"
2237
dependencies = [
2238
 "async-trait",
2239
 "bytes",
2240
 "eventsource-stream",
2241
 "futures",
2242
 "reqwest 0.13.4",
2243
 "serde",
2244
 "serde_json",
2245
 "thiserror 2.0.18",
2246
 "tokio",
2247
 "url",
2248
]
2249
2250 2111
[[package]]
2251 2112
name = "openssl-probe"
2252 2113
version = "0.2.1"

@@ -2484,7 +2345,6 @@ version = "0.11.14"

2484 2345
source = "registry+https://github.com/rust-lang/crates.io-index"
2485 2346
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
2486 2347
dependencies = [
2487
 "aws-lc-rs",
2488 2348
 "bytes",
2489 2349
 "getrandom 0.3.4",
2490 2350
 "lru-slab",

@@ -2724,54 +2584,11 @@ dependencies = [

2724 2584
 "url",
2725 2585
 "wasm-bindgen",
2726 2586
 "wasm-bindgen-futures",
2727
 "wasm-streams 0.4.2",
2587
 "wasm-streams",
2728 2588
 "web-sys",
2729 2589
 "webpki-roots",
2730 2590
]
2731 2591
2732
[[package]]
2733
name = "reqwest"
2734
version = "0.13.4"
2735
source = "registry+https://github.com/rust-lang/crates.io-index"
2736
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
2737
dependencies = [
2738
 "base64",
2739
 "bytes",
2740
 "encoding_rs",
2741
 "futures-core",
2742
 "futures-util",
2743
 "h2",
2744
 "http",
2745
 "http-body",
2746
 "http-body-util",
2747
 "hyper",
2748
 "hyper-rustls",
2749
 "hyper-util",
2750
 "js-sys",
2751
 "log",
2752
 "mime",
2753
 "percent-encoding",
2754
 "pin-project-lite",
2755
 "quinn",
2756
 "rustls",
2757
 "rustls-pki-types",
2758
 "rustls-platform-verifier",
2759
 "serde",
2760
 "serde_json",
2761
 "sync_wrapper",
2762
 "tokio",
2763
 "tokio-rustls",
2764
 "tokio-util",
2765
 "tower",
2766
 "tower-http",
2767
 "tower-service",
2768
 "url",
2769
 "wasm-bindgen",
2770
 "wasm-bindgen-futures",
2771
 "wasm-streams 0.5.0",
2772
 "web-sys",
2773
]
2774
2775 2592
[[package]]
2776 2593
name = "rfc6979"
2777 2594
version = "0.4.0"

@@ -2811,15 +2628,6 @@ version = "2.1.2"

2811 2628
source = "registry+https://github.com/rust-lang/crates.io-index"
2812 2629
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
2813 2630
2814
[[package]]
2815
name = "rustc_version"
2816
version = "0.4.1"
2817
source = "registry+https://github.com/rust-lang/crates.io-index"
2818
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
2819
dependencies = [
2820
 "semver",
2821
]
2822
2823 2631
[[package]]
2824 2632
name = "rustix"
2825 2633
version = "0.38.44"

@@ -2852,7 +2660,6 @@ version = "0.23.40"

2852 2660
source = "registry+https://github.com/rust-lang/crates.io-index"
2853 2661
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
2854 2662
dependencies = [
2855
 "aws-lc-rs",
2856 2663
 "once_cell",
2857 2664
 "ring",
2858 2665
 "rustls-pki-types",

@@ -2883,40 +2690,12 @@ dependencies = [

2883 2690
 "zeroize",
2884 2691
]
2885 2692
2886
[[package]]
2887
name = "rustls-platform-verifier"
2888
version = "0.7.0"
2889
source = "registry+https://github.com/rust-lang/crates.io-index"
2890
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
2891
dependencies = [
2892
 "core-foundation 0.10.1",
2893
 "core-foundation-sys",
2894
 "jni 0.22.4",
2895
 "log",
2896
 "once_cell",
2897
 "rustls",
2898
 "rustls-native-certs",
2899
 "rustls-platform-verifier-android",
2900
 "rustls-webpki",
2901
 "security-framework",
2902
 "security-framework-sys",
2903
 "webpki-root-certs",
2904
 "windows-sys 0.61.2",
2905
]
2906
2907
[[package]]
2908
name = "rustls-platform-verifier-android"
2909
version = "0.1.1"
2910
source = "registry+https://github.com/rust-lang/crates.io-index"
2911
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
2912
2913 2693
[[package]]
2914 2694
name = "rustls-webpki"
2915 2695
version = "0.103.13"
2916 2696
source = "registry+https://github.com/rust-lang/crates.io-index"
2917 2697
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
2918 2698
dependencies = [
2919
 "aws-lc-rs",
2920 2699
 "ring",
2921 2700
 "rustls-pki-types",
2922 2701
 "untrusted",

@@ -2996,7 +2775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"

2996 2775
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
2997 2776
dependencies = [
2998 2777
 "bitflags 2.11.1",
2999
 "core-foundation 0.10.1",
2778
 "core-foundation",
3000 2779
 "core-foundation-sys",
3001 2780
 "libc",
3002 2781
 "security-framework-sys",

@@ -3184,22 +2963,6 @@ version = "0.3.10"

3184 2963
source = "registry+https://github.com/rust-lang/crates.io-index"
3185 2964
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
3186 2965
3187
[[package]]
3188
name = "simd_cesu8"
3189
version = "1.2.0"
3190
source = "registry+https://github.com/rust-lang/crates.io-index"
3191
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
3192
dependencies = [
3193
 "rustc_version",
3194
 "simdutf8",
3195
]
3196
3197
[[package]]
3198
name = "simdutf8"
3199
version = "0.1.5"
3200
source = "registry+https://github.com/rust-lang/crates.io-index"
3201
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
3202
3203 2966
[[package]]
3204 2967
name = "slab"
3205 2968
version = "0.4.12"

@@ -3364,27 +3127,6 @@ dependencies = [

3364 3127
 "windows 0.61.3",
3365 3128
]
3366 3129
3367
[[package]]
3368
name = "system-configuration"
3369
version = "0.7.0"
3370
source = "registry+https://github.com/rust-lang/crates.io-index"
3371
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
3372
dependencies = [
3373
 "bitflags 2.11.1",
3374
 "core-foundation 0.9.4",
3375
 "system-configuration-sys",
3376
]
3377
3378
[[package]]
3379
name = "system-configuration-sys"
3380
version = "0.6.0"
3381
source = "registry+https://github.com/rust-lang/crates.io-index"
3382
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
3383
dependencies = [
3384
 "core-foundation-sys",
3385
 "libc",
3386
]
3387
3388 3130
[[package]]
3389 3131
name = "target-lexicon"
3390 3132
version = "0.13.5"

@@ -3575,7 +3317,6 @@ dependencies = [

3575 3317
 "bytes",
3576 3318
 "futures-core",
3577 3319
 "futures-sink",
3578
 "libc",
3579 3320
 "pin-project-lite",
3580 3321
 "tokio",
3581 3322
]

@@ -4018,19 +3759,6 @@ dependencies = [

4018 3759
 "web-sys",
4019 3760
]
4020 3761
4021
[[package]]
4022
name = "wasm-streams"
4023
version = "0.5.0"
4024
source = "registry+https://github.com/rust-lang/crates.io-index"
4025
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
4026
dependencies = [
4027
 "futures-util",
4028
 "js-sys",
4029
 "wasm-bindgen",
4030
 "wasm-bindgen-futures",
4031
 "web-sys",
4032
]
4033
4034 3762
[[package]]
4035 3763
name = "wasmparser"
4036 3764
version = "0.236.1"

@@ -4286,15 +4014,6 @@ dependencies = [

4286 4014
 "wasm-bindgen",
4287 4015
]
4288 4016
4289
[[package]]
4290
name = "webpki-root-certs"
4291
version = "1.0.9"
4292
source = "registry+https://github.com/rust-lang/crates.io-index"
4293
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
4294
dependencies = [
4295
 "rustls-pki-types",
4296
]
4297
4298 4017
[[package]]
4299 4018
name = "webpki-roots"
4300 4019
version = "1.0.7"

@@ -4387,7 +4106,7 @@ dependencies = [

4387 4106
 "windows-interface",
4388 4107
 "windows-link 0.1.3",
4389 4108
 "windows-result 0.3.4",
4390
 "windows-strings 0.4.2",
4109
 "windows-strings",
4391 4110
]
4392 4111
4393 4112
[[package]]

@@ -4445,17 +4164,6 @@ dependencies = [

4445 4164
 "windows-link 0.1.3",
4446 4165
]
4447 4166
4448
[[package]]
4449
name = "windows-registry"
4450
version = "0.6.1"
4451
source = "registry+https://github.com/rust-lang/crates.io-index"
4452
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
4453
dependencies = [
4454
 "windows-link 0.2.1",
4455
 "windows-result 0.4.1",
4456
 "windows-strings 0.5.1",
4457
]
4458
4459 4167
[[package]]
4460 4168
name = "windows-result"
4461 4169
version = "0.1.2"

@@ -4474,15 +4182,6 @@ dependencies = [

4474 4182
 "windows-link 0.1.3",
4475 4183
]
4476 4184
4477
[[package]]
4478
name = "windows-result"
4479
version = "0.4.1"
4480
source = "registry+https://github.com/rust-lang/crates.io-index"
4481
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
4482
dependencies = [
4483
 "windows-link 0.2.1",
4484
]
4485
4486 4185
[[package]]
4487 4186
name = "windows-strings"
4488 4187
version = "0.4.2"

@@ -4492,15 +4191,6 @@ dependencies = [

4492 4191
 "windows-link 0.1.3",
4493 4192
]
4494 4193
4495
[[package]]
4496
name = "windows-strings"
4497
version = "0.5.1"
4498
source = "registry+https://github.com/rust-lang/crates.io-index"
4499
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
4500
dependencies = [
4501
 "windows-link 0.2.1",
4502
]
4503
4504 4194
[[package]]
4505 4195
name = "windows-sys"
4506 4196
version = "0.45.0"
crates/coder-lite/Cargo.toml modified +8 -1

@@ -13,10 +13,17 @@ name = "coder-lite"

13 13
path = "src/main.rs"
14 14
15 15
[dependencies]
16
# The capable runtime lives next door and stays there: tools (`shell`,
17
# `skill`, `openagents`, `capability`, `delegate`) with their refusal patterns
18
# and output ceiling, the grok-derived composer, lanes, the live model
19
# catalog, metering, and thread revocation. Taken as a library rather than
20
# copied, so there is one implementation of each and coder-lite cannot drift
21
# from it. What coder-lite owns is the front: the frame, the palette, the
22
# spinner, the system prompt, and the session loop in `interactive.rs`.
23
openagents-cli = { path = "../openagents-cli" }
16 24
tokio = { version = "1", features = ["full"] }
17 25
crossterm = { version = "0.28", features = ["event-stream"] }
18 26
ratatui = { version = "0.29", default-features = false, features = ["crossterm"] }
19
openresponses-rust = "2026.7.26"
20 27
futures = "0.3"
21 28
serde = { workspace = true, features = ["derive"] }
22 29
serde_json = { workspace = true }
crates/coder-lite/src/acp_harness.rs modified +113 -6

@@ -1,15 +1,61 @@

1 1
//! ACP child agent harness for coder-lite.
2 2
//!
3 3
//! Spawns an ACP-compatible CLI agent over stdio and streams JSON-RPC
4
//! `session/update` events as they arrive.
4
//! `session/update` events as they arrive: `initialize`, `session/new`, an
5
//! optional `session/set_mode`, then `session/prompt`. A
6
//! `session/request_permission` the agent sends back is answered without
7
//! asking the reader, preferring whichever option the agent marked `allow*`.
8
//!
9
//! ## The child is stopped with its whole tree
10
//!
11
//! A coding agent shells out. Killing only the agent leaves its build, its
12
//! test run, or its `sleep` behind with nothing left to stop them, so the
13
//! child is spawned into a process group of its own and
14
//! [`openagents_cli::signals::stop_tree`] signals the group — `SIGTERM`, then
15
//! `SIGKILL` after a grace period, so an agent that writes a transcript on the
16
//! way out gets to write it. This used to be a bare `child.kill()`, which
17
//! stopped the agent and orphaned everything under it.
5 18
6 19
use std::path::Path;
7 20
use std::process::Stdio;
8 21
use std::time::Duration;
9 22
23
use openagents_cli::signals::stop_tree;
10 24
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
11 25
use tokio::process::{ChildStdin, ChildStdout, Command};
12 26
27
/// How much the child is allowed to do without being asked.
28
///
29
/// The names are coder-lite's; the wire carries the agent's own. A build of
30
/// the agent that does not know a mode is not a reason to lose the child, so
31
/// setting it is best effort.
32
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33
pub enum PermissionMode {
34
    Dangerous,
35
    Prompt,
36
    ReadOnly,
37
}
38
39
impl PermissionMode {
40
    pub fn parse(name: &str) -> Option<Self> {
41
        match name.trim().to_lowercase().as_str() {
42
            "dangerous" | "bypass" => Some(PermissionMode::Dangerous),
43
            "prompt" | "default" | "ask" => Some(PermissionMode::Prompt),
44
            "read-only" | "readonly" => Some(PermissionMode::ReadOnly),
45
            _ => None,
46
        }
47
    }
48
49
    /// The mode id sent in `session/set_mode`.
50
    pub fn mode_id(self) -> &'static str {
51
        match self {
52
            PermissionMode::Dangerous => "bypass",
53
            PermissionMode::Prompt => "default",
54
            PermissionMode::ReadOnly => "read-only",
55
        }
56
    }
57
}
58
13 59
#[derive(Debug)]
14 60
pub enum AcpFailure {
15 61
    Unstartable(String),

@@ -37,6 +83,9 @@ pub enum AcpEvent {

37 83
pub struct AcpHarness {
38 84
    pub command: String,
39 85
    pub args: Vec<String>,
86
    /// Sent as `session/set_mode` after the session opens. `None` leaves the
87
    /// agent's own default, which is a different answer from naming one.
88
    pub mode: Option<PermissionMode>,
40 89
}
41 90
42 91
impl Default for AcpHarness {

@@ -44,6 +93,7 @@ impl Default for AcpHarness {

44 93
        Self {
45 94
            command: "devin".to_string(),
46 95
            args: vec!["acp".to_string()],
96
            mode: None,
47 97
        }
48 98
    }
49 99
}

@@ -61,12 +111,18 @@ impl AcpHarness {

61 111
    where
62 112
        F: FnMut(AcpEvent) + Send,
63 113
    {
64
        let mut child = Command::new(&self.command)
114
        let mut command = Command::new(&self.command);
115
        command
65 116
            .args(&self.args)
66 117
            .current_dir(cwd)
67 118
            .stdin(Stdio::piped())
68 119
            .stdout(Stdio::piped())
69
            .stderr(Stdio::piped())
120
            .stderr(Stdio::piped());
121
        // Its own process group, so stopping the child stops what the child
122
        // started.
123
        #[cfg(unix)]
124
        command.process_group(0);
125
        let mut child = command
70 126
            .spawn()
71 127
            .map_err(|error| {
72 128
                AcpFailure::Unstartable(if error.kind() == std::io::ErrorKind::NotFound {

@@ -86,7 +142,7 @@ impl AcpHarness {

86 142
        let stdin = child.stdin.take();
87 143
        let stdout = child.stdout.take();
88 144
        let (Some(mut stdin), Some(stdout)) = (stdin, stdout) else {
89
            let _ = child.kill().await;
145
            stop_tree(&mut child).await;
90 146
            return Err(AcpFailure::Refused(
91 147
                "the agent's standard streams could not be opened".to_string(),
92 148
            ));

@@ -97,7 +153,7 @@ impl AcpHarness {

97 153
            .converse(prompt, cwd, &mut stdin, &mut lines, &mut on_event)
98 154
            .await;
99 155
100
        let _ = child.kill().await;
156
        stop_tree(&mut child).await;
101 157
        outcome
102 158
    }
103 159

@@ -151,6 +207,22 @@ impl AcpHarness {

151 207
            id: session_id.clone(),
152 208
        });
153 209
210
        if let Some(mode) = self.mode {
211
            // Best effort: a build of the agent without this mode should not
212
            // cost the child over the name of a permission setting.
213
            let _ = request(
214
                stdin,
215
                lines,
216
                &mut seq,
217
                "session/set_mode",
218
                serde_json::json!({"sessionId": session_id, "modeId": mode.mode_id()}),
219
                REQUEST_TIMEOUT,
220
                &mut answer,
221
                on_event,
222
            )
223
            .await;
224
        }
225
154 226
        request(
155 227
            stdin,
156 228
            lines,

@@ -230,10 +302,16 @@ where

230 302
                continue;
231 303
            }
232 304
            if let Some(error) = message.get("error") {
305
                // The agent's own bytes, and `serde_json` does not escape
306
                // non-ASCII, so a refusal carrying an accent or an emoji
307
                // across byte 200 used to panic here and take the whole
308
                // session with it. Floored to a character boundary, as the
309
                // four cuts in `28704f72ff` were.
233 310
                let text = serde_json::to_string(error).unwrap_or_default();
311
                let end = openagents_cli::tracker::floor_char_boundary(&text, 200);
234 312
                return Err(AcpFailure::Refused(format!(
235 313
                    "the agent refused `{method}`: {}",
236
                    &text[..text.len().min(200)]
314
                    &text[..end]
237 315
                )));
238 316
            }
239 317
            return Ok(message

@@ -359,3 +437,32 @@ fn first_allow_option(params: &serde_json::Value) -> Option<String> {

359 437
        .and_then(|v| v.as_str())
360 438
        .map(String::from)
361 439
}
440
441
#[cfg(test)]
442
mod tests {
443
    /// A refusal carrying a multi-byte character across the 200-byte bound
444
    /// used to panic here, which killed the whole session rather than the one
445
    /// delegation. Same defect class as `28704f72ff`.
446
    #[test]
447
    fn a_long_refusal_with_a_multibyte_character_on_the_bound_does_not_panic() {
448
        // Sized so the multi-byte character straddles byte 200 of the
449
        // *encoded* JSON, not of the message: the envelope counts too, and a
450
        // fixture that guessed the offset proved nothing. Searched rather than
451
        // computed, so a change in how `serde_json` encodes cannot silently
452
        // turn this into a test of a string that was ASCII all along. The
453
        // length check matters as much as the boundary one — `is_char_boundary`
454
        // is also false for an index past the end.
455
        let text = (150..250)
456
            .map(|width| {
457
                let message = format!("{}\u{20ac}", "x".repeat(width));
458
                serde_json::to_string(&serde_json::json!({ "message": message })).unwrap()
459
            })
460
            .find(|text| text.len() > 200 && !text.is_char_boundary(200))
461
            .expect("no width made the character straddle byte 200");
462
463
        let end = openagents_cli::tracker::floor_char_boundary(&text, 200);
464
        let head = &text[..end]; // the old code was `&text[..200]`, which panics
465
        assert!(end < 200);
466
        assert!(head.len() == end);
467
    }
468
}
crates/coder-lite/src/acp_tool.rs added +401

@@ -0,0 +1,401 @@

1
//! The `acp` tool: hand a task to a coding agent installed on this machine.
2
//!
3
//! This is coder-lite's own capability rather than one of the five every
4
//! session has, so it is registered as a [`HostTool`] and answered here. It is
5
//! declared only when [`crate::acp::find_agents`] found at least one agent
6
//! installed, and its `agent` parameter enumerates exactly those — a session
7
//! on a machine with no ACP agent does not see the tool at all, which is the
8
//! difference between a capability and a claim.
9
//!
10
//! It is not `delegate`, and the two are kept apart on purpose. `delegate`
11
//! starts child `openagents` coder agents on this session's own lane and
12
//! budget; `acp` speaks the Agent Client Protocol to a different program — a
13
//! Devin, a Claude Code, whatever the registry found — which brings its own
14
//! credentials and its own bill. A model that could not tell them apart would
15
//! reach for whichever it saw first.
16
//!
17
//! ## One agent per user turn
18
//!
19
//! An exported trajectory showed twenty-four consecutive delegations for a
20
//! single "do a test delegation" message: the model handed the task off, read
21
//! the answer, and handed it off again. Each one is a whole second agent on
22
//! somebody's bill, so the second call in a turn is refused and told why,
23
//! which leaves the model with nothing to do but answer.
24
//!
25
//! This replaces the `tool_choice: none` clamp that did the same job while the
26
//! ACP path was the openresponses runtime's only tool (commit `afea5551fa`).
27
//! The mechanism changed because the turn loop did: the runtime beneath this
28
//! sends no `tool_choice`, and a refusal is the stronger of the two anyway —
29
//! it says what the limit is rather than silently withdrawing the tool, and it
30
//! holds whether or not a deployment honours the field.
31
32
use std::path::PathBuf;
33
use std::sync::Arc;
34
use std::sync::atomic::{AtomicBool, Ordering};
35
36
use openagents_cli::tools::{HostTool, ToolCall, ToolDefinition};
37
38
use crate::acp::Agent;
39
use crate::acp_harness::{AcpEvent, AcpFailure, AcpHarness, PermissionMode};
40
use crate::runtime::{Control, Sink, send};
41
42
/// The name the model calls it by.
43
pub const ACP_TOOL: &str = "acp";
44
45
/// A plan upsell is not work done.
46
///
47
/// An agent that answers "upgrade your plan to continue" has returned a string
48
/// rather than performed the task, and reporting that as a successful tool
49
/// result is how a session comes to believe a file was edited when nothing
50
/// touched it.
51
fn is_refusal(answer: &str) -> bool {
52
    answer.to_lowercase().contains("upgrade your plan to continue")
53
}
54
55
/// The `acp` tool for `agents`, or `None` when none are installed.
56
///
57
/// `spent` is cleared at the top of every user turn by
58
/// [`crate::runtime::Session::execute_turn`]; see the module header.
59
pub fn acp_host_tool(
60
    agents: Vec<Agent>,
61
    cwd: PathBuf,
62
    sink: Sink,
63
    spent: Arc<AtomicBool>,
64
) -> Option<HostTool> {
65
    if agents.is_empty() {
66
        return None;
67
    }
68
69
    let ids: Vec<String> = agents.iter().map(|agent| agent.id.clone()).collect();
70
    let listed = agents
71
        .iter()
72
        .map(|agent| format!("`{}` ({})", agent.id, agent.name))
73
        .collect::<Vec<_>>()
74
        .join(", ");
75
76
    let definition = ToolDefinition {
77
        name: ACP_TOOL.to_string(),
78
        description: format!(
79
            "Hand one self-contained task to a coding agent installed on this machine, over the \
80
             Agent Client Protocol, and return what it answered. The agent runs in {} with its \
81
             own tools, its own credentials, and no context from this conversation, so the prompt \
82
             has to say everything it needs: the files, the command, and what to report back. It \
83
             cannot ask a question. Installed here: {}. Prefer `shell` for a single command — an \
84
             ACP agent is for work worth a whole agent.",
85
            cwd.display(),
86
            listed
87
        ),
88
        parameters: serde_json::json!({
89
            "type": "object",
90
            "properties": {
91
                "agent": {
92
                    "type": "string",
93
                    "enum": ids,
94
                    "description": "Which installed agent performs the task."
95
                },
96
                "prompt": {
97
                    "type": "string",
98
                    "description": "The complete, self-contained instruction the agent performs."
99
                },
100
                "mode": {
101
                    "type": "string",
102
                    "enum": ["read-only", "prompt", "dangerous"],
103
                    "description": "How much the agent may do unattended. Omit to leave the agent's own default; `read-only` for a look that changes nothing."
104
                }
105
            },
106
            "required": ["agent", "prompt"]
107
        }),
108
    };
109
110
    let run = Arc::new(move |call: &ToolCall| {
111
        let agents = agents.clone();
112
        let cwd = cwd.clone();
113
        let sink = Arc::clone(&sink);
114
        let spent = Arc::clone(&spent);
115
        let call_id = call.id.clone();
116
        let arguments = call.arguments.clone();
117
        let future = async move {
118
            // Claimed before anything is checked, so a malformed second call
119
            // cannot spend the turn's one delegation on an error message.
120
            if spent.swap(true, Ordering::SeqCst) {
121
                return (
122
                    "This turn has already handed work to an agent, and one is the limit: a \
123
                     second agent is a second bill for the same request. Answer with what the \
124
                     first one returned, or ask for another turn."
125
                        .to_string(),
126
                    true,
127
                );
128
            }
129
            let string = |key: &str| {
130
                arguments
131
                    .get(key)
132
                    .and_then(|v| v.as_str())
133
                    .map(str::trim)
134
                    .filter(|v| !v.is_empty())
135
                    .map(str::to_string)
136
            };
137
138
            let Some(wanted) = string("agent") else {
139
                return (
140
                    format!(
141
                        "No agent was named. `agent` is required and must be one of: {}.",
142
                        agents
143
                            .iter()
144
                            .map(|a| a.id.as_str())
145
                            .collect::<Vec<_>>()
146
                            .join(", ")
147
                    ),
148
                    true,
149
                );
150
            };
151
            let Some(agent) = agents.iter().find(|a| a.id == wanted).cloned() else {
152
                return (
153
                    format!(
154
                        "No agent named `{wanted}` is installed here. Installed: {}.",
155
                        agents
156
                            .iter()
157
                            .map(|a| a.id.as_str())
158
                            .collect::<Vec<_>>()
159
                            .join(", ")
160
                    ),
161
                    true,
162
                );
163
            };
164
            let Some(prompt) = string("prompt") else {
165
                return (
166
                    "No task was given. `prompt` is required and must say what the agent does."
167
                        .to_string(),
168
                    true,
169
                );
170
            };
171
            // A mode this build does not know is refused by name rather than
172
            // quietly dropped: the reader asked for read-only and would
173
            // otherwise get whatever the agent's default is.
174
            let mode = match string("mode") {
175
                None => None,
176
                Some(named) => match PermissionMode::parse(&named) {
177
                    Some(mode) => Some(mode),
178
                    None => {
179
                        return (
180
                            format!(
181
                                "`{named}` is not a mode. Use `read-only`, `prompt`, or \
182
                                 `dangerous`, or omit it for the agent's own default."
183
                            ),
184
                            true,
185
                        )
186
                    }
187
                },
188
            };
189
190
            let streaming = Arc::clone(&sink);
191
            let id = call_id.clone();
192
            let result = AcpHarness {
193
                command: agent.command,
194
                args: agent.args,
195
                mode,
196
            }
197
            .run(&prompt, &cwd, move |event| {
198
                // What the child is doing, into the box under its header,
199
                // while it is still doing it.
200
                let chunk = match event {
201
                    AcpEvent::Text { chunk } => chunk,
202
                    AcpEvent::Tool { kind, title } => format!("[{kind}] {title}\n"),
203
                    AcpEvent::Tokens { input, output } => {
204
                        format!("[{input} in / {output} out tokens]\n")
205
                    }
206
                    AcpEvent::Session { .. } => return,
207
                };
208
                send(
209
                    &streaming,
210
                    Control::ToolOutput {
211
                        call_id: id.clone(),
212
                        chunk,
213
                    },
214
                );
215
            })
216
            .await;
217
218
            match result {
219
                Ok(answer) if is_refusal(&answer) => (
220
                    format!("`{wanted}` refused the task rather than doing it: {answer}"),
221
                    true,
222
                ),
223
                Ok(answer) if answer.trim().is_empty() => (
224
                    format!("`{wanted}` finished and said nothing."),
225
                    false,
226
                ),
227
                Ok(answer) => (answer, false),
228
                Err(AcpFailure::Unstartable(why)) => {
229
                    (format!("`{wanted}` could not be started: {why}"), true)
230
                }
231
                Err(AcpFailure::Refused(why)) => {
232
                    (format!("`{wanted}` did not finish the task: {why}"), true)
233
                }
234
            }
235
        };
236
        Box::pin(future) as std::pin::Pin<Box<dyn std::future::Future<Output = (String, bool)> + Send>>
237
    });
238
239
    Some(HostTool { definition, run })
240
}
241
242
#[cfg(test)]
243
mod tests {
244
    use super::*;
245
    use std::sync::Mutex;
246
    use std::sync::mpsc;
247
248
    fn agent(id: &str) -> Agent {
249
        Agent {
250
            id: id.to_string(),
251
            name: id.to_string(),
252
            command: "definitely-not-a-real-binary-xyz".to_string(),
253
            args: vec!["acp".to_string()],
254
        }
255
    }
256
257
    fn sink() -> (Sink, mpsc::Receiver<Control>) {
258
        let (tx, rx) = mpsc::channel();
259
        (Arc::new(Mutex::new(tx)), rx)
260
    }
261
262
    /// A tool whose one delegation has not been spent yet.
263
    fn fresh(agents: Vec<Agent>, sink: Sink) -> Option<HostTool> {
264
        acp_host_tool(
265
            agents,
266
            std::env::temp_dir(),
267
            sink,
268
            Arc::new(AtomicBool::new(false)),
269
        )
270
    }
271
272
    fn call(arguments: serde_json::Value) -> ToolCall {
273
        ToolCall {
274
            id: "1".to_string(),
275
            name: "acp".to_string(),
276
            arguments,
277
        }
278
    }
279
280
    /// A machine with no ACP agent installed does not get the tool. The
281
    /// alternative is a tool whose `agent` enum is empty, which a model can
282
    /// call and nothing can answer.
283
    #[test]
284
    fn no_installed_agent_means_no_tool() {
285
        let (sink, _rx) = sink();
286
        assert!(fresh(Vec::new(), sink).is_none());
287
    }
288
289
    #[test]
290
    fn the_declaration_enumerates_exactly_the_installed_agents() {
291
        let (sink, _rx) = sink();
292
        let tool = fresh(vec![agent("devin"), agent("grok-build")], sink).expect("declared");
293
        assert_eq!(tool.definition.name, "acp");
294
        let ids = &tool.definition.parameters["properties"]["agent"]["enum"];
295
        assert_eq!(ids, &serde_json::json!(["devin", "grok-build"]));
296
        assert!(tool.definition.description.contains("`devin`"));
297
    }
298
299
    #[tokio::test]
300
    async fn an_agent_that_is_not_installed_is_refused_by_name() {
301
        let (sink, _rx) = sink();
302
        let tool = fresh(vec![agent("devin")], sink).expect("declared");
303
        let (output, is_error) = (tool.run)(&ToolCall {
304
            id: "1".to_string(),
305
            name: "acp".to_string(),
306
            arguments: serde_json::json!({"agent": "nobody", "prompt": "do it"}),
307
        })
308
        .await;
309
        assert!(is_error);
310
        assert!(output.contains("nobody"), "{output}");
311
        assert!(output.contains("devin"), "{output}");
312
    }
313
314
    #[tokio::test]
315
    async fn a_mode_this_build_does_not_know_is_refused_rather_than_dropped() {
316
        let (sink, _rx) = sink();
317
        let tool = fresh(vec![agent("devin")], sink).expect("declared");
318
        let (output, is_error) = (tool.run)(&ToolCall {
319
            id: "1".to_string(),
320
            name: "acp".to_string(),
321
            arguments: serde_json::json!({"agent": "devin", "prompt": "look", "mode": "whatever"}),
322
        })
323
        .await;
324
        assert!(is_error);
325
        assert!(output.contains("read-only"), "{output}");
326
    }
327
328
    /// An agent that is not on PATH is said to be missing. It used to be
329
    /// reported to the model as the tool's answer.
330
    #[tokio::test]
331
    async fn an_agent_that_will_not_start_is_an_error_and_says_why() {
332
        let (sink, _rx) = sink();
333
        let tool = fresh(vec![agent("devin")], sink).expect("declared");
334
        let (output, is_error) = (tool.run)(&ToolCall {
335
            id: "1".to_string(),
336
            name: "acp".to_string(),
337
            arguments: serde_json::json!({"agent": "devin", "prompt": "do it"}),
338
        })
339
        .await;
340
        assert!(is_error, "{output}");
341
        assert!(output.contains("could not be started"), "{output}");
342
    }
343
344
    /// Twenty-four consecutive delegations for one message is what this
345
    /// exists to stop; the mechanism it replaces was `tool_choice: none`
346
    /// (commit `afea5551fa`).
347
    #[tokio::test]
348
    async fn only_one_agent_is_handed_work_per_turn() {
349
        let (sink, _rx) = sink();
350
        let spent = Arc::new(AtomicBool::new(false));
351
        let tool = acp_host_tool(
352
            vec![agent("devin")],
353
            std::env::temp_dir(),
354
            sink,
355
            Arc::clone(&spent),
356
        )
357
        .expect("declared");
358
359
        // The first call is let through — it fails only because the stand-in
360
        // binary does not exist, which is a different refusal.
361
        let (first, _) = (tool.run)(&call(
362
            serde_json::json!({"agent": "devin", "prompt": "do it"}),
363
        ))
364
        .await;
365
        assert!(first.contains("could not be started"), "{first}");
366
367
        let (second, is_error) = (tool.run)(&call(
368
            serde_json::json!({"agent": "devin", "prompt": "again"}),
369
        ))
370
        .await;
371
        assert!(is_error, "{second}");
372
        assert!(second.contains("already handed work"), "{second}");
373
        assert!(second.contains("one is the limit"), "{second}");
374
375
        // A new turn clears it.
376
        spent.store(false, Ordering::SeqCst);
377
        let (third, _) = (tool.run)(&call(
378
            serde_json::json!({"agent": "devin", "prompt": "next turn"}),
379
        ))
380
        .await;
381
        assert!(third.contains("could not be started"), "{third}");
382
    }
383
384
    /// A malformed second call must not be the one that gets through.
385
    #[tokio::test]
386
    async fn a_refused_second_call_is_refused_before_its_arguments_are_read() {
387
        let (sink, _rx) = sink();
388
        let spent = Arc::new(AtomicBool::new(true));
389
        let tool = acp_host_tool(vec![agent("devin")], std::env::temp_dir(), sink, spent)
390
            .expect("declared");
391
        let (output, is_error) = (tool.run)(&call(serde_json::json!({}))).await;
392
        assert!(is_error);
393
        assert!(output.contains("already handed work"), "{output}");
394
    }
395
396
    #[test]
397
    fn a_plan_upsell_is_not_a_completed_task() {
398
        assert!(is_refusal("Please upgrade your plan to continue."));
399
        assert!(!is_refusal("Done. Edited src/main.rs."));
400
    }
401
}
crates/coder-lite/src/commands.rs added +405

@@ -0,0 +1,405 @@

1
//! The session's own commands: everything typed into the composer that starts
2
//! with a `/`.
3
//!
4
//! [`COMMANDS`] is one list read by three things — `/help`, Tab completion,
5
//! and the dispatch in [`run`] — so a command cannot be listed without being
6
//! handled, and `every_listed_command_is_handled` in
7
//! [`crate::interactive`] fails if one ever is. A `/` line whose name is not
8
//! in the list is refused rather than sent to the model: a mistyped `/diff`
9
//! that silently became a prompt is a worse answer than being told.
10
//!
11
//! None of these reaches a model. They run here, print into the transcript as
12
//! [`Role::Output`], and are exported as notices rather than as model steps.
13
14
use std::path::Path;
15
use std::sync::mpsc::Sender;
16
17
use openagents_cli::tools::{OUTPUT_LIMIT, check_shell_refusal};
18
19
use crate::runtime::Control;
20
use crate::tui::{CoderUi, Entry, Role, ToolCall};
21
22
/// The commands, and what each one does. The second column is what `/help`
23
/// prints, so it says what the command actually does and nothing more.
24
pub const COMMANDS: &[(&str, &str)] = &[
25
    ("clear", "clear the transcript"),
26
    (
27
        "diff",
28
        "what changed since HEAD: /diff, /diff --staged, /diff <path>…",
29
    ),
30
    (
31
        "export",
32
        "write the transcript to ~/.openagents/exports as an ATIF document",
33
    ),
34
    ("help", "list these commands and the keys"),
35
    (
36
        "resume",
37
        "coding-agent sessions other tools left on this machine: /resume, /resume <number>",
38
    ),
39
    (
40
        "run",
41
        "run a command here and show its output: /run cargo test",
42
    ),
43
];
44
45
/// The keys the frame handles. Listed by `/help`, and every one of them is
46
/// wired in [`crate::interactive`] — this is the hint text, and a key in it
47
/// that does nothing is the defect this list exists to prevent.
48
const KEYS: &[(&str, &str)] = &[
49
    ("Enter", "send"),
50
    ("Alt+Enter / Ctrl+J", "newline"),
51
    ("Up / Down", "move the caret, then walk history, then scroll"),
52
    ("PageUp / PageDown", "scroll the transcript"),
53
    ("Tab", "complete a command or a path"),
54
    (
55
        "Ctrl+A / Ctrl+E / Ctrl+W / Ctrl+K / Ctrl+U / Alt+B / Alt+F",
56
        "edit the line",
57
    ),
58
    ("Esc / Ctrl+C / Ctrl+D", "leave, revoking the thread"),
59
];
60
61
/// The command names, for Tab completion.
62
pub fn names() -> Vec<&'static str> {
63
    COMMANDS.iter().map(|(name, _)| *name).collect()
64
}
65
66
/// Whether `name` is one this module runs.
67
pub fn handles(name: &str) -> bool {
68
    matches!(name, "clear" | "diff" | "export" | "help" | "resume" | "run")
69
}
70
71
/// Run one `/` line. `line` still carries its leading slash.
72
pub fn run(ui: &mut CoderUi, line: &str, tx: &Sender<Control>, cwd: &Path) {
73
    let body = line.trim_start_matches('/');
74
    let mut words = body.split_whitespace();
75
    let Some(name) = words.next() else {
76
        output(ui, "A command needs a name. Try `/help`.");
77
        return;
78
    };
79
    let arguments: Vec<String> = words.map(str::to_string).collect();
80
    let rest = body[name.len()..].trim().to_string();
81
82
    match name {
83
        "help" => output(ui, &help()),
84
        "clear" => {
85
            ui.entries.clear();
86
            ui.scroll_override = None;
87
        }
88
        "export" => crate::interactive::export(ui),
89
        "diff" => spawn_diff(ui, arguments, tx, cwd),
90
        "run" => spawn_run(ui, &rest, tx, cwd),
91
        "resume" => spawn_resume(ui, &arguments, tx, cwd),
92
        other => output(
93
            ui,
94
            &format!("There is no `/{other}`. `/help` lists the commands."),
95
        ),
96
    }
97
}
98
99
fn output(ui: &mut CoderUi, text: &str) {
100
    ui.entries.push(Entry::new(Role::Output, text));
101
    ui.scroll_override = None;
102
}
103
104
fn help() -> String {
105
    let mut lines = vec!["**Commands**".to_string(), String::new()];
106
    for (name, what) in COMMANDS {
107
        lines.push(format!("- `/{name}` — {what}"));
108
    }
109
    lines.push(String::new());
110
    lines.push("**Keys**".to_string());
111
    lines.push(String::new());
112
    for (key, what) in KEYS {
113
        lines.push(format!("- `{key}` — {what}"));
114
    }
115
    lines.join("\n")
116
}
117
118
/// The call id a command's own output box is filed under.
119
fn command_call_id(ui: &CoderUi) -> String {
120
    format!("command-{}", ui.entries.len())
121
}
122
123
// ────────────────────────────────────────────────────────────────── /diff
124
125
fn spawn_diff(_ui: &mut CoderUi, arguments: Vec<String>, tx: &Sender<Control>, cwd: &Path) {
126
    let tx = tx.clone();
127
    let cwd = cwd.to_path_buf();
128
    tokio::spawn(async move {
129
        let text = match openagents_cli::interactive::collect_diff(&arguments, &cwd).await {
130
            Err(why) => why,
131
            Ok(files) if files.is_empty() => "Nothing has changed.".to_string(),
132
            Ok(files) => render_diff(&files),
133
        };
134
        let _ = tx.send(Control::Output(text));
135
    });
136
}
137
138
/// A diff as markdown: a summary line per file, then the unified body in a
139
/// fenced block so the transcript's own renderer highlights it in the palette
140
/// it highlights everything else in.
141
fn render_diff(files: &[openagents_cli::diff::FileDiff]) -> String {
142
    use openagents_cli::diff::Tag;
143
144
    let mut summary = Vec::new();
145
    let mut body = Vec::new();
146
    for file in files {
147
        let (added, removed) = file.stats();
148
        let named = match &file.renamed_from {
149
            Some(from) => format!("{from} → {}", file.path),
150
            None => file.path.clone(),
151
        };
152
        summary.push(format!("- `{named}` +{added} −{removed}"));
153
154
        body.push(format!("--- a/{}", file.renamed_from.as_ref().unwrap_or(&file.path)));
155
        body.push(format!("+++ b/{}", file.path));
156
        if let Some(note) = &file.note {
157
            body.push(format!("# {note}"));
158
            continue;
159
        }
160
        for hunk in &file.hunks {
161
            body.push(hunk.header());
162
            for line in &hunk.lines {
163
                let marker = match line.tag {
164
                    Tag::Insert => '+',
165
                    Tag::Delete => '-',
166
                    Tag::Equal => ' ',
167
                };
168
                body.push(format!("{marker}{}", line.text));
169
            }
170
        }
171
    }
172
173
    let mut out = summary.join("\n");
174
    out.push_str("\n\n```diff\n");
175
    out.push_str(&bounded(body.join("\n")));
176
    out.push_str("\n```");
177
    out
178
}
179
180
/// The same ceiling the `shell` tool holds its output to, for the same reason:
181
/// a diff of a vendored directory is megabytes and the frame has to stay up.
182
fn bounded(text: String) -> String {
183
    if text.len() <= OUTPUT_LIMIT {
184
        return text;
185
    }
186
    // A diff is the repository's own bytes. Floored to a character boundary
187
    // rather than sliced at a byte index, which is the defect `c48fa5b138`
188
    // and `28704f72ff` went through this tree removing.
189
    let cut = openagents_cli::tracker::floor_char_boundary(&text, OUTPUT_LIMIT);
190
    format!(
191
        "{}\n[truncated: {} characters, limit is {OUTPUT_LIMIT}]",
192
        &text[..cut],
193
        text.len()
194
    )
195
}
196
197
// ─────────────────────────────────────────────────────────────────── /run
198
199
/// Run a command here and stream its output into a box.
200
///
201
/// Not a pseudoterminal: there is no pane to attach to and nothing takes keys
202
/// while it runs, so it is the same non-interactive spawn the `shell` tool
203
/// makes, held to the same refusal list and the same output ceiling. `/help`
204
/// says "run a command here and show its output" rather than anything about a
205
/// terminal, because that is what it does.
206
fn spawn_run(ui: &mut CoderUi, command: &str, tx: &Sender<Control>, cwd: &Path) {
207
    if command.is_empty() {
208
        output(ui, "`/run` needs a command: `/run git status`.");
209
        return;
210
    }
211
    if let Some(refusal) = check_shell_refusal(command) {
212
        output(ui, &refusal);
213
        return;
214
    }
215
216
    let call_id = command_call_id(ui);
217
    let mut entry = Entry::tool_call(format!("run {command}"));
218
    entry.tool = Some(ToolCall {
219
        call_id: call_id.clone(),
220
        function_name: "run".to_string(),
221
        arguments: serde_json::json!({ "command": command }),
222
        output: None,
223
        error: None,
224
    });
225
    ui.entries.push(entry);
226
    ui.scroll_override = None;
227
228
    let tx = tx.clone();
229
    let cwd = cwd.to_path_buf();
230
    let command = command.to_string();
231
    tokio::spawn(async move {
232
        use tokio::io::AsyncReadExt;
233
234
        let mut spawn = tokio::process::Command::new("/bin/sh");
235
        spawn
236
            .arg("-c")
237
            .arg(&command)
238
            .current_dir(&cwd)
239
            .stdout(std::process::Stdio::piped())
240
            .stderr(std::process::Stdio::piped())
241
            .kill_on_drop(true);
242
        #[cfg(unix)]
243
        spawn.process_group(0);
244
245
        let mut child = match spawn.spawn() {
246
            Ok(child) => child,
247
            Err(error) => {
248
                let _ = tx.send(Control::ToolOutput {
249
                    call_id: call_id.clone(),
250
                    chunk: format!("could not start it: {error}"),
251
                });
252
                let _ = tx.send(Control::ToolDone {
253
                    call_id,
254
                    is_error: true,
255
                });
256
                return;
257
            }
258
        };
259
260
        let mut stdout = child.stdout.take();
261
        let mut stderr = child.stderr.take();
262
        let mut printed = 0usize;
263
        let mut buffer = [0u8; 4096];
264
        loop {
265
            let read = match (&mut stdout, &mut stderr) {
266
                (Some(out), _) => out.read(&mut buffer).await,
267
                (None, Some(err)) => err.read(&mut buffer).await,
268
                (None, None) => break,
269
            };
270
            match read {
271
                Ok(0) => {
272
                    // stdout first, then stderr, then done.
273
                    if stdout.is_some() {
274
                        stdout = None;
275
                    } else {
276
                        stderr = None;
277
                    }
278
                }
279
                Ok(n) => {
280
                    if printed < OUTPUT_LIMIT {
281
                        let chunk = String::from_utf8_lossy(&buffer[..n]).to_string();
282
                        printed += n;
283
                        let _ = tx.send(Control::ToolOutput {
284
                            call_id: call_id.clone(),
285
                            chunk,
286
                        });
287
                    }
288
                }
289
                Err(_) => break,
290
            }
291
        }
292
293
        let status = child.wait().await;
294
        let (note, failed) = match status {
295
            Ok(status) if status.success() => (String::new(), false),
296
            Ok(status) => (
297
                format!("\n[exited with code {}]", status.code().unwrap_or(1)),
298
                true,
299
            ),
300
            Err(error) => (format!("\n[it did not finish: {error}]"), true),
301
        };
302
        if !note.is_empty() {
303
            let _ = tx.send(Control::ToolOutput {
304
                call_id: call_id.clone(),
305
                chunk: note,
306
            });
307
        }
308
        let _ = tx.send(Control::ToolDone {
309
            call_id,
310
            is_error: failed,
311
        });
312
    });
313
}
314
315
// ──────────────────────────────────────────────────────────────── /resume
316
317
fn spawn_resume(ui: &mut CoderUi, arguments: &[String], tx: &Sender<Control>, cwd: &Path) {
318
    // A bare `/resume` lists; `/resume <n>` picks. A word that is not a
319
    // positive number is refused rather than read as a list request, because
320
    // silently listing after a mistyped pick is how someone resumes the wrong
321
    // session.
322
    let selection = match arguments.first() {
323
        None => None,
324
        Some(word) => match word.parse::<usize>() {
325
            Ok(number) if number >= 1 => Some(number),
326
            _ => {
327
                output(
328
                    ui,
329
                    &format!(
330
                        "`/resume` takes a number from the list: `/resume 1`. `{word}` is not one."
331
                    ),
332
                );
333
                return;
334
            }
335
        },
336
    };
337
338
    let tx = tx.clone();
339
    let cwd = cwd.to_path_buf();
340
    tokio::spawn(async move {
341
        // The scan compiles and runs a wasm guest and walks two state
342
        // directories, all of it synchronous. On a blocking thread so the
343
        // frame keeps drawing while it works.
344
        let home = openagents_cli::auth::home_directory();
345
        let scanned = tokio::task::spawn_blocking(move || {
346
            openagents_cli::foreign_resume::foreign_resume_turn(&cwd, &home, selection)
347
        })
348
        .await;
349
        let text = match scanned {
350
            Ok(text) => text,
351
            Err(error) => format!("The scan did not finish: {error}"),
352
        };
353
        let _ = tx.send(Control::Output(text));
354
    });
355
}
356
357
#[cfg(test)]
358
mod tests {
359
    use super::*;
360
361
    #[test]
362
    fn help_lists_every_command_and_nothing_else() {
363
        let text = help();
364
        for (name, what) in COMMANDS {
365
            assert!(text.contains(&format!("`/{name}`")), "{text}");
366
            assert!(text.contains(what), "{text}");
367
        }
368
    }
369
370
    /// A key in the hint text that nothing handles is the defect the rule in
371
    /// issue #105 is about. These are the ones `interactive` wires.
372
    #[test]
373
    fn the_key_hints_name_only_keys_the_session_handles() {
374
        let listed: Vec<&str> = KEYS.iter().map(|(key, _)| *key).collect();
375
        assert!(listed.contains(&"Enter"));
376
        assert!(listed.contains(&"Tab"));
377
        assert!(listed.contains(&"Esc / Ctrl+C / Ctrl+D"));
378
        // Nothing about a pane, a diff inspector, or a detach key: none of
379
        // those exist here.
380
        let text = help();
381
        for absent in ["Ctrl+]", "detach", "inspector"] {
382
            assert!(!text.contains(absent), "`{absent}` is claimed: {text}");
383
        }
384
    }
385
386
    #[test]
387
    fn a_diff_renders_as_a_summary_and_a_fenced_body() {
388
        let files = openagents_cli::diff::parse_unified(
389
            "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1,2 +1,2 @@\n line\n-old\n+new\n",
390
        );
391
        assert!(!files.is_empty(), "the fixture parsed to nothing");
392
        let text = render_diff(&files);
393
        assert!(text.contains("`a.txt` +1 −1"), "{text}");
394
        assert!(text.contains("```diff"), "{text}");
395
        assert!(text.contains("+new"), "{text}");
396
        assert!(text.contains("-old"), "{text}");
397
    }
398
399
    #[test]
400
    fn a_long_diff_is_cut_at_the_same_ceiling_the_shell_tool_uses() {
401
        let text = bounded("x".repeat(OUTPUT_LIMIT + 100));
402
        assert!(text.contains("truncated"), "it was not cut");
403
        assert!(text.len() < OUTPUT_LIMIT + 200);
404
    }
405
}
crates/coder-lite/src/export.rs modified +20 -3

@@ -10,7 +10,17 @@ use std::time::{SystemTime, UNIX_EPOCH};

10 10
11 11
const SCHEMA_VERSION: &str = "ATIF-v1.7";
12 12
const AGENT_NAME: &str = "openagents-coder";
13
const INTERFACE_COMMANDS: &[&str] = &["/export", "/system", "/skills"];
13
/// Lines that are the reader talking to the session rather than to a model.
14
///
15
/// They are typed into the composer like a prompt and look like one in the
16
/// transcript, but no model ever saw them, so a trajectory that recorded them
17
/// as user turns would be describing a conversation that did not happen. The
18
/// list is matched on the whole line for the commands that take no argument
19
/// and on the first word for the ones that do.
20
const INTERFACE_COMMANDS: &[&str] = &["/export", "/system", "/skills", "/help", "/clear"];
21
22
/// The same, for commands that carry arguments: `/diff HEAD`, `/run cargo test`.
23
const INTERFACE_COMMAND_PREFIXES: &[&str] = &["/diff", "/run", "/resume", "/export"];
14 24
15 25
pub struct ExportedTrajectory {
16 26
    pub path: String,

@@ -130,7 +140,11 @@ pub fn git_info() -> Option<(String, String)> {

130 140
131 141
fn is_interface_command(text: &str) -> bool {
132 142
    let t = text.trim();
133
    INTERFACE_COMMANDS.iter().any(|cmd| t == *cmd)
143
    if INTERFACE_COMMANDS.iter().any(|cmd| t == *cmd) {
144
        return true;
145
    }
146
    let first = t.split_whitespace().next().unwrap_or_default();
147
    INTERFACE_COMMAND_PREFIXES.iter().any(|cmd| first == *cmd)
134 148
}
135 149
136 150
fn step_of(

@@ -201,7 +215,10 @@ pub fn export_trajectory(

201 215
202 216
    for (i, entry) in entries.iter().enumerate() {
203 217
        if let Some(notice) = match entry.role {
204
            Role::Notice => Some(json!({
218
            // What the session said, and what its own commands printed. Both
219
            // are notices rather than steps: no model produced either, and
220
            // `step_of` leaves both out of `steps` for the same reason.
221
            Role::Notice | Role::Output => Some(json!({
205 222
                "timestamp": iso_for_ms(entry.at),
206 223
                "text": entry.text,
207 224
            })),
crates/coder-lite/src/interactive.rs modified +399 -190

@@ -1,12 +1,21 @@

1
//! Interactive coder-lite TUI session
1
//! The interactive coder-lite session.
2
//!
3
//! One session, one thread, one frame. The session is
4
//! [`crate::runtime::Session`] and it is held for the life of the process
5
//! rather than built per turn — a session rebuilt on every Enter forgets the
6
//! conversation and leaves a thread open behind it, which is what this used to
7
//! do — and it is revoked on the way out.
2 8
//!
3 9
//! Key handling follows the pattern used in grok-build's ratatui-textarea and
4 10
//! grok-pager: destructure `crossterm::event::KeyEvent` by `code` and
5 11
//! `modifiers` so control chords do not fall through to plain character input.
12
//! Everything the composer claims is either handled here or handed to
13
//! [`openagents_cli::composer`], which handles it; `/help` lists exactly that
14
//! set and nothing else.
6 15
7
use crate::acp;
16
use crate::commands;
8 17
use crate::export::{export_trajectory, git_info};
9
use crate::runtime::{CoderRuntimeSession, Control};
18
use crate::runtime::{Control, Session, tool_title};
10 19
use crate::tui::{CoderUi, Entry, Role, ToolCall};
11 20
use crossterm::{
12 21
    ExecutableCommand,

@@ -16,19 +25,90 @@ use crossterm::{

16 25
    },
17 26
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
18 27
};
28
use openagents_cli::composer::complete::{Completion, complete};
29
use openagents_cli::composer::history::History;
30
use openagents_cli::composer::ComposerAction;
31
use openagents_cli::runtime::Lane;
19 32
use ratatui::Terminal;
20 33
use ratatui::backend::CrosstermBackend;
21
use std::env;
22 34
use std::io::{stderr, stdout};
23
use std::sync::mpsc;
35
use std::path::PathBuf;
36
use std::sync::Arc;
37
use std::sync::mpsc::{self, Sender};
24 38
use std::time::Duration;
39
use tokio::sync::Mutex;
40
41
/// How long the exit waits for a thread to be revoked before it says it could
42
/// not be. A turn still streaming would otherwise hold the exit for as long as
43
/// the model wants.
44
const REVOCATION_GRACE: Duration = Duration::from_secs(10);
45
46
/// What `--lane` and `--reasoning` settled on before the screen was entered.
47
#[derive(Debug, Clone, Default)]
48
pub struct SessionOptions {
49
    /// The lane name as it was typed, for the delegation gate and refusals.
50
    pub lane_name: String,
51
    /// `--reasoning`, recorded on the thread at open. `None` leaves the
52
    /// deployment's own default, which is a different answer from naming one.
53
    pub reasoning: Option<String>,
54
}
25 55
26
pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {
56
pub async fn run_tui(options: SessionOptions) -> Result<(), Box<dyn std::error::Error>> {
27 57
    if !atty_is_terminal() {
28 58
        println!("Non-interactive terminal detected. Run coder-lite from a TTY.");
29 59
        return Ok(());
30 60
    }
31 61
62
    let lane = Lane::from_str(&options.lane_name);
63
    let (tx, rx) = mpsc::channel::<Control>();
64
    let mut ui = CoderUi::new();
65
    let mut history = History::load(History::default_path());
66
67
    let (repo, branch) = git_info().unwrap_or(("unknown".to_string(), "unknown".to_string()));
68
    ui.repo = repo;
69
    ui.branch = branch;
70
    ui.reasoning = options.reasoning.clone();
71
72
    // Only agents that are actually installed. `find_agents` checks each one
73
    // before reporting it, so the `acp` tool is declared over a list of
74
    // programs that exist on this machine rather than a registry's wish list.
75
    let agents = crate::acp::find_agents().await.unwrap_or_default();
76
    let acp_line = if agents.is_empty() {
77
        "no ACP agents installed".to_string()
78
    } else {
79
        format!(
80
            "acp: {}",
81
            agents
82
                .iter()
83
                .map(|a| a.id.as_str())
84
                .collect::<Vec<_>>()
85
                .join(", ")
86
        )
87
    };
88
    ui.agents = agents.clone();
89
90
    let session = Arc::new(Mutex::new(Session::open(
91
        lane.clone(),
92
        &options.lane_name,
93
        options.reasoning.clone(),
94
        agents,
95
        tx.clone(),
96
    )));
97
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
98
99
    // What the session is, said once, from what it actually holds. The model
100
    // is not named here: no model has answered yet, and naming the lane's
101
    // preferred id as though it had is exactly the class of claim this UI must
102
    // not make.
103
    ui.entries.push(Entry::new(
104
        Role::Notice,
105
        format!(
106
            "{} · {} · {acp_line} · /help",
107
            lane.label(),
108
            crate::runtime::api_base()
109
        ),
110
    ));
111
32 112
    enable_raw_mode()?;
33 113
    let mut stdout = stdout();
34 114
    stdout.execute(EnterAlternateScreen)?;

@@ -43,103 +123,9 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

43 123
    let mut terminal = Terminal::new(backend)?;
44 124
    terminal.show_cursor()?;
45 125
46
    let (tx, rx) = mpsc::channel::<Control>();
47
    let mut ui = CoderUi::new();
48
49
    match acp::find_agents().await {
50
        Ok(agents) => {
51
            let list = agents
52
                .iter()
53
                .map(|a| a.id.as_str())
54
                .collect::<Vec<_>>()
55
                .join(", ");
56
            ui.entries.push(Entry::new(
57
                Role::Notice,
58
                format!("found ACP agents: {}", list),
59
            ));
60
            ui.agents = agents;
61
        }
62
        Err(_) => {
63
            ui.entries
64
                .push(Entry::new(Role::Notice, "found ACP agents: none"));
65
        }
66
    }
67
68
    let (repo, branch) = git_info().unwrap_or(("unknown".to_string(), "unknown".to_string()));
69
    ui.repo = repo;
70
    ui.branch = branch;
71
    ui.model = env::var("OPENAGENTS_MODEL").unwrap_or_default();
72
73 126
    loop {
74 127
        while let Ok(control) = rx.try_recv() {
75
            match control {
76
                Control::Chunk(chunk) => {
77
                    // Append text to the current assistant entry.
78
                    if let Some(last) = ui.entries.iter_mut().rfind(|e| e.role == Role::Assistant) {
79
                        last.push_text(&chunk);
80
                        ui.scroll_override = None;
81
                    }
82
                }
83
                Control::Done => {
84
                    // Tell the markdown engine the stream closed so it flushes
85
                    // any bytes held back at a chunk boundary.
86
                    if let Some(last) = ui.entries.iter_mut().rfind(|e| e.role == Role::Assistant) {
87
                        last.finish_text();
88
                    }
89
                    ui.loading = false;
90
                }
91
                Control::Tool {
92
                    function_name,
93
                    arguments,
94
                    title,
95
                } => {
96
                    let parsed = serde_json::from_str(&arguments)
97
                        .unwrap_or_else(|_| serde_json::json!({ "unparsed_arguments": arguments }));
98
                    let call_id = format!("call-{}", ui.entries.len());
99
                    let agent = parsed
100
                        .get("agent")
101
                        .and_then(|v| v.as_str())
102
                        .unwrap_or("unknown")
103
                        .to_string();
104
                    let mut entry = Entry::tool_call(format!("delegate {}: {}", agent, title));
105
                    entry.tool = Some(ToolCall {
106
                        call_id,
107
                        function_name,
108
                        arguments: parsed,
109
                        output: None,
110
                        error: None,
111
                    });
112
                    ui.entries.push(entry);
113
                    ui.scroll_override = None;
114
                }
115
                Control::ToolTitle(title) => {
116
                    if let Some(last) = ui.entries.last_mut() {
117
                        if last.role == Role::Tool {
118
                            let agent = last
119
                                .text
120
                                .split_whitespace()
121
                                .nth(1)
122
                                .and_then(|s| s.strip_suffix(':'))
123
                                .unwrap_or("unknown")
124
                                .to_string();
125
                            last.text = format!("delegate {}: {}", agent, title);
126
                        }
127
                    }
128
                    ui.scroll_override = None;
129
                }
130
                Control::ToolText(chunk) => {
131
                    if let Some(last) = ui.entries.last_mut() {
132
                        if last.role == Role::Tool {
133
                            last.output.get_or_insert_with(String::new).push_str(&chunk);
134
                            if let Some(ref mut tool) = last.tool {
135
                                tool.output = last.output.clone();
136
                            }
137
                        }
138
                    }
139
                    ui.scroll_override = None;
140
                }
141
                Control::ToolDone => {}
142
            }
128
            apply(&mut ui, control);
143 129
        }
144 130
145 131
        terminal.draw(|f| {

@@ -158,97 +144,38 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

158 144
            terminal.set_cursor_position(cursor)?;
159 145
        }
160 146
161
        if event::poll(Duration::from_millis(50))? {
162
            if let Event::Key(key) = event::read()? {
163
                if key.kind != KeyEventKind::Press {
164
                    continue;
165
                }
166
                match key {
167
                    KeyEvent {
168
                        code: KeyCode::Esc, ..
169
                    }
170
                    | KeyEvent {
171
                        code: KeyCode::Char('q' | 'c' | 'd'),
172
                        modifiers: KeyModifiers::CONTROL,
173
                        ..
174
                    } => break,
175
                    KeyEvent {
176
                        code: KeyCode::Enter,
177
                        modifiers,
178
                        ..
179
                    } => {
180
                        if modifiers.contains(KeyModifiers::SHIFT) {
181
                            ui.composer.push('\n');
182
                        } else if !ui.composer.trim().is_empty() {
183
                            let prompt = ui.composer.clone();
184
                            ui.composer.clear();
185
                            ui.scroll_override = None;
186
187
                            if prompt.trim() == "/export" {
188
                                ui.entries.push(Entry::new(Role::You, prompt));
189
                                let model = ui.model.clone();
190
                                let result =
191
                                    export_trajectory(&ui.entries, &model, &ui.repo, &ui.branch);
192
                                ui.entries.push(Entry::new(
193
                                    Role::Notice,
194
                                    format!(
195
                                        "exported {} steps to {} (copied: {})",
196
                                        result.steps, result.path, result.copied
197
                                    ),
198
                                ));
199
                            } else {
200
                                ui.entries.push(Entry::new(Role::You, prompt.clone()));
201
                                ui.entries.push(Entry::new(Role::Assistant, String::new()));
202
                                ui.loading = true;
203
204
                                let mut session = CoderRuntimeSession::new();
205
                                session.agents = ui.agents.clone();
206
                                let tx = tx.clone();
207
                                tokio::spawn(async move {
208
                                    let _ = session.execute_turn(&prompt, tx).await;
209
                                });
210
                            }
211
                        }
212
                    }
213
                    KeyEvent {
214
                        code: KeyCode::Backspace,
215
                        ..
216
                    } => {
217
                        ui.composer.pop();
218
                    }
219
                    KeyEvent {
220
                        code: KeyCode::Up, ..
221
                    } => ui.scroll_by(-1),
222
                    KeyEvent {
223
                        code: KeyCode::Down,
224
                        ..
225
                    } => ui.scroll_by(1),
226
                    KeyEvent {
227
                        code: KeyCode::PageUp,
228
                        ..
229
                    } => {
230
                        let page = ui.transcript_height.max(1) as i32;
231
                        ui.scroll_by(-page);
232
                    }
233
                    KeyEvent {
234
                        code: KeyCode::PageDown,
235
                        ..
236
                    } => {
237
                        let page = ui.transcript_height.max(1) as i32;
238
                        ui.scroll_by(page);
239
                    }
240
                    KeyEvent {
241
                        code: KeyCode::Char(c),
242
                        modifiers,
243
                        ..
244
                    } if !modifiers.contains(KeyModifiers::CONTROL)
245
                        && !modifiers.contains(KeyModifiers::ALT) =>
246
                    {
247
                        ui.composer.push(c);
248
                    }
249
                    _ => {}
147
        if !event::poll(Duration::from_millis(50))? {
148
            continue;
149
        }
150
        let Event::Key(key) = event::read()? else {
151
            continue;
152
        };
153
        if key.kind != KeyEventKind::Press {
154
            continue;
155
        }
156
157
        let width = composer_width(terminal.size()?.width);
158
159
        // Leaving is checked before the composer sees the key, so Ctrl+C is
160
        // never swallowed by an editing chord.
161
        if is_quit(&key) {
162
            break;
163
        }
164
165
        match ui.composer.handle_key(&key, width) {
166
            ComposerAction::Submit(text) => {
167
                history.record(&text);
168
                history.stop_walking();
169
                if !text.trim().is_empty() {
170
                    submit(&mut ui, text, &session, &tx, &cwd);
250 171
                }
251 172
            }
173
            ComposerAction::Redraw => history.stop_walking(),
174
            ComposerAction::Moved => {}
175
            // The composer did not want it, so it is the session's.
176
            ComposerAction::Ignored => {
177
                handle_session_key(&mut ui, &mut history, &key, width, &cwd);
178
            }
252 179
        }
253 180
    }
254 181

@@ -256,10 +183,292 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

256 183
    let _ = crossterm::execute!(stderr, PopKeyboardEnhancementFlags);
257 184
    disable_raw_mode()?;
258 185
    std::io::stdout().execute(LeaveAlternateScreen)?;
186
187
    // The screen is gone, so these land on the normal one. Revoking the thread
188
    // is the point: one left open holds its grant's remaining budget, and the
189
    // `Drop` backstop can only spawn a `DELETE` this process may exit before
190
    // polling.
191
    match tokio::time::timeout(REVOCATION_GRACE, async {
192
        session.lock().await.close().await
193
    })
194
    .await
195
    {
196
        Ok(Ok(Some(line))) => println!("{line}"),
197
        Ok(Ok(None)) => {}
198
        Ok(Err(error)) => eprintln!("coder-lite: the thread was not revoked: {error}"),
199
        Err(_) => eprintln!(
200
            "coder-lite: the session was still working after {}s, so its thread was left to \
201
             the best-effort revocation.",
202
            REVOCATION_GRACE.as_secs()
203
        ),
204
    }
259 205
    Ok(())
260 206
}
261 207
208
/// The columns the composer soft-wraps to: the frame's width less its border
209
/// and the `" > "` gutter. It has to match what `CoderUi::render` uses, or the
210
/// caret is drawn a column off from where the next character lands.
211
pub fn composer_width(frame_width: u16) -> usize {
212
    (frame_width as usize)
213
        .saturating_sub(2)
214
        .saturating_sub(3)
215
        .max(1)
216
}
217
218
fn is_quit(key: &KeyEvent) -> bool {
219
    matches!(
220
        key,
221
        KeyEvent {
222
            code: KeyCode::Esc,
223
            ..
224
        } | KeyEvent {
225
            code: KeyCode::Char('c' | 'd' | 'q'),
226
            modifiers: KeyModifiers::CONTROL,
227
            ..
228
        }
229
    )
230
}
231
232
/// Apply one message from the runtime to the frame.
233
///
234
/// Split out of the loop so a test can drive it without a terminal.
235
pub fn apply(ui: &mut CoderUi, control: Control) {
236
    match control {
237
        Control::Chunk(chunk) => {
238
            if let Some(last) = ui.entries.iter_mut().rfind(|e| e.role == Role::Assistant) {
239
                last.push_text(&chunk);
240
                ui.scroll_override = None;
241
            }
242
        }
243
        Control::Tool {
244
            call_id,
245
            name,
246
            arguments,
247
        } => {
248
            let parsed = serde_json::from_str(&arguments)
249
                .unwrap_or_else(|_| serde_json::json!({ "unparsed_arguments": arguments.clone() }));
250
            let mut entry = Entry::tool_call(tool_title(&name, &arguments));
251
            entry.tool = Some(ToolCall {
252
                call_id,
253
                function_name: name,
254
                arguments: parsed,
255
                output: None,
256
                error: None,
257
            });
258
            ui.entries.push(entry);
259
            ui.scroll_override = None;
260
        }
261
        Control::ToolOutput { call_id, chunk } => {
262
            if let Some(entry) = tool_entry(ui, &call_id) {
263
                entry.output.get_or_insert_with(String::new).push_str(&chunk);
264
                let seen = entry.output.clone();
265
                if let Some(tool) = entry.tool.as_mut() {
266
                    tool.output = seen;
267
                }
268
            }
269
            ui.scroll_override = None;
270
        }
271
        Control::ToolDone { call_id, is_error } => {
272
            if is_error {
273
                if let Some(entry) = tool_entry(ui, &call_id) {
274
                    // Said on the header, where the reader is looking. A
275
                    // failed call that reads like a successful one is how a
276
                    // session comes to believe a build passed.
277
                    if !entry.text.ends_with(" — failed") {
278
                        entry.text.push_str(" — failed");
279
                    }
280
                    let seen = entry.output.clone();
281
                    if let Some(tool) = entry.tool.as_mut() {
282
                        tool.error = seen;
283
                    }
284
                }
285
            }
286
            ui.scroll_override = None;
287
        }
288
        // What answered, from the grant. Recorded on the frame's own field,
289
        // which stays empty until something has actually answered.
290
        Control::Model(model) => ui.model = model,
291
        Control::Usage(usage) => {
292
            if usage.reported() {
293
                ui.entries.push(Entry::new(Role::Notice, usage.line()));
294
            }
295
        }
296
        Control::Notice(text) => {
297
            if !text.trim().is_empty() {
298
                ui.entries.push(Entry::new(Role::Notice, text));
299
                ui.scroll_override = None;
300
            }
301
        }
302
        Control::Output(text) => {
303
            if !text.trim().is_empty() {
304
                ui.entries.push(Entry::new(Role::Output, text));
305
                ui.scroll_override = None;
306
            }
307
        }
308
        Control::Failed(why) => {
309
            // The streaming entry settles as whatever did arrive before the
310
            // failure, and the failure goes next to it rather than into it.
311
            if let Some(last) = ui.entries.iter_mut().rfind(|e| e.role == Role::Assistant) {
312
                last.finish_text();
313
            }
314
            ui.entries.push(Entry::new(Role::Notice, why));
315
            ui.loading = false;
316
            ui.scroll_override = None;
317
        }
318
        Control::Done => {
319
            if let Some(last) = ui.entries.iter_mut().rfind(|e| e.role == Role::Assistant) {
320
                last.finish_text();
321
            }
322
            ui.loading = false;
323
        }
324
    }
325
}
326
327
fn tool_entry<'a>(ui: &'a mut CoderUi, call_id: &str) -> Option<&'a mut Entry> {
328
    ui.entries.iter_mut().rev().find(|entry| {
329
        entry.role == Role::Tool
330
            && entry
331
                .tool
332
                .as_ref()
333
                .is_some_and(|tool| tool.call_id == call_id)
334
    })
335
}
336
337
/// A key the composer did not want: history, scrolling, and Tab.
338
fn handle_session_key(
339
    ui: &mut CoderUi,
340
    history: &mut History,
341
    key: &KeyEvent,
342
    width: usize,
343
    cwd: &std::path::Path,
344
) {
345
    match key.code {
346
        // Off the top of the composer walks the input history, and only once
347
        // the history has nothing more does the transcript scroll. Reversing
348
        // that order makes the history unreachable on a one-line composer,
349
        // which is the only shape it is ever walked from.
350
        KeyCode::Up => match history.previous(ui.composer.text()) {
351
            Some(prompt) => ui.composer.set_text(&prompt),
352
            None => ui.scroll_by(-1),
353
        },
354
        KeyCode::Down => match history.forward() {
355
            Some(prompt) => ui.composer.set_text(&prompt),
356
            None => ui.scroll_by(1),
357
        },
358
        KeyCode::PageUp => {
359
            let page = ui.transcript_height.max(1) as i32;
360
            ui.scroll_by(-page);
361
        }
362
        KeyCode::PageDown => {
363
            let page = ui.transcript_height.max(1) as i32;
364
            ui.scroll_by(page);
365
        }
366
        KeyCode::Tab => {
367
            let Completion { insert, candidates } = complete(
368
                ui.composer.text(),
369
                ui.composer.cursor_byte(),
370
                &commands::names(),
371
                cwd,
372
            );
373
            if !insert.is_empty() {
374
                ui.composer.insert_str(&insert);
375
            }
376
            if candidates.len() > 1 {
377
                // Listed rather than chosen. Tab never inserts a candidate
378
                // that was not the only one.
379
                ui.entries
380
                    .push(Entry::new(Role::Output, candidates.join("  ")));
381
                ui.scroll_override = None;
382
            }
383
            let _ = width;
384
        }
385
        _ => {}
386
    }
387
}
388
389
/// Send what was typed: a command to the session, or a prompt to the model.
390
fn submit(
391
    ui: &mut CoderUi,
392
    text: String,
393
    session: &Arc<Mutex<Session>>,
394
    tx: &Sender<Control>,
395
    cwd: &std::path::Path,
396
) {
397
    ui.scroll_override = None;
398
    ui.entries.push(Entry::new(Role::You, text.clone()));
399
400
    if text.trim_start().starts_with('/') {
401
        commands::run(ui, text.trim(), tx, cwd);
402
        return;
403
    }
404
405
    ui.entries.push(Entry::new(Role::Assistant, String::new()));
406
    ui.loading = true;
407
408
    let session = Arc::clone(session);
409
    let tx = tx.clone();
410
    tokio::spawn(async move {
411
        session.lock().await.execute_turn(&text, tx).await;
412
    });
413
}
414
415
/// `/export` is here rather than in `commands` because it reads the whole
416
/// transcript, which the command dispatch does not otherwise see.
417
pub fn export(ui: &mut CoderUi) {
418
    let model = if ui.model.is_empty() {
419
        // What actually answered, or nothing. A trajectory stamped with the
420
        // lane's preferred model where no model answered is a trajectory that
421
        // names a model that never ran.
422
        "unknown"
423
    } else {
424
        ui.model.as_str()
425
    }
426
    .to_string();
427
    let result = export_trajectory(&ui.entries, &model, &ui.repo, &ui.branch);
428
    ui.entries.push(Entry::new(
429
        Role::Notice,
430
        format!(
431
            "exported {} steps to {} (copied: {})",
432
            result.steps, result.path, result.copied
433
        ),
434
    ));
435
}
436
437
/// The commands the composer completes, so a name that is not handled cannot
438
/// be offered. Re-exported here because that is where the completer looks.
439
pub use commands::names as command_names;
440
262 441
fn atty_is_terminal() -> bool {
263 442
    std::io::IsTerminal::is_terminal(&std::io::stdin())
264 443
        && std::io::IsTerminal::is_terminal(&std::io::stdout())
265 444
}
445
446
/// Every command the session lists is one it handles.
447
///
448
/// The property that keeps `/help` honest. It is asserted here rather than
449
/// left to a reader noticing, because the list and the dispatch are in two
450
/// places and only a test holds them together.
451
#[cfg(test)]
452
mod tests {
453
    use super::*;
454
455
    #[test]
456
    fn every_listed_command_is_handled() {
457
        for (name, _) in crate::commands::COMMANDS {
458
            assert!(
459
                commands::handles(name),
460
                "`/{name}` is listed by /help and nothing runs it"
461
            );
462
        }
463
    }
464
465
    #[test]
466
    fn the_completer_offers_exactly_the_handled_commands() {
467
        let mut offered = command_names();
468
        offered.sort_unstable();
469
        let mut listed: Vec<&str> =
470
            crate::commands::COMMANDS.iter().map(|(name, _)| *name).collect();
471
        listed.sort_unstable();
472
        assert_eq!(offered, listed);
473
    }
474
}
crates/coder-lite/src/lib.rs modified +8 -1

@@ -1,7 +1,14 @@

1
//! coder-lite: a minimal ratatui TUI boot crate
1
//! coder-lite: the OpenAgents coder TUI.
2
//!
3
//! The frame, the palette, the composer wiring, the session's own commands,
4
//! and the ACP path are here. The runtime beneath — tools, threads, grants,
5
//! lanes, metering, revocation — is `openagents_cli`, used as a library so
6
//! there is one implementation of each and this crate cannot drift from it.
2 7
3 8
pub mod acp;
4 9
pub mod acp_harness;
10
pub mod acp_tool;
11
pub mod commands;
5 12
pub mod export;
6 13
pub mod interactive;
7 14
pub mod markdown;
crates/coder-lite/src/main.rs modified +129 -13

@@ -1,4 +1,16 @@

1 1
//! coder-lite binary entry point.
2
//!
3
//! ## What this binary is called
4
//!
5
//! It is `coder-lite`, and it is the coder TUI. `oa coder`, in the
6
//! `openagents-cli` crate next door, is a second front end onto the same
7
//! runtime and still exists; the two share their tools, their composer, their
8
//! lanes, and their metering, and differ in the frame they draw.
9
//! [`HELP`] says so, because a `--help` that named only one of them would be
10
//! telling a reader the other did not exist.
11
//!
12
//! It is not `openagents`. That name belongs to the CLI on `PATH` and this
13
//! binary does not take it.
2 14
3 15
use std::env;
4 16
use std::path::PathBuf;

@@ -7,29 +19,133 @@ use tokio::net::TcpStream;

7 19
use tokio::process::Command;
8 20
use tokio::time::{sleep, timeout};
9 21
22
use coder_lite::interactive::SessionOptions;
23
10 24
const DEV_BASE_URL: &str = "http://localhost:4000/api/v1";
11 25
const DEV_API_KEY: &str = "fake";
12 26
27
/// Every flag this binary reads. A flag listed here does what it says or the
28
/// binary refuses to start; there is nothing accepted-and-ignored.
29
const HELP: &str = "\
30
coder-lite — the OpenAgents coder, in a terminal.
31
32
Usage: coder-lite [options]
33
34
Options:
35
  --dev              Talk to a server on this machine at http://localhost:4000.
36
                     Starts one from ../openagents.com if none is running, and
37
                     tolerates one that already is.
38
  --lane <name>      Which model answers. `auto` leaves it to the deployment;
39
                     `flash` and `pro` are tiers; `local` or `ollama:<model>`
40
                     answers from this machine; any other name is checked
41
                     against GET /api/v1/models and refused if it is not
42
                     served. Defaults to `auto`.
43
  --reasoning <how>  Recorded on the thread as its reasoning effort. Omit to
44
                     leave the deployment's own default.
45
  -h, --help         Print this and exit.
46
  -V, --version      Print the version and exit.
47
48
Environment:
49
  OPENAGENTS_BASE_URL   The /api/v1 base to use. `--dev` sets it.
50
  OPENAGENTS_API_KEY    The credential to spend. Falls back to `oa auth`.
51
  OPENAGENTS_WEB_REPO   Where `--dev` looks for start_server.sh.
52
  ACP_REGISTRY          Where the `acp` tool looks for installed agents.
53
54
`oa coder`, in the openagents-cli crate, is a second front end onto the same
55
runtime, tools, composer, lanes, and metering. This binary is not `openagents`
56
and does not take that name.
57
58
Inside the session, `/help` lists the commands and the keys.
59
";
60
13 61
#[tokio::main]
14 62
async fn main() -> Result<(), Box<dyn std::error::Error>> {
15
    let dev = env::args().any(|a| a == "--dev");
16
17
    if dev {
18
        boot_dev_server().await?;
19
        // SAFETY: edition 2024 marks `set_var` unsafe because another thread
20
        // reading the environment concurrently is UB. This runs before the TUI
21
        // and its tokio tasks start, so no other thread exists yet.
22
        unsafe {
23
            if env::var("OPENAGENTS_BASE_URL").is_err() {
24
                env::set_var("OPENAGENTS_BASE_URL", DEV_BASE_URL);
63
    let arguments: Vec<String> = env::args().skip(1).collect();
64
    let options = match parse(&arguments) {
65
        Ok(Parsed::Run(options, dev)) => {
66
            if dev {
67
                boot_dev_server().await?;
68
                // SAFETY: edition 2024 marks `set_var` unsafe because another
69
                // thread reading the environment concurrently is UB. This runs
70
                // before the TUI and its tokio tasks start, so no other thread
71
                // exists yet.
72
                unsafe {
73
                    if env::var("OPENAGENTS_BASE_URL").is_err() {
74
                        env::set_var("OPENAGENTS_BASE_URL", DEV_BASE_URL);
75
                    }
76
                    if env::var("OPENAGENTS_API_KEY").is_err() {
77
                        env::set_var("OPENAGENTS_API_KEY", DEV_API_KEY);
78
                    }
79
                }
80
            }
81
            options
82
        }
83
        Ok(Parsed::Said) => return Ok(()),
84
        Err(refusal) => {
85
            eprintln!("coder-lite: {refusal}");
86
            std::process::exit(2);
87
        }
88
    };
89
90
    coder_lite::interactive::run_tui(options).await
91
}
92
93
enum Parsed {
94
    /// Run a session. The flag is whether `--dev` was given.
95
    Run(SessionOptions, bool),
96
    /// `--help` or `--version` answered and there is nothing to run.
97
    Said,
98
}
99
100
/// Read the command line, or say what is wrong with it.
101
///
102
/// An unknown flag is refused rather than ignored: a flag that is silently
103
/// dropped is a flag that lied about being read.
104
fn parse(arguments: &[String]) -> Result<Parsed, String> {
105
    let mut options = SessionOptions {
106
        lane_name: "auto".to_string(),
107
        reasoning: None,
108
    };
109
    let mut dev = false;
110
    let mut index = 0;
111
112
    while index < arguments.len() {
113
        let argument = arguments[index].as_str();
114
        let value = |name: &str| -> Result<String, String> {
115
            arguments
116
                .get(index + 1)
117
                .filter(|next| !next.starts_with('-'))
118
                .cloned()
119
                .ok_or_else(|| format!("{name} needs a value"))
120
        };
121
        match argument {
122
            "-h" | "--help" => {
123
                print!("{HELP}");
124
                return Ok(Parsed::Said);
125
            }
126
            "-V" | "--version" => {
127
                println!("coder-lite {}", env!("CARGO_PKG_VERSION"));
128
                return Ok(Parsed::Said);
129
            }
130
            "--dev" => dev = true,
131
            "--lane" => {
132
                options.lane_name = value("--lane")?;
133
                index += 1;
134
            }
135
            "--reasoning" => {
136
                options.reasoning = Some(value("--reasoning")?);
137
                index += 1;
25 138
            }
26
            if env::var("OPENAGENTS_API_KEY").is_err() {
27
                env::set_var("OPENAGENTS_API_KEY", DEV_API_KEY);
139
            other => {
140
                return Err(format!(
141
                    "`{other}` is not a flag this binary reads. `--help` lists the ones it does."
142
                ))
28 143
            }
29 144
        }
145
        index += 1;
30 146
    }
31 147
32
    coder_lite::interactive::run_tui().await
148
    Ok(Parsed::Run(options, dev))
33 149
}
34 150
35 151
async fn boot_dev_server() -> Result<(), Box<dyn std::error::Error>> {
crates/coder-lite/src/runtime.rs modified +459 -221

@@ -1,255 +1,493 @@

1
//! Open Responses streaming client for coder-lite.
1
//! coder-lite's turn: its own voice over the capable runtime next door.
2
//!
3
//! The session underneath is [`openagents_cli::runtime::CoderRuntimeSession`]
4
//! — threads, grants, the inference proxy, the live model catalog, lanes,
5
//! metering, and revocation — and it is used rather than copied, so there is
6
//! one implementation of each of those and this crate cannot drift from it.
7
//!
8
//! What this file owns is the part that is coder-lite's:
9
//!
10
//! - [`SYSTEM_INSTRUCTIONS`], carried verbatim. It is the reason the session
11
//!   answers as a terminal rather than as an assistant, and a merge that
12
//!   reworded it would have changed the product.
13
//! - [`Control`], the one-way channel the TUI loop reads. Text, tool calls,
14
//!   tool results, the model that answered, what the turn spent, and the
15
//!   failures — each as its own message, so the frame shows what happened and
16
//!   not a summary written afterwards.
17
//!
18
//! ## Nothing here invents an answer
19
//!
20
//! Every path is the model's own words or a [`Control::Failed`] naming what
21
//! refused. There is no fallback model, no synthesized reply, and no invented
22
//! grant: the session below refuses out loud and this file carries the refusal
23
//! to the screen instead of painting over it.
2 24
3
use futures::StreamExt;
4
use openresponses_rust::{
5
    CreateResponseBody, FunctionOutput, Input, Item, StreamingClient, StreamingEvent, Tool,
6
    ToolChoice, ToolChoiceParam,
7
};
8 25
use std::env;
9
use std::path::PathBuf;
26
use std::sync::atomic::{AtomicBool, Ordering};
10 27
use std::sync::mpsc::Sender;
28
use std::sync::{Arc, Mutex};
11 29
12
use crate::acp::Agent;
13
use crate::acp_harness::{AcpEvent, AcpHarness};
30
use openagents_cli::runtime::{
31
    ChatMessage, CoderRuntimeSession, Lane, ToolEvent, TurnUsage,
32
};
33
use openagents_cli::tools::{DelegationGate, HarnessToolRegistry, ToolDefinition};
14 34
35
/// coder-lite's voice. Carried verbatim from the first version of this file;
36
/// see the module header for why it does not move.
15 37
const SYSTEM_INSTRUCTIONS: &str = "You are OpenAgents Coder. Do not say you are from Google, Anthropic, OpenAI, or any other company. Do not mention your model, training, or architecture. Respond as a neutral, terse terminal: no greetings, no \"As an AI\", no explanations of your role, and no unnecessary padding. Use short sentences and dense, factual output. Answer questions directly. Output only code and minimal context when asked for code.";
16 38
39
type Failure = Box<dyn std::error::Error + Send + Sync>;
40
41
/// What the runtime tells the frame, in the order it happened.
42
#[derive(Debug, Clone)]
17 43
pub enum Control {
44
    /// A piece of the reply, as the model wrote it.
18 45
    Chunk(String),
19
    Done,
46
    /// A tool call started. The header goes up now; the box fills in later.
20 47
    Tool {
21
        function_name: String,
48
        call_id: String,
49
        name: String,
50
        /// The raw JSON string from the wire.
22 51
        arguments: String,
23
        title: String,
24 52
    },
25
    ToolTitle(String),
26
    ToolText(String),
27
    ToolDone,
53
    /// More of what that call has printed, appended to its box.
54
    ToolOutput { call_id: String, chunk: String },
55
    /// That call finished, and whether it worked.
56
    ToolDone { call_id: String, is_error: bool },
57
    /// The model that actually answered, as its grant pins it.
58
    Model(String),
59
    /// What the turn spent, as the server reported it.
60
    Usage(TurnUsage),
61
    /// Something worth saying that is not the model talking.
62
    Notice(String),
63
    /// What one of the session's own commands printed. Markdown, rendered the
64
    /// way an answer is, and exported as a notice rather than a model step.
65
    Output(String),
66
    /// The turn did not answer, and this is why. Never an answer.
67
    Failed(String),
68
    /// The turn is over, one way or the other.
69
    Done,
28 70
}
29 71
30
pub struct CoderRuntimeSession {
31
    pub api_key: String,
32
    pub base_url: String,
33
    pub history: Vec<Item>,
34
    pub agents: Vec<Agent>,
72
/// A `Sender` an observer can hold: `Fn` observers are shared, and the frame
73
/// loop's receiver is on the other end of exactly one channel.
74
pub type Sink = Arc<Mutex<Sender<Control>>>;
75
76
/// Put a message on the frame's channel, or drop it if the frame is gone.
77
pub fn send(sink: &Sink, message: Control) {
78
    if let Ok(tx) = sink.lock() {
79
        let _ = tx.send(message);
80
    }
35 81
}
36 82
37
impl CoderRuntimeSession {
38
    pub fn new() -> Self {
39
        Self {
40
            api_key: env::var("OPENAGENTS_API_KEY").unwrap_or_default(),
41
            base_url: env::var("OPENAGENTS_BASE_URL")
42
                .unwrap_or_else(|_| "https://openagents.com/api/v1".to_string()),
43
            history: vec![Item::system_message(SYSTEM_INSTRUCTIONS)],
44
            agents: Vec::new(),
83
/// The system message this session opens with.
84
///
85
/// [`SYSTEM_INSTRUCTIONS`] first and unchanged, then the tools — because a
86
/// model that is told it has no tools when it has five will not use them, and
87
/// one told it has tools it does not have will claim to have run them. The
88
/// list is generated from what was actually declared, so the two cannot
89
/// disagree.
90
pub fn system_prompt(tools: &[ToolDefinition]) -> String {
91
    let mut lines = vec![SYSTEM_INSTRUCTIONS.to_string(), String::new()];
92
    if tools.is_empty() {
93
        lines.push(
94
            "You have no tools in this session: you cannot read or write files, run commands, or \
95
             reach anything outside this conversation. Say plainly when something would need a \
96
             tool you do not have."
97
                .to_string(),
98
        );
99
    } else {
100
        lines.push(format!("You have {} tools, and no others:", tools.len()));
101
        for tool in tools {
102
            lines.push(format!("- `{}`", tool.name));
103
        }
104
        lines.push(String::new());
105
        lines.push(
106
            "That list is complete: a capability not on it is one you do not have. Read a tool's \
107
             description before assuming what it covers. Never say you ran something you did not \
108
             run."
109
                .to_string(),
110
        );
111
    }
112
    lines.join("\n")
113
}
114
115
/// The `/api/v1` base this session talks to.
116
///
117
/// `OPENAGENTS_BASE_URL` first, because that is what `--dev` sets and a reader
118
/// who pointed the session at a server on this machine meant it. Then the
119
/// endpoint `oa auth` selected, so coder-lite and `oa` agree about where they
120
/// are without a second configuration file.
121
pub fn api_base() -> String {
122
    for name in ["OPENAGENTS_BASE_URL", "OPENAGENTS_API_BASE"] {
123
        if let Ok(value) = env::var(name) {
124
            let value = value.trim().to_string();
125
            if !value.is_empty() {
126
                return value;
127
            }
128
        }
129
    }
130
    match openagents_cli::auth::resolve_endpoint(None, None) {
131
        Ok(endpoint) => format!("{}/api/v1", endpoint.origin),
132
        Err(_) => "https://openagents.com/api/v1".to_string(),
133
    }
134
}
135
136
/// The credential this session spends, or `None`.
137
///
138
/// `None` is carried rather than papered over: a thread request without one is
139
/// refused by the server, and that refusal is what the reader should see.
140
pub fn user_token() -> Option<String> {
141
    if let Ok(value) = env::var("OPENAGENTS_API_KEY") {
142
        let value = value.trim().to_string();
143
        if !value.is_empty() {
144
            return Some(value);
45 145
        }
46 146
    }
147
    let endpoint = openagents_cli::auth::resolve_endpoint(None, None).ok()?;
148
    openagents_cli::auth::CredentialStore::for_origin(&endpoint.origin).get_token()
149
}
150
151
/// The session a coder-lite frame drives.
152
pub struct Session {
153
    inner: CoderRuntimeSession,
154
    lane: Lane,
155
    /// Whether this user turn has already handed work to an ACP agent.
156
    ///
157
    /// Cleared at the top of every turn and set by the `acp` tool itself; see
158
    /// [`crate::acp_tool`] for why one is the limit.
159
    acp_spent: Arc<AtomicBool>,
160
}
161
162
impl Session {
163
    /// Open a session on `lane`, reporting everything it does to `tx`.
164
    ///
165
    /// The tools are the full set — `shell`, `skill`, `openagents`,
166
    /// `capability`, and `delegate` — on the same terms `oa coder` gets them:
167
    /// children run on this lane, on this credential, and cannot delegate
168
    /// again.
169
    pub fn open(
170
        lane: Lane,
171
        lane_name: &str,
172
        reasoning: Option<String>,
173
        agents: Vec<crate::acp::Agent>,
174
        tx: Sender<Control>,
175
    ) -> Self {
176
        Self::open_at(
177
            lane,
178
            lane_name,
179
            reasoning,
180
            agents,
181
            api_base(),
182
            user_token(),
183
            tx,
184
        )
185
    }
47 186
48
    pub async fn execute_turn(
49
        &mut self,
50
        prompt: &str,
187
    /// [`Self::open`] against a named server with a named credential.
188
    ///
189
    /// The environment is process-global and a test that set it would race
190
    /// every other test in the same binary, so the two values the environment
191
    /// supplies are parameters here and read from the environment exactly once,
192
    /// in `open`.
193
    #[allow(clippy::too_many_arguments)]
194
    pub fn open_at(
195
        lane: Lane,
196
        lane_name: &str,
197
        reasoning: Option<String>,
198
        agents: Vec<crate::acp::Agent>,
199
        api_base: String,
200
        token: Option<String>,
51 201
        tx: Sender<Control>,
52
    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
53
        if self.api_key.is_empty() {
54
            let _ = tx.send(Control::Chunk(
55
                "[error: OPENAGENTS_API_KEY is not set]".to_string(),
56
            ));
57
            let _ = tx.send(Control::Done);
58
            return Err("OPENAGENTS_API_KEY is not set".into());
202
    ) -> Self {
203
        let mut tools = HarnessToolRegistry::with_delegation(
204
            None,
205
            DelegationGate {
206
                lane: lane_name.to_string(),
207
                user_token: token.clone(),
208
                max_count: openagents_cli::delegate::MAX_DELEGATE_COUNT,
209
                // coder-lite takes no `--child-*` flags yet, so children start
210
                // on the defaults. Set rather than defaulted at the struct so
211
                // adding those flags here is a visible edit, not a silent
212
                // inheritance of whatever the field grows into.
213
                child: openagents_cli::delegate::ChildOptions::default(),
214
            },
215
        );
216
217
        let sink: Sink = Arc::new(Mutex::new(tx));
218
        let observed = Arc::clone(&sink);
219
220
        // coder-lite's own capability, declared only where there is one to
221
        // declare: `find_agents` reports installed agents, so a machine with
222
        // none does not see the tool.
223
        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
224
        let found = !agents.is_empty();
225
        let acp_spent = Arc::new(AtomicBool::new(false));
226
        if let Some(tool) = crate::acp_tool::acp_host_tool(
227
            agents,
228
            cwd,
229
            Arc::clone(&sink),
230
            Arc::clone(&acp_spent),
231
        ) {
232
            if let Err(refusal) = tools.add_host_tool(tool) {
233
                send(&sink, Control::Notice(refusal));
234
            }
235
        } else if found {
236
            // Unreachable while `acp_host_tool` refuses only an empty list,
237
            // and here so that stops being silently true if it stops being.
238
            send(
239
                &sink,
240
                Control::Notice("ACP agents were found but no `acp` tool was declared".to_string()),
241
            );
59 242
        }
60 243
61
        self.history.push(Item::user_message(prompt));
62
63
        let client = StreamingClient::with_base_url(&self.api_key, &self.base_url);
64
        let tools = self.delegate_tool();
65
        let mut post_tool = false;
66
67
        loop {
68
            let tool_choice = if post_tool {
69
                Some(ToolChoiceParam::Simple(ToolChoice::None))
70
            } else {
71
                Some(ToolChoiceParam::Simple(ToolChoice::Auto))
72
            };
73
            let request = CreateResponseBody {
74
                model: env::var("OPENAGENTS_MODEL").ok(),
75
                input: Some(Input::Items(self.history.clone())),
76
                tools: tools.clone(),
77
                tool_choice,
78
                stream: Some(true),
79
                ..Default::default()
80
            };
81
82
            let mut stream = match client.stream_response(request).await {
83
                Ok(s) => s,
84
                Err(e) => {
85
                    let _ = tx.send(Control::Chunk(format!("[error: {}]", e)));
86
                    let _ = tx.send(Control::Done);
87
                    return Err(e.into());
88
                }
89
            };
90
91
            let mut collected = String::new();
92
            let mut pending_tool: Option<(String, String, String, String)> = None;
93
94
            while let Some(event) = stream.next().await {
95
                match event {
96
                    Ok(StreamingEvent::OutputTextDelta { delta, .. }) => {
97
                        collected.push_str(&delta);
98
                        let _ = tx.send(Control::Chunk(delta));
99
                    }
100
                    Ok(StreamingEvent::ReasoningDelta { delta, .. }) => {
101
                        let _ = tx.send(Control::Chunk(delta));
102
                    }
103
                    Ok(StreamingEvent::OutputItemDone {
104
                        item: Some(Item::FunctionCall {
105
                            call_id,
106
                            name,
107
                            arguments,
108
                            ..
109
                        }),
110
                        ..
111
                    }) if name == "delegate" => {
112
                        let args = serde_json::from_str::<serde_json::Value>(&arguments)
113
                            .unwrap_or(serde_json::json!({}));
114
                        let agent = args
115
                            .get("agent")
116
                            .and_then(|v| v.as_str())
117
                            .unwrap_or("")
118
                            .to_string();
119
                        let task = args
120
                            .get("prompt")
121
                            .and_then(|v| v.as_str())
122
                            .unwrap_or("")
123
                            .to_string();
124
                        pending_tool = Some((call_id, agent, task, arguments));
125
                    }
126
                    Ok(StreamingEvent::Error { error, .. }) => {
127
                        let msg = format!("[error: {:?}]", error);
128
                        let _ = tx.send(Control::Chunk(msg));
129
                    }
130
                    Ok(_) => {}
131
                    Err(e) => {
132
                        let _ = tx.send(Control::Chunk(format!("[error: {}]", e)));
133
                        let _ = tx.send(Control::Done);
134
                        return Err(e.into());
135
                    }
136
                }
244
        let mut inner = CoderRuntimeSession::new(lane.clone(), Some(api_base), token, tools)
245
        .observing_tools(Arc::new(move |event: ToolEvent| match event {
246
            ToolEvent::Started {
247
                call_id,
248
                name,
249
                arguments,
250
            } => send(
251
                &observed,
252
                Control::Tool {
253
                    call_id,
254
                    name,
255
                    arguments,
256
                },
257
            ),
258
            ToolEvent::Finished {
259
                call_id,
260
                output,
261
                is_error,
262
                ..
263
            } => {
264
                send(
265
                    &observed,
266
                    Control::ToolOutput {
267
                        call_id: call_id.clone(),
268
                        chunk: output,
269
                    },
270
                );
271
                send(&observed, Control::ToolDone { call_id, is_error });
137 272
            }
273
        }));
274
        inner.reasoning = reasoning;
275
        inner.repository = repository();
276
277
        // Seeded here so the session below leaves it alone: `execute_turn`
278
        // writes its own system prompt only into an empty message list, and
279
        // this one is coder-lite's.
280
        let prompt = system_prompt(&inner.tools.list_tools());
281
        inner.messages.push(ChatMessage {
282
            role: "system".to_string(),
283
            content: Some(prompt),
284
            tool_calls: None,
285
            tool_call_id: None,
286
        });
138 287
139
            let mut turn_failed = false;
140
            if let Some((call_id, agent_id, task, raw_args)) = pending_tool.take() {
141
                if let Some(agent) = self.agents.iter().find(|a| a.id == agent_id).cloned() {
142
                    let _ = tx.send(Control::Tool {
143
                        function_name: "delegate".to_string(),
144
                        arguments: raw_args,
145
                        title: task.clone(),
146
                    });
147
148
                    let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
149
                    let mut header_sent = false;
150
                    let result = {
151
                        let tx = tx.clone();
152
                        AcpHarness {
153
                            command: agent.command,
154
                            args: agent.args,
155
                        }
156
                        .run(&task, &cwd, |event| {
157
                            match event {
158
                                AcpEvent::Tool { title, .. } => {
159
                                    header_sent = true;
160
                                    let _ = tx.send(Control::ToolTitle(title));
161
                                }
162
                                AcpEvent::Text { chunk } => {
163
                                    let _ = tx.send(Control::ToolText(chunk));
164
                                }
165
                                _ => {}
166
                            }
167
                        })
168
                        .await
169
                    };
170
171
                    match &result {
172
                        Ok(answer)
173
                            if answer
174
                                .to_lowercase()
175
                                .contains("upgrade your plan to continue") =>
176
                        {
177
                            let _ = tx.send(Control::ToolTitle("refused".to_string()));
178
                            let _ = tx.send(Control::ToolText(answer.clone()));
179
                            turn_failed = true;
180
                        }
181
                        Ok(_) if !header_sent => {
182
                            let _ = tx.send(Control::ToolTitle("completed".to_string()));
183
                        }
184
                        Err(_) => {
185
                            let _ = tx.send(Control::ToolTitle("error".to_string()));
186
                            let _ = tx.send(Control::ToolText(
187
                                result.as_ref().err().unwrap().to_string(),
188
                            ));
189
                            turn_failed = true;
190
                        }
191
                        _ => {}
192
                    }
193
194
                    let _ = tx.send(Control::ToolDone);
195
                    let output = result.unwrap_or_else(|e| e.to_string());
196
                    self.history.push(Item::FunctionCallOutput {
197
                        id: None,
198
                        call_id,
199
                        output: FunctionOutput::Text(output),
200
                        status: None,
201
                    });
202
                    if turn_failed {
203
                        break;
204
                    }
205
                    post_tool = true;
206
                    continue;
207
                } else {
208
                    let msg = format!("unknown ACP agent: {}", agent_id);
209
                    let _ = tx.send(Control::Chunk(msg.clone()));
210
                    self.history.push(Item::FunctionCallOutput {
211
                        id: None,
212
                        call_id,
213
                        output: FunctionOutput::Text(msg),
214
                        status: None,
215
                    });
216
                    post_tool = true;
217
                    continue;
288
        Self {
289
            inner,
290
            lane,
291
            acp_spent,
292
        }
293
    }
294
295
    /// The lane this session was opened on. What was asked for, not what
296
    /// answered — [`Control::Model`] carries that.
297
    pub fn lane(&self) -> &Lane {
298
        &self.lane
299
    }
300
301
    /// Run one turn, streaming everything it does down `tx`.
302
    ///
303
    /// Always ends with exactly one [`Control::Done`], so a frame cannot be
304
    /// left spinning over a turn that has finished.
305
    pub async fn execute_turn(&mut self, prompt: &str, tx: Sender<Control>) {
306
        // A fresh turn may hand work to an agent again. The limit is per user
307
        // turn, not per session.
308
        self.acp_spent.store(false, Ordering::SeqCst);
309
        let sink: Sink = Arc::new(Mutex::new(tx));
310
        let chunks = Arc::clone(&sink);
311
        // Whether the reply reached the frame as it was written. The answer
312
        // `execute_turn` returns has normally already streamed, so repeating
313
        // it would print it twice; this is what tells the one case from the
314
        // other.
315
        let streamed = Arc::new(AtomicBool::new(false));
316
        let saw = Arc::clone(&streamed);
317
        let result = self
318
            .inner
319
            .execute_turn(prompt, move |chunk| {
320
                if !chunk.is_empty() {
321
                    saw.store(true, Ordering::Relaxed);
322
                    send(&chunks, Control::Chunk(chunk.to_string()));
218 323
                }
219
            }
324
            })
325
            .await;
220 326
221
            if !collected.is_empty() {
222
                self.history.push(Item::assistant_message(collected));
327
        if let Some(model) = &self.inner.last_model {
328
            send(&sink, Control::Model(model.clone()));
329
        }
330
        if self.inner.last_usage.reported() {
331
            send(&sink, Control::Usage(self.inner.last_usage));
332
        }
333
        match result {
334
            Ok(answer) => {
335
                // The fallback for a path that answered without streaming, and
336
                // nothing else: an empty answer stays empty rather than
337
                // becoming a sentence somebody could read as a reply.
338
                if !answer.is_empty() && !streamed.load(Ordering::Relaxed) {
339
                    send(&sink, Control::Chunk(answer));
340
                }
223 341
            }
224
            break;
342
            Err(error) => send(&sink, Control::Failed(error.to_string())),
225 343
        }
344
        for failure in self.inner.record_failures.drain(..) {
345
            send(&sink, Control::Notice(failure));
346
        }
347
        send(&sink, Control::Done);
348
    }
226 349
227
        let _ = tx.send(Control::Done);
228
        Ok(())
350
    /// Revoke this session's thread and say what the server billed.
351
    ///
352
    /// Awaited by the caller rather than left to `Drop`: a thread left open
353
    /// holds its grant's remaining budget, and the `Drop` backstop can only
354
    /// spawn a `DELETE` this process may exit before polling.
355
    pub async fn close(&mut self) -> Result<Option<String>, Failure> {
356
        let spent = self.inner.close().await?;
357
        Ok(self.inner.spend_line(spent))
229 358
    }
359
}
360
361
/// The repository this session was opened in, as `owner/name`, when it is one.
362
///
363
/// Recorded on the thread so `oa coder --resume` has something to filter on. A
364
/// directory that is not an OpenAgents checkout has none, which is not an
365
/// error — the thread is simply not attributable to a repository.
366
fn repository() -> Option<String> {
367
    let endpoint = openagents_cli::auth::resolve_endpoint(None, None).ok()?;
368
    openagents_cli::repo::infer_repository(&endpoint.origin, None).ok()
369
}
370
371
/// The one-line header a tool call shows above its output box.
372
///
373
/// Built from the call's own arguments, so it says what was actually asked
374
/// for. A tool this does not know by name still gets a header rather than a
375
/// blank one, because a call with no header is a call the reader cannot see.
376
pub fn tool_title(name: &str, arguments: &str) -> String {
377
    let parsed: serde_json::Value =
378
        serde_json::from_str(arguments).unwrap_or(serde_json::Value::Null);
379
    let string = |key: &str| {
380
        parsed
381
            .get(key)
382
            .and_then(|v| v.as_str())
383
            .map(str::trim)
384
            .filter(|v| !v.is_empty())
385
            .map(str::to_string)
386
    };
230 387
231
    fn delegate_tool(&self) -> Option<Vec<Tool>> {
232
        if self.agents.is_empty() {
233
            return None;
388
    let detail = match name {
389
        "shell" => string("command"),
390
        "skill" => string("name"),
391
        "openagents" => parsed.get("args").and_then(|v| v.as_array()).map(|args| {
392
            args.iter()
393
                .filter_map(|v| v.as_str())
394
                .collect::<Vec<_>>()
395
                .join(" ")
396
        }),
397
        "capability" => string("name").or_else(|| string("query")),
398
        "delegate" => {
399
            let count = parsed
400
                .get("count")
401
                .and_then(|v| v.as_u64())
402
                .filter(|n| *n > 1)
403
                .map(|n| format!("×{n} "))
404
                .unwrap_or_default();
405
            string("prompt").map(|prompt| format!("{count}{prompt}"))
234 406
        }
235
        let ids: Vec<String> = self.agents.iter().map(|a| a.id.clone()).collect();
236
        let tool = Tool::function("delegate")
237
            .with_description("Delegate a coding task to an ACP agent on this machine.")
238
            .with_parameters(serde_json::json!({
239
                "type": "object",
240
                "properties": {
241
                    "agent": {
242
                        "type": "string",
243
                        "enum": ids,
244
                        "description": "the ACP agent to delegate to"
245
                    },
246
                    "prompt": {
247
                        "type": "string",
248
                        "description": "the task for the child agent"
249
                    }
250
                },
251
                "required": ["agent", "prompt"]
252
            }));
253
        Some(vec![tool])
407
        // A plugin loaded through `capability` declares a tool under its own
408
        // name and over its own schema, so there is nothing general to read
409
        // out of it but the arguments themselves.
410
        _ => (parsed != serde_json::Value::Null).then(|| parsed.to_string()),
411
    };
412
413
    match detail {
414
        Some(detail) => format!("{name} {}", one_line(&detail)),
415
        None => name.to_string(),
416
    }
417
}
418
419
/// The first line of `text`, marked when there was more.
420
fn one_line(text: &str) -> String {
421
    let first = text.lines().next().unwrap_or_default().trim();
422
    if text.lines().nth(1).is_some() {
423
        format!("{first} …")
424
    } else {
425
        first.to_string()
426
    }
427
}
428
429
#[cfg(test)]
430
mod tests {
431
    use super::*;
432
433
    /// The prompt is the product. A merge that reworded it would pass every
434
    /// other test in this crate.
435
    #[test]
436
    fn the_system_prompt_opens_with_the_terse_instructions_unchanged() {
437
        let prompt = system_prompt(&[]);
438
        assert!(
439
            prompt.starts_with(SYSTEM_INSTRUCTIONS),
440
            "the instructions were not carried verbatim: {prompt}"
441
        );
442
        assert!(SYSTEM_INSTRUCTIONS.contains("no greetings"));
443
        assert!(SYSTEM_INSTRUCTIONS.contains("no unnecessary padding"));
444
    }
445
446
    /// A model told it has tools it does not have will claim to have run them.
447
    #[test]
448
    fn the_prompt_names_every_declared_tool_and_claims_no_others() {
449
        let tools = vec![
450
            ToolDefinition {
451
                name: "shell".to_string(),
452
                description: String::new(),
453
                parameters: serde_json::json!({}),
454
            },
455
            ToolDefinition {
456
                name: "skill".to_string(),
457
                description: String::new(),
458
                parameters: serde_json::json!({}),
459
            },
460
        ];
461
        let prompt = system_prompt(&tools);
462
        assert!(prompt.contains("You have 2 tools, and no others:"), "{prompt}");
463
        assert!(prompt.contains("- `shell`"), "{prompt}");
464
        assert!(prompt.contains("- `skill`"), "{prompt}");
465
466
        let none = system_prompt(&[]);
467
        assert!(none.contains("You have no tools in this session"), "{none}");
468
    }
469
470
    #[test]
471
    fn a_tool_header_says_what_the_call_asked_for() {
472
        assert_eq!(
473
            tool_title("shell", r#"{"command":"cargo test -p coder-lite"}"#),
474
            "shell cargo test -p coder-lite"
475
        );
476
        assert_eq!(tool_title("skill", r#"{"name":"effect"}"#), "skill effect");
477
        assert_eq!(
478
            tool_title("openagents", r#"{"args":["issue","list"]}"#),
479
            "openagents issue list"
480
        );
481
        assert_eq!(
482
            tool_title("delegate", r#"{"prompt":"read it","count":3}"#),
483
            "delegate ×3 read it"
484
        );
485
        // A multi-line command is one line on the header, and says so.
486
        assert_eq!(
487
            tool_title("shell", "{\"command\":\"one\\ntwo\"}"),
488
            "shell one …"
489
        );
490
        // Arguments that will not parse are not a reason to draw no header.
491
        assert_eq!(tool_title("shell", "not json"), "shell");
254 492
    }
255 493
}
crates/coder-lite/src/tui.rs modified +53 -53

@@ -7,10 +7,11 @@ use ratatui::{

7 7
    text::{Line, Span, Text},
8 8
    widgets::{Block, Borders, Paragraph},
9 9
};
10
use unicode_width::UnicodeWidthStr;
11 10
use serde_json::Value;
12 11
use std::time::{SystemTime, UNIX_EPOCH};
13 12
13
use openagents_cli::composer::Composer;
14
14 15
use crate::markdown::theme::{BACKGROUND_COLOR, TEXT_COLOR};
15 16
use crate::osc8::PlacedLink;
16 17
use crate::transcript::MarkdownContent;

@@ -24,6 +25,22 @@ pub enum Role {

24 25
    Tool,
25 26
    Reasoning,
26 27
    Notice,
28
    /// What one of the session's own commands printed — `/diff`, `/help`,
29
    /// `/resume`.
30
    ///
31
    /// Rendered through the markdown engine like an assistant turn, so a diff
32
    /// or a table reads the way it should, and exported as a notice rather
33
    /// than as a model step: the session wrote it, not a model, and a
34
    /// trajectory that says otherwise is a trajectory that lies about who
35
    /// produced what.
36
    Output,
37
}
38
39
impl Role {
40
    /// Whether this role's text goes through the streaming markdown engine.
41
    fn is_markdown(&self) -> bool {
42
        matches!(self, Role::Assistant | Role::Output)
43
    }
27 44
}
28 45
29 46
/// One tool call captured for ATIF export.

@@ -96,7 +113,7 @@ impl Entry {

96 113
    /// a rendering failure can always fall back to showing what arrived, and
97 114
    /// it is what `/export` writes into the ATIF document.
98 115
    pub fn push_text(&mut self, chunk: &str) {
99
        if self.role == Role::Assistant {
116
        if self.role.is_markdown() {
100 117
            // Order matters. `markdown_mut` seeds a fresh renderer from
101 118
            // `self.text`, so the chunk must reach the renderer *before* it
102 119
            // joins `self.text` — otherwise the first chunk of a stream is

@@ -108,7 +125,7 @@ impl Entry {

108 125
109 126
    /// Tell the markdown engine the stream ended, flushing any held-back bytes.
110 127
    pub fn finish_text(&mut self) {
111
        if self.role == Role::Assistant && self.md.is_some() {
128
        if self.role.is_markdown() && self.md.is_some() {
112 129
            self.markdown_mut().finish();
113 130
        }
114 131
    }

@@ -132,9 +149,19 @@ impl Entry {

132 149
133 150
#[derive(Debug)]
134 151
pub struct CoderUi {
135
    pub composer: String,
152
    /// The line being typed.
153
    ///
154
    /// The grok-derived multi-line editor from `openagents-cli`, so the
155
    /// readline chords — Ctrl+A, Ctrl+E, Ctrl+W, Ctrl+K, Ctrl+U, Alt+B, Alt+F
156
    /// — and word motions work here, and the caret is where the caret is
157
    /// rather than always at the end of the text.
158
    pub composer: Composer,
136 159
    pub repo: String,
137 160
    pub branch: String,
161
    /// The model that answered the last turn, as its grant pinned it.
162
    ///
163
    /// Empty until one has. Never a guess: `--lane` says what was asked for,
164
    /// and this says what answered, and the two are not the same fact.
138 165
    pub model: String,
139 166
    pub reasoning: Option<String>,
140 167
    pub running: bool,

@@ -188,51 +215,14 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {

188 215
    lines
189 216
}
190 217
191
fn wrap_input(text: &str, width: usize) -> Vec<String> {
192
    if width == 0 {
193
        return vec![text.to_string()];
194
    }
195
    let mut lines = Vec::new();
196
197
    for paragraph in text.split('\n') {
198
        let mut current = String::new();
199
        let mut current_width = 0;
200
        for c in paragraph.chars() {
201
            let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
202
            if current_width + w <= width {
203
                current.push(c);
204
                current_width += w;
205
            } else {
206
                lines.push(current);
207
                current = c.to_string();
208
                current_width = w;
209
            }
210
        }
211
212
        if current.is_empty() {
213
            if lines.is_empty() {
214
                lines.push(String::new());
215
            }
216
        } else {
217
            lines.push(current);
218
        }
219
    }
220
221
    if lines.is_empty() {
222
        lines.push(String::new());
223
    }
224
225
    lines
226
}
227
228 218
impl CoderUi {
229 219
    pub fn new() -> Self {
230 220
        Self {
231
            composer: String::new(),
221
            composer: Composer::new(),
232 222
            repo: "~/work/openagents".to_string(),
233 223
            branch: "main".to_string(),
234
            model: "sol-high".to_string(),
235
            reasoning: Some("medium".to_string()),
224
            model: String::new(),
225
            reasoning: None,
236 226
            running: true,
237 227
            entries: vec![],
238 228
            scroll_override: None,

@@ -259,7 +249,13 @@ impl CoderUi {

259 249
            .saturating_sub(2)
260 250
            .saturating_sub(3)
261 251
            .max(1);
262
        let input_chunks = wrap_input(&self.composer, input_width);
252
        let input_chunks: Vec<String> = self
253
            .composer
254
            .rows(input_width)
255
            .into_iter()
256
            .map(str::to_string)
257
            .collect();
258
        let (caret_row, caret_col) = self.composer.cursor_rowcol(input_width);
263 259
        let total_input_lines = input_chunks.len() as u16;
264 260
        let max_input_lines: u16 = 8;
265 261
        let visible_input_lines = total_input_lines.min(max_input_lines);

@@ -323,14 +319,18 @@ impl CoderUi {

323 319
        );
324 320
        frame.render_widget(input, input_area);
325 321
326
        let last_chunk = input_chunks.last().map(|s| s.as_str()).unwrap_or("");
327
        let last_prefix_width: u16 = 3;
328
        let cursor_x = input_area.x
329
            + 1
330
            + last_prefix_width
331
            + last_chunk.width().min(input_width as usize) as u16;
332
        let cursor_y = input_area.y + 1 + visible_input_lines.saturating_sub(1);
322
        // Where the caret actually is, not where the text ends: Ctrl+A and the
323
        // word motions move it, and a caret drawn at the end of the line while
324
        // the next character lands in the middle is a frame that lies.
325
        let caret_screen_row = (caret_row as u16).saturating_sub(input_scroll);
326
        let cursor_x = input_area.x + 1 + 3 + caret_col as u16;
327
        let cursor_y = input_area.y + 1 + caret_screen_row.min(visible_input_lines.saturating_sub(1));
333 328
        frame.set_cursor_position(Position::new(cursor_x, cursor_y));
329
        // A block cursor, as grok-build's textarea draws one: the hardware
330
        // cursor alone is easy to lose in the alternate screen, and a trailing
331
        // space with nothing over it looks like a line that ends earlier than
332
        // it does. `REVERSED` swaps the two palette colours for one cell, so
333
        // the block is ground-on-amber and the palette is untouched.
334 334
        let buf = frame.buffer_mut();
335 335
        if let Some(cell) = buf.cell_mut((cursor_x, cursor_y)) {
336 336
            cell.modifier.insert(Modifier::REVERSED);

@@ -382,7 +382,7 @@ fn render_entry(

382 382
    let text_style = Style::default().fg(TEXT_COLOR).bg(BACKGROUND_COLOR);
383 383
384 384
    match entry.role {
385
        Role::Assistant if !entry.text.is_empty() => {
385
        ref role if role.is_markdown() && !entry.text.is_empty() => {
386 386
            let width = width.max(1);
387 387
            let md = entry.markdown_mut();
388 388
            let mut lines = md.lines(width).to_vec();
crates/coder-lite/tests/frame.rs added +237

@@ -0,0 +1,237 @@

1
//! What the frame does with what the runtime tells it.
2
//!
3
//! `apply` is the whole of it, and it is public so this can drive it without a
4
//! terminal. The properties here are the ones issue #105 names: a status field
5
//! reports a value it received or stays empty, a failure does not become an
6
//! answer, and nothing the session prints leaves the palette.
7
8
use coder_lite::interactive::apply;
9
use coder_lite::runtime::Control;
10
use coder_lite::tui::{CoderUi, Entry, Role};
11
use openagents_cli::runtime::TurnUsage;
12
use ratatui::Terminal;
13
use ratatui::backend::TestBackend;
14
use ratatui::style::Color;
15
16
fn draw(ui: &mut CoderUi) -> ratatui::buffer::Buffer {
17
    let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
18
    terminal
19
        .draw(|f| {
20
            let area = f.area();
21
            ui.render(f, area);
22
        })
23
        .unwrap();
24
    terminal.backend().buffer().clone()
25
}
26
27
fn text_of(buffer: &ratatui::buffer::Buffer) -> String {
28
    buffer.content.iter().map(|c| c.symbol()).collect()
29
}
30
31
/// The model field holds what answered, and nothing until something has.
32
#[test]
33
fn the_model_field_is_empty_until_a_model_answers() {
34
    let mut ui = CoderUi::new();
35
    assert_eq!(ui.model, "", "a model was named before one answered");
36
    apply(&mut ui, Control::Model("gemini-3.7-flash".to_string()));
37
    assert_eq!(ui.model, "gemini-3.7-flash");
38
}
39
40
/// A turn that failed does not read afterwards as one that answered.
41
#[test]
42
fn a_failure_settles_the_stream_and_is_not_written_into_it() {
43
    let mut ui = CoderUi::new();
44
    ui.entries.push(Entry::new(Role::Assistant, "half an ans"));
45
    ui.loading = true;
46
47
    apply(
48
        &mut ui,
49
        Control::Failed("the proxy refused the turn: 503".to_string()),
50
    );
51
52
    let assistant = ui
53
        .entries
54
        .iter()
55
        .rfind(|e| e.role == Role::Assistant)
56
        .expect("the streamed entry survived");
57
    assert_eq!(
58
        assistant.text, "half an ans",
59
        "the failure was appended to the reply"
60
    );
61
    assert!(
62
        ui.entries.iter().any(|e| e.role == Role::Notice && e.text.contains("503")),
63
        "the failure was swallowed"
64
    );
65
    assert!(!ui.loading, "the frame kept spinning over a finished turn");
66
}
67
68
/// The header says a call failed, where the reader is looking.
69
#[test]
70
fn a_failed_tool_call_says_so_on_its_header() {
71
    let mut ui = CoderUi::new();
72
    apply(
73
        &mut ui,
74
        Control::Tool {
75
            call_id: "c1".to_string(),
76
            name: "shell".to_string(),
77
            arguments: r#"{"command":"cargo test"}"#.to_string(),
78
        },
79
    );
80
    let header = ui.entries.last().unwrap().text.clone();
81
    assert_eq!(header, "shell cargo test");
82
83
    apply(
84
        &mut ui,
85
        Control::ToolOutput {
86
            call_id: "c1".to_string(),
87
            chunk: "The command exited with code 1.".to_string(),
88
        },
89
    );
90
    apply(
91
        &mut ui,
92
        Control::ToolDone {
93
            call_id: "c1".to_string(),
94
            is_error: true,
95
        },
96
    );
97
98
    let entry = ui.entries.last().unwrap();
99
    assert_eq!(entry.text, "shell cargo test — failed");
100
    assert!(entry.output.as_deref().unwrap().contains("exited with code 1"));
101
    // And the ATIF record carries the failure too, not just the screen.
102
    assert!(entry.tool.as_ref().unwrap().error.is_some());
103
}
104
105
/// Output for a call the frame never saw start goes nowhere rather than onto
106
/// whatever entry happens to be last.
107
#[test]
108
fn output_for_an_unknown_call_lands_on_nothing() {
109
    let mut ui = CoderUi::new();
110
    ui.entries.push(Entry::new(Role::Assistant, "an answer"));
111
    apply(
112
        &mut ui,
113
        Control::ToolOutput {
114
            call_id: "never-started".to_string(),
115
            chunk: "stray".to_string(),
116
        },
117
    );
118
    assert!(
119
        !ui.entries.iter().any(|e| e.text.contains("stray")),
120
        "stray output was attached to an unrelated entry"
121
    );
122
}
123
124
/// Usage is shown only when the server reported some.
125
#[test]
126
fn an_unreported_usage_is_not_printed_as_zero() {
127
    let mut ui = CoderUi::new();
128
    apply(&mut ui, Control::Usage(TurnUsage::default()));
129
    assert!(ui.entries.is_empty(), "a zero was reported as a figure");
130
131
    apply(
132
        &mut ui,
133
        Control::Usage(TurnUsage {
134
            prompt_tokens: 10,
135
            completion_tokens: 5,
136
            total_tokens: 15,
137
        }),
138
    );
139
    assert_eq!(ui.entries.len(), 1);
140
    assert!(ui.entries[0].text.contains("15 tokens"), "{:?}", ui.entries[0].text);
141
}
142
143
/// What `/diff` and `/help` print is still coder-lite.
144
#[test]
145
fn a_command_s_output_keeps_the_amber_palette() {
146
    let mut ui = CoderUi::new();
147
    apply(
148
        &mut ui,
149
        Control::Output("**Commands**\n\n- `/help` — list them\n\n```diff\n+added\n-removed\n```".to_string()),
150
    );
151
    let buffer = draw(&mut ui);
152
153
    let amber = Color::Rgb(255, 176, 0);
154
    let ground = Color::Rgb(8, 6, 0);
155
    for y in 0..buffer.area.height {
156
        for x in 0..buffer.area.width {
157
            let cell = buffer.cell((x, y)).unwrap();
158
            assert_eq!(cell.fg, amber, "cell ({x},{y}) {:?} drifted off amber", cell.fg);
159
            assert_eq!(cell.bg, ground, "cell ({x},{y}) {:?} drifted off ground", cell.bg);
160
        }
161
    }
162
    let text = text_of(&buffer);
163
    assert!(text.contains("+added"), "{text}");
164
    assert!(text.contains("Commands"), "{text}");
165
}
166
167
/// Typing goes into the composer and the caret follows it.
168
#[test]
169
fn what_is_typed_is_drawn_in_the_input_box() {
170
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
171
172
    let mut ui = CoderUi::new();
173
    let width = coder_lite::interactive::composer_width(80);
174
    for ch in "explain rust".chars() {
175
        ui.composer
176
            .handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), width);
177
    }
178
    let buffer = draw(&mut ui);
179
    assert!(text_of(&buffer).contains("explain rust"));
180
181
    // Ctrl+A is one of the chords `/help` claims. It moves the caret without
182
    // moving the text.
183
    ui.composer.handle_key(
184
        &KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
185
        width,
186
    );
187
    assert_eq!(ui.composer.cursor_rowcol(width), (0, 0));
188
    assert_eq!(ui.composer.text(), "explain rust");
189
}
190
191
/// What `c06badb472` fixed, against the composer that replaced the code it
192
/// fixed.
193
///
194
/// That commit rewrote `wrap_input` to wrap by character because the old one
195
/// went through `split_whitespace`, which threw trailing spaces away, and it
196
/// put a `REVERSED` block on the cursor cell so a line ending in a space did
197
/// not look like a line that ended earlier. The wrapping is now
198
/// `openagents_cli::composer`, which slices byte ranges out of the text and so
199
/// cannot lose a space; this holds it to that.
200
#[test]
201
fn trailing_spaces_are_kept_and_the_block_cursor_sits_after_them() {
202
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
203
    use ratatui::style::Modifier;
204
205
    let mut ui = CoderUi::new();
206
    let width = coder_lite::interactive::composer_width(80);
207
    for ch in "ab   ".chars() {
208
        ui.composer
209
            .handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), width);
210
    }
211
    assert_eq!(ui.composer.text(), "ab   ", "the trailing spaces were eaten");
212
    // Five columns in, not two: the caret is past the spaces.
213
    assert_eq!(ui.composer.cursor_rowcol(width), (0, 5));
214
215
    let buffer = draw(&mut ui);
216
    let reversed: Vec<(u16, u16)> = (0..buffer.area.height)
217
        .flat_map(|y| (0..buffer.area.width).map(move |x| (x, y)))
218
        .filter(|(x, y)| {
219
            buffer
220
                .cell((*x, *y))
221
                .unwrap()
222
                .modifier
223
                .contains(Modifier::REVERSED)
224
        })
225
        .collect();
226
    assert_eq!(reversed.len(), 1, "expected exactly one block cursor");
227
228
    // One cell past `ab   `, inside the border and the `" > "` gutter.
229
    let (x, _) = reversed[0];
230
    assert_eq!(x, 1 + 3 + 5, "the block cursor is not after the spaces");
231
232
    // And it is still the palette: REVERSED swaps the two colours for that
233
    // cell, it does not introduce a third.
234
    let cell = buffer.cell(reversed[0]).unwrap();
235
    assert_eq!(cell.fg, Color::Rgb(255, 176, 0));
236
    assert_eq!(cell.bg, Color::Rgb(8, 6, 0));
237
}
crates/coder-lite/tests/smoke.rs deleted -49

@@ -1,49 +0,0 @@

1
use coder_lite::runtime::{CoderRuntimeSession, Control};
2
use std::sync::mpsc;
3
use std::time::Duration;
4
5
#[tokio::test]
6
async fn smoke_openresponses_stream() {
7
    // SAFETY: edition 2024 marks `set_var` unsafe. Set before any task that
8
    // reads the environment is spawned.
9
    unsafe {
10
        if env_unset("OPENAGENTS_API_KEY") {
11
            std::env::set_var("OPENAGENTS_API_KEY", "fake");
12
        }
13
        if env_unset("OPENAGENTS_BASE_URL") {
14
            std::env::set_var("OPENAGENTS_BASE_URL", "https://openagents.com/api/v1");
15
        }
16
    }
17
18
    let (tx, rx) = mpsc::channel::<Control>();
19
    let mut session = CoderRuntimeSession::new();
20
21
    tokio::spawn(async move {
22
        let _ = session.execute_turn("hi", tx).await;
23
    });
24
25
    let mut collected = String::new();
26
    let mut done = false;
27
    let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
28
29
    while !done && tokio::time::Instant::now() < deadline {
30
        match rx.try_recv() {
31
            Ok(Control::Chunk(c)) => {
32
                eprint!("{}", c);
33
                collected.push_str(&c);
34
            }
35
            Ok(Control::Done) => done = true,
36
            Ok(_) => {}
37
            Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
38
        }
39
    }
40
41
    eprintln!("\n---COLLECTED---");
42
    eprintln!("{}", collected);
43
    assert!(!collected.is_empty(), "no response chunks received");
44
    assert!(!collected.starts_with("[error:"), "received an error: {}", collected);
45
}
46
47
fn env_unset(name: &str) -> bool {
48
    std::env::var(name).is_err()
49
}
crates/coder-lite/tests/turn.rs added +496

@@ -0,0 +1,496 @@

1
//! What a coder-lite turn does on the wire, proved against a real socket.
2
//!
3
//! The session under test is the production one — [`coder_lite::runtime::Session`]
4
//! over `openagents_cli`'s thread, grant, and proxy path — talking to a server
5
//! this file starts on a real port. Nothing is mocked between the session and
6
//! the socket.
7
//!
8
//! Two properties are what these are for:
9
//!
10
//! - **A tool really runs.** The stub asks for `shell`, and the assertion is
11
//!   that the command's own output came back through the frame's channel. A
12
//!   test that only checked the tool was *declared* passes on a session that
13
//!   runs nothing.
14
//! - **A refusal is a refusal.** The version of this runtime that these guard
15
//!   against answered a rejected request with a fabricated grant and a
16
//!   sentence about an offline fallback. So the refusal tests assert on
17
//!   `Failed` and on what it says, and assert that no reply text arrived.
18
19
use coder_lite::runtime::{Control, Session};
20
use openagents_cli::runtime::Lane;
21
use std::sync::mpsc::{Receiver, Sender, channel};
22
use std::sync::{Arc, Mutex};
23
use std::time::Duration;
24
use tokio::io::{AsyncReadExt, AsyncWriteExt};
25
26
// ───────────────────────────────────────────────────────────────── the server
27
28
enum Reply {
29
    Body(u16, &'static str, String),
30
    Sse(Vec<String>),
31
}
32
33
struct Stub {
34
    base: String,
35
    requests: Arc<Mutex<Vec<String>>>,
36
}
37
38
impl Stub {
39
    fn request_lines(&self) -> Vec<String> {
40
        self.requests
41
            .lock()
42
            .unwrap()
43
            .iter()
44
            .map(|r| r.lines().next().unwrap_or_default().to_string())
45
            .collect()
46
    }
47
}
48
49
fn start<H>(handler: H) -> Stub
50
where
51
    H: Fn(&str, &str) -> Reply + Send + Sync + 'static,
52
{
53
    let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
54
    listener.set_nonblocking(true).unwrap();
55
    let port = listener.local_addr().unwrap().port();
56
    let listener = tokio::net::TcpListener::from_std(listener).unwrap();
57
    let origin = format!("http://127.0.0.1:{port}");
58
    let base = format!("{origin}/api/v1");
59
    let requests = Arc::new(Mutex::new(Vec::new()));
60
61
    let seen = Arc::clone(&requests);
62
    let handler = Arc::new(handler);
63
    tokio::spawn(async move {
64
        loop {
65
            let Ok((mut socket, _)) = listener.accept().await else {
66
                return;
67
            };
68
            let seen = Arc::clone(&seen);
69
            let handler = Arc::clone(&handler);
70
            let origin = origin.clone();
71
            tokio::spawn(async move {
72
                let Some(request) = read_request(&mut socket).await else {
73
                    return;
74
                };
75
                seen.lock().unwrap().push(request.clone());
76
                match handler(&request, &origin) {
77
                    Reply::Body(status, content_type, body) => {
78
                        let head = format!(
79
                            "HTTP/1.1 {status} X\r\ncontent-type: {content_type}\r\n\
80
                             content-length: {}\r\nconnection: close\r\n\r\n",
81
                            body.len()
82
                        );
83
                        let _ = socket.write_all(head.as_bytes()).await;
84
                        let _ = socket.write_all(body.as_bytes()).await;
85
                    }
86
                    Reply::Sse(frames) => {
87
                        let _ = socket
88
                            .write_all(
89
                                b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
90
                                  connection: close\r\n\r\n",
91
                            )
92
                            .await;
93
                        let _ = socket.flush().await;
94
                        for frame in frames {
95
                            let _ = socket
96
                                .write_all(format!("data: {frame}\n\n").as_bytes())
97
                                .await;
98
                            let _ = socket.flush().await;
99
                        }
100
                        let _ = socket.write_all(b"data: [DONE]\n\n").await;
101
                    }
102
                }
103
                let _ = socket.flush().await;
104
                let _ = socket.shutdown().await;
105
            });
106
        }
107
    });
108
109
    Stub { base, requests }
110
}
111
112
async fn read_request(socket: &mut tokio::net::TcpStream) -> Option<String> {
113
    let mut request = Vec::new();
114
    let mut buffer = [0u8; 4096];
115
    loop {
116
        let read = socket.read(&mut buffer).await.ok()?;
117
        if read == 0 {
118
            break;
119
        }
120
        request.extend_from_slice(&buffer[..read]);
121
        let text = String::from_utf8_lossy(&request);
122
        if let Some(headers_end) = text.find("\r\n\r\n") {
123
            let length = text
124
                .lines()
125
                .find_map(|line| {
126
                    line.strip_prefix("content-length: ")
127
                        .or_else(|| line.strip_prefix("Content-Length: "))
128
                })
129
                .and_then(|value| value.trim().parse::<usize>().ok())
130
                .unwrap_or(0);
131
            if request.len() >= headers_end + 4 + length {
132
                break;
133
            }
134
        }
135
    }
136
    Some(String::from_utf8_lossy(&request).to_string())
137
}
138
139
fn grant(origin: &str) -> String {
140
    format!(
141
        r#"{{"thread":{{"id":"th_test"}},"grant":{{"token":"sig_test","url":"{origin}/api/inference/proxy","model":"gemini-3.7-flash"}}}}"#
142
    )
143
}
144
145
/// One OpenAI-shaped chunk carrying text.
146
fn text(piece: &str) -> String {
147
    serde_json::json!({"choices":[{"delta":{"content": piece}}]}).to_string()
148
}
149
150
/// One chunk asking for a tool.
151
fn call(name: &str, arguments: serde_json::Value) -> String {
152
    serde_json::json!({"choices":[{"delta":{"tool_calls":[{
153
        "index": 0,
154
        "id": "call_1",
155
        "function": {"name": name, "arguments": arguments.to_string()}
156
    }]}}]})
157
    .to_string()
158
}
159
160
fn usage(total: u64) -> String {
161
    serde_json::json!({"choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":total}}).to_string()
162
}
163
164
// ───────────────────────────────────────────────────────────────── the harness
165
166
fn session(base: &str, tx: Sender<Control>) -> Session {
167
    Session::open_at(
168
        Lane::Flash,
169
        "flash",
170
        None,
171
        // No ACP agents: whether one happens to be installed on the machine
172
        // running the tests is not this test's business.
173
        Vec::new(),
174
        base.to_string(),
175
        Some("oat_test".to_string()),
176
        tx,
177
    )
178
}
179
180
/// Everything the frame was told, in order, once the turn has finished.
181
fn drain(rx: &Receiver<Control>) -> Vec<Control> {
182
    let mut seen = Vec::new();
183
    while let Ok(control) = rx.try_recv() {
184
        seen.push(control);
185
    }
186
    seen
187
}
188
189
fn reply_text(seen: &[Control]) -> String {
190
    seen.iter()
191
        .filter_map(|c| match c {
192
            Control::Chunk(chunk) => Some(chunk.as_str()),
193
            _ => None,
194
        })
195
        .collect()
196
}
197
198
fn tool_output(seen: &[Control]) -> String {
199
    seen.iter()
200
        .filter_map(|c| match c {
201
            Control::ToolOutput { chunk, .. } => Some(chunk.as_str()),
202
            _ => None,
203
        })
204
        .collect()
205
}
206
207
// ─────────────────────────────────────────────────────────────────── the tests
208
209
/// The whole loop: a thread is opened, the model asks for `shell`, the command
210
/// runs on this machine, its output goes back, and the second step answers.
211
#[tokio::test]
212
async fn a_turn_opens_a_thread_runs_a_tool_and_streams_the_answer() {
213
    let step = Arc::new(Mutex::new(0usize));
214
    let stub = start(move |request, origin| {
215
        if request.contains("POST /api/v1/threads ") {
216
            return Reply::Body(200, "application/json", grant(origin));
217
        }
218
        if request.contains("/api/inference/proxy") {
219
            let mut at = step.lock().unwrap();
220
            *at += 1;
221
            return if *at == 1 {
222
                Reply::Sse(vec![call(
223
                    "shell",
224
                    serde_json::json!({"command": "printf ONE_TWO_THREE"}),
225
                )])
226
            } else {
227
                Reply::Sse(vec![text("ran it"), usage(42)])
228
            };
229
        }
230
        Reply::Body(200, "application/json", "{}".to_string())
231
    });
232
233
    let (tx, rx) = channel();
234
    let mut session = session(&stub.base, tx.clone());
235
    session.execute_turn("run it", tx).await;
236
237
    let seen = drain(&rx);
238
    assert!(
239
        seen.iter().any(|c| matches!(c, Control::Done)),
240
        "the turn never finished: {seen:?}"
241
    );
242
    assert!(
243
        !seen.iter().any(|c| matches!(c, Control::Failed(_))),
244
        "the turn failed: {seen:?}"
245
    );
246
247
    // The tool was announced before it answered, so the frame could show it.
248
    let announced = seen.iter().position(|c| matches!(c, Control::Tool { .. }));
249
    let answered = seen
250
        .iter()
251
        .position(|c| matches!(c, Control::ToolOutput { .. }));
252
    assert!(announced.is_some(), "no tool call reached the frame: {seen:?}");
253
    assert!(announced < answered, "the result preceded the call: {seen:?}");
254
255
    // And it really ran: this is the command's own output.
256
    assert!(
257
        tool_output(&seen).contains("ONE_TWO_THREE"),
258
        "the shell tool did not run: {seen:?}"
259
    );
260
    assert!(
261
        seen.iter()
262
            .any(|c| matches!(c, Control::ToolDone { is_error: false, .. })),
263
        "the call was not settled: {seen:?}"
264
    );
265
266
    assert_eq!(reply_text(&seen), "ran it");
267
268
    // What answered, as the grant pinned it — not the lane's preference.
269
    assert!(
270
        seen.iter()
271
            .any(|c| matches!(c, Control::Model(model) if model == "gemini-3.7-flash")),
272
        "the model that answered was not reported: {seen:?}"
273
    );
274
    // And what it cost, as the server reported it.
275
    assert!(
276
        seen.iter()
277
            .any(|c| matches!(c, Control::Usage(u) if u.total_tokens == 42)),
278
        "the usage was not reported: {seen:?}"
279
    );
280
281
    let lines = stub.request_lines();
282
    assert!(lines.iter().any(|l| l.starts_with("POST /api/v1/threads")));
283
}
284
285
/// A failing command is a failing command. Reporting it as a success is how a
286
/// session comes to believe a build passed.
287
#[tokio::test]
288
async fn a_tool_that_failed_is_settled_as_a_failure() {
289
    let step = Arc::new(Mutex::new(0usize));
290
    let stub = start(move |request, origin| {
291
        if request.contains("POST /api/v1/threads ") {
292
            return Reply::Body(200, "application/json", grant(origin));
293
        }
294
        if request.contains("/api/inference/proxy") {
295
            let mut at = step.lock().unwrap();
296
            *at += 1;
297
            return if *at == 1 {
298
                Reply::Sse(vec![call("shell", serde_json::json!({"command": "exit 7"}))])
299
            } else {
300
                Reply::Sse(vec![text("it failed")])
301
            };
302
        }
303
        Reply::Body(200, "application/json", "{}".to_string())
304
    });
305
306
    let (tx, rx) = channel();
307
    let mut session = session(&stub.base, tx.clone());
308
    session.execute_turn("run it", tx).await;
309
310
    let seen = drain(&rx);
311
    assert!(
312
        seen.iter()
313
            .any(|c| matches!(c, Control::ToolDone { is_error: true, .. })),
314
        "a command that exited 7 was settled as a success: {seen:?}"
315
    );
316
    assert!(tool_output(&seen).contains('7'), "{seen:?}");
317
}
318
319
/// The gate that refuses what cannot be undone travels with the tool.
320
#[tokio::test]
321
async fn a_destructive_command_is_refused_and_the_refusal_says_why() {
322
    let step = Arc::new(Mutex::new(0usize));
323
    let stub = start(move |request, origin| {
324
        if request.contains("POST /api/v1/threads ") {
325
            return Reply::Body(200, "application/json", grant(origin));
326
        }
327
        if request.contains("/api/inference/proxy") {
328
            let mut at = step.lock().unwrap();
329
            *at += 1;
330
            return if *at == 1 {
331
                Reply::Sse(vec![call("shell", serde_json::json!({"command": "rm -rf ~/"}))])
332
            } else {
333
                Reply::Sse(vec![text("understood")])
334
            };
335
        }
336
        Reply::Body(200, "application/json", "{}".to_string())
337
    });
338
339
    let (tx, rx) = channel();
340
    let mut session = session(&stub.base, tx.clone());
341
    session.execute_turn("clean up", tx).await;
342
343
    let seen = drain(&rx);
344
    let output = tool_output(&seen);
345
    assert!(
346
        output.contains("erase a root or a home directory"),
347
        "the refusal did not travel with the tool: {output}"
348
    );
349
    assert!(
350
        seen.iter()
351
            .any(|c| matches!(c, Control::ToolDone { is_error: true, .. })),
352
        "the refusal was settled as a success: {seen:?}"
353
    );
354
}
355
356
/// A server that will not open a thread ends the turn. It used to end it with
357
/// a grant the client made up out of the caller's own credential.
358
#[tokio::test]
359
async fn a_refused_thread_fails_the_turn_and_invents_no_reply() {
360
    let stub = start(|request, _| {
361
        if request.contains("POST /api/v1/threads ") {
362
            return Reply::Body(
363
                402,
364
                "application/json",
365
                r#"{"error":"insufficient_credit"}"#.to_string(),
366
            );
367
        }
368
        Reply::Body(200, "application/json", "{}".to_string())
369
    });
370
371
    let (tx, rx) = channel();
372
    let mut session = session(&stub.base, tx.clone());
373
    session.execute_turn("hello", tx).await;
374
375
    let seen = drain(&rx);
376
    let failure = seen
377
        .iter()
378
        .find_map(|c| match c {
379
            Control::Failed(why) => Some(why.clone()),
380
            _ => None,
381
        })
382
        .expect(&format!("the refusal was swallowed: {seen:?}"));
383
    assert!(failure.contains("402"), "{failure}");
384
    assert!(failure.contains("insufficient_credit"), "{failure}");
385
    assert_eq!(reply_text(&seen), "", "a reply was invented: {seen:?}");
386
    assert!(seen.iter().any(|c| matches!(c, Control::Done)));
387
    // No model answered, so none is named.
388
    assert!(
389
        !seen.iter().any(|c| matches!(c, Control::Model(_))),
390
        "a model was named where none answered: {seen:?}"
391
    );
392
}
393
394
/// A proxy that refuses mid-turn is the same rule one level down.
395
#[tokio::test]
396
async fn a_refused_proxy_fails_the_turn() {
397
    let stub = start(|request, origin| {
398
        if request.contains("POST /api/v1/threads ") {
399
            return Reply::Body(200, "application/json", grant(origin));
400
        }
401
        if request.contains("/api/inference/proxy") {
402
            return Reply::Body(
403
                503,
404
                "application/json",
405
                r#"{"error":"upstream_unavailable"}"#.to_string(),
406
            );
407
        }
408
        Reply::Body(200, "application/json", "{}".to_string())
409
    });
410
411
    let (tx, rx) = channel();
412
    let mut session = session(&stub.base, tx.clone());
413
    session.execute_turn("hello", tx).await;
414
415
    let seen = drain(&rx);
416
    let failure = seen
417
        .iter()
418
        .find_map(|c| match c {
419
            Control::Failed(why) => Some(why.clone()),
420
            _ => None,
421
        })
422
        .expect("the refusal was swallowed");
423
    assert!(failure.contains("503"), "{failure}");
424
    assert_eq!(reply_text(&seen), "", "a reply was invented");
425
}
426
427
/// Leaving revokes the thread and reports what the server billed. A thread
428
/// left open holds its grant's remaining budget.
429
#[tokio::test]
430
async fn closing_revokes_the_thread_and_reports_the_server_figure() {
431
    let stub = start(|request, origin| {
432
        if request.contains("POST /api/v1/threads ") {
433
            return Reply::Body(200, "application/json", grant(origin));
434
        }
435
        if request.contains("DELETE /api/v1/threads/th_test") {
436
            return Reply::Body(
437
                200,
438
                "application/json",
439
                r#"{"grant":{"spent":{"total_tokens":99}}}"#.to_string(),
440
            );
441
        }
442
        if request.contains("/api/inference/proxy") {
443
            return Reply::Sse(vec![text("hi"), usage(99)]);
444
        }
445
        Reply::Body(200, "application/json", "{}".to_string())
446
    });
447
448
    let (tx, rx) = channel();
449
    let mut session = session(&stub.base, tx.clone());
450
    session.execute_turn("hello", tx).await;
451
    let _ = drain(&rx);
452
453
    let line = tokio::time::timeout(Duration::from_secs(10), session.close())
454
        .await
455
        .expect("close hung")
456
        .expect("close failed")
457
        .expect("the server reported no spend");
458
    assert!(line.contains("99"), "{line}");
459
460
    assert!(
461
        stub.request_lines()
462
            .iter()
463
            .any(|l| l.starts_with("DELETE /api/v1/threads/th_test")),
464
        "the thread was never revoked: {:?}",
465
        stub.request_lines()
466
    );
467
}
468
469
/// A second turn keeps the first turn's thread rather than opening another.
470
/// Opening one per turn threw away the conversation's own budget and left a
471
/// trail of threads nothing revoked.
472
#[tokio::test]
473
async fn a_second_turn_reuses_the_first_turns_thread() {
474
    let stub = start(|request, origin| {
475
        if request.contains("POST /api/v1/threads ") {
476
            return Reply::Body(200, "application/json", grant(origin));
477
        }
478
        if request.contains("/api/inference/proxy") {
479
            return Reply::Sse(vec![text("ok")]);
480
        }
481
        Reply::Body(200, "application/json", "{}".to_string())
482
    });
483
484
    let (tx, rx) = channel();
485
    let mut session = session(&stub.base, tx.clone());
486
    session.execute_turn("one", tx.clone()).await;
487
    session.execute_turn("two", tx).await;
488
    let _ = drain(&rx);
489
490
    let opens = stub
491
        .request_lines()
492
        .iter()
493
        .filter(|l| l.starts_with("POST /api/v1/threads "))
494
        .count();
495
    assert_eq!(opens, 1, "a thread was opened per turn: {opens}");
496
}
crates/openagents-cli/src/runtime.rs modified +64

@@ -55,10 +55,43 @@ use eventsource_stream::Eventsource;

55 55
use futures::StreamExt;
56 56
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
57 57
use serde::{Deserialize, Serialize};
58
use std::sync::Arc;
58 59
use std::time::Duration;
59 60
60 61
type Failure = Box<dyn std::error::Error + Send + Sync>;
61 62
63
/// What a session says about the tools it runs, while it runs them.
64
///
65
/// The chunk callback carries the model's words and nothing else, so a caller
66
/// drawing a frame had no way to show that a `shell` call was in flight: a
67
/// two-minute test run was two minutes of a spinner over an empty transcript.
68
/// The call and its result are two events rather than one for exactly that
69
/// reason — the header goes up when the call starts, and the output fills in
70
/// when it returns.
71
#[derive(Debug, Clone)]
72
pub enum ToolEvent {
73
    /// A call the model made, as the arguments arrived on the wire.
74
    Started {
75
        call_id: String,
76
        name: String,
77
        /// The raw JSON string, not a re-encoding of it.
78
        arguments: String,
79
    },
80
    /// What the call answered, and whether it worked.
81
    Finished {
82
        call_id: String,
83
        name: String,
84
        output: String,
85
        is_error: bool,
86
    },
87
}
88
89
/// Who to tell about [`ToolEvent`]s.
90
///
91
/// `Arc` rather than `Box` because the session is moved into a task of its own
92
/// and the observer usually outlives the call that installed it.
93
pub type ToolObserver = Arc<dyn Fn(ToolEvent) + Send + Sync>;
94
62 95
pub const THREAD_LANE_NOTICE: &str =
63 96
    "You answer through the OpenAgents inference proxy, on a thread opened for this session. \
64 97
    Every round of tool calls re-sends the whole conversation to a metered model, so batch \

@@ -407,6 +440,11 @@ pub struct CoderRuntimeSession {

407 440
    pub http: reqwest::Client,
408 441
    pub tools: HarnessToolRegistry,
409 442
    pub messages: Vec<ChatMessage>,
443
    /// Told about every tool this session runs, as it runs it.
444
    ///
445
    /// `None` by default: a caller that does not draw a frame has nothing to
446
    /// do with the events, and the turn behaves exactly as it did before.
447
    pub tool_observer: Option<ToolObserver>,
410 448
    /// The thread to revoke when the session closes.
411 449
    thread_id: Option<String>,
412 450
}

@@ -451,10 +489,23 @@ impl CoderRuntimeSession {

451 489
                .unwrap_or_default(),
452 490
            tools,
453 491
            messages: Vec::new(),
492
            tool_observer: None,
454 493
            thread_id: None,
455 494
        }
456 495
    }
457 496
497
    /// Report every tool this session runs to `observer`.
498
    pub fn observing_tools(mut self, observer: ToolObserver) -> Self {
499
        self.tool_observer = Some(observer);
500
        self
501
    }
502
503
    fn tell(&self, event: ToolEvent) {
504
        if let Some(observer) = &self.tool_observer {
505
            observer(event);
506
        }
507
    }
508
458 509
    pub fn build_system_prompt(&self, tool_defs: &[ToolDefinition]) -> String {
459 510
        let notice = if self.lane.is_local() {
460 511
            LOCAL_LANE_NOTICE

@@ -1155,7 +1206,20 @@ impl CoderRuntimeSession {

1155 1206
                name: name.clone(),
1156 1207
                arguments,
1157 1208
            };
1209
            // Before the call, so a caller drawing a frame can show what is in
1210
            // flight rather than only what has already finished.
1211
            self.tell(ToolEvent::Started {
1212
                call_id: id.clone(),
1213
                name: name.clone(),
1214
                arguments: args_str.clone(),
1215
            });
1158 1216
            let result = self.tools.execute_tool(&call).await;
1217
            self.tell(ToolEvent::Finished {
1218
                call_id: id.clone(),
1219
                name: name.clone(),
1220
                output: result.output.clone(),
1221
                is_error: result.is_error,
1222
            });
1159 1223
            ran.push(ThreadRecord::tool_ran(
1160 1224
                &id,
1161 1225
                &name,
crates/openagents-cli/src/tools.rs modified +173

@@ -53,6 +53,31 @@ pub const OUTPUT_LIMIT: usize = 30_000;

53 53
pub const BUILTIN_TOOL_NAMES: [&str; 5] =
54 54
    ["shell", "skill", "openagents", "capability", "delegate"];
55 55
56
/// A tool the front-end driving the session answers itself.
57
///
58
/// The five tools above are every session's, and they stay here. A front-end
59
/// can have a capability no other caller has — coder-lite's ACP path, which
60
/// hands a task to a coding agent installed on this machine, is the one this
61
/// exists for — and it belongs in the same declaration the other five are in,
62
/// because the model reads one list and the system prompt counts it.
63
///
64
/// The handler is given the whole [`ToolCall`] rather than just its arguments
65
/// so it can stream what it is doing against the call's own id while it runs.
66
/// A name that collides with a built-in is refused at registration: a host
67
/// tool that shadowed `shell` would be a tool the model believes it knows the
68
/// behaviour of and does not.
69
pub struct HostTool {
70
    pub definition: ToolDefinition,
71
    pub run: HostToolFn,
72
}
73
74
/// What a host tool does, and whether it worked.
75
pub type HostToolFn = Arc<
76
    dyn Fn(&ToolCall) -> std::pin::Pin<Box<dyn std::future::Future<Output = (String, bool)> + Send>>
77
        + Send
78
        + Sync,
79
>;
80
56 81
/// The largest index at or below `max` that is a character boundary in `text`.
57 82
///
58 83
/// Slicing a `String` by a byte index panics when the index lands inside a

@@ -163,6 +188,8 @@ pub struct HarnessToolRegistry {

163 188
    /// Plugins the model loaded through `capability`, in load order. Each one
164 189
    /// declares a further tool under its own manifest name.
165 190
    loaded: Mutex<Vec<Arc<LoadedPlugin>>>,
191
    /// Tools the front-end driving this session answers itself.
192
    host: Vec<HostTool>,
166 193
}
167 194
168 195
impl HarnessToolRegistry {

@@ -200,6 +227,7 @@ impl HarnessToolRegistry {

200 227
            catalog,
201 228
            plugin_approval: Approval::default(),
202 229
            loaded: Mutex::new(Vec::new()),
230
            host: Vec::new(),
203 231
        };
204 232
        registry.load_local_skills();
205 233
        registry

@@ -293,6 +321,48 @@ impl HarnessToolRegistry {

293 321
        }
294 322
    }
295 323
324
    /// Declare a tool the caller answers itself.
325
    ///
326
    /// Three names are refused rather than accepted-and-shadowed, because in
327
    /// each case one of the two answers could never be reached and the model
328
    /// would be told a capability exists that does something else:
329
    ///
330
    /// - one of [`BUILTIN_TOOL_NAMES`], which this module answers first;
331
    /// - a name another host tool already holds;
332
    /// - a name a plugin in this session's catalog claims. A plugin is
333
    ///   dispatched from the fallthrough arm *below* the host tools, so a host
334
    ///   tool of the same name would shadow it exactly as a builtin would.
335
    ///   [`crate::plugins::validate_manifest`] cannot see this collision — it
336
    ///   validates a manifest on disk, long before a front end decides what to
337
    ///   declare — so it is caught here, where the catalog and the host tool
338
    ///   are both known.
339
    ///
340
    /// The caller is expected to say so rather than swallow the refusal: a
341
    /// capability that quietly failed to register is one the reader believes
342
    /// they have.
343
    pub fn add_host_tool(&mut self, tool: HostTool) -> Result<(), String> {
344
        let name = tool.definition.name.clone();
345
        if BUILTIN_TOOL_NAMES.contains(&name.as_str()) {
346
            return Err(format!(
347
                "`{name}` is one of this session's own tools and cannot be replaced by a host \
348
                 tool. The reserved names are {}.",
349
                BUILTIN_TOOL_NAMES.join(", ")
350
            ));
351
        }
352
        if self.host.iter().any(|held| held.definition.name == name) {
353
            return Err(format!("a host tool named `{name}` is already declared"));
354
        }
355
        if let Some(entry) = self.catalog.iter().find(|entry| entry.name == name) {
356
            return Err(format!(
357
                "a plugin named `{name}` is installed at {}, and a host tool of that name would \
358
                 shadow it. Rename one of them.",
359
                entry.manifest_path.display()
360
            ));
361
        }
362
        self.host.push(tool);
363
        Ok(())
364
    }
365
296 366
    /// The plugins loaded into this session so far.
297 367
    pub fn loaded_plugins(&self) -> Vec<Arc<LoadedPlugin>> {
298 368
        self.loaded

@@ -398,6 +468,11 @@ impl HarnessToolRegistry {

398 468
            });
399 469
        }
400 470
471
        // Whatever the front-end answers itself.
472
        for tool in &self.host {
473
            tools.push(tool.definition.clone());
474
        }
475
401 476
        // A plugin the model loaded through `capability` declares a tool of
402 477
        // its own, under its manifest name and over its manifest's input
403 478
        // schema. Nothing appears here that has not been digest-verified,

@@ -557,6 +632,17 @@ impl HarnessToolRegistry {

557 632
                }
558 633
            }
559 634
            other => {
635
                // The front-end's own tools first: they were declared before
636
                // any plugin was loaded, and `add_host_tool` has already
637
                // refused a name that collides with a built-in.
638
                if let Some(tool) = self.host.iter().find(|t| t.definition.name == other) {
639
                    let (output, is_error) = (tool.run)(call).await;
640
                    return ToolOutput {
641
                        call_id: call.id.clone(),
642
                        output,
643
                        is_error,
644
                    };
645
                }
560 646
                // A loaded plugin answers under its own manifest name.
561 647
                let plugin = self
562 648
                    .loaded_plugins()

@@ -1341,6 +1427,93 @@ mod tests {

1341 1427
        }
1342 1428
    }
1343 1429
1430
    // ───────────────────────────────────────────────── tools the host answers
1431
1432
    fn host_tool(name: &str, answer: &'static str) -> HostTool {
1433
        HostTool {
1434
            definition: ToolDefinition {
1435
                name: name.to_string(),
1436
                description: "A tool the front-end answers.".to_string(),
1437
                parameters: serde_json::json!({"type": "object"}),
1438
            },
1439
            run: Arc::new(move |call: &ToolCall| {
1440
                let id = call.id.clone();
1441
                Box::pin(async move { (format!("{answer} for {id}"), false) })
1442
            }),
1443
        }
1444
    }
1445
1446
    #[tokio::test]
1447
    async fn a_host_tool_is_declared_alongside_the_built_ins_and_answers() {
1448
        let root = tempfile::tempdir().unwrap();
1449
        let mut registry = HarnessToolRegistry::new(Some(root.path().to_path_buf()));
1450
        registry.add_host_tool(host_tool("acp", "handed off")).unwrap();
1451
1452
        let names: Vec<String> = registry.list_tools().into_iter().map(|t| t.name).collect();
1453
        assert_eq!(names, vec!["shell", "skill", "openagents", "capability", "acp"]);
1454
1455
        let out = registry
1456
            .execute_tool(&ToolCall {
1457
                id: "call_1".to_string(),
1458
                name: "acp".to_string(),
1459
                arguments: serde_json::json!({}),
1460
            })
1461
            .await;
1462
        assert!(!out.is_error);
1463
        assert_eq!(out.output, "handed off for call_1");
1464
    }
1465
1466
    /// Two answers to one name is a model told a tool does one thing while
1467
    /// something else does another.
1468
    #[test]
1469
    fn a_host_tool_cannot_take_a_built_in_name_or_its_own_twice() {
1470
        let root = tempfile::tempdir().unwrap();
1471
        let mut registry = HarnessToolRegistry::new(Some(root.path().to_path_buf()));
1472
        for reserved in BUILTIN_TOOL_NAMES {
1473
            let refusal = registry
1474
                .add_host_tool(host_tool(reserved, "x"))
1475
                .expect_err("a reserved name was accepted");
1476
            assert!(refusal.contains(reserved), "{refusal}");
1477
        }
1478
        registry.add_host_tool(host_tool("acp", "x")).unwrap();
1479
        assert!(registry.add_host_tool(host_tool("acp", "y")).is_err());
1480
        // And the refused names declared nothing.
1481
        let names: Vec<String> = registry.list_tools().into_iter().map(|t| t.name).collect();
1482
        assert_eq!(names.iter().filter(|n| *n == "acp").count(), 1);
1483
    }
1484
1485
    /// The collision `validate_manifest` cannot see. A plugin is installed
1486
    /// and valid; a front end then declares a host tool of the same name. The
1487
    /// host tool is dispatched first, so the plugin would be declared to the
1488
    /// model and never reached — the same defect `name_reserved` exists to
1489
    /// prevent, one layer up.
1490
    #[test]
1491
    fn a_host_tool_cannot_shadow_an_installed_plugin() {
1492
        let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..");
1493
        if !repo
1494
            .join("plugins")
1495
            .join("word-stats")
1496
            .join("manifest.json")
1497
            .is_file()
1498
        {
1499
            return;
1500
        }
1501
        let mut registry = HarnessToolRegistry::new(Some(repo));
1502
        assert!(
1503
            registry.catalog.iter().any(|e| e.name == "word_stats"),
1504
            "the checked-in catalog was not discovered"
1505
        );
1506
1507
        let refusal = registry
1508
            .add_host_tool(host_tool("word_stats", "the host's answer"))
1509
            .expect_err("a host tool shadowed an installed plugin");
1510
        assert!(refusal.contains("word_stats"), "{refusal}");
1511
        assert!(refusal.contains("shadow"), "{refusal}");
1512
        // And nothing was declared under the contested name twice.
1513
        let names: Vec<String> = registry.list_tools().into_iter().map(|t| t.name).collect();
1514
        assert_eq!(names.iter().filter(|n| *n == "word_stats").count(), 0);
1515
    }
1516
1344 1517
    #[tokio::test]
1345 1518
    async fn a_capability_search_names_a_plugin_and_loading_it_declares_its_tool() {
1346 1519
        let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..");

This page updates live while a promote is in flight · changelog