@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,416 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "reflect"
7
+ "strings"
8
+ "sync"
9
+ "testing"
10
+
11
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
12
+
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
15
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
16
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pisession"
17
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
18
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
19
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
20
+ )
21
+
22
+ // Twins of upstream test/chat-driver.test.ts: the chat driver end to end: a client turn reaching a
23
+ // backend, streamed output coming back as actions, and queued-message consumption. The backend
24
+ // is a scripted fake rather than a real agent: the point is the host's turn arbitration.
25
+
26
+ func scripted(t *testing.T) *scriptedBackend {
27
+ return newScriptedBackend(func(text string) []mapper.Event {
28
+ if text == "long running" {
29
+ return nil
30
+ }
31
+ return say(t, "echo: "+text)
32
+ })
33
+ }
34
+
35
+ func turnCount(f *fixture, n int) func() bool {
36
+ return func() bool { return len(f.chat().Turns) == n }
37
+ }
38
+
39
+ func TestChatDriver(t *testing.T) {
40
+ twin.Run(t, "chat-driver", "creates the session's default chat and points defaultChat at it", func(t *testing.T) {
41
+ f := startFixture(t, scripted(t))
42
+ session := f.session()
43
+ if session.Lifecycle != ahptypes.SessionLifecycleReady {
44
+ t.Fatalf("lifecycle = %s", session.Lifecycle)
45
+ }
46
+ if len(session.Chats) != 1 || session.Chats[0].Resource != f.chatChannel {
47
+ t.Fatalf("chats = %+v", session.Chats)
48
+ }
49
+ if session.DefaultChat == nil || *session.DefaultChat != f.chatChannel {
50
+ t.Fatalf("defaultChat = %v", session.DefaultChat)
51
+ }
52
+ })
53
+
54
+ twin.Run(t, "chat-driver", "runs a client turn through the backend and streams the reply back", func(t *testing.T) {
55
+ b := scripted(t)
56
+ f := startFixture(t, b)
57
+ f.turnStarted("t1", userMessage("hello"))
58
+ testkit.Eventually(t, "the streamed turn to complete", turnCount(f, 1))
59
+ if got := b.promptList(); !reflect.DeepEqual(got, []string{"hello"}) {
60
+ t.Fatalf("prompts = %v", got)
61
+ }
62
+ turn := f.chat().Turns[0]
63
+ if turn.State != ahptypes.TurnStateComplete {
64
+ t.Fatalf("state = %s", turn.State)
65
+ }
66
+ part, ok := turn.ResponseParts[0].Value.(*ahptypes.MarkdownResponsePart)
67
+ if !ok || part.Content != "echo: hello" {
68
+ t.Fatalf("first part = %+v", turn.ResponseParts[0].Value)
69
+ }
70
+ })
71
+
72
+ twin.Run(t, "chat-driver", "adapts supported attachments into pi prompt text", func(t *testing.T) {
73
+ b := scripted(t)
74
+ f := startFixture(t, b)
75
+ text := "inspect the path from the client"
76
+ expected := text + "\n\n/outside.ts\n\nselected context\n\nembedded context"
77
+ message := userMessage(text)
78
+ message["attachments"] = []any{
79
+ obj{"type": "resource", "label": "outside.ts", "uri": "file:///outside.ts"},
80
+ obj{"type": "simple", "label": "selection", "modelRepresentation": "selected context"},
81
+ embeddedText("embedded context", "note.txt"),
82
+ embeddedImage("screenshot.png"),
83
+ }
84
+ f.turnStarted("t-path", message)
85
+ testkit.Eventually(t, "the attachment-expanded prompt to reach the backend", func() bool {
86
+ for _, p := range b.promptList() {
87
+ if p == expected {
88
+ return true
89
+ }
90
+ }
91
+ return false
92
+ })
93
+ b.mu.Lock()
94
+ defer b.mu.Unlock()
95
+ if got := b.prompts[len(b.prompts)-1]; got != expected {
96
+ t.Fatalf("prompt = %q", got)
97
+ }
98
+ want := []mapper.Image{{Type: "image", Data: onePixelPNG, MimeType: "image/png"}}
99
+ if got := b.promptImages[len(b.promptImages)-1]; !reflect.DeepEqual(got, want) {
100
+ t.Fatalf("images = %+v", got)
101
+ }
102
+ })
103
+
104
+ twin.Run(t, "chat-driver", "keeps the session catalog's chat summary in step", func(t *testing.T) {
105
+ f := startFixture(t, scripted(t))
106
+ f.turnStarted("t-summary", userMessage("update the summary"))
107
+ testkit.Eventually(t, "the summary-driving turn to complete", turnCount(f, 1))
108
+ testkit.Eventually(t, "the session summary to follow the chat", func() bool {
109
+ s, c := f.session(), f.chat()
110
+ return s.Chats[0].Status == c.Status && s.Chats[0].ModifiedAt == c.ModifiedAt
111
+ })
112
+ })
113
+
114
+ twin.Run(t, "chat-driver", "forwards steering text and images to the active backend", func(t *testing.T) {
115
+ b := scripted(t)
116
+ f := startFixture(t, b)
117
+ f.turnStarted("t-steering", userMessage("long running"))
118
+ testkit.Eventually(t, "the steerable prompt to reach the backend", func() bool { return len(b.promptList()) == 1 })
119
+ steer := userMessage("focus on tests")
120
+ steer["attachments"] = []any{
121
+ obj{"type": "simple", "label": "context", "modelRepresentation": "steering context"},
122
+ embeddedImage("steering.png"),
123
+ }
124
+ f.pending("steering", "steer-1", steer)
125
+ testkit.Eventually(t, "the steering message to reach the backend", func() bool {
126
+ b.mu.Lock()
127
+ defer b.mu.Unlock()
128
+ return len(b.steers) == 1
129
+ })
130
+ b.mu.Lock()
131
+ defer b.mu.Unlock()
132
+ if !reflect.DeepEqual(b.steers, []string{"focus on tests\n\nsteering context"}) {
133
+ t.Fatalf("steers = %v", b.steers)
134
+ }
135
+ if want := []mapper.Image{{Type: "image", Data: onePixelPNG, MimeType: "image/png"}}; !reflect.DeepEqual(b.steerImages[0], want) {
136
+ t.Fatalf("images = %+v", b.steerImages[0])
137
+ }
138
+ })
139
+
140
+ twin.Run(t, "chat-driver", "consumes a queued message as its own turn once the chat goes idle", func(t *testing.T) {
141
+ // Queued messages never reach pi: the protocol's own state is the queue, and the host
142
+ // starts a fresh turn for the head entry when idle.
143
+ b := scripted(t)
144
+ f := startFixture(t, b)
145
+ message := userMessage("then do this")
146
+ message["attachments"] = []any{embeddedText("queued context", "queued.txt"), embeddedImage("queued.png")}
147
+ f.pending("queued", "q-1", message)
148
+ testkit.Eventually(t, "the queued prompt to reach the backend", func() bool { return len(b.promptList()) == 1 })
149
+ testkit.Eventually(t, "the queued turn to complete", func() bool {
150
+ c := f.chat()
151
+ return len(c.Turns) == 1 && c.Turns[0].State == ahptypes.TurnStateComplete
152
+ })
153
+ b.mu.Lock()
154
+ if got := b.prompts[len(b.prompts)-1]; got != "then do this\n\nqueued context" {
155
+ t.Fatalf("prompt = %q", got)
156
+ }
157
+ if want := []mapper.Image{{Type: "image", Data: onePixelPNG, MimeType: "image/png"}}; !reflect.DeepEqual(b.promptImages[0], want) {
158
+ t.Fatalf("images = %+v", b.promptImages[0])
159
+ }
160
+ b.mu.Unlock()
161
+ // The reducer removes the entry atomically with creating the turn, so a client can never
162
+ // see it both queued and running.
163
+ if q := f.chat().QueuedMessages; len(q) != 0 {
164
+ t.Fatalf("queuedMessages = %+v", q)
165
+ }
166
+ })
167
+
168
+ twin.Run(t, "chat-driver", "holds a queued message until the active turn settles", func(t *testing.T) {
169
+ b := scripted(t)
170
+ f := startFixture(t, b)
171
+ f.turnStarted("t-blocking", userMessage("long running"))
172
+ testkit.Eventually(t, "the active prompt to reach the backend", func() bool { return len(b.promptList()) == 1 })
173
+ f.pending("queued", "q-after-active", userMessage("run after"))
174
+ f.client.Ping()
175
+ waiting := f.chat()
176
+ if len(b.promptList()) != 1 {
177
+ t.Fatal("queued message prompted before the active turn settled")
178
+ }
179
+ if waiting.ActiveTurn == nil || waiting.ActiveTurn.Id != "t-blocking" {
180
+ t.Fatalf("activeTurn = %+v", waiting.ActiveTurn)
181
+ }
182
+ if len(waiting.QueuedMessages) == 0 || waiting.QueuedMessages[0].Id != "q-after-active" {
183
+ t.Fatalf("queued = %+v", waiting.QueuedMessages)
184
+ }
185
+ b.emit(event(t, `{"type":"agent_settled"}`))
186
+ testkit.Eventually(t, "the queued message to start after settlement", func() bool { return len(b.promptList()) == 2 })
187
+ testkit.Eventually(t, "the queued turn to complete", func() bool {
188
+ c := f.chat()
189
+ return len(c.Turns) == 2 && c.Turns[1].State == ahptypes.TurnStateComplete && len(c.QueuedMessages) == 0
190
+ })
191
+ completed := f.chat()
192
+ if got := b.promptList(); got[len(got)-1] != "run after" {
193
+ t.Fatalf("prompts = %v", got)
194
+ }
195
+ if completed.Turns[len(completed.Turns)-1].Message.Text != "run after" {
196
+ t.Fatalf("turns = %+v", turnShapes(completed.Turns))
197
+ }
198
+ })
199
+
200
+ twin.Run(t, "chat-driver", "does not start a prompt cancelled during model selection", func(t *testing.T) {
201
+ b := scripted(t)
202
+ b.selectionGate = newRelease()
203
+ defer b.selectionGate.open()
204
+ f := startFixture(t, b)
205
+ message := userMessage("do not run")
206
+ message["model"] = obj{"id": "pi/test-model"}
207
+ f.turnStarted("t-select-cancel", message)
208
+ testkit.Eventually(t, "model selection to start", func() bool { return b.count(func(b *scriptedBackend) int { return b.selections }) == 1 })
209
+ f.pending("queued", "after-select-cancel", userMessage("run after selection cancellation"))
210
+ f.cancel("t-select-cancel")
211
+ testkit.Eventually(t, "preflight cancellation to reach the backend", func() bool { return b.count(func(b *scriptedBackend) int { return b.aborts }) == 1 })
212
+
213
+ b.selectionGate.open()
214
+ testkit.Eventually(t, "queued work to complete after cancelled preflight settles", turnCount(f, 2))
215
+ if got := b.promptList(); !reflect.DeepEqual(got, []string{"run after selection cancellation"}) {
216
+ t.Fatalf("prompts = %v", got)
217
+ }
218
+ completed := f.chat()
219
+ want := []turnShape{
220
+ {"t-select-cancel", "do not run", ahptypes.TurnStateCancelled},
221
+ {"turn-after-select-cancel", "run after selection cancellation", ahptypes.TurnStateComplete},
222
+ }
223
+ testkit.Eventually(t, "the queued turn to complete", func() bool { return f.chat().Turns[1].State == ahptypes.TurnStateComplete })
224
+ completed = f.chat()
225
+ if got := turnShapes(completed.Turns); !reflect.DeepEqual(got, want) {
226
+ t.Fatalf("turns = %+v, want %+v", got, want)
227
+ }
228
+
229
+ before := turnShapes(completed.Turns)
230
+ f.client.Dispatch(f.chatChannel, obj{"type": "chat/truncated", "turnId": "t-select-cancel"})
231
+ f.client.Ping()
232
+ if got := turnShapes(f.chat().Turns); !reflect.DeepEqual(got, before) {
233
+ t.Fatalf("a turn cancelled before persistence must not acquire a truncation anchor: %+v", got)
234
+ }
235
+ })
236
+
237
+ twin.Run(t, "chat-driver", "does not run a prompt cancelled during backend preflight", func(t *testing.T) {
238
+ b := scripted(t)
239
+ b.promptGate = newRelease()
240
+ defer b.promptGate.open()
241
+ f := startFixture(t, b)
242
+ message := userMessage("do not run")
243
+ message["attachments"] = []any{embeddedImage("screenshot.png")}
244
+ f.turnStarted("t-preflight-cancel", message)
245
+ testkit.Eventually(t, "backend preflight to start", func() bool { return b.count(func(b *scriptedBackend) int { return b.promptCalls }) == 1 })
246
+ f.pending("queued", "after-preflight-cancel", userMessage("run after image preflight"))
247
+ f.cancel("t-preflight-cancel")
248
+ testkit.Eventually(t, "preflight cancellation to reach the backend", func() bool { return b.count(func(b *scriptedBackend) int { return b.aborts }) == 1 })
249
+
250
+ b.promptGate.open()
251
+ testkit.Eventually(t, "queued work to complete after backend preflight settles", func() bool {
252
+ c := f.chat()
253
+ return len(c.Turns) == 2 && c.Turns[1].State == ahptypes.TurnStateComplete
254
+ })
255
+ if got := b.promptList(); !reflect.DeepEqual(got, []string{"run after image preflight"}) {
256
+ t.Fatalf("prompts = %v", got)
257
+ }
258
+ want := []turnShape{
259
+ {"t-preflight-cancel", "do not run", ahptypes.TurnStateCancelled},
260
+ {"turn-after-preflight-cancel", "run after image preflight", ahptypes.TurnStateComplete},
261
+ }
262
+ if got := turnShapes(f.chat().Turns); !reflect.DeepEqual(got, want) {
263
+ t.Fatalf("turns = %+v, want %+v", got, want)
264
+ }
265
+ })
266
+
267
+ twin.Run(t, "chat-driver", "anchors a cancelled turn before resuming queued work", func(t *testing.T) {
268
+ b := scripted(t)
269
+ f := startFixture(t, b)
270
+ f.turnStarted("t-cancel", userMessage("long running"))
271
+ testkit.Eventually(t, "the cancellable prompt to reach the backend", func() bool { return len(b.promptList()) == 1 })
272
+ if a := f.chat().ActiveTurn; a == nil || a.Id != "t-cancel" {
273
+ t.Fatalf("activeTurn = %+v", a)
274
+ }
275
+
276
+ live, ok := f.sessions.Get(f.sessionChannel)
277
+ if !ok {
278
+ t.Fatal("no live session")
279
+ }
280
+ // AgentSession notifies listeners, then records the same user message.
281
+ user := `{"role":"user","content":"long running","timestamp":0}`
282
+ b.emit(event(t, `{"type":"message_start","message":`+user+`}`))
283
+ b.emit(event(t, `{"type":"message_end","message":`+user+`}`))
284
+ manager := live.SessionManager.(*pisession.Manager)
285
+ if _, err := manager.AppendMessage(event(t, user)); err != nil {
286
+ t.Fatal(err)
287
+ }
288
+ if _, err := manager.AppendMessage(event(t, `{"role":"assistant","content":[],"stopReason":"aborted","timestamp":0}`)); err != nil {
289
+ t.Fatal(err)
290
+ }
291
+
292
+ f.pending("queued", "after-cancel", userMessage("run after cancel"))
293
+ f.cancel("t-cancel")
294
+ testkit.Eventually(t, "cancellation to reach the backend", func() bool { return b.count(func(b *scriptedBackend) int { return b.aborts }) == 1 })
295
+ testkit.Eventually(t, "queued work to complete after backend cancellation", func() bool {
296
+ c := f.chat()
297
+ return len(c.Turns) == 2 && c.Turns[1].State == ahptypes.TurnStateComplete
298
+ })
299
+ chat := f.chat()
300
+ if chat.Turns[0].Id != "t-cancel" || chat.Turns[0].State != ahptypes.TurnStateCancelled {
301
+ t.Fatalf("turns = %+v", turnShapes(chat.Turns))
302
+ }
303
+ if chat.Turns[1].Message.Text != "run after cancel" {
304
+ t.Fatalf("turns = %+v", turnShapes(chat.Turns))
305
+ }
306
+ testkit.Eventually(t, "the session summary to follow the chat", func() bool { return f.session().Chats[0].Status == f.chat().Status })
307
+
308
+ f.client.Dispatch(f.chatChannel, obj{"type": "chat/truncated", "turnId": "t-cancel"})
309
+ f.client.Ping()
310
+ var ids []string
311
+ for _, turn := range f.chat().Turns {
312
+ ids = append(ids, turn.Id)
313
+ }
314
+ if !reflect.DeepEqual(ids, []string{"t-cancel"}) {
315
+ t.Fatalf("a recorded cancelled turn must remain a valid truncation target; turns = %v", ids)
316
+ }
317
+ })
318
+ }
319
+
320
+ func TestChatDriverBackendFailure(t *testing.T) {
321
+ twin.Run(t, "chat-driver", "marks the session failed when the backend cannot start", func(t *testing.T) {
322
+ h, reg := startWith(t, pi.RegistryOptions{CreateBackend: func(*pi.LiveSession) (pi.Backend, error) {
323
+ return nil, errors.New("no credentials")
324
+ }})
325
+ uri := wire.SessionURI(newID())
326
+ if err := reg.Create(context.Background(), ahptypes.CreateSessionParams{Channel: uri}); err != nil {
327
+ t.Fatal(err)
328
+ }
329
+ testkit.Eventually(t, "backend startup failure to reach session state", func() bool {
330
+ return h.Store().Session(uri).Lifecycle != ahptypes.SessionLifecycleCreating
331
+ })
332
+ state := h.Store().Session(uri)
333
+ if state.Lifecycle != ahptypes.SessionLifecycleFailed {
334
+ t.Fatalf("lifecycle = %s", state.Lifecycle)
335
+ }
336
+ if state.CreationError == nil || !strings.Contains(state.CreationError.Message, "no credentials") {
337
+ t.Fatalf("creationError = %+v", state.CreationError)
338
+ }
339
+ })
340
+
341
+ twin.Run(t, "chat-driver", "closes the turn when prompt() rejects before any agent event", func(t *testing.T) {
342
+ backend := funcBackend{prompt: func(context.Context, string) error { return errors.New("model unavailable") }}
343
+ f := startFixture(t, backend)
344
+ // Dispatched through the wire so the reducer creates the active turn before the side
345
+ // effect runs: the same ordering production relies on.
346
+ f.turnStarted("t1", userMessage("hi"))
347
+ // Nothing will ever emit agent_settled, so without explicit handling the turn would stay
348
+ // active and the session stuck at InProgress.
349
+ testkit.Eventually(t, "the rejected prompt to close its turn", turnCount(f, 1))
350
+ state := f.chat()
351
+ if state.ActiveTurn != nil {
352
+ t.Fatalf("activeTurn = %+v", state.ActiveTurn)
353
+ }
354
+ turn := state.Turns[0]
355
+ if turn.State != ahptypes.TurnStateError {
356
+ t.Fatalf("state = %s", turn.State)
357
+ }
358
+ last, ok := turn.ResponseParts[len(turn.ResponseParts)-1].Value.(*ahptypes.ErrorResponsePart)
359
+ if !ok || !strings.Contains(last.Error.Message, "model unavailable") {
360
+ t.Fatalf("error part = %+v", turn.ResponseParts)
361
+ }
362
+ testkit.Eventually(t, "the session summary to follow the chat", func() bool {
363
+ return f.session().Chats[0].Status == f.chat().Status
364
+ })
365
+ overrides := f.sessions.CatalogueOverrides()
366
+ if len(overrides) == 0 || overrides[0].Summary.Status != f.chat().Status {
367
+ t.Fatalf("catalogue overrides = %+v", overrides)
368
+ }
369
+ })
370
+
371
+ twin.Run(t, "chat-driver", "clears the pending message once pi consumes it", func(t *testing.T) {
372
+ // The failure this covers looks like a hang: pi injects the steering message into the
373
+ // run, but ChatState.steeringMessage never clears, so the client shows it as forever
374
+ // unsent even though the model got it.
375
+ var mu sync.Mutex
376
+ steered := make(chan struct{})
377
+ var listeners []func(mapper.Event)
378
+ emit := func(literal string) {
379
+ mu.Lock()
380
+ ls := append([]func(mapper.Event){}, listeners...)
381
+ mu.Unlock()
382
+ for _, l := range ls {
383
+ l(event(t, literal))
384
+ }
385
+ }
386
+ backend := funcBackend{
387
+ subscribe: func(l func(mapper.Event)) func() {
388
+ mu.Lock()
389
+ listeners = append(listeners, l)
390
+ mu.Unlock()
391
+ return func() {}
392
+ },
393
+ prompt: func(context.Context, string) error { emit(`{"type":"agent_start"}`); return nil },
394
+ // pi acknowledges the message by growing its queue...
395
+ steer: func(context.Context, string) error {
396
+ emit(`{"type":"queue_update","steering":["focus on tests"],"followUp":[]}`)
397
+ close(steered)
398
+ return nil
399
+ },
400
+ }
401
+ f := startFixture(t, backend)
402
+ f.turnStarted("t1", userMessage("go"))
403
+ f.pending("steering", "steer-1", userMessage("focus on tests"))
404
+ testkit.Eventually(t, "the steering message to enter protocol state", func() bool { return f.chat().SteeringMessage != nil })
405
+ // The side effect runs on its own goroutine here (upstream's runs inline in the same
406
+ // tick), so wait for pi's acknowledgement before consuming.
407
+ <-steered
408
+ // ...and consumes it by shrinking the queue right before injecting.
409
+ emit(`{"type":"queue_update","steering":[],"followUp":[]}`)
410
+ testkit.Eventually(t, "the consumed steering message to leave protocol state", func() bool { return f.chat().SteeringMessage == nil })
411
+ // The text itself is not recorded here: pi delivers it as an ordinary user message, and
412
+ // the mapper turns that into its own turn. Only the pending slot is cleared by this path.
413
+ })
414
+ }
415
+
416
+ var _ = host.Options{}
@@ -0,0 +1,212 @@
1
+ package pi
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+
8
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
9
+
10
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
11
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
12
+ )
13
+
14
+ // unsupportedClientActionReason is the policy for client-dispatchable actions this host does not
15
+ // carry out. Refusing is the honest answer: silently accepting would move client state away from
16
+ // what the agent does.
17
+ func unsupportedClientActionReason(actionType string) string {
18
+ t := func(a ahptypes.ActionType) string { return string(a) }
19
+ switch actionType {
20
+ case t(ahptypes.ActionTypeSessionActiveClientSet), t(ahptypes.ActionTypeSessionActiveClientRemoved):
21
+ return "This host does not accept active clients"
22
+ case t(ahptypes.ActionTypeSessionWorkingDirectorySet), t(ahptypes.ActionTypeSessionWorkingDirectoryRemoved),
23
+ t(ahptypes.ActionTypeSessionWorkingDirectoryReplaced), t(ahptypes.ActionTypeChatWorkingDirectorySet),
24
+ t(ahptypes.ActionTypeChatWorkingDirectoryRemoved):
25
+ return "This agent does not support changing working directories"
26
+ case t(ahptypes.ActionTypeSessionCustomizationToggled):
27
+ return "This host does not support customizations"
28
+ case t(ahptypes.ActionTypeSessionMcpServerStartRequested), t(ahptypes.ActionTypeSessionMcpServerStopRequested),
29
+ // Not in the upstream policy: the pinned spec commit is newer than the npm 0.9.0 package
30
+ // pi-ahp was written against, and added these two client-dispatchable actions. Refused
31
+ // like their siblings (see PORT.md).
32
+ t(ahptypes.ActionTypeSessionMcpServerBackgroundRequested):
33
+ return "This host does not support MCP servers"
34
+ case t(ahptypes.ActionTypeSessionIsReadChanged), t(ahptypes.ActionTypeSessionIsArchivedChanged),
35
+ t(ahptypes.ActionTypeChatIsArchivedChanged): // chat/isArchivedChanged: newer than upstream's spec, see above
36
+ return "This host does not persist read or archive state"
37
+ case t(ahptypes.ActionTypeSessionConfigChanged):
38
+ return "This session has no mutable configuration"
39
+ case t(ahptypes.ActionTypeChatToolCallConfirmed), t(ahptypes.ActionTypeChatToolCallComplete),
40
+ t(ahptypes.ActionTypeChatToolCallResultConfirmed), t(ahptypes.ActionTypeChatToolCallContentChanged):
41
+ return "This host does not support client tool execution or confirmation"
42
+ case t(ahptypes.ActionTypeChatInputAnswerChanged), t(ahptypes.ActionTypeChatInputCompleted):
43
+ return "This host does not support interactive input requests"
44
+ case t(ahptypes.ActionTypeChatTurnResume):
45
+ return "This host cannot resume an errored turn"
46
+ }
47
+ return ""
48
+ }
49
+
50
+ // userMessageRejectionReason is the message check plus the requirement that a client speaks as a
51
+ // user. The message is raw decoded JSON, so a malformed shape is reported rather than trusted.
52
+ func userMessageRejectionReason(message any, originReason string) string {
53
+ if reason := mapper.MessageRejectionReason(message); reason != "" {
54
+ return reason
55
+ }
56
+ m, _ := message.(map[string]any)
57
+ origin, _ := m["origin"].(map[string]any)
58
+ if origin["kind"] == string(ahptypes.MessageKindUser) {
59
+ return ""
60
+ }
61
+ return originReason
62
+ }
63
+
64
+ func (r *Registry) validateClientAction(channel string, action host.ClientAction) string {
65
+ r.mu.Lock()
66
+ session := r.sessions[channel]
67
+ if session == nil {
68
+ session = r.byChat[channel]
69
+ }
70
+ _, channelDisposing := r.disposals[channel]
71
+ disposing := channelDisposing
72
+ if session != nil {
73
+ _, sessionDisposing := r.disposals[session.URI]
74
+ disposing = disposing || sessionDisposing
75
+ }
76
+ r.mu.Unlock()
77
+ if disposing {
78
+ return "This session is being disposed"
79
+ }
80
+ if reason := unsupportedClientActionReason(action.Type); reason != "" {
81
+ return reason
82
+ }
83
+
84
+ var raw map[string]any
85
+ dec := json.NewDecoder(bytes.NewReader(action.Raw))
86
+ dec.UseNumber()
87
+ _ = dec.Decode(&raw)
88
+ chat := r.host.Store().Chat(channel)
89
+ str := func(key string) (string, bool) { s, ok := raw[key].(string); return s, ok }
90
+
91
+ switch action.Type {
92
+ case string(ahptypes.ActionTypeSessionTitleChanged):
93
+ if _, ok := str("title"); !ok {
94
+ return "A session title must be a string"
95
+ }
96
+ case string(ahptypes.ActionTypeChatTurnStarted):
97
+ _, okID := str("turnId")
98
+ _, okAt := str("startedAt")
99
+ if !okID || !okAt {
100
+ return "A turn requires string turnId and startedAt fields"
101
+ }
102
+ if reason := userMessageRejectionReason(plain(raw["message"]), "A client can only start a turn with a user message"); reason != "" {
103
+ return reason
104
+ }
105
+ if _, present := raw["queuedMessageId"]; present {
106
+ return "Only the host can start a queued message"
107
+ }
108
+ if chat != nil && chat.ActiveTurn != nil {
109
+ return "A turn is already active"
110
+ }
111
+ case string(ahptypes.ActionTypeChatTurnCancelled):
112
+ id, okID := str("turnId")
113
+ _, okDuration := raw["duration"].(json.Number)
114
+ if !okID || !okDuration {
115
+ return "Turn cancellation requires a turnId and duration"
116
+ }
117
+ if chat == nil || chat.ActiveTurn == nil || chat.ActiveTurn.Id != id {
118
+ return "No matching active turn to cancel"
119
+ }
120
+ case string(ahptypes.ActionTypeChatPendingMessageSet):
121
+ _, okID := str("id")
122
+ kind, _ := str("kind")
123
+ if !okID || (kind != string(ahptypes.PendingMessageKindSteering) && kind != string(ahptypes.PendingMessageKindQueued)) {
124
+ return "A pending message requires an id and supported kind"
125
+ }
126
+ return userMessageRejectionReason(plain(raw["message"]), "A client can only queue a user message")
127
+ case string(ahptypes.ActionTypeChatPendingMessageRemoved):
128
+ id, okID := str("id")
129
+ if !okID {
130
+ return "A pending message removal requires an id"
131
+ }
132
+ kind, _ := str("kind")
133
+ if kind == string(ahptypes.PendingMessageKindSteering) {
134
+ return "A steering message cannot be withdrawn after pi has queued it"
135
+ }
136
+ if kind != string(ahptypes.PendingMessageKindQueued) {
137
+ return "Unsupported pending message kind"
138
+ }
139
+ if chat != nil {
140
+ for _, m := range chat.QueuedMessages {
141
+ if m.Id == id {
142
+ return ""
143
+ }
144
+ }
145
+ }
146
+ return "No matching queued message to remove"
147
+ case string(ahptypes.ActionTypeChatQueuedMessagesReordered):
148
+ order, ok := raw["order"].([]any)
149
+ if ok {
150
+ for _, id := range order {
151
+ if _, isStr := id.(string); !isStr {
152
+ ok = false
153
+ }
154
+ }
155
+ }
156
+ if !ok {
157
+ return "Queued message order must be an array of ids"
158
+ }
159
+ case string(ahptypes.ActionTypeChatDraftChanged):
160
+ draft, present := raw["draft"]
161
+ if !present {
162
+ return ""
163
+ }
164
+ return userMessageRejectionReason(plain(draft), "A client can only draft a user message")
165
+ case string(ahptypes.ActionTypeChatTruncated):
166
+ var turnID *string
167
+ if v, present := raw["turnId"]; present {
168
+ s, ok := v.(string)
169
+ if !ok {
170
+ return "A truncation turnId must be a string"
171
+ }
172
+ turnID = &s
173
+ }
174
+ // Accepting an impossible truncation would shorten the client's view while Pi kept using
175
+ // context the user believes is gone.
176
+ owner, ok := r.GetByChat(channel)
177
+ if !ok {
178
+ return ""
179
+ }
180
+ if _, ok := TruncationAnchor(owner, turnID); !ok {
181
+ label := "(all)"
182
+ if turnID != nil {
183
+ label = *turnID
184
+ }
185
+ return fmt.Sprintf("Cannot truncate: no session entry matches turn %s", label)
186
+ }
187
+ }
188
+ return ""
189
+ }
190
+
191
+ // plain converts json.Number values to float64 so the shared message checks (written over
192
+ // standard decoded JSON) see numbers as they expect.
193
+ func plain(v any) any {
194
+ switch x := v.(type) {
195
+ case map[string]any:
196
+ out := make(map[string]any, len(x))
197
+ for k, e := range x {
198
+ out[k] = plain(e)
199
+ }
200
+ return out
201
+ case []any:
202
+ out := make([]any, len(x))
203
+ for i, e := range x {
204
+ out[i] = plain(e)
205
+ }
206
+ return out
207
+ case json.Number:
208
+ f, _ := x.Float64()
209
+ return f
210
+ }
211
+ return v
212
+ }