@pi-in-go/pigpen-ahp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/CREDITS.md +27 -0
  2. package/LICENSE +23 -0
  3. package/README.md +148 -0
  4. package/extensions/ahp/access_options_test.go +116 -0
  5. package/extensions/ahp/extension.go +54 -0
  6. package/extensions/ahp/go.mod +12 -0
  7. package/extensions/ahp/go.sum +2 -0
  8. package/extensions/ahp/go.work +8 -0
  9. package/extensions/ahp/internal/channels/chat.go +110 -0
  10. package/extensions/ahp/internal/channels/root.go +32 -0
  11. package/extensions/ahp/internal/channels/session.go +128 -0
  12. package/extensions/ahp/internal/channels/session_test.go +108 -0
  13. package/extensions/ahp/internal/compose/compose.go +153 -0
  14. package/extensions/ahp/internal/compose/compose_test.go +120 -0
  15. package/extensions/ahp/internal/gaps/gaps_test.go +88 -0
  16. package/extensions/ahp/internal/host/handshake_test.go +206 -0
  17. package/extensions/ahp/internal/host/host.go +1305 -0
  18. package/extensions/ahp/internal/host/reconnect_test.go +197 -0
  19. package/extensions/ahp/internal/host/replaywindow_test.go +31 -0
  20. package/extensions/ahp/internal/host/schema_test.go +79 -0
  21. package/extensions/ahp/internal/host/store.go +291 -0
  22. package/extensions/ahp/internal/host/store_update_test.go +85 -0
  23. package/extensions/ahp/internal/host/subscriptions_test.go +160 -0
  24. package/extensions/ahp/internal/host/surface_test.go +95 -0
  25. package/extensions/ahp/internal/host/workarounds.go +458 -0
  26. package/extensions/ahp/internal/host/workarounds_test.go +96 -0
  27. package/extensions/ahp/internal/live/live.go +328 -0
  28. package/extensions/ahp/internal/live/live_test.go +91 -0
  29. package/extensions/ahp/internal/live/store.go +113 -0
  30. package/extensions/ahp/internal/mapper/activity.go +199 -0
  31. package/extensions/ahp/internal/mapper/activity_test.go +188 -0
  32. package/extensions/ahp/internal/mapper/bench_test.go +38 -0
  33. package/extensions/ahp/internal/mapper/event_mapper_test.go +380 -0
  34. package/extensions/ahp/internal/mapper/fixtures_test.go +387 -0
  35. package/extensions/ahp/internal/mapper/helpers_test.go +251 -0
  36. package/extensions/ahp/internal/mapper/js.go +131 -0
  37. package/extensions/ahp/internal/mapper/mapper.go +600 -0
  38. package/extensions/ahp/internal/mapper/message_input_test.go +196 -0
  39. package/extensions/ahp/internal/mapper/messageinput.go +272 -0
  40. package/extensions/ahp/internal/mapper/schema_helpers_test.go +5 -0
  41. package/extensions/ahp/internal/mapper/testdata/LICENSE-pi-ahp +21 -0
  42. package/extensions/ahp/internal/mapper/testdata/fixtures/abort.json +711 -0
  43. package/extensions/ahp/internal/mapper/testdata/fixtures/bash-long-output.json +1312 -0
  44. package/extensions/ahp/internal/mapper/testdata/fixtures/compaction.json +6285 -0
  45. package/extensions/ahp/internal/mapper/testdata/fixtures/parallel-tools.json +2622 -0
  46. package/extensions/ahp/internal/mapper/testdata/fixtures/plain-text.json +1856 -0
  47. package/extensions/ahp/internal/mapper/testdata/fixtures/single-tool.json +1128 -0
  48. package/extensions/ahp/internal/mapper/testdata/fixtures/steering.json +3926 -0
  49. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-bash.json +2372 -0
  50. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-edit.json +3983 -0
  51. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-error.json +2850 -0
  52. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-find.json +1162 -0
  53. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-grep.json +2783 -0
  54. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-loop.json +1693 -0
  55. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-ls.json +2427 -0
  56. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-write.json +1271 -0
  57. package/extensions/ahp/internal/mapper/title.go +34 -0
  58. package/extensions/ahp/internal/mapper/usermsg.go +87 -0
  59. package/extensions/ahp/internal/pi/activeturn_test.go +296 -0
  60. package/extensions/ahp/internal/pi/backend.go +64 -0
  61. package/extensions/ahp/internal/pi/catalogue.go +370 -0
  62. package/extensions/ahp/internal/pi/catalogue_test.go +271 -0
  63. package/extensions/ahp/internal/pi/chatdriver.go +533 -0
  64. package/extensions/ahp/internal/pi/chatdriver_test.go +416 -0
  65. package/extensions/ahp/internal/pi/clientactions.go +212 -0
  66. package/extensions/ahp/internal/pi/clientactions_test.go +273 -0
  67. package/extensions/ahp/internal/pi/completions.go +230 -0
  68. package/extensions/ahp/internal/pi/completions_test.go +272 -0
  69. package/extensions/ahp/internal/pi/config_test.go +269 -0
  70. package/extensions/ahp/internal/pi/deletesession.go +69 -0
  71. package/extensions/ahp/internal/pi/deletesession_test.go +153 -0
  72. package/extensions/ahp/internal/pi/disposal_test.go +362 -0
  73. package/extensions/ahp/internal/pi/fixture_test.go +308 -0
  74. package/extensions/ahp/internal/pi/foreign_test.go +122 -0
  75. package/extensions/ahp/internal/pi/harness_test.go +83 -0
  76. package/extensions/ahp/internal/pi/history.go +269 -0
  77. package/extensions/ahp/internal/pi/hydrated_test.go +171 -0
  78. package/extensions/ahp/internal/pi/hydratedlifecycle_test.go +196 -0
  79. package/extensions/ahp/internal/pi/hydration_test.go +209 -0
  80. package/extensions/ahp/internal/pi/hydrator.go +212 -0
  81. package/extensions/ahp/internal/pi/imageinput.go +224 -0
  82. package/extensions/ahp/internal/pi/imageinput_test.go +57 -0
  83. package/extensions/ahp/internal/pi/lifecycle_test.go +381 -0
  84. package/extensions/ahp/internal/pi/models.go +212 -0
  85. package/extensions/ahp/internal/pi/models_test.go +128 -0
  86. package/extensions/ahp/internal/pi/paging.go +95 -0
  87. package/extensions/ahp/internal/pi/pagingtruncate_test.go +323 -0
  88. package/extensions/ahp/internal/pi/projecttrust.go +222 -0
  89. package/extensions/ahp/internal/pi/registry.go +924 -0
  90. package/extensions/ahp/internal/pi/restart_test.go +185 -0
  91. package/extensions/ahp/internal/pi/services.go +70 -0
  92. package/extensions/ahp/internal/pi/sessionconfig.go +64 -0
  93. package/extensions/ahp/internal/pi/sessionfiles_test.go +74 -0
  94. package/extensions/ahp/internal/pi/sessionstore.go +83 -0
  95. package/extensions/ahp/internal/pi/summary_test.go +426 -0
  96. package/extensions/ahp/internal/pi/workarounds_wire_test.go +208 -0
  97. package/extensions/ahp/internal/pisession/fromentries_test.go +32 -0
  98. package/extensions/ahp/internal/pisession/json.go +31 -0
  99. package/extensions/ahp/internal/pisession/pisession.go +580 -0
  100. package/extensions/ahp/internal/settings/access.go +63 -0
  101. package/extensions/ahp/internal/settings/access_test.go +52 -0
  102. package/extensions/ahp/internal/settings/settings.go +117 -0
  103. package/extensions/ahp/internal/settings/settings_test.go +104 -0
  104. package/extensions/ahp/internal/svc/etag_other.go +15 -0
  105. package/extensions/ahp/internal/svc/etag_unix.go +34 -0
  106. package/extensions/ahp/internal/svc/glob.go +197 -0
  107. package/extensions/ahp/internal/svc/mime.go +37 -0
  108. package/extensions/ahp/internal/svc/paths.go +108 -0
  109. package/extensions/ahp/internal/svc/pty.go +64 -0
  110. package/extensions/ahp/internal/svc/pty_darwin.go +42 -0
  111. package/extensions/ahp/internal/svc/pty_linux.go +36 -0
  112. package/extensions/ahp/internal/svc/pty_other.go +9 -0
  113. package/extensions/ahp/internal/svc/pty_test.go +114 -0
  114. package/extensions/ahp/internal/svc/pty_unix.go +147 -0
  115. package/extensions/ahp/internal/svc/resource.go +495 -0
  116. package/extensions/ahp/internal/svc/resource_test.go +501 -0
  117. package/extensions/ahp/internal/svc/stat_bsd.go +9 -0
  118. package/extensions/ahp/internal/svc/stat_linux.go +9 -0
  119. package/extensions/ahp/internal/svc/terminal.go +454 -0
  120. package/extensions/ahp/internal/svc/terminal_test.go +512 -0
  121. package/extensions/ahp/internal/svc/watch.go +590 -0
  122. package/extensions/ahp/internal/svc/watch_test.go +627 -0
  123. package/extensions/ahp/internal/svc/watchevents_test.go +452 -0
  124. package/extensions/ahp/internal/svc/watchpolicy.go +81 -0
  125. package/extensions/ahp/internal/svc/watchpolicy_test.go +151 -0
  126. package/extensions/ahp/internal/svc/watchracy_test.go +61 -0
  127. package/extensions/ahp/internal/testkit/schema/LICENSE-agent-host-protocol +21 -0
  128. package/extensions/ahp/internal/testkit/schema/actions.schema.json +9208 -0
  129. package/extensions/ahp/internal/testkit/schema/commands.schema.json +10862 -0
  130. package/extensions/ahp/internal/testkit/schema/errors.schema.json +10928 -0
  131. package/extensions/ahp/internal/testkit/schema/notifications.schema.json +6667 -0
  132. package/extensions/ahp/internal/testkit/schema/state.schema.json +6392 -0
  133. package/extensions/ahp/internal/testkit/schema.go +360 -0
  134. package/extensions/ahp/internal/testkit/testkit.go +367 -0
  135. package/extensions/ahp/internal/twin/twin.go +27 -0
  136. package/extensions/ahp/internal/wire/dispatchable.go +106 -0
  137. package/extensions/ahp/internal/wire/dispatchable_list.go +15 -0
  138. package/extensions/ahp/internal/wire/helpers_test.go +5 -0
  139. package/extensions/ahp/internal/wire/wire.go +218 -0
  140. package/extensions/ahp/internal/wire/wire_test.go +123 -0
  141. package/extensions/ahp/internal/ws/helpers_test.go +7 -0
  142. package/extensions/ahp/internal/ws/vscode_test.go +198 -0
  143. package/extensions/ahp/internal/ws/ws.go +674 -0
  144. package/extensions/ahp/internal/ws/ws_test.go +324 -0
  145. package/extensions/ahp/realpig_test.go +589 -0
  146. package/extensions/ahp/runtime.go +311 -0
  147. package/extensions/ahp/runtime_test.go +110 -0
  148. package/extensions/ahp/testdata/upstream-tests.json +3833 -0
  149. package/extensions/ahp/third_party/agent-host-protocol-go/LICENSE +21 -0
  150. package/extensions/ahp/third_party/agent-host-protocol-go/NOTICE-PIGPEN.md +18 -0
  151. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/client.go +1011 -0
  152. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/error.go +111 -0
  153. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/multi_host_state_mirror.go +239 -0
  154. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/reducers.go +1939 -0
  155. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/transport.go +176 -0
  156. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/actions.generated.go +2447 -0
  157. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/commands.generated.go +1546 -0
  158. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/common.go +205 -0
  159. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/errors.generated.go +65 -0
  160. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/messages.generated.go +138 -0
  161. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/notifications.generated.go +264 -0
  162. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/state.generated.go +6171 -0
  163. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/version.generated.go +31 -0
  164. package/extensions/ahp/third_party/agent-host-protocol-go/go.mod +3 -0
  165. package/extensions/ahp/twins_test.go +91 -0
  166. package/package.json +40 -0
  167. package/proof/PORT.md +121 -0
  168. package/proof/mutations.json +152 -0
  169. package/proof/oracle/LICENSE +21 -0
  170. package/proof/oracle/README.md +120 -0
  171. package/proof/oracle/UPSTREAM.md +7 -0
  172. package/proof/oracle/package.json +68 -0
  173. package/proof/oracle/src/bin/cli.ts +66 -0
  174. package/proof/oracle/src/bin/tunnel.ts +128 -0
  175. package/proof/oracle/src/channels/chat.ts +134 -0
  176. package/proof/oracle/src/channels/root.ts +39 -0
  177. package/proof/oracle/src/channels/session.ts +118 -0
  178. package/proof/oracle/src/channels/terminal.ts +11 -0
  179. package/proof/oracle/src/core/channels.ts +108 -0
  180. package/proof/oracle/src/core/client-workarounds.ts +358 -0
  181. package/proof/oracle/src/core/connection.ts +41 -0
  182. package/proof/oracle/src/core/host.ts +882 -0
  183. package/proof/oracle/src/core/sequencer.ts +75 -0
  184. package/proof/oracle/src/core/state-store.ts +162 -0
  185. package/proof/oracle/src/core/uri.ts +25 -0
  186. package/proof/oracle/src/host/direct-settings.ts +110 -0
  187. package/proof/oracle/src/host/pi-host.ts +220 -0
  188. package/proof/oracle/src/host/serve.ts +71 -0
  189. package/proof/oracle/src/host/terminal-service.ts +346 -0
  190. package/proof/oracle/src/pi/activity.ts +171 -0
  191. package/proof/oracle/src/pi/changeset-service.ts +646 -0
  192. package/proof/oracle/src/pi/changeset-uri.ts +64 -0
  193. package/proof/oracle/src/pi/chat-driver.ts +529 -0
  194. package/proof/oracle/src/pi/completions.ts +160 -0
  195. package/proof/oracle/src/pi/delete-session.ts +53 -0
  196. package/proof/oracle/src/pi/event-mapper.ts +648 -0
  197. package/proof/oracle/src/pi/git-changes.ts +605 -0
  198. package/proof/oracle/src/pi/history.ts +305 -0
  199. package/proof/oracle/src/pi/image-input.ts +56 -0
  200. package/proof/oracle/src/pi/image-mime.ts +8 -0
  201. package/proof/oracle/src/pi/in-process-backend.ts +155 -0
  202. package/proof/oracle/src/pi/message-input.ts +212 -0
  203. package/proof/oracle/src/pi/models.ts +124 -0
  204. package/proof/oracle/src/pi/project-trust.ts +66 -0
  205. package/proof/oracle/src/pi/provider.ts +2 -0
  206. package/proof/oracle/src/pi/resource-paths.ts +80 -0
  207. package/proof/oracle/src/pi/resource-service.ts +356 -0
  208. package/proof/oracle/src/pi/resource-watch-policy.ts +35 -0
  209. package/proof/oracle/src/pi/resource-watch.ts +357 -0
  210. package/proof/oracle/src/pi/session-catalogue.ts +318 -0
  211. package/proof/oracle/src/pi/session-config.ts +84 -0
  212. package/proof/oracle/src/pi/session-history.ts +75 -0
  213. package/proof/oracle/src/pi/session-hydrator.ts +219 -0
  214. package/proof/oracle/src/pi/session-registry.ts +815 -0
  215. package/proof/oracle/src/pi/session-storage.ts +29 -0
  216. package/proof/oracle/src/pi/session-title.ts +18 -0
  217. package/proof/oracle/src/pi/turn-paging.ts +94 -0
  218. package/proof/oracle/src/pi/user-message.ts +66 -0
  219. package/proof/oracle/src/protocol/errors.ts +44 -0
  220. package/proof/oracle/src/protocol/jsonrpc.ts +89 -0
  221. package/proof/oracle/src/protocol/version.ts +39 -0
  222. package/proof/oracle/src/transport/websocket.ts +136 -0
  223. package/proof/oracle/src/tunnel/devtunnel.ts +293 -0
  224. package/proof/oracle/src/tunnel/discovery.ts +37 -0
  225. package/proof/oracle/test/active-turn-reconnect.test.ts +274 -0
  226. package/proof/oracle/test/activity.test.ts +171 -0
  227. package/proof/oracle/test/changeset-lifecycle.test.serial.ts +303 -0
  228. package/proof/oracle/test/changeset-uri.test.ts +37 -0
  229. package/proof/oracle/test/changeset.test.serial.ts +487 -0
  230. package/proof/oracle/test/chat-driver.test.ts +691 -0
  231. package/proof/oracle/test/client-actions.test.ts +409 -0
  232. package/proof/oracle/test/client-workarounds.test.ts +320 -0
  233. package/proof/oracle/test/completions.test.ts +323 -0
  234. package/proof/oracle/test/delete-session.test.ts +142 -0
  235. package/proof/oracle/test/direct-settings.test.ts +81 -0
  236. package/proof/oracle/test/event-mapper.test.ts +619 -0
  237. package/proof/oracle/test/fetch-turns.test.ts +207 -0
  238. package/proof/oracle/test/fixtures/abort.json +711 -0
  239. package/proof/oracle/test/fixtures/bash-long-output.json +1312 -0
  240. package/proof/oracle/test/fixtures/compaction.json +6285 -0
  241. package/proof/oracle/test/fixtures/parallel-tools.json +2622 -0
  242. package/proof/oracle/test/fixtures/plain-text.json +1856 -0
  243. package/proof/oracle/test/fixtures/single-tool.json +1128 -0
  244. package/proof/oracle/test/fixtures/steering.json +3926 -0
  245. package/proof/oracle/test/fixtures/tool-bash.json +2372 -0
  246. package/proof/oracle/test/fixtures/tool-edit.json +3983 -0
  247. package/proof/oracle/test/fixtures/tool-error.json +2850 -0
  248. package/proof/oracle/test/fixtures/tool-find.json +1162 -0
  249. package/proof/oracle/test/fixtures/tool-grep.json +2783 -0
  250. package/proof/oracle/test/fixtures/tool-loop.json +1693 -0
  251. package/proof/oracle/test/fixtures/tool-ls.json +2427 -0
  252. package/proof/oracle/test/fixtures/tool-write.json +1271 -0
  253. package/proof/oracle/test/handshake.test.ts +275 -0
  254. package/proof/oracle/test/harness.ts +153 -0
  255. package/proof/oracle/test/hydrated-session-lifecycle.test.ts +211 -0
  256. package/proof/oracle/test/image-input.test.ts +28 -0
  257. package/proof/oracle/test/image-session.test.ts +69 -0
  258. package/proof/oracle/test/live-turn.test.ts +304 -0
  259. package/proof/oracle/test/mapper-fixtures.test.ts +293 -0
  260. package/proof/oracle/test/message-input.test.ts +181 -0
  261. package/proof/oracle/test/model-discovery.test.ts +170 -0
  262. package/proof/oracle/test/models.test.ts +109 -0
  263. package/proof/oracle/test/pi-host.test.ts +91 -0
  264. package/proof/oracle/test/pi-replay.test.ts +141 -0
  265. package/proof/oracle/test/project-trust.test.ts +125 -0
  266. package/proof/oracle/test/protocol-surface.test.ts +121 -0
  267. package/proof/oracle/test/pty.test.ts +100 -0
  268. package/proof/oracle/test/reconnect.test.ts +389 -0
  269. package/proof/oracle/test/resource-watch-policy.test.ts +78 -0
  270. package/proof/oracle/test/resource-watch.test.serial.ts +536 -0
  271. package/proof/oracle/test/resource.test.ts +499 -0
  272. package/proof/oracle/test/schema.test.ts +88 -0
  273. package/proof/oracle/test/session-catalogue.test.ts +314 -0
  274. package/proof/oracle/test/session-config.test.ts +224 -0
  275. package/proof/oracle/test/session-disposal.test.ts +364 -0
  276. package/proof/oracle/test/session-hydration.test.ts +194 -0
  277. package/proof/oracle/test/session-lifecycle.test.ts +416 -0
  278. package/proof/oracle/test/session-storage.test.serial.ts +32 -0
  279. package/proof/oracle/test/session-summary.test.ts +418 -0
  280. package/proof/oracle/test/subscriptions.test.ts +198 -0
  281. package/proof/oracle/test/support/assertions.ts +32 -0
  282. package/proof/oracle/test/support/async.ts +26 -0
  283. package/proof/oracle/test/support/hydrated-session.ts +205 -0
  284. package/proof/oracle/test/support/images.ts +8 -0
  285. package/proof/oracle/test/support/recorded-fixtures.ts +60 -0
  286. package/proof/oracle/test/support/recorded-scenarios.ts +145 -0
  287. package/proof/oracle/test/support/replay.ts +194 -0
  288. package/proof/oracle/test/support/schema.ts +221 -0
  289. package/proof/oracle/test/support/session-files.ts +12 -0
  290. package/proof/oracle/test/support/session-storage.ts +22 -0
  291. package/proof/oracle/test/support/upstream.ts +25 -0
  292. package/proof/oracle/test/support/watch-events.ts +108 -0
  293. package/proof/oracle/test/terminal-service.test.ts +495 -0
  294. package/proof/oracle/test/truncate.test.ts +230 -0
  295. package/proof/oracle/test/tunnel.test.ts +258 -0
  296. package/proof/oracle/test/upstream-workarounds.test.ts +37 -0
  297. package/proof/oracle/test/uri.test.ts +24 -0
  298. package/proof/oracle/test/watch-events.test.ts +118 -0
  299. package/proof/oracle/tsconfig.json +32 -0
  300. package/proof/proof-piglet/piglet.yaml +18 -0
  301. package/proof/tools/fakellm/go.mod +3 -0
  302. package/proof/tools/fakellm/main.go +109 -0
  303. package/proof/tools/gen-dispatchable.py +30 -0
  304. package/provenance.json +28 -0
@@ -0,0 +1,381 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "encoding/base64"
5
+ "os"
6
+ "reflect"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
11
+
12
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
15
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
16
+ )
17
+
18
+ // Twins of upstream test/session-lifecycle.test.ts: create, ready, dispose, and the catalogue
19
+ // notifications that keep every client's session list current. The fixture is storage-only (no
20
+ // backend), so readiness is immediate.
21
+
22
+ type lifecycle struct {
23
+ t *testing.T
24
+ h *harness
25
+ workspace string
26
+ }
27
+
28
+ func newLifecycle(t *testing.T) *lifecycle {
29
+ t.Helper()
30
+ workspace := t.TempDir()
31
+ return &lifecycle{t: t, h: startHarness(t, harnessOptions{workingDir: workspace}), workspace: workspace}
32
+ }
33
+
34
+ func (l *lifecycle) initialized(subscriptions ...string) *testkit.Client {
35
+ l.t.Helper()
36
+ if len(subscriptions) == 0 {
37
+ subscriptions = []string{wire.RootChannel}
38
+ }
39
+ c := testkit.Connect(l.t, l.h.host)
40
+ l.t.Cleanup(c.Close)
41
+ c.Initialize(nextClientID(), obj{"initialSubscriptions": subscriptions})
42
+ return c
43
+ }
44
+
45
+ // awaitAction waits for the next action envelope on channel whose action has the given type.
46
+ func awaitAction(t *testing.T, c *testkit.Client, channel, actionType string) (env ahptypes.ActionEnvelope) {
47
+ t.Helper()
48
+ n, ok := c.Await(func(n testkit.Notification) bool {
49
+ e, isAction := n.Envelope()
50
+ if !isAction || e.Channel != channel {
51
+ return false
52
+ }
53
+ return actionType == "" || testkit.Normalize(t, e.Action).(map[string]any)["type"] == actionType
54
+ }, testkit.Timeout)
55
+ if !ok {
56
+ t.Fatalf("timed out waiting for %s on %s", actionType, channel)
57
+ }
58
+ env, _ = n.Envelope()
59
+ return env
60
+ }
61
+
62
+ func awaitNote(t *testing.T, c *testkit.Client, method string) testkit.Notification {
63
+ t.Helper()
64
+ n, ok := c.Await(func(n testkit.Notification) bool { return n.Method == method }, testkit.Timeout)
65
+ if !ok {
66
+ t.Fatalf("timed out waiting for %s", method)
67
+ }
68
+ return n
69
+ }
70
+
71
+ func TestSessionLifecycle(t *testing.T) {
72
+ twin.Run(t, "session-lifecycle", "creates a session at the client-chosen URI and reports it ready", func(t *testing.T) {
73
+ l := newLifecycle(t)
74
+ c := l.initialized()
75
+ uri := wire.SessionURI(newID())
76
+ c.Must("createSession", obj{"channel": uri, "provider": pi.Provider})
77
+ state := snapshotState[ahptypes.SessionState](t, c, uri)
78
+ if state.Provider != pi.Provider {
79
+ t.Fatalf("provider = %q", state.Provider)
80
+ }
81
+ // Creation is asynchronous in the protocol, but this storage-only fixture has no backend
82
+ // to start, so readiness is immediate.
83
+ testkit.Eventually(t, "the session to be ready", func() bool {
84
+ return l.h.host.Store().Session(uri).Lifecycle == ahptypes.SessionLifecycleReady
85
+ })
86
+ state = *l.h.host.Store().Session(uri)
87
+ if !reflect.DeepEqual(state.WorkingDirectories, []ahptypes.URI{wire.PathToFileURI(l.workspace)}) {
88
+ t.Fatalf("workingDirectories = %v", state.WorkingDirectories)
89
+ }
90
+ // Every session gets exactly one chat, and it is the default. The agent declares no
91
+ // multipleChats capability, which is what tells a client not to call createChat.
92
+ if len(state.Chats) != 1 || state.DefaultChat == nil || *state.DefaultChat != state.Chats[0].Resource {
93
+ t.Fatalf("chats = %+v default %v", state.Chats, state.DefaultChat)
94
+ }
95
+ testkit.AssertValid(t, "state", "SessionState", state)
96
+ })
97
+
98
+ twin.Run(t, "session-lifecycle", "uses the first user message as an unnamed session's display title", func(t *testing.T) {
99
+ l := newLifecycle(t)
100
+ c := l.initialized()
101
+ id := newID()
102
+ uri, chat := wire.SessionURI(id), wire.ChatURI(id)
103
+ c.Must("createSession", obj{"channel": uri})
104
+ c.Subscribe(uri)
105
+ c.Subscribe(chat)
106
+ message := userMessage(" Review\n auth handling ")
107
+ message["attachments"] = []any{embeddedText("attachment text is not a session title", "context.txt")}
108
+ c.Dispatch(chat, obj{"type": "chat/turnStarted", "turnId": "title-turn", "startedAt": "2025-01-01T00:00:00.000Z", "message": message})
109
+ env := awaitAction(t, c, uri, "session/titleChanged")
110
+ c.Ping()
111
+ sameJSON(t, env.Action, obj{"type": "session/titleChanged", "title": "Review auth handling"}, "title action")
112
+ if env.Origin != nil {
113
+ t.Fatal("origin must be absent")
114
+ }
115
+ session := l.h.host.Store().Session(uri)
116
+ if session.Title != "Review auth handling" || session.Chats[0].Title != "Review auth handling" || l.h.host.Store().Chat(chat).Title != "Review auth handling" {
117
+ t.Fatalf("titles: %q %q %q", session.Title, session.Chats[0].Title, l.h.host.Store().Chat(chat).Title)
118
+ }
119
+ live, _ := l.h.services.Registry.Get(uri)
120
+ if name := live.SessionManager.SessionName(); name != "" {
121
+ t.Fatalf("the fallback title must not be persisted as a session name: %q", name)
122
+ }
123
+ var listed struct{ Items []obj }
124
+ c.Decode(c.Must("listSessions", obj{"channel": wire.RootChannel}), &listed)
125
+ found := false
126
+ for _, item := range listed.Items {
127
+ if item["resource"] == uri {
128
+ found = item["title"] == "Review auth handling"
129
+ }
130
+ }
131
+ if !found {
132
+ t.Fatalf("listing = %v", listed.Items)
133
+ }
134
+ })
135
+
136
+ twin.Run(t, "session-lifecycle", "uses pi's session id as the URI's uuid", func(t *testing.T) {
137
+ l := newLifecycle(t)
138
+ c := l.initialized()
139
+ id := newID()
140
+ c.Must("createSession", obj{"channel": wire.SessionURI(id)})
141
+ live, ok := l.h.services.Registry.Get(wire.SessionURI(id))
142
+ if !ok {
143
+ t.Fatal("no live session")
144
+ }
145
+ // One identity space, so no persistent uuid -> session-file mapping.
146
+ if live.SessionID != id {
147
+ t.Fatalf("sessionId = %q", live.SessionID)
148
+ }
149
+ if got := live.SessionManager.File(); !strings.HasSuffix(got, "_"+id+".jsonl") {
150
+ t.Fatalf("session file = %q", got)
151
+ }
152
+ })
153
+
154
+ twin.Run(t, "session-lifecycle", "announces the new session on the root channel", func(t *testing.T) {
155
+ l := newLifecycle(t)
156
+ c := l.initialized()
157
+ uri := wire.SessionURI(newID())
158
+ c.Must("createSession", obj{"channel": uri})
159
+ n := awaitNote(t, c, "root/sessionAdded")
160
+ var params struct{ Summary ahptypes.SessionSummary }
161
+ c.Decode(n.Params, &params)
162
+ if params.Summary.Resource != uri {
163
+ t.Fatalf("resource = %s", params.Summary.Resource)
164
+ }
165
+ testkit.AssertValid(t, "state", "SessionSummary", params.Summary)
166
+ })
167
+
168
+ twin.Run(t, "session-lifecycle", "rejects a duplicate session URI with SessionAlreadyExists", func(t *testing.T) {
169
+ l := newLifecycle(t)
170
+ c := l.initialized()
171
+ uri := wire.SessionURI(newID())
172
+ c.Must("createSession", obj{"channel": uri})
173
+ c.ExpectError("createSession", obj{"channel": uri}, -32003)
174
+ })
175
+
176
+ twin.Run(t, "session-lifecycle", "rejects an unknown provider with ProviderNotFound", func(t *testing.T) {
177
+ l := newLifecycle(t)
178
+ c := l.initialized()
179
+ c.ExpectError("createSession", obj{"channel": wire.SessionURI(newID()), "provider": "claude"}, -32002)
180
+ })
181
+
182
+ twin.Run(t, "session-lifecycle", "honours a client-supplied working directory", func(t *testing.T) {
183
+ l := newLifecycle(t)
184
+ c := l.initialized()
185
+ other := t.TempDir() + "/pi ahp cwd"
186
+ if err := os.MkdirAll(other, 0o755); err != nil {
187
+ t.Fatal(err)
188
+ }
189
+ uri := wire.SessionURI(newID())
190
+ workingDirectory := wire.PathToFileURI(other)
191
+ c.Must("createSession", obj{"channel": uri, "workingDirectories": []string{workingDirectory}})
192
+ if got := l.h.host.Store().Session(uri).WorkingDirectories; !reflect.DeepEqual(got, []ahptypes.URI{workingDirectory}) {
193
+ t.Fatalf("workingDirectories = %v", got)
194
+ }
195
+ })
196
+
197
+ twin.Run(t, "session-lifecycle", "tracks the active session count in root state", func(t *testing.T) {
198
+ l := newLifecycle(t)
199
+ c := l.initialized()
200
+ count := func() int64 {
201
+ if a := l.h.host.Store().Root(wire.RootChannel).ActiveSessions; a != nil {
202
+ return *a
203
+ }
204
+ return 0
205
+ }
206
+ before := count()
207
+ c.Must("createSession", obj{"channel": wire.SessionURI(newID())})
208
+ if after := count(); after != before+1 {
209
+ t.Fatalf("activeSessions %d -> %d", before, after)
210
+ }
211
+ })
212
+
213
+ twin.Run(t, "session-lifecycle", "disposes a session, drops its channel, and announces the removal", func(t *testing.T) {
214
+ l := newLifecycle(t)
215
+ c := l.initialized()
216
+ id := newID()
217
+ uri, chat := wire.SessionURI(id), wire.ChatURI(id)
218
+ c.Must("createSession", obj{"channel": uri})
219
+ c.Subscribe(uri)
220
+ c.Subscribe(chat)
221
+ if !l.h.host.Store().Has(uri) || l.h.host.SubscriberCount(uri) != 1 || l.h.host.SubscriberCount(chat) != 1 {
222
+ t.Fatal("the session is not set up")
223
+ }
224
+ c.Must("disposeSession", obj{"channel": uri})
225
+ n := awaitNote(t, c, "root/sessionRemoved")
226
+ var params struct{ Session string }
227
+ c.Decode(n.Params, &params)
228
+ if params.Session != uri {
229
+ t.Fatalf("removed = %s", params.Session)
230
+ }
231
+ if l.h.host.Store().Has(uri) || l.h.services.Registry.Has(uri) || l.h.host.SubscriberCount(uri) != 0 || l.h.host.SubscriberCount(chat) != 0 {
232
+ t.Fatal("the session was not fully removed")
233
+ }
234
+ })
235
+
236
+ twin.Run(t, "session-lifecycle", "does not let session disposal target another channel kind", func(t *testing.T) {
237
+ l := newLifecycle(t)
238
+ c := l.initialized()
239
+ c.ExpectError("disposeSession", obj{"channel": wire.RootChannel}, wire.CodeInvalidParams)
240
+ if !l.h.host.Store().Has(wire.RootChannel) {
241
+ t.Fatal("the root channel was disposed")
242
+ }
243
+ })
244
+
245
+ twin.Run(t, "session-lifecycle", "rejects disposing a session that does not exist", func(t *testing.T) {
246
+ l := newLifecycle(t)
247
+ c := l.initialized()
248
+ c.ExpectError("disposeSession", obj{"channel": wire.SessionURI("nope")}, -32001)
249
+ })
250
+
251
+ twin.Run(t, "session-lifecycle", "accepts session/titleChanged from a client", func(t *testing.T) {
252
+ l := newLifecycle(t)
253
+ c := l.initialized()
254
+ uri := wire.SessionURI(newID())
255
+ c.Must("createSession", obj{"channel": uri})
256
+ c.Subscribe(uri)
257
+ c.Dispatch(uri, obj{"type": "session/titleChanged", "title": "Refactor auth"})
258
+ // Wait for the echo so the reducer has run before asserting.
259
+ awaitAction(t, c, uri, "session/titleChanged")
260
+ if got := l.h.host.Store().Session(uri).Title; got != "Refactor auth" {
261
+ t.Fatalf("title = %q", got)
262
+ }
263
+ })
264
+
265
+ twin.Run(t, "session-lifecycle", "applies VS Code's chat-addressed rename to the owning session", func(t *testing.T) {
266
+ l := newLifecycle(t)
267
+ c := testkit.Connect(t, l.h.host)
268
+ t.Cleanup(c.Close)
269
+ c.Initialize(nextClientID(), obj{"clientInfo": obj{"name": "vscode-editor-window"}})
270
+ id := newID()
271
+ clientSession := "pi:/" + id
272
+ clientChat := "ahp-chat://default/" + base64.RawURLEncoding.EncodeToString([]byte(clientSession))
273
+ c.Must("createSession", obj{"channel": clientSession})
274
+ c.Subscribe(clientSession)
275
+ c.Dispatch(clientChat, obj{"type": "session/titleChanged", "title": "Renamed from VS Code"})
276
+ // session/ready may land first: the registry finishes creating on its own goroutine.
277
+ env := awaitAction(t, c, clientSession, "session/titleChanged")
278
+ if env.Channel != clientSession {
279
+ t.Fatalf("channel = %s", env.Channel)
280
+ }
281
+ sameJSON(t, env.Action, obj{"type": "session/titleChanged", "title": "Renamed from VS Code"}, "action")
282
+ if env.RejectionReason != nil {
283
+ t.Fatalf("rejected: %s", *env.RejectionReason)
284
+ }
285
+ if got := l.h.host.Store().Session(wire.SessionURI(id)).Title; got != "Renamed from VS Code" {
286
+ t.Fatalf("title = %q", got)
287
+ }
288
+ })
289
+
290
+ twin.Run(t, "session-lifecycle", "declines VS Code's active client without blocking session creation", func(t *testing.T) {
291
+ l := newLifecycle(t)
292
+ clientID := nextClientID()
293
+ c := testkit.Connect(t, l.h.host)
294
+ t.Cleanup(c.Close)
295
+ c.Initialize(clientID, obj{"clientInfo": obj{"name": "vscode-editor-window"}})
296
+ id := newID()
297
+ uri, clientURI := wire.SessionURI(id), "pi:/"+id
298
+ activeClient := obj{"clientId": clientID, "tools": []any{}}
299
+ // VS Code supplies this eagerly. It is ignored rather than making an otherwise usable
300
+ // session fail.
301
+ c.Must("createSession", obj{"channel": clientURI, "activeClient": activeClient})
302
+ c.Subscribe(clientURI)
303
+ for _, action := range []obj{
304
+ {"type": "session/activeClientSet", "activeClient": activeClient},
305
+ {"type": "session/activeClientRemoved", "clientId": clientID},
306
+ } {
307
+ c.Dispatch(clientURI, action)
308
+ env := awaitAction(t, c, clientURI, action["type"].(string))
309
+ if env.RejectionReason == nil || *env.RejectionReason != "This host does not accept active clients" {
310
+ t.Fatalf("rejectionReason = %v", env.RejectionReason)
311
+ }
312
+ }
313
+ if got := l.h.host.Store().Session(uri).ActiveClients; len(got) != 0 {
314
+ t.Fatalf("activeClients = %v", got)
315
+ }
316
+ })
317
+ }
318
+
319
+ func TestRenamingASession(t *testing.T) {
320
+ renamed := func(t *testing.T, l *lifecycle, uri, title string) {
321
+ t.Helper()
322
+ c := testkit.Connect(t, l.h.host)
323
+ t.Cleanup(c.Close)
324
+ c.Initialize(nextClientID(), nil)
325
+ c.Subscribe(uri)
326
+ seq := c.Dispatch(uri, obj{"type": "session/titleChanged", "title": title})
327
+ c.NextEnvelope(uri, seq)
328
+ c.Ping()
329
+ }
330
+
331
+ twin.Run(t, "session-lifecycle", "records the new name on the session", func(t *testing.T) {
332
+ l := newLifecycle(t)
333
+ c := l.initialized()
334
+ uri := wire.SessionURI(newID())
335
+ c.Must("createSession", obj{"channel": uri})
336
+ renamed(t, l, uri, "Refactor auth middleware")
337
+ // The same call Pi's own /resume rename makes, so a session renamed here reads the same
338
+ // from Pi's CLI.
339
+ live, _ := l.h.services.Registry.Get(uri)
340
+ testkit.Eventually(t, "the name to be recorded", func() bool { return live.SessionManager.SessionName() == "Refactor auth middleware" })
341
+ })
342
+
343
+ twin.Run(t, "session-lifecycle", "does not create a file for a session that never got a reply", func(t *testing.T) {
344
+ l := newLifecycle(t)
345
+ c := l.initialized()
346
+ uri := wire.SessionURI(newID())
347
+ c.Must("createSession", obj{"channel": uri})
348
+ renamed(t, l, uri, "Never answered")
349
+ // Pi withholds the file until a session has an assistant message, so an empty
350
+ // conversation leaves nothing behind: renaming does not change that policy, and forcing a
351
+ // write here would litter the disk with empty sessions.
352
+ live, _ := l.h.services.Registry.Get(uri)
353
+ file := live.SessionManager.File()
354
+ if file == "" {
355
+ t.Fatal("no session file path")
356
+ }
357
+ if _, err := os.Stat(file); !os.IsNotExist(err) {
358
+ t.Fatalf("a file exists for an unanswered session: %v", err)
359
+ }
360
+ })
361
+
362
+ twin.Run(t, "session-lifecycle", "mirrors the name onto the chat and the catalogue entry", func(t *testing.T) {
363
+ l := newLifecycle(t)
364
+ c := l.initialized()
365
+ id := newID()
366
+ uri := wire.SessionURI(id)
367
+ c.Must("createSession", obj{"channel": uri})
368
+ renamed(t, l, uri, "Ship the release")
369
+ session, chat := l.h.host.Store().Session(uri), l.h.host.Store().Chat(wire.ChatURI(id))
370
+ if session.Title != "Ship the release" || chat.Title != "Ship the release" || session.Chats[0].Title != "Ship the release" {
371
+ t.Fatalf("titles: %q %q %q", session.Title, chat.Title, session.Chats[0].Title)
372
+ }
373
+ // ChatState denormalises its summary fields, so both representations have to move together
374
+ // or a client watching only the session drifts.
375
+ c.Dispatch(wire.ChatURI(id), obj{"type": "chat/turnStarted", "turnId": "after-rename", "startedAt": "2025-01-01T00:00:00.000Z", "message": userMessage("This must not replace the explicit name")})
376
+ c.Ping()
377
+ if got := l.h.host.Store().Session(uri).Title; got != "Ship the release" {
378
+ t.Fatalf("title = %q", got)
379
+ }
380
+ })
381
+ }
@@ -0,0 +1,212 @@
1
+ package pi
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+
7
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
8
+ )
9
+
10
+ // Mapping Pi's model catalogue onto protocol agent/model descriptions (port of src/pi/models.ts).
11
+
12
+ // ThinkingConfigKey is the model-config key that carries the reasoning effort.
13
+ const ThinkingConfigKey = "thinkingLevel"
14
+
15
+ // Model is a Pi model (pi-ai's Model): the fields the mapping reads. It is decoded from the JSON
16
+ // the SDK's model registry returns.
17
+ type Model struct {
18
+ ID string `json:"id"`
19
+ Name string `json:"name"`
20
+ API string `json:"api"`
21
+ Provider string `json:"provider"`
22
+ BaseURL string `json:"baseUrl,omitempty"`
23
+ // Reasoning reports whether the model can think.
24
+ Reasoning bool `json:"reasoning"`
25
+ Input []string `json:"input"`
26
+ // Cost is passed through untouched as pricing metadata.
27
+ Cost json.RawMessage `json:"cost,omitempty"`
28
+ ContextWindow int64 `json:"contextWindow"`
29
+ MaxTokens int64 `json:"maxTokens"`
30
+ // ThinkingLevelMap maps a level to the provider's own value; null declares the level
31
+ // unsupported, and absence is "not declared" (which matters for xhigh and max).
32
+ ThinkingLevelMap map[string]json.RawMessage `json:"thinkingLevelMap,omitempty"`
33
+ }
34
+
35
+ // ModelFromMap decodes a model from the generic JSON the SDK hands over.
36
+ func ModelFromMap(m map[string]any) (Model, error) {
37
+ raw, err := json.Marshal(m)
38
+ if err != nil {
39
+ return Model{}, err
40
+ }
41
+ var model Model
42
+ return model, json.Unmarshal(raw, &model)
43
+ }
44
+
45
+ // ModelSelectionID is the wire id of a model: Pi's (provider, id) identity, qualified so two
46
+ // providers offering the same model id stay distinct.
47
+ func ModelSelectionID(provider, id string) string { return provider + "/" + id }
48
+
49
+ // SelectionID is the wire id of the model.
50
+ func (m Model) SelectionID() string { return ModelSelectionID(m.Provider, m.ID) }
51
+
52
+ // FindModelBySelectionID resolves a wire id. The qualified form is authoritative. A bare id
53
+ // (legacy clients) is accepted only when it is unambiguous, or when it names the model already in
54
+ // use, so a client can never silently land on the wrong provider's model.
55
+ func FindModelBySelectionID(models []Model, selectionID string, current *Model) (Model, bool) {
56
+ for _, m := range models {
57
+ if m.SelectionID() == selectionID {
58
+ return m, true
59
+ }
60
+ }
61
+ var legacy []Model
62
+ for _, m := range models {
63
+ if m.ID == selectionID {
64
+ legacy = append(legacy, m)
65
+ }
66
+ }
67
+ if len(legacy) == 1 {
68
+ return legacy[0], true
69
+ }
70
+ if current != nil && current.ID == selectionID {
71
+ for _, m := range legacy {
72
+ if m.Provider == current.Provider {
73
+ return m, true
74
+ }
75
+ }
76
+ }
77
+ return Model{}, false
78
+ }
79
+
80
+ var extendedThinkingLevels = []string{"off", "minimal", "low", "medium", "high", "xhigh", "max"}
81
+
82
+ // SupportedThinkingLevels is pi-ai's getSupportedThinkingLevels: a model that cannot reason
83
+ // offers only "off"; a level mapped to null is unsupported; xhigh and max are opt-in (they must be
84
+ // mapped to something).
85
+ func SupportedThinkingLevels(m Model) []string {
86
+ if !m.Reasoning {
87
+ return []string{"off"}
88
+ }
89
+ var out []string
90
+ for _, level := range extendedThinkingLevels {
91
+ mapped, declared := m.ThinkingLevelMap[level]
92
+ if declared && strings.TrimSpace(string(mapped)) == "null" {
93
+ continue
94
+ }
95
+ if (level == "xhigh" || level == "max") && !declared {
96
+ continue
97
+ }
98
+ out = append(out, level)
99
+ }
100
+ return out
101
+ }
102
+
103
+ // ClampThinkingLevel is pi-ai's clampThinkingLevel: the requested level if supported, else the
104
+ // nearest supported one (higher first, then lower).
105
+ func ClampThinkingLevel(m Model, level string) string {
106
+ available := SupportedThinkingLevels(m)
107
+ has := func(l string) bool {
108
+ for _, a := range available {
109
+ if a == l {
110
+ return true
111
+ }
112
+ }
113
+ return false
114
+ }
115
+ if has(level) {
116
+ return level
117
+ }
118
+ index := -1
119
+ for i, l := range extendedThinkingLevels {
120
+ if l == level {
121
+ index = i
122
+ }
123
+ }
124
+ if index == -1 {
125
+ if len(available) > 0 {
126
+ return available[0]
127
+ }
128
+ return "off"
129
+ }
130
+ for i := index; i < len(extendedThinkingLevels); i++ {
131
+ if has(extendedThinkingLevels[i]) {
132
+ return extendedThinkingLevels[i]
133
+ }
134
+ }
135
+ for i := index - 1; i >= 0; i-- {
136
+ if has(extendedThinkingLevels[i]) {
137
+ return extendedThinkingLevels[i]
138
+ }
139
+ }
140
+ if len(available) > 0 {
141
+ return available[0]
142
+ }
143
+ return "off"
144
+ }
145
+
146
+ func thinkingConfigSchema(m Model) *ahptypes.ConfigSchema {
147
+ levels := SupportedThinkingLevels(m)
148
+ // A model with only "off" has nothing to configure; omitting the schema keeps the client from
149
+ // rendering a pointless single-option picker.
150
+ if len(levels) <= 1 {
151
+ return nil
152
+ }
153
+ def := levels[0]
154
+ for _, l := range levels {
155
+ if l == "medium" {
156
+ def = "medium"
157
+ }
158
+ }
159
+ enum := make([]json.RawMessage, len(levels))
160
+ labels := make([]string, len(levels))
161
+ for i, l := range levels {
162
+ enum[i], _ = json.Marshal(l)
163
+ labels[i] = strings.ToUpper(l[:1]) + l[1:]
164
+ }
165
+ defRaw := json.RawMessage(mustJSON(def))
166
+ description := "How much reasoning effort the model spends before answering."
167
+ return &ahptypes.ConfigSchema{Type: "object", Properties: map[string]ahptypes.ConfigPropertySchema{
168
+ ThinkingConfigKey: {Type: "string", Title: "Thinking", Description: &description, Default: &defRaw, Enum: enum, EnumLabels: labels},
169
+ }}
170
+ }
171
+
172
+ func mustJSON(v any) []byte {
173
+ raw, err := json.Marshal(v)
174
+ if err != nil {
175
+ panic(err)
176
+ }
177
+ return raw
178
+ }
179
+
180
+ // ToSessionModelInfo describes a Pi model to a client.
181
+ func ToSessionModelInfo(m Model) ahptypes.SessionModelInfo {
182
+ vision := false
183
+ for _, in := range m.Input {
184
+ vision = vision || in == "image"
185
+ }
186
+ ctx, out := m.ContextWindow, m.MaxTokens
187
+ info := ahptypes.SessionModelInfo{
188
+ Id: m.SelectionID(), Provider: Provider, Name: m.Name,
189
+ MaxContextWindow: &ctx, MaxOutputTokens: &out, SupportsVision: &vision,
190
+ ConfigSchema: thinkingConfigSchema(m),
191
+ // _meta is the protocol's documented place for provider-specific extras; clients may
192
+ // surface pricing but must not depend on it.
193
+ Meta: map[string]json.RawMessage{"piProvider": mustJSON(m.Provider), "api": mustJSON(m.API)},
194
+ }
195
+ if len(m.Cost) > 0 {
196
+ info.Meta["pricing"] = m.Cost
197
+ }
198
+ return info
199
+ }
200
+
201
+ // BuildAgentInfo describes this host's one agent. No capabilities are declared: one chat, one
202
+ // working directory; their absence is what tells a client not to attempt those calls.
203
+ func BuildAgentInfo(models []Model) ahptypes.AgentInfo {
204
+ infos := make([]ahptypes.SessionModelInfo, 0, len(models))
205
+ for _, m := range models {
206
+ infos = append(infos, ToSessionModelInfo(m))
207
+ }
208
+ return ahptypes.AgentInfo{
209
+ Provider: Provider, DisplayName: "pi", Description: "pi coding agent", Models: infos,
210
+ ProtectedResources: []ahptypes.ProtectedResourceMetadata{},
211
+ }
212
+ }