@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,153 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "reflect"
8
+ "runtime"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
14
+ )
15
+
16
+ // Twins of upstream test/delete-session.test.ts. The fake trash is a POSIX shell script, so these
17
+ // run on Unix only (the deletion code itself is portable).
18
+
19
+ func fakeTrash(t *testing.T, root, behavior string) (bin, calls string) {
20
+ t.Helper()
21
+ if runtime.GOOS == "windows" {
22
+ t.Skip("the fake trash command is a POSIX shell script")
23
+ }
24
+ bin = filepath.Join(root, "bin")
25
+ calls = filepath.Join(root, "trash-calls.json")
26
+ if err := os.Mkdir(bin, 0o755); err != nil {
27
+ t.Fatal(err)
28
+ }
29
+ script := []string{"#!/bin/sh",
30
+ // Record the arguments as a JSON array.
31
+ `{ printf '['; sep=""; for a in "$@"; do printf '%s"%s"' "$sep" "$a"; sep=","; done; printf ']'; } > ` + calls,
32
+ `for a in "$@"; do target="$a"; done`,
33
+ }
34
+ if behavior == "remove" || behavior == "remove-and-fail" {
35
+ script = append(script, `rm -f -- "$target"`)
36
+ }
37
+ if behavior == "fail" || behavior == "remove-and-fail" {
38
+ script = append(script, "exit 1")
39
+ }
40
+ if err := os.WriteFile(filepath.Join(bin, "trash"), []byte(strings.Join(script, "\n")+"\n"), 0o755); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ return bin, calls
44
+ }
45
+
46
+ func write(t *testing.T, path string) {
47
+ t.Helper()
48
+ if err := os.WriteFile(path, []byte("session\n"), 0o644); err != nil {
49
+ t.Fatal(err)
50
+ }
51
+ }
52
+
53
+ func exists(path string) bool { _, err := os.Lstat(path); return err == nil }
54
+
55
+ func TestSessionFileDeletion(t *testing.T) {
56
+ twin.Run(t, "delete-session", "treats an already-missing file as success", func(t *testing.T) {
57
+ got := pi.DeleteSessionFile(filepath.Join(t.TempDir(), "session.jsonl"))
58
+ if !reflect.DeepEqual(got, pi.DeleteResult{OK: true, Method: "missing"}) {
59
+ t.Fatalf("got %+v", got)
60
+ }
61
+ })
62
+
63
+ twin.Run(t, "delete-session", "prefers trash and guards a path that looks like an option", func(t *testing.T) {
64
+ root := t.TempDir()
65
+ bin, calls := fakeTrash(t, root, "remove")
66
+ previous, _ := os.Getwd()
67
+ if err := os.Chdir(root); err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ defer os.Chdir(previous)
71
+ write(t, "-session.jsonl")
72
+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
73
+ if got := pi.DeleteSessionFile("-session.jsonl"); !reflect.DeepEqual(got, pi.DeleteResult{OK: true, Method: "trash"}) {
74
+ t.Fatalf("got %+v", got)
75
+ }
76
+ if exists("-session.jsonl") {
77
+ t.Fatal("the file remains")
78
+ }
79
+ raw, _ := os.ReadFile(calls)
80
+ var args []string
81
+ if err := json.Unmarshal(raw, &args); err != nil || !reflect.DeepEqual(args, []string{"--", "-session.jsonl"}) {
82
+ t.Fatalf("trash arguments = %s (%v)", raw, err)
83
+ }
84
+ })
85
+
86
+ twin.Run(t, "delete-session", "falls back to unlink when trash exits successfully but leaves the file", func(t *testing.T) {
87
+ root := t.TempDir()
88
+ bin, _ := fakeTrash(t, root, "leave")
89
+ target := filepath.Join(root, "session.jsonl")
90
+ write(t, target)
91
+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
92
+ if got := pi.DeleteSessionFile(target); !reflect.DeepEqual(got, pi.DeleteResult{OK: true, Method: "unlink"}) {
93
+ t.Fatalf("got %+v", got)
94
+ }
95
+ if exists(target) {
96
+ t.Fatal("the file remains")
97
+ }
98
+ })
99
+
100
+ twin.Run(t, "delete-session", "recognizes trash success even when the command exits nonzero", func(t *testing.T) {
101
+ root := t.TempDir()
102
+ bin, _ := fakeTrash(t, root, "remove-and-fail")
103
+ target := filepath.Join(root, "session.jsonl")
104
+ write(t, target)
105
+ t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
106
+ if got := pi.DeleteSessionFile(target); !reflect.DeepEqual(got, pi.DeleteResult{OK: true, Method: "trash"}) {
107
+ t.Fatalf("got %+v", got)
108
+ }
109
+ if exists(target) {
110
+ t.Fatal("the file remains")
111
+ }
112
+ })
113
+
114
+ twin.Run(t, "delete-session", "falls back to unlink when trash is unavailable or fails", func(t *testing.T) {
115
+ for _, behavior := range []string{"missing", "fail"} {
116
+ root := t.TempDir()
117
+ var bin string
118
+ if behavior == "fail" {
119
+ bin, _ = fakeTrash(t, root, "fail")
120
+ } else {
121
+ bin = filepath.Join(root, "empty-bin")
122
+ if err := os.Mkdir(bin, 0o755); err != nil {
123
+ t.Fatal(err)
124
+ }
125
+ }
126
+ target := filepath.Join(root, "session.jsonl")
127
+ write(t, target)
128
+ t.Setenv("PATH", bin)
129
+ if got := pi.DeleteSessionFile(target); !reflect.DeepEqual(got, pi.DeleteResult{OK: true, Method: "unlink"}) {
130
+ t.Fatalf("%s: got %+v", behavior, got)
131
+ }
132
+ if exists(target) {
133
+ t.Fatalf("%s: the file remains", behavior)
134
+ }
135
+ }
136
+ })
137
+
138
+ twin.Run(t, "delete-session", "returns an unlink error instead of claiming deletion", func(t *testing.T) {
139
+ root := t.TempDir()
140
+ target := filepath.Join(root, "directory.jsonl")
141
+ if err := os.Mkdir(target, 0o755); err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ t.Setenv("PATH", root)
145
+ got := pi.DeleteSessionFile(target)
146
+ if got.OK || got.Method != "unlink" || !strings.Contains(strings.ToLower(got.Error), "director") {
147
+ t.Fatalf("got %+v", got)
148
+ }
149
+ if !exists(target) {
150
+ t.Fatal("the directory was removed")
151
+ }
152
+ })
153
+ }
@@ -0,0 +1,362 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "sync"
10
+ "sync/atomic"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
15
+
16
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
17
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
18
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
19
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
20
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
21
+ )
22
+
23
+ // Twins of upstream test/session-disposal.test.ts: durable session removal and its concurrency
24
+ // boundary.
25
+
26
+ type disposalFixture struct {
27
+ t *testing.T
28
+ h *harness
29
+ client *testkit.Client
30
+ }
31
+
32
+ func startDisposal(t *testing.T, create pi.BackendFactory, deleteFile func(string) (pi.SessionFileDeletionResult, error)) *disposalFixture {
33
+ t.Helper()
34
+ root := t.TempDir()
35
+ workspace := filepath.Join(root, "workspace")
36
+ if err := os.Mkdir(workspace, 0o755); err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ h := startHarness(t, harnessOptions{workingDir: workspace, sessionRoot: filepath.Join(root, "sessions"), createBackend: create, deleteFile: deleteFile})
40
+ c := testkit.Connect(t, h.host)
41
+ t.Cleanup(c.Close)
42
+ c.Initialize(nextClientID(), nil)
43
+ return &disposalFixture{t: t, h: h, client: c}
44
+ }
45
+
46
+ func (f *disposalFixture) createReady() (uri, chat string) {
47
+ f.t.Helper()
48
+ id := newID()
49
+ uri, chat = wire.SessionURI(id), wire.ChatURI(id)
50
+ f.client.Must("createSession", obj{"channel": uri})
51
+ testkit.Eventually(f.t, "the session backend to become ready", func() bool {
52
+ return f.h.host.Store().Session(uri).Lifecycle == ahptypes.SessionLifecycleReady
53
+ })
54
+ return uri, chat
55
+ }
56
+
57
+ func (f *disposalFixture) activeSessions() int64 {
58
+ if a := f.h.host.Store().Root(wire.RootChannel).ActiveSessions; a != nil {
59
+ return *a
60
+ }
61
+ return 0
62
+ }
63
+
64
+ func (f *disposalFixture) dispose(uri string) <-chan *testkit.RPCError {
65
+ out := make(chan *testkit.RPCError, 1)
66
+ go func() { _, err := f.client.Request("disposeSession", obj{"channel": uri}); out <- err }()
67
+ return out
68
+ }
69
+
70
+ func wait(t *testing.T, what string, ch <-chan *testkit.RPCError) *testkit.RPCError {
71
+ t.Helper()
72
+ select {
73
+ case err := <-ch:
74
+ return err
75
+ case <-time.After(testkit.Timeout):
76
+ t.Fatalf("timed out waiting for %s", what)
77
+ return nil
78
+ }
79
+ }
80
+
81
+ // disposableBackend is a backend with closures for the parts a disposal test needs to observe.
82
+ type disposableBackend struct {
83
+ funcBackend
84
+ selectModel func(context.Context) error
85
+ dispose func() error
86
+ }
87
+
88
+ func (b disposableBackend) SelectModel(ctx context.Context, _ ahptypes.ModelSelection) error {
89
+ if b.selectModel == nil {
90
+ return nil
91
+ }
92
+ return b.selectModel(ctx)
93
+ }
94
+ func (b disposableBackend) Dispose() error {
95
+ if b.dispose == nil {
96
+ return nil
97
+ }
98
+ return b.dispose()
99
+ }
100
+
101
+ func TestSessionDisposal(t *testing.T) {
102
+ twin.Run(t, "session-disposal", "keeps the session usable when durable deletion fails", func(t *testing.T) {
103
+ var attempts atomic.Int32
104
+ f := startDisposal(t, nil, func(string) (pi.SessionFileDeletionResult, error) {
105
+ attempts.Add(1)
106
+ return pi.SessionFileDeletionResult{Error: "permission denied"}, nil
107
+ })
108
+ uri, _ := f.createReady()
109
+ before := f.activeSessions()
110
+ if before != 1 {
111
+ t.Fatalf("activeSessions = %d", before)
112
+ }
113
+ f.client.ExpectError("disposeSession", obj{"channel": uri}, wire.CodeInternalError)
114
+ _, rpcErr := f.client.Request("disposeSession", obj{"channel": uri})
115
+ if rpcErr == nil || !strings.Contains(rpcErr.Message, "permission denied") {
116
+ t.Fatalf("error = %v", rpcErr)
117
+ }
118
+ if attempts.Load() != 2 { // one per request above; each settles before the next starts
119
+ t.Fatalf("deletion attempts = %d", attempts.Load())
120
+ }
121
+ if !f.h.services.Registry.Has(uri) || f.activeSessions() != before {
122
+ t.Fatal("the session must remain")
123
+ }
124
+ var listed struct{ Items []obj }
125
+ f.client.Decode(f.client.Must("listSessions", obj{"channel": wire.RootChannel}), &listed)
126
+ if len(listed.Items) != 1 || listed.Items[0]["resource"] != uri {
127
+ t.Fatalf("listing = %v", listed.Items)
128
+ }
129
+ f.client.Dispatch(uri, obj{"type": "session/titleChanged", "title": "Still here"})
130
+ f.client.Ping()
131
+ if got := f.h.host.Store().Session(uri).Title; got != "Still here" {
132
+ t.Fatalf("title = %q", got)
133
+ }
134
+ })
135
+
136
+ twin.Run(t, "session-disposal", "coalesces concurrent requests and rejects new work until deletion settles", func(t *testing.T) {
137
+ var attempts atomic.Int32
138
+ finish := make(chan bool)
139
+ defer func() {
140
+ select {
141
+ case finish <- false:
142
+ default:
143
+ }
144
+ }()
145
+ f := startDisposal(t, nil, func(string) (pi.SessionFileDeletionResult, error) {
146
+ attempts.Add(1)
147
+ return pi.SessionFileDeletionResult{OK: <-finish}, nil
148
+ })
149
+ uri, _ := f.createReady()
150
+ f.client.Subscribe(uri)
151
+ titleBefore := f.h.host.Store().Session(uri).Title
152
+
153
+ first := f.dispose(uri)
154
+ testkit.Eventually(t, "durable deletion to start", func() bool { return attempts.Load() == 1 })
155
+ second := f.dispose(uri)
156
+ testkit.Eventually(t, "the second request to coalesce", func() bool { return f.h.services.Registry.DisposalWaiters(uri) == 1 })
157
+ seq := f.client.Dispatch(uri, obj{"type": "session/titleChanged", "title": "Too late"})
158
+ env := f.client.NextEnvelope(uri, seq)
159
+ if env.RejectionReason == nil || *env.RejectionReason == "" {
160
+ t.Fatal("new work during deletion must be rejected")
161
+ }
162
+ if got := f.h.host.Store().Session(uri).Title; got != titleBefore {
163
+ t.Fatalf("title = %q", got)
164
+ }
165
+ finish <- true
166
+ if err := wait(t, "the first disposal", first); err != nil {
167
+ t.Fatal(err)
168
+ }
169
+ if err := wait(t, "the coalesced disposal", second); err != nil {
170
+ t.Fatal(err)
171
+ }
172
+ if attempts.Load() != 1 {
173
+ t.Fatalf("deletion attempts = %d", attempts.Load())
174
+ }
175
+ if f.h.host.Store().Has(uri) {
176
+ t.Fatal("the session channel remains")
177
+ }
178
+ })
179
+
180
+ twin.Run(t, "session-disposal", "quiesces an active backend and commits removal despite cleanup failure", func(t *testing.T) {
181
+ var mu sync.Mutex
182
+ var promptStarted, promptSettled, aborted, disposed, sawQuiescence bool
183
+ release := newRelease()
184
+ defer release.open()
185
+ backend := disposableBackend{
186
+ funcBackend: funcBackend{
187
+ prompt: func(context.Context, string) error {
188
+ mu.Lock()
189
+ promptStarted = true
190
+ mu.Unlock()
191
+ <-release.ch
192
+ mu.Lock()
193
+ promptSettled = true
194
+ mu.Unlock()
195
+ return nil
196
+ },
197
+ abort: func(context.Context) error {
198
+ mu.Lock()
199
+ aborted = true
200
+ mu.Unlock()
201
+ release.open()
202
+ return nil
203
+ },
204
+ },
205
+ dispose: func() error {
206
+ mu.Lock()
207
+ disposed = true
208
+ mu.Unlock()
209
+ return errors.New("cleanup failed after deletion")
210
+ },
211
+ }
212
+ f := startDisposal(t, func(*pi.LiveSession) (pi.Backend, error) { return backend, nil }, func(string) (pi.SessionFileDeletionResult, error) {
213
+ mu.Lock()
214
+ sawQuiescence = aborted && promptSettled
215
+ mu.Unlock()
216
+ return pi.SessionFileDeletionResult{OK: true}, nil
217
+ })
218
+ uri, chat := f.createReady()
219
+ f.client.Subscribe(chat)
220
+ f.client.Dispatch(chat, obj{"type": "chat/turnStarted", "turnId": "turn-to-delete", "startedAt": "2025-01-01T00:00:00.000Z", "message": userMessage("keep running")})
221
+ testkit.Eventually(t, "the active prompt to start", func() bool { mu.Lock(); defer mu.Unlock(); return promptStarted })
222
+ f.client.Must("disposeSession", obj{"channel": uri})
223
+ mu.Lock()
224
+ defer mu.Unlock()
225
+ if !sawQuiescence {
226
+ t.Fatal("deletion ran before the backend was quiescent")
227
+ }
228
+ if !disposed || f.h.host.Store().Has(uri) {
229
+ t.Fatalf("disposed=%v channelPresent=%v", disposed, f.h.host.Store().Has(uri))
230
+ }
231
+ })
232
+
233
+ twin.Run(t, "session-disposal", "resumes queued work when backend quiescence fails", func(t *testing.T) {
234
+ var mu sync.Mutex
235
+ var listeners []func(mapper.Event)
236
+ var prompts []string
237
+ var attempts atomic.Int32
238
+ backend := funcBackend{
239
+ subscribe: func(l func(mapper.Event)) func() {
240
+ mu.Lock()
241
+ listeners = append(listeners, l)
242
+ mu.Unlock()
243
+ return func() {}
244
+ },
245
+ prompt: func(_ context.Context, text string) error {
246
+ mu.Lock()
247
+ prompts = append(prompts, text)
248
+ mu.Unlock()
249
+ return nil
250
+ },
251
+ abort: func(context.Context) error {
252
+ mu.Lock()
253
+ ls := append([]func(mapper.Event){}, listeners...)
254
+ mu.Unlock()
255
+ for _, l := range ls {
256
+ l(mapper.Event{"type": "agent_settled"})
257
+ }
258
+ return errors.New("abort failed")
259
+ },
260
+ }
261
+ f := startDisposal(t, func(*pi.LiveSession) (pi.Backend, error) { return backend, nil }, func(string) (pi.SessionFileDeletionResult, error) {
262
+ attempts.Add(1)
263
+ return pi.SessionFileDeletionResult{OK: true}, nil
264
+ })
265
+ uri, chat := f.createReady()
266
+ f.client.Subscribe(chat)
267
+ f.client.Dispatch(chat, obj{"type": "chat/turnStarted", "turnId": "active", "startedAt": "2025-01-01T00:00:00.000Z", "message": userMessage("active")})
268
+ testkit.Eventually(t, "the active prompt to start", func() bool { mu.Lock(); defer mu.Unlock(); return len(prompts) == 1 })
269
+ f.client.Dispatch(chat, obj{"type": "chat/pendingMessageSet", "kind": "queued", "id": "after-failure", "message": userMessage("after failure")})
270
+ f.client.Ping()
271
+ rpcErr := f.client.ExpectError("disposeSession", obj{"channel": uri}, wire.CodeInternalError)
272
+ if !strings.Contains(rpcErr.Message, "abort failed") {
273
+ t.Fatalf("error = %v", rpcErr)
274
+ }
275
+ testkit.Eventually(t, "queued work to resume", func() bool {
276
+ mu.Lock()
277
+ defer mu.Unlock()
278
+ for _, p := range prompts {
279
+ if p == "after failure" {
280
+ return true
281
+ }
282
+ }
283
+ return false
284
+ })
285
+ if attempts.Load() != 0 || !f.h.services.Registry.Has(uri) {
286
+ t.Fatalf("deletion attempts %d, live %v", attempts.Load(), f.h.services.Registry.Has(uri))
287
+ }
288
+ })
289
+
290
+ twin.Run(t, "session-disposal", "drains an in-flight model selection before deleting", func(t *testing.T) {
291
+ var selectionStarted, selectionSettled, deletedEarly atomic.Bool
292
+ var prompts, attempts atomic.Int32
293
+ var aborted atomic.Bool
294
+ selection := newRelease()
295
+ defer selection.open()
296
+ backend := disposableBackend{
297
+ funcBackend: funcBackend{
298
+ prompt: func(context.Context, string) error { prompts.Add(1); return nil },
299
+ abort: func(context.Context) error { aborted.Store(true); return nil },
300
+ },
301
+ selectModel: func(context.Context) error {
302
+ selectionStarted.Store(true)
303
+ <-selection.ch
304
+ selectionSettled.Store(true)
305
+ return nil
306
+ },
307
+ }
308
+ f := startDisposal(t, func(*pi.LiveSession) (pi.Backend, error) { return backend, nil }, func(string) (pi.SessionFileDeletionResult, error) {
309
+ attempts.Add(1)
310
+ if !selectionSettled.Load() {
311
+ deletedEarly.Store(true)
312
+ }
313
+ return pi.SessionFileDeletionResult{OK: true}, nil
314
+ })
315
+ uri, chat := f.createReady()
316
+ f.client.Subscribe(chat)
317
+ message := userMessage("switch first")
318
+ message["model"] = obj{"id": "pi/other-model"}
319
+ f.client.Dispatch(chat, obj{"type": "chat/turnStarted", "turnId": "turn-selecting", "startedAt": "2025-01-01T00:00:00.000Z", "message": message})
320
+ testkit.Eventually(t, "model selection to start", selectionStarted.Load)
321
+
322
+ disposing := f.dispose(uri)
323
+ // Disposal runs on its own goroutine here (upstream's request is handled in order), so
324
+ // wait until it has quiesced the agent before checking that deletion is held back.
325
+ testkit.Eventually(t, "disposal to quiesce the backend", aborted.Load)
326
+ f.client.Ping()
327
+ if attempts.Load() != 0 {
328
+ t.Fatalf("deletion started while a session-writing model selection was in flight")
329
+ }
330
+ selection.open()
331
+ if err := wait(t, "the disposal", disposing); err != nil {
332
+ t.Fatal(err)
333
+ }
334
+ if attempts.Load() != 1 || deletedEarly.Load() {
335
+ t.Fatalf("attempts %d, deletion overtook the model selection: %v", attempts.Load(), deletedEarly.Load())
336
+ }
337
+ if prompts.Load() != 0 {
338
+ t.Fatal("a prompt started after disposal quiesced its turn")
339
+ }
340
+ })
341
+
342
+ twin.Run(t, "session-disposal", "disposes a backend that finishes starting after removal", func(t *testing.T) {
343
+ start := make(chan struct{})
344
+ var disposals atomic.Int32
345
+ f := startDisposal(t, func(*pi.LiveSession) (pi.Backend, error) {
346
+ <-start
347
+ return disposableBackend{dispose: func() error { disposals.Add(1); return nil }}, nil
348
+ }, nil)
349
+ uri := wire.SessionURI(newID())
350
+ f.client.Must("createSession", obj{"channel": uri})
351
+ f.client.Must("disposeSession", obj{"channel": uri})
352
+ close(start)
353
+ testkit.Eventually(t, "the late backend to be disposed", func() bool { return disposals.Load() == 1 })
354
+ if f.h.host.Store().Has(uri) {
355
+ t.Fatal("the session channel remains")
356
+ }
357
+ time.Sleep(30 * time.Millisecond)
358
+ if disposals.Load() != 1 {
359
+ t.Fatalf("backend disposed %d times", disposals.Load())
360
+ }
361
+ })
362
+ }