@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,589 @@
1
+ package ahp
2
+
3
+ // The real-host proof: the extension, built and loaded by a real `pig`, serving a real PiG
4
+ // session whose model is a scripted local server, driven by a WebSocket AHP client. It runs only
5
+ // when PIGPEN_AHP_REAL=1 and PIG_BIN names the pig executable; everything happens under a
6
+ // temporary HOME, PIG_HOME and PIG_CODING_AGENT_DIR (nothing of the caller's is read or written).
7
+
8
+ import (
9
+ "encoding/json"
10
+ "fmt"
11
+ "io"
12
+ "net"
13
+ "net/http"
14
+ "os"
15
+ "os/exec"
16
+ "path/filepath"
17
+ "regexp"
18
+ "strings"
19
+ "sync"
20
+ "testing"
21
+ "time"
22
+
23
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
24
+ "github.com/MichaelKinsy/pigpen/ahp/internal/ws"
25
+ )
26
+
27
+ type realOpts struct {
28
+ script string
29
+ settings func(port int, token string) string
30
+ args []string
31
+ root string // reuse a previous run's directory (its sessions included)
32
+ noFlag bool // start without --ahp
33
+ }
34
+
35
+ type realPig struct {
36
+ root string
37
+ stdin io.WriteCloser
38
+ stop func()
39
+ t *testing.T
40
+ port int
41
+ token string
42
+ client *ws.Client
43
+ mu sync.Mutex
44
+ id int
45
+ notes []map[string]any
46
+ }
47
+
48
+ func freeTCPPort(t *testing.T) int {
49
+ l, err := net.Listen("tcp", "127.0.0.1:0")
50
+ if err != nil {
51
+ t.Fatal(err)
52
+ }
53
+ defer l.Close()
54
+ return l.Addr().(*net.TCPAddr).Port
55
+ }
56
+
57
+ // startRealPig starts fakellm and pig (RPC mode, stdin held open) and returns once the listener
58
+ // accepts connections. settingsBody is the AHP settings file.
59
+ func startRealPig(t *testing.T, o realOpts) *realPig {
60
+ t.Helper()
61
+ pigBin := os.Getenv("PIGPEN_AHP_PIG") // a Piglet Binary with the extension fused in, else PIG_BIN
62
+ if pigBin == "" {
63
+ pigBin = os.Getenv("PIG_BIN")
64
+ }
65
+ if os.Getenv("PIGPEN_AHP_REAL") != "1" || pigBin == "" {
66
+ t.Skip("real-pig proof: set PIGPEN_AHP_REAL=1 and PIG_BIN")
67
+ }
68
+ root := o.root
69
+ if root == "" {
70
+ root = t.TempDir()
71
+ }
72
+ home, agent, work := filepath.Join(root, "home"), filepath.Join(root, "agent"), filepath.Join(root, "work")
73
+ pighome := filepath.Join(root, "pighome")
74
+ for _, d := range []string{home, agent, work, pighome} {
75
+ if err := os.MkdirAll(d, 0o755); err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ }
79
+ // the scripted model
80
+ fake := filepath.Join(root, "fakellm")
81
+ build := exec.Command("go", "build", "-o", fake, ".")
82
+ build.Dir = filepath.Join("..", "..", "proof", "tools", "fakellm")
83
+ build.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod")
84
+ if out, err := build.CombinedOutput(); err != nil {
85
+ t.Fatalf("building fakellm: %v\n%s", err, out)
86
+ }
87
+ scriptPath := filepath.Join(root, "turns.json")
88
+ if err := os.WriteFile(scriptPath, []byte(o.script), 0o644); err != nil {
89
+ t.Fatal(err)
90
+ }
91
+ llm := exec.Command(fake, "-script", scriptPath)
92
+ stdout, _ := llm.StdoutPipe()
93
+ if err := llm.Start(); err != nil {
94
+ t.Fatal(err)
95
+ }
96
+ t.Cleanup(func() { _ = llm.Process.Kill(); _ = llm.Wait() })
97
+ buf := make([]byte, 256)
98
+ n, _ := stdout.Read(buf)
99
+ baseURL := strings.TrimSpace(strings.TrimPrefix(string(buf[:n]), "LISTENING "))
100
+ models := fmt.Sprintf(`{"providers":{"fake":{"baseUrl":%q,"api":"openai-completions","apiKey":"k","models":[{"id":"fake-1","name":"fake-1","reasoning":false,"input":["text"],"contextWindow":100000,"maxTokens":4096,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}}}`, baseURL)
101
+ if err := os.WriteFile(filepath.Join(agent, "models.json"), []byte(models), 0o600); err != nil {
102
+ t.Fatal(err)
103
+ }
104
+
105
+ port, token := freeTCPPort(t), "proof-token"
106
+ settings := filepath.Join(root, "ahp-settings.json")
107
+ if o.settings != nil {
108
+ if err := os.WriteFile(settings, []byte(o.settings(port, token)), 0o600); err != nil {
109
+ t.Fatal(err)
110
+ }
111
+ }
112
+ args := []string{"--mode", "rpc", "--provider", "fake", "--model", "fake-1", "--offline", "--ahp-settings", settings}
113
+ if os.Getenv("PIGPEN_AHP_FUSED") != "1" {
114
+ // PIG_BIN is a plain pig: load the extension from this directory. With a Piglet Binary that
115
+ // has the extension fused in (PIGPEN_AHP_FUSED=1) it is already there.
116
+ args = append(args, "--no-extensions", "-e", ".")
117
+ }
118
+ if !o.noFlag {
119
+ args = append(args, "--ahp")
120
+ }
121
+ args = append(args, o.args...)
122
+ // the extension directory is this package's directory
123
+ here, _ := os.Getwd()
124
+ for i, a := range args {
125
+ if a == "-e" {
126
+ args[i+1] = here
127
+ }
128
+ }
129
+ cmd := exec.Command(pigBin, args...)
130
+ cmd.Dir = work
131
+ cmd.Env = []string{"HOME=" + home, "PIG_HOME=" + pighome, "PIG_CODING_AGENT_DIR=" + agent, "PI_CODING_AGENT_DIR=" + agent,
132
+ "PATH=" + os.Getenv("PATH"), "GOCACHE=" + os.Getenv("GOCACHE"), "GOMODCACHE=" + os.Getenv("GOMODCACHE"), "GOTOOLCHAIN=local", "GOFLAGS=" + os.Getenv("GOFLAGS")}
133
+ stdin, err := cmd.StdinPipe()
134
+ if err != nil {
135
+ t.Fatal(err)
136
+ }
137
+ logFile, _ := os.Create(filepath.Join(root, "pig.log"))
138
+ var stopOnce sync.Once
139
+ var stopFn func()
140
+ cmd.Stdout, cmd.Stderr = logFile, logFile
141
+ if err := cmd.Start(); err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ stopFn = func() {
145
+ stopOnce.Do(func() {
146
+ _ = stdin.Close()
147
+ done := make(chan struct{})
148
+ go func() { _ = cmd.Wait(); close(done) }()
149
+ select {
150
+ case <-done:
151
+ case <-time.After(5 * time.Second):
152
+ _ = cmd.Process.Kill()
153
+ <-done
154
+ }
155
+ })
156
+ }
157
+ t.Cleanup(func() {
158
+ stopFn()
159
+ if t.Failed() {
160
+ if b, err := os.ReadFile(filepath.Join(root, "pig.log")); err == nil {
161
+ if len(b) > 3000 {
162
+ b = b[:3000]
163
+ }
164
+ t.Logf("pig output (first 3000 bytes):\n%s", b)
165
+ }
166
+ }
167
+ })
168
+ p := &realPig{t: t, port: port, token: token, root: root, stdin: stdin, stop: stopFn}
169
+ if o.noFlag {
170
+ return p
171
+ }
172
+ deadline := time.Now().Add(120 * time.Second) // the first run builds the extension
173
+ for {
174
+ c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
175
+ if err == nil {
176
+ c.Close()
177
+ break
178
+ }
179
+ if time.Now().After(deadline) {
180
+ t.Fatalf("the listener never came up: %v", err)
181
+ }
182
+ time.Sleep(200 * time.Millisecond)
183
+ }
184
+ return p
185
+ }
186
+
187
+ // pigLog is everything pig printed so far.
188
+ func (p *realPig) pigLog() string {
189
+ b, _ := os.ReadFile(filepath.Join(p.root, "pig.log"))
190
+ return string(b)
191
+ }
192
+
193
+ // rpcPrompt types a prompt into the session, as a local user would.
194
+ func (p *realPig) rpcPrompt(text string) {
195
+ line, _ := json.Marshal(map[string]any{"type": "prompt", "message": text})
196
+ if _, err := p.stdin.Write(append(line, '\n')); err != nil {
197
+ p.t.Fatal(err)
198
+ }
199
+ }
200
+
201
+ func (p *realPig) connect(origin string) (*ws.Client, *http.Response, error) {
202
+ header := http.Header{}
203
+ if origin != "" {
204
+ header.Set("Origin", origin)
205
+ }
206
+ return ws.Dial(fmt.Sprintf("ws://127.0.0.1:%d/?token=%s", p.port, p.token), header)
207
+ }
208
+
209
+ func (p *realPig) open() {
210
+ c, _, err := p.connect("")
211
+ if err != nil {
212
+ p.t.Fatal(err)
213
+ }
214
+ p.client = c
215
+ p.t.Cleanup(func() { c.Close() })
216
+ }
217
+
218
+ // request sends a JSON-RPC request and returns its result or error object, keeping every
219
+ // notification that arrives meanwhile.
220
+ func (p *realPig) request(method string, params map[string]any) (json.RawMessage, map[string]any) {
221
+ p.t.Helper()
222
+ p.mu.Lock()
223
+ p.id++
224
+ id := p.id
225
+ p.mu.Unlock()
226
+ frame, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
227
+ if err := p.client.WriteText(frame); err != nil {
228
+ p.t.Fatal(err)
229
+ }
230
+ for {
231
+ msg := p.next(30 * time.Second)
232
+ if got, ok := msg["id"]; ok && int(got.(float64)) == id {
233
+ if e, bad := msg["error"].(map[string]any); bad {
234
+ return nil, e
235
+ }
236
+ raw, _ := json.Marshal(msg["result"])
237
+ return raw, nil
238
+ }
239
+ }
240
+ }
241
+
242
+ func (p *realPig) notify(method string, params map[string]any) {
243
+ frame, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
244
+ if err := p.client.WriteText(frame); err != nil {
245
+ p.t.Fatal(err)
246
+ }
247
+ }
248
+
249
+ func (p *realPig) next(timeout time.Duration) map[string]any {
250
+ msg, err := p.tryNext(timeout)
251
+ if err != nil {
252
+ p.t.Fatalf("reading from the host: %v", err)
253
+ }
254
+ return msg
255
+ }
256
+
257
+ func (p *realPig) tryNext(timeout time.Duration) (map[string]any, error) {
258
+ raw, err := p.client.ReadMessage(timeout)
259
+ if err != nil {
260
+ return nil, err
261
+ }
262
+ var msg map[string]any
263
+ _ = json.Unmarshal(raw, &msg)
264
+ if _, isResponse := msg["id"]; !isResponse {
265
+ p.notes = append(p.notes, msg)
266
+ }
267
+ return msg, nil
268
+ }
269
+
270
+ // awaitAction reads until an action of type on channel arrives.
271
+ func (p *realPig) awaitAction(channel, actionType string, timeout time.Duration) map[string]any {
272
+ p.t.Helper()
273
+ deadline := time.Now().Add(timeout)
274
+ check := func(n map[string]any) map[string]any {
275
+ params, _ := n["params"].(map[string]any)
276
+ action, _ := params["action"].(map[string]any)
277
+ if params["channel"] == channel && action["type"] == actionType {
278
+ return action
279
+ }
280
+ return nil
281
+ }
282
+ for _, n := range p.notes {
283
+ if a := check(n); a != nil {
284
+ return a
285
+ }
286
+ }
287
+ for time.Now().Before(deadline) {
288
+ msg, err := p.tryNext(time.Until(deadline))
289
+ if err != nil {
290
+ break
291
+ }
292
+ if a := check(msg); a != nil {
293
+ return a
294
+ }
295
+ }
296
+ var seen []string
297
+ for _, n := range p.notes {
298
+ params, _ := n["params"].(map[string]any)
299
+ action, _ := params["action"].(map[string]any)
300
+ seen = append(seen, fmt.Sprintf("%v %v %v", n["method"], params["channel"], action["type"]))
301
+ }
302
+ p.t.Fatalf("timed out waiting for %s on %s; saw:\n%s", actionType, channel, strings.Join(seen, "\n"))
303
+ return nil
304
+ }
305
+
306
+ func TestRealPigServesTheLiveSession(t *testing.T) {
307
+ p := startRealPig(t, realOpts{script: `[{"chunks":["Hello ","from PiG"]}]`, settings: basicSettings})
308
+
309
+ // a missing token, and a foreign browser origin, are refused
310
+ if _, resp, err := ws.Dial(fmt.Sprintf("ws://127.0.0.1:%d/", p.port), nil); err == nil || resp == nil || resp.StatusCode != http.StatusForbidden {
311
+ t.Fatalf("a connection without the token must be refused (err=%v resp=%v)", err, resp)
312
+ }
313
+ if _, resp, err := p.connect("https://evil.example"); err == nil || resp == nil || resp.StatusCode != http.StatusForbidden {
314
+ t.Fatalf("a foreign Origin must be refused (err=%v resp=%v)", err, resp)
315
+ }
316
+
317
+ p.open()
318
+ raw, rpcErr := p.request("initialize", map[string]any{"channel": "ahp-root://", "clientId": "real-proof", "protocolVersions": []string{"0.9.0"}, "initialSubscriptions": []string{"ahp-root://"}})
319
+ if rpcErr != nil {
320
+ t.Fatalf("initialize: %v", rpcErr)
321
+ }
322
+ var init struct {
323
+ ServerInfo struct{ Name string } `json:"serverInfo"`
324
+ }
325
+ _ = json.Unmarshal(raw, &init)
326
+ if init.ServerInfo.Name != "pigpen-ahp" {
327
+ t.Fatalf("serverInfo %s", raw)
328
+ }
329
+
330
+ // nothing beyond the session is exposed
331
+ if _, e := p.request("resourceRead", map[string]any{"channel": "ahp-root://", "uri": "file:///etc/hostname"}); e == nil || e["code"].(float64) != -32601 {
332
+ t.Fatalf("the filesystem must not be exposed by default: %v", e)
333
+ }
334
+ if _, e := p.request("createTerminal", map[string]any{"channel": "ahp-terminal:/x", "claim": map[string]any{"kind": "client", "clientId": "real-proof"}}); e == nil || e["code"].(float64) != -32601 {
335
+ t.Fatalf("terminals must not be exposed by default: %v", e)
336
+ }
337
+
338
+ // the running session is in the catalogue
339
+ raw, rpcErr = p.request("listSessions", map[string]any{"channel": "ahp-root://"})
340
+ if rpcErr != nil {
341
+ t.Fatal(rpcErr)
342
+ }
343
+ var list struct{ Items []struct{ Resource string } }
344
+ _ = json.Unmarshal(raw, &list)
345
+ if len(list.Items) != 1 || !strings.HasPrefix(list.Items[0].Resource, "ahp-session:/") {
346
+ t.Fatalf("catalogue %s", raw)
347
+ }
348
+ session := list.Items[0].Resource
349
+ chat := "ahp-chat:/" + strings.TrimPrefix(session, "ahp-session:/")
350
+ if _, e := p.request("subscribe", map[string]any{"channel": session}); e != nil {
351
+ t.Fatalf("subscribe session: %v", e)
352
+ }
353
+ if _, e := p.request("subscribe", map[string]any{"channel": chat}); e != nil {
354
+ t.Fatalf("subscribe chat: %v", e)
355
+ }
356
+
357
+ // a remote turn drives the real agent loop against the scripted model
358
+ p.notify("dispatchAction", map[string]any{"channel": chat, "clientSeq": 1, "action": map[string]any{
359
+ "type": "chat/turnStarted", "turnId": "remote-1", "startedAt": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
360
+ "message": map[string]any{"text": "say hello", "origin": map[string]any{"kind": "user"}}}})
361
+ p.awaitAction(chat, "chat/turnComplete", 20*time.Second)
362
+
363
+ raw, rpcErr = p.request("subscribe", map[string]any{"channel": chat})
364
+ if rpcErr != nil {
365
+ t.Fatal(rpcErr)
366
+ }
367
+ if !strings.Contains(string(raw), "Hello from PiG") {
368
+ t.Fatalf("the completed turn does not carry the model's answer: %s", raw)
369
+ }
370
+ }
371
+
372
+ func basicSettings(port int, token string) string {
373
+ return fmt.Sprintf(`{"port": %d, "token": %q, "host": "127.0.0.1"}`, port, token)
374
+ }
375
+
376
+ // handshake opens a connection and initialises it; it returns the live session and its chat.
377
+ func (p *realPig) handshake() (session, chat string) {
378
+ p.open()
379
+ if _, e := p.request("initialize", map[string]any{"channel": "ahp-root://", "clientId": "real-proof", "protocolVersions": []string{"0.9.0"}, "initialSubscriptions": []string{"ahp-root://"}}); e != nil {
380
+ p.t.Fatalf("initialize: %v", e)
381
+ }
382
+ raw, e := p.request("listSessions", map[string]any{"channel": "ahp-root://"})
383
+ if e != nil {
384
+ p.t.Fatal(e)
385
+ }
386
+ var list struct{ Items []struct{ Resource string } }
387
+ _ = json.Unmarshal(raw, &list)
388
+ if len(list.Items) != 1 {
389
+ p.t.Fatalf("catalogue %s", raw)
390
+ }
391
+ session = list.Items[0].Resource
392
+ chat = "ahp-chat:/" + strings.TrimPrefix(session, "ahp-session:/")
393
+ for _, ch := range []string{session, chat} {
394
+ if _, e := p.request("subscribe", map[string]any{"channel": ch}); e != nil {
395
+ p.t.Fatalf("subscribe %s: %v", ch, e)
396
+ }
397
+ }
398
+ return session, chat
399
+ }
400
+
401
+ func TestRealPigListenerIsOffUnlessAsked(t *testing.T) {
402
+ p := startRealPig(t, realOpts{script: `[]`, noFlag: true})
403
+ p.rpcPrompt("/ahp status")
404
+ testkit.Eventually(t, "the status notification", func() bool { return strings.Contains(p.pigLog(), "AHP listener is off") })
405
+ if _, err := os.Stat(filepath.Join(p.root, "ahp-settings.json")); err == nil {
406
+ t.Fatal("the settings file (with its token) must not be created unless the listener is started")
407
+ }
408
+ if c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", p.port), 300*time.Millisecond); err == nil {
409
+ c.Close()
410
+ t.Fatal("a listener is open although none was asked for")
411
+ }
412
+ // /ahp start is the explicit request
413
+ p.rpcPrompt("/ahp start")
414
+ testkit.Eventually(t, "the listener to start", func() bool { return strings.Contains(p.pigLog(), "AHP listening on ws://127.0.0.1:") })
415
+ // the notice leads with the address as VS Code's "Agents: Add Remote Agent Host..." and SSH launcher read it
416
+ // (the settings file, and its generated token and port, only came into being with this /ahp start)
417
+ vscode := regexp.MustCompile(`AHP listening on ws://127\.0\.0\.1:\d+\?tkn=[A-Za-z0-9_-]+ - for VS Code`)
418
+ testkit.Eventually(t, "the VS Code form in the notice", func() bool { return vscode.MatchString(p.pigLog()) })
419
+ }
420
+
421
+ func TestRealPigMirrorsALocalPrompt(t *testing.T) {
422
+ p := startRealPig(t, realOpts{script: `[{"chunks":["typed ","locally"]}]`, settings: basicSettings})
423
+ _, chat := p.handshake()
424
+ p.rpcPrompt("typed into the session")
425
+ p.awaitAction(chat, "chat/turnStarted", 20*time.Second)
426
+ p.awaitAction(chat, "chat/turnComplete", 20*time.Second)
427
+ raw, e := p.request("subscribe", map[string]any{"channel": chat})
428
+ if e != nil {
429
+ t.Fatal(e)
430
+ }
431
+ if !strings.Contains(string(raw), "typed into the session") || !strings.Contains(string(raw), "typed locally") {
432
+ t.Fatalf("the local turn is not in the chat: %s", raw)
433
+ }
434
+ }
435
+
436
+ func TestRealPigOptInFilesystemAndTerminal(t *testing.T) {
437
+ p := startRealPig(t, realOpts{script: `[]`, settings: func(port int, token string) string {
438
+ return fmt.Sprintf(`{"port": %d, "token": %q, "filesystem": {"enabled": true}, "terminals": {"enabled": true}}`, port, token)
439
+ }})
440
+ p.handshake()
441
+ work := filepath.Join(p.root, "work")
442
+ if err := os.WriteFile(filepath.Join(work, "note.txt"), []byte("inside\n"), 0o644); err != nil {
443
+ t.Fatal(err)
444
+ }
445
+ raw, e := p.request("resourceRead", map[string]any{"channel": "ahp-root://", "uri": "file://" + filepath.ToSlash(filepath.Join(work, "note.txt"))})
446
+ if e != nil || !strings.Contains(string(raw), "inside") {
447
+ t.Fatalf("a file inside the working directory must be readable: %s %v", raw, e)
448
+ }
449
+ // the default root is the session's working directory: nothing outside it, not even beside it
450
+ if _, e := p.request("resourceRead", map[string]any{"channel": "ahp-root://", "uri": "file:///etc/hostname"}); e == nil || e["code"].(float64) != -32009 {
451
+ t.Fatalf("a file outside the roots must be refused: %v", e)
452
+ }
453
+ sibling := filepath.Join(p.root, "beside-work.txt")
454
+ if err := os.WriteFile(sibling, []byte("beside\n"), 0o600); err != nil {
455
+ t.Fatal(err)
456
+ }
457
+ if _, e := p.request("resourceRead", map[string]any{"channel": "ahp-root://", "uri": "file://" + filepath.ToSlash(sibling)}); e == nil || e["code"].(float64) != -32009 {
458
+ t.Fatalf("a file beside the working directory must be refused: %v", e)
459
+ }
460
+ // a terminal: claimed by this client, output flows back
461
+ terminal := "ahp-terminal:/" + "0a3c1e52-6b7d-4f3a-9d10-0000000000aa"
462
+ if _, e := p.request("createTerminal", map[string]any{"channel": terminal, "claim": map[string]any{"kind": "client", "clientId": "real-proof"}, "cwd": "file://" + filepath.ToSlash(work)}); e != nil {
463
+ t.Fatalf("createTerminal: %v", e)
464
+ }
465
+ if _, e := p.request("subscribe", map[string]any{"channel": terminal}); e != nil {
466
+ t.Fatal(e)
467
+ }
468
+ p.notify("dispatchAction", map[string]any{"channel": terminal, "clientSeq": 1, "action": map[string]any{"type": "terminal/input", "data": "echo pty-$((6*7))\r"}})
469
+ deadline := time.Now().Add(20 * time.Second)
470
+ for time.Now().Before(deadline) {
471
+ msg, err := p.tryNext(time.Until(deadline))
472
+ if err != nil {
473
+ break
474
+ }
475
+ if strings.Contains(fmt.Sprint(msg), "pty-42") {
476
+ return
477
+ }
478
+ }
479
+ t.Fatal("the shell's output never arrived")
480
+ }
481
+
482
+ func TestRealPigServesAResumedSessionWithItsHistory(t *testing.T) {
483
+ first := startRealPig(t, realOpts{script: `[{"text":"the first answer"}]`, noFlag: true})
484
+ first.rpcPrompt("the first question")
485
+ testkit.Eventually(t, "the first turn to settle", func() bool { return strings.Contains(first.pigLog(), `"type":"agent_settled"`) })
486
+ first.stop()
487
+
488
+ second := startRealPig(t, realOpts{script: `[{"text":"the second answer"}]`, settings: basicSettings, root: first.root, args: []string{"--continue"}})
489
+ _, chat := second.handshake()
490
+ raw, e := second.request("subscribe", map[string]any{"channel": chat})
491
+ if e != nil {
492
+ t.Fatal(e)
493
+ }
494
+ if !strings.Contains(string(raw), "the first question") || !strings.Contains(string(raw), "the first answer") {
495
+ t.Fatalf("the resumed session's history was not served: %s", raw)
496
+ }
497
+ // and the resumed session keeps working through AHP
498
+ second.notify("dispatchAction", map[string]any{"channel": chat, "clientSeq": 1, "action": map[string]any{
499
+ "type": "chat/turnStarted", "turnId": "remote-2", "startedAt": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
500
+ "message": map[string]any{"text": "the second question", "origin": map[string]any{"kind": "user"}}}})
501
+ second.awaitAction(chat, "chat/turnComplete", 20*time.Second)
502
+ raw, _ = second.request("subscribe", map[string]any{"channel": chat})
503
+ if !strings.Contains(string(raw), "the second answer") {
504
+ t.Fatalf("the second turn's answer is missing: %s", raw)
505
+ }
506
+ }
507
+
508
+ func TestRealPigCancelsARemoteTurn(t *testing.T) {
509
+ p := startRealPig(t, realOpts{script: `[{"chunks":["a","b","c","d","e","f","g","h","i","j"],"delayMs":1500}]`, settings: basicSettings})
510
+ _, chat := p.handshake()
511
+ p.notify("dispatchAction", map[string]any{"channel": chat, "clientSeq": 1, "action": map[string]any{
512
+ "type": "chat/turnStarted", "turnId": "remote-c", "startedAt": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
513
+ "message": map[string]any{"text": "go slowly", "origin": map[string]any{"kind": "user"}}}})
514
+ p.awaitAction(chat, "chat/delta", 20*time.Second)
515
+ p.notify("dispatchAction", map[string]any{"channel": chat, "clientSeq": 2, "action": map[string]any{"type": "chat/turnCancelled", "turnId": "remote-c", "duration": 0}})
516
+ // the script would run for 15 s: an abort ends the run within seconds, not at its natural end
517
+ deadline := time.Now().Add(6 * time.Second)
518
+ for !strings.Contains(p.pigLog(), `"type":"agent_end"`) {
519
+ if time.Now().After(deadline) {
520
+ t.Fatal("the agent kept running after the remote cancel")
521
+ }
522
+ time.Sleep(50 * time.Millisecond)
523
+ }
524
+ if strings.Contains(p.pigLog(), `"delta":"j"`) {
525
+ t.Fatal("the model's whole answer was streamed although the turn was cancelled")
526
+ }
527
+ }
528
+
529
+ func TestRealPigRefusesANonLoopbackHostWithoutAToken(t *testing.T) {
530
+ p := startRealPig(t, realOpts{script: `[]`, noFlag: true, settings: func(port int, _ string) string {
531
+ return fmt.Sprintf(`{"port": %d, "token": null, "host": "0.0.0.0"}`, port)
532
+ }})
533
+ p.rpcPrompt("/ahp start")
534
+ testkit.Eventually(t, "the refusal", func() bool { return strings.Contains(p.pigLog(), "not loopback, so a token is required") })
535
+ if c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", p.port), 300*time.Millisecond); err == nil {
536
+ c.Close()
537
+ t.Fatal("a non-loopback listener without a token was opened")
538
+ }
539
+ }
540
+
541
+ // rpcCommand sends an RPC command other than a prompt.
542
+ func (p *realPig) rpcCommand(cmd map[string]any) {
543
+ line, _ := json.Marshal(cmd)
544
+ if _, err := p.stdin.Write(append(line, '\n')); err != nil {
545
+ p.t.Fatal(err)
546
+ }
547
+ }
548
+
549
+ func (p *realPig) listening() bool {
550
+ c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", p.port), 300*time.Millisecond)
551
+ if err == nil {
552
+ c.Close()
553
+ return true
554
+ }
555
+ return false
556
+ }
557
+
558
+ // Review (rev-pigpen-ahp): what happens to the listener when the PiG session is replaced.
559
+ // With --ahp it serves the new session on the same port; after /ahp start it closes with the
560
+ // replaced session, and says so.
561
+ func TestRealPigSessionReplacement(t *testing.T) {
562
+ t.Run("--ahp serves the new session", func(t *testing.T) {
563
+ p := startRealPig(t, realOpts{script: `[{"text":"after the replacement"}]`, settings: basicSettings})
564
+ before, _ := p.handshake()
565
+ p.rpcCommand(map[string]any{"type": "new_session"})
566
+ testkit.Eventually(t, "new_session to finish", func() bool { return strings.Contains(p.pigLog(), `"command":"new_session","success":true`) })
567
+ testkit.Eventually(t, "the listener to be back", p.listening)
568
+ p.notes = nil
569
+ after, chat := p.handshake()
570
+ if after == before {
571
+ t.Fatalf("still serving the replaced session %s", before)
572
+ }
573
+ p.notify("dispatchAction", map[string]any{"channel": chat, "clientSeq": 1, "action": map[string]any{
574
+ "type": "chat/turnStarted", "turnId": "remote-n", "startedAt": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
575
+ "message": map[string]any{"text": "are you there", "origin": map[string]any{"kind": "user"}}}})
576
+ p.awaitAction(chat, "chat/turnComplete", 20*time.Second)
577
+ })
578
+ t.Run("/ahp start closes with the session and says so", func(t *testing.T) {
579
+ p := startRealPig(t, realOpts{script: `[]`, noFlag: true, settings: basicSettings})
580
+ p.rpcPrompt("/ahp start")
581
+ testkit.Eventually(t, "the listener to start", func() bool { return strings.Contains(p.pigLog(), "AHP listening on ws://127.0.0.1:") })
582
+ p.rpcCommand(map[string]any{"type": "new_session"})
583
+ testkit.Eventually(t, "new_session to finish", func() bool { return strings.Contains(p.pigLog(), `"command":"new_session","success":true`) })
584
+ if p.listening() {
585
+ t.Fatal("a listener opened with /ahp start must not follow the session into a replacement unasked")
586
+ }
587
+ testkit.Eventually(t, "the notice that the listener closed", func() bool { return strings.Contains(p.pigLog(), "AHP listener stopped") })
588
+ })
589
+ }