@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,426 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "os"
7
+ "reflect"
8
+ "testing"
9
+
10
+ "github.com/microsoft/agent-host-protocol/clients/go/ahp"
11
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
12
+
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/channels"
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
15
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
16
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
17
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
18
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
19
+ )
20
+
21
+ // Twins of the wire-level "session summary over the wire" describe of upstream
22
+ // test/session-summary.test.ts: projections checked with the unmodified AHP reducers.
23
+
24
+ const (
25
+ startAt = "2025-01-01T00:00:00.000Z"
26
+ endAt = "2025-01-01T00:00:01.000Z"
27
+ )
28
+
29
+ func noopBackend(t *testing.T) *scriptedBackend {
30
+ return newScriptedBackend(func(string) []mapper.Event { return nil })
31
+ }
32
+
33
+ // summaryFixture is upstream's fixture(): a session created through its provider alias, with the
34
+ // session and chat subscribed.
35
+ type summaryFixture struct {
36
+ t *testing.T
37
+ h *harness
38
+ client *testkit.Client
39
+ clientID string
40
+ session string // canonical
41
+ alias string
42
+ chat string
43
+ initial ahptypes.SessionState
44
+ mark int
45
+ }
46
+
47
+ func newSummaryFixture(t *testing.T, factory pi.BackendFactory) *summaryFixture {
48
+ t.Helper()
49
+ defaultBackend := factory == nil
50
+ if defaultBackend {
51
+ factory = func(*pi.LiveSession) (pi.Backend, error) { return noopBackend(t), nil }
52
+ }
53
+ h := startHarness(t, harnessOptions{createBackend: factory})
54
+ client := testkit.Connect(t, h.host)
55
+ t.Cleanup(client.Close)
56
+ clientID := nextClientID()
57
+ client.Initialize(clientID, obj{"initialSubscriptions": []string{wire.RootChannel}})
58
+ id := newID()
59
+ f := &summaryFixture{t: t, h: h, client: client, clientID: clientID, session: wire.SessionURI(id), alias: "pi:/" + id, chat: wire.ChatURI(id)}
60
+ client.Must("createSession", obj{"channel": f.alias})
61
+ // The registry finishes creating on its own goroutine (upstream does it in the same tick), so
62
+ // wait for the lifecycle to leave "creating" before taking the baseline the replay tests measure from.
63
+ if defaultBackend {
64
+ testkit.Eventually(t, "the session to finish creating", func() bool {
65
+ s := f.sessionState()
66
+ return s != nil && s.Lifecycle != ahptypes.SessionLifecycleCreating
67
+ })
68
+ }
69
+ sub := client.Subscribe(f.alias)
70
+ f.initial = decodeSession(t, sub.Snapshot.State)
71
+ client.Subscribe(f.chat)
72
+ client.Ping()
73
+ f.mark = len(client.Notifications())
74
+ return f
75
+ }
76
+
77
+ func decodeSession(t *testing.T, state any) ahptypes.SessionState {
78
+ t.Helper()
79
+ raw, err := json.Marshal(state)
80
+ if err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ var s ahptypes.SessionState
84
+ if err := json.Unmarshal(raw, &s); err != nil {
85
+ t.Fatal(err)
86
+ }
87
+ return s
88
+ }
89
+
90
+ func (f *summaryFixture) clear() { f.mark = len(f.client.Notifications()) }
91
+
92
+ func (f *summaryFixture) notes() []testkit.Notification { return f.client.Notifications()[f.mark:] }
93
+
94
+ func (f *summaryFixture) envelopes() []ahptypes.ActionEnvelope {
95
+ var out []ahptypes.ActionEnvelope
96
+ for _, n := range f.notes() {
97
+ if env, ok := n.Envelope(); ok {
98
+ out = append(out, env)
99
+ }
100
+ }
101
+ return out
102
+ }
103
+
104
+ func (f *summaryFixture) changes() []obj {
105
+ out := []obj{}
106
+ for _, n := range f.notes() {
107
+ if n.Method == "root/sessionSummaryChanged" {
108
+ out = append(out, testkit.Normalize(f.t, n.Params).(map[string]any))
109
+ }
110
+ }
111
+ return out
112
+ }
113
+
114
+ func (f *summaryFixture) start() {
115
+ f.client.Dispatch(f.chat, obj{"type": "chat/turnStarted", "turnId": "turn", "startedAt": startAt, "message": userMessage("go")})
116
+ f.client.Ping()
117
+ }
118
+
119
+ func (f *summaryFixture) list() obj {
120
+ f.t.Helper()
121
+ raw := f.client.Must("listSessions", obj{"channel": wire.RootChannel})
122
+ var result struct{ Items []obj }
123
+ if err := json.Unmarshal(raw, &result); err != nil {
124
+ f.t.Fatal(err)
125
+ }
126
+ if len(result.Items) != 1 {
127
+ f.t.Fatalf("listSessions returned %d items: %s", len(result.Items), raw)
128
+ }
129
+ return result.Items[0]
130
+ }
131
+
132
+ func (f *summaryFixture) sessionState() *ahptypes.SessionState {
133
+ return f.h.host.Store().Session(f.session)
134
+ }
135
+ func (f *summaryFixture) chatState() *ahptypes.ChatState { return f.h.host.Store().Chat(f.chat) }
136
+
137
+ func changeNote(f *summaryFixture, changes obj) obj {
138
+ return obj{"channel": wire.RootChannel, "session": f.alias, "changes": changes}
139
+ }
140
+
141
+ func sameJSON(t *testing.T, got, want any, msg string) {
142
+ t.Helper()
143
+ g, w := testkit.Normalize(t, got), testkit.Normalize(t, want)
144
+ if !reflect.DeepEqual(g, w) {
145
+ gj, _ := json.MarshalIndent(g, "", " ")
146
+ wj, _ := json.MarshalIndent(w, "", " ")
147
+ t.Fatalf("%s\n got: %s\nwant: %s", msg, gj, wj)
148
+ }
149
+ }
150
+
151
+ func TestSessionSummaryOverTheWire(t *testing.T) {
152
+ twin.Run(t, "session-summary", "projects an active turn closed by backend startup failure", func(t *testing.T) {
153
+ startup := newRelease()
154
+ f := newSummaryFixture(t, func(*pi.LiveSession) (pi.Backend, error) {
155
+ <-startup.ch
156
+ return nil, errors.New("backend unavailable")
157
+ })
158
+ f.start()
159
+ f.clear()
160
+ live, _ := f.h.services.Registry.Get(f.session)
161
+ var attaching <-chan struct{}
162
+ testkit.Eventually(t, "the backend attach to be in flight", func() bool { attaching = live.Attaching(); return attaching != nil })
163
+ startup.open()
164
+ <-attaching
165
+ f.client.Ping()
166
+ sameJSON(t, f.changes(), []obj{changeNote(f, obj{"status": float64(ahptypes.SessionStatusError)})}, "root notifications")
167
+ state := f.sessionState()
168
+ if state.Lifecycle != ahptypes.SessionLifecycleFailed {
169
+ t.Fatalf("lifecycle = %s", state.Lifecycle)
170
+ }
171
+ if state.Chats[0].Status != ahptypes.SessionStatusError {
172
+ t.Fatalf("chat status = %d", state.Chats[0].Status)
173
+ }
174
+ if got := f.list()["status"]; got != float64(ahptypes.SessionStatusError) {
175
+ t.Fatalf("listed status = %v", got)
176
+ }
177
+ })
178
+
179
+ twin.Run(t, "session-summary", "publishes exact start deltas and lists an unpersisted session for new clients", func(t *testing.T) {
180
+ f := newSummaryFixture(t, nil)
181
+ f.start()
182
+ sameJSON(t, f.changes(), []obj{
183
+ changeNote(f, obj{"title": "go"}),
184
+ changeNote(f, obj{"status": float64(ahptypes.SessionStatusInProgress), "modifiedAt": startAt}),
185
+ }, "root notifications")
186
+ actions := f.envelopes()
187
+ if len(actions) != 3 {
188
+ t.Fatalf("%d action envelopes, want 3", len(actions))
189
+ }
190
+ if _, ok := actions[0].Action.Value.(*ahptypes.ChatTurnStartedAction); !ok {
191
+ t.Fatalf("first action = %T", actions[0].Action.Value)
192
+ }
193
+ title := actions[1]
194
+ if title.Channel != f.alias {
195
+ t.Fatalf("title channel = %s", title.Channel)
196
+ }
197
+ sameJSON(t, title.Action, obj{"type": "session/titleChanged", "title": "go"}, "title action")
198
+ if title.Origin != nil {
199
+ t.Fatal("a host-originated action must carry no origin")
200
+ }
201
+ update := actions[2]
202
+ if update.Channel != f.alias {
203
+ t.Fatalf("update channel = %s", update.Channel)
204
+ }
205
+ summary := channels.ChatSummaryOf(f.chatState())
206
+ expected := testkit.Normalize(t, summary).(map[string]any)
207
+ delete(expected, "resource")
208
+ sameJSON(t, update.Action, obj{"type": "session/chatUpdated", "chat": f.chat, "changes": expected}, "chat update action")
209
+ if update.RejectionReason != nil || update.Origin != nil {
210
+ t.Fatal("the projection must be an unrejected host action")
211
+ }
212
+ if title.ServerSeq != actions[0].ServerSeq+1 || update.ServerSeq != title.ServerSeq+1 {
213
+ t.Fatalf("serverSeq %d %d %d not consecutive", actions[0].ServerSeq, title.ServerSeq, update.ServerSeq)
214
+ }
215
+ if got := f.sessionState().Status; got != ahptypes.SessionStatusIdle {
216
+ t.Fatalf("session status = %d; the reducer cannot update it", got)
217
+ }
218
+ live, _ := f.h.services.Registry.Get(f.session)
219
+ if _, err := os.Stat(live.SessionManager.File()); !os.IsNotExist(err) {
220
+ t.Fatalf("session file exists before an assistant message: %v", err)
221
+ }
222
+ listed := f.list()
223
+ if listed["resource"] != f.alias || listed["title"] != "go" || listed["status"] != float64(ahptypes.SessionStatusInProgress) || listed["modifiedAt"] != startAt {
224
+ t.Fatalf("live listing = %v", listed)
225
+ }
226
+ if _, has := listed["activity"]; has {
227
+ t.Fatal("the root catalogue must not carry activity")
228
+ }
229
+ other := testkit.Connect(t, f.h.host)
230
+ t.Cleanup(other.Close)
231
+ other.Initialize(nextClientID(), nil)
232
+ raw := other.Must("listSessions", obj{"channel": wire.RootChannel})
233
+ want := obj{}
234
+ for k, v := range listed {
235
+ want[k] = v
236
+ }
237
+ want["resource"] = f.session
238
+ var result struct{ Items []any }
239
+ _ = json.Unmarshal(raw, &result)
240
+ sameJSON(t, result.Items, []any{want}, "another client's listing")
241
+ })
242
+
243
+ twin.Run(t, "session-summary", "clears activity through a full upsert and sends no redundant root updates or delta projections", func(t *testing.T) {
244
+ f := newSummaryFixture(t, nil)
245
+ f.start()
246
+ startActions := f.envelopes()
247
+ f.clear()
248
+ thinking := "Thinking"
249
+ f.h.host.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatActivityChangedAction{Type: ahptypes.ActionTypeChatActivityChanged, Activity: &thinking}})
250
+ f.h.host.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatActivityChangedAction{Type: ahptypes.ActionTypeChatActivityChanged}})
251
+ f.client.Ping()
252
+ if got := f.changes(); len(got) != 0 {
253
+ t.Fatalf("activity is not published on the root catalogue: %v", got)
254
+ }
255
+ actions := f.envelopes()
256
+ var types []string
257
+ for _, e := range actions {
258
+ types = append(types, testkit.Normalize(t, e.Action).(map[string]any)["type"].(string))
259
+ }
260
+ want := []string{"chat/activityChanged", "session/chatUpdated", "session/activityChanged", "chat/activityChanged", "session/chatAdded", "session/activityChanged"}
261
+ if !reflect.DeepEqual(types, want) {
262
+ t.Fatalf("actions = %v, want %v", types, want)
263
+ }
264
+ mirror := f.initial
265
+ for _, e := range append(startActions, actions...) {
266
+ if e.Channel == f.alias {
267
+ ahp.ApplyActionToSession(&mirror, e.Action)
268
+ }
269
+ }
270
+ sameJSON(t, mirror, f.sessionState(), "the client's mirror must converge on the host's state")
271
+ if mirror.Chats[0].Activity != nil || mirror.Activity != nil {
272
+ t.Fatalf("activity should be cleared: %v %v", mirror.Chats[0].Activity, mirror.Activity)
273
+ }
274
+ f.h.host.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatResponsePartAction{
275
+ Type: ahptypes.ActionTypeChatResponsePart, TurnId: "turn",
276
+ Part: ahptypes.ResponsePart{Value: &ahptypes.MarkdownResponsePart{Kind: ahptypes.ResponsePartKindMarkdown, Id: "text", Content: "a"}},
277
+ }})
278
+ f.client.Ping()
279
+ f.clear()
280
+ for i := 0; i < 20; i++ {
281
+ f.h.host.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatDeltaAction{Type: ahptypes.ActionTypeChatDelta, TurnId: "turn", PartId: "text", Content: "b"}})
282
+ }
283
+ f.client.Ping()
284
+ envelopes := f.envelopes()
285
+ if len(envelopes) != 20 {
286
+ t.Fatalf("%d envelopes, want 20 (no delta projections)", len(envelopes))
287
+ }
288
+ for _, e := range envelopes {
289
+ if _, ok := e.Action.Value.(*ahptypes.ChatDeltaAction); !ok || e.Channel != f.chat {
290
+ t.Fatalf("unexpected envelope %s %T", e.Channel, e.Action.Value)
291
+ }
292
+ }
293
+ if got := f.changes(); len(got) != 0 {
294
+ t.Fatalf("root updates for deltas: %v", got)
295
+ }
296
+ })
297
+
298
+ twin.Run(t, "session-summary", "projects complete and ignores rejected/no-op actions", func(t *testing.T) { projectsOutcome(t, "complete") })
299
+ twin.Run(t, "session-summary", "projects cancel and ignores rejected/no-op actions", func(t *testing.T) { projectsOutcome(t, "cancel") })
300
+ twin.Run(t, "session-summary", "projects error and ignores rejected/no-op actions", func(t *testing.T) { projectsOutcome(t, "error") })
301
+ twin.Run(t, "session-summary", "projects truncate and ignores rejected/no-op actions", func(t *testing.T) { projectsOutcome(t, "truncate") })
302
+ twin.Run(t, "session-summary", "replays running session projections to the same state as snapshot fallback", func(t *testing.T) { replaysProjection(t, true) })
303
+ twin.Run(t, "session-summary", "replays completed session projections to the same state as snapshot fallback", func(t *testing.T) { replaysProjection(t, false) })
304
+ }
305
+
306
+ func projectsOutcome(t *testing.T, outcome string) {
307
+ f := newSummaryFixture(t, nil)
308
+ f.start()
309
+ f.clear()
310
+ before := testkit.Normalize(t, f.sessionState())
311
+ f.client.Dispatch(f.chat, obj{"type": "chat/turnCancelled", "turnId": "wrong", "duration": 1000})
312
+ f.client.Ping()
313
+ f.h.host.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatTurnCompleteAction{Type: ahptypes.ActionTypeChatTurnComplete, TurnId: "wrong", Duration: 1000}})
314
+ f.client.Ping()
315
+ envs := f.envelopes()
316
+ if len(envs) != 2 || envs[0].RejectionReason == nil {
317
+ t.Fatalf("envelopes = %d, first rejection %v", len(envs), envs[0].RejectionReason)
318
+ }
319
+ if got := f.changes(); len(got) != 0 {
320
+ t.Fatalf("rejected/no-op actions must not change the catalogue: %v", got)
321
+ }
322
+ sameJSON(t, f.sessionState(), before, "session state")
323
+ f.clear()
324
+ switch outcome {
325
+ case "cancel":
326
+ f.client.Dispatch(f.chat, obj{"type": "chat/turnCancelled", "turnId": "turn", "duration": 1000})
327
+ case "truncate":
328
+ f.h.host.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatTruncatedAction{Type: ahptypes.ActionTypeChatTruncated}})
329
+ case "error":
330
+ f.h.host.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatErrorAction{
331
+ Type: ahptypes.ActionTypeChatError, TurnId: "turn", Duration: 1000,
332
+ Part: ahptypes.ErrorResponsePart{Kind: ahptypes.ResponsePartKindError, Error: ahptypes.ErrorInfo{ErrorType: "test", Message: "failed"}},
333
+ }})
334
+ default:
335
+ f.h.host.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatTurnCompleteAction{Type: ahptypes.ActionTypeChatTurnComplete, TurnId: "turn", Duration: 1000}})
336
+ }
337
+ f.client.Ping()
338
+ status := ahptypes.SessionStatusIdle
339
+ if outcome == "error" {
340
+ status = ahptypes.SessionStatusError
341
+ }
342
+ changes := obj{"status": float64(status)}
343
+ if outcome != "truncate" {
344
+ changes["modifiedAt"] = endAt
345
+ }
346
+ sameJSON(t, f.changes(), []obj{changeNote(f, changes)}, "root notifications")
347
+ listed := f.list()
348
+ wantModified := endAt
349
+ if outcome == "truncate" {
350
+ wantModified = startAt
351
+ }
352
+ if listed["status"] != float64(status) || listed["modifiedAt"] != wantModified {
353
+ t.Fatalf("listed = %v", listed)
354
+ }
355
+ if got := f.sessionState().Chats[0].Status; got != status {
356
+ t.Fatalf("chat summary status = %d", got)
357
+ }
358
+ }
359
+
360
+ func replaysProjection(t *testing.T, running bool) {
361
+ f := newSummaryFixture(t, nil)
362
+ baseline := f.h.host.ServerSeq()
363
+ f.client.Close()
364
+ h := f.h.host
365
+ h.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatTurnStartedAction{
366
+ Type: ahptypes.ActionTypeChatTurnStarted, TurnId: "turn", StartedAt: startAt,
367
+ Message: ahptypes.Message{Text: "offline", Origin: ahptypes.MessageOrigin{Kind: ahptypes.MessageKindUser}},
368
+ }})
369
+ working := "Working"
370
+ h.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatActivityChangedAction{Type: ahptypes.ActionTypeChatActivityChanged, Activity: &working}})
371
+ if !running {
372
+ h.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatActivityChangedAction{Type: ahptypes.ActionTypeChatActivityChanged}})
373
+ h.DispatchServerAction(f.chat, ahptypes.StateAction{Value: &ahptypes.ChatTurnCompleteAction{Type: ahptypes.ActionTypeChatTurnComplete, TurnId: "turn", Duration: 1000}})
374
+ }
375
+ resumed := testkit.Connect(t, h)
376
+ t.Cleanup(resumed.Close)
377
+ var replay struct {
378
+ Type string
379
+ Actions []ahptypes.ActionEnvelope
380
+ }
381
+ resumed.Decode(resumed.Must("reconnect", obj{"channel": wire.RootChannel, "clientId": f.clientID, "lastSeenServerSeq": baseline, "subscriptions": []string{f.alias, f.chat}}), &replay)
382
+ if replay.Type != "replay" {
383
+ t.Fatalf("reconnect type = %q, want replay", replay.Type)
384
+ }
385
+ mirror := f.initial
386
+ for _, e := range replay.Actions {
387
+ if e.Channel == f.alias {
388
+ ahp.ApplyActionToSession(&mirror, e.Action)
389
+ }
390
+ }
391
+ fresh := testkit.Connect(t, h)
392
+ t.Cleanup(fresh.Close)
393
+ var fallback struct {
394
+ Type string
395
+ Snapshots []struct{ State json.RawMessage }
396
+ }
397
+ fresh.Decode(fresh.Must("reconnect", obj{"channel": wire.RootChannel, "clientId": nextClientID(), "lastSeenServerSeq": baseline, "subscriptions": []string{f.alias}}), &fallback)
398
+ if fallback.Type != "snapshot" || len(fallback.Snapshots) != 1 {
399
+ t.Fatalf("fallback = %+v", fallback)
400
+ }
401
+ sameJSON(t, mirror, fallback.Snapshots[0].State, "replayed mirror vs snapshot fallback")
402
+ if mirror.Status != ahptypes.SessionStatusIdle {
403
+ t.Fatalf("mirror status = %d", mirror.Status)
404
+ }
405
+ wantModified := endAt
406
+ if running {
407
+ wantModified = startAt
408
+ }
409
+ if mirror.Chats[0].ModifiedAt != wantModified {
410
+ t.Fatalf("chat modifiedAt = %s", mirror.Chats[0].ModifiedAt)
411
+ }
412
+ if running != (mirror.Chats[0].Activity != nil && *mirror.Chats[0].Activity == "Working") || (!running && mirror.Chats[0].Activity != nil) {
413
+ t.Fatalf("chat activity = %v", mirror.Chats[0].Activity)
414
+ }
415
+ // Root notifications are not replayed: a fresh list supplies the final status.
416
+ raw := fresh.Must("listSessions", obj{"channel": wire.RootChannel})
417
+ var result struct{ Items []obj }
418
+ _ = json.Unmarshal(raw, &result)
419
+ wantStatus := ahptypes.SessionStatusIdle
420
+ if running {
421
+ wantStatus = ahptypes.SessionStatusInProgress
422
+ }
423
+ if result.Items[0]["status"] != float64(wantStatus) {
424
+ t.Fatalf("listed status = %v", result.Items[0]["status"])
425
+ }
426
+ }
@@ -0,0 +1,208 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "encoding/base64"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
10
+
11
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
12
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
15
+ )
16
+
17
+ // Twins of the wire-level describes of upstream test/client-workarounds.test.ts: per-connection
18
+ // URI compatibility without leaking client dialects into core state. (The classification and
19
+ // rewriting unit twins live in internal/host and internal/wire.)
20
+
21
+ func derivedChatURI(providerSession string) string {
22
+ return "ahp-chat://default/" + base64.RawURLEncoding.EncodeToString([]byte(providerSession))
23
+ }
24
+
25
+ func expectVSCodeDisposalRefusal(t *testing.T, c *testkit.Client, channel string) {
26
+ t.Helper()
27
+ e := c.ExpectError("disposeSession", obj{"channel": channel}, wire.CodeInvalidRequest)
28
+ for _, want := range []string{"temporarily disabled for VS Code", "VS Code provisional-session lifecycle bug", "session was kept"} {
29
+ if !strings.Contains(e.Message, want) {
30
+ t.Fatalf("refusal %q lacks %q", e.Message, want)
31
+ }
32
+ }
33
+ }
34
+
35
+ func initialSnapshotResources(t *testing.T, f *hydratedFixture, clientID string, subscriptions []string, clientInfo obj) []string {
36
+ t.Helper()
37
+ c := testkit.Connect(t, f.host)
38
+ defer c.Close()
39
+ extra := obj{"initialSubscriptions": subscriptions}
40
+ if clientInfo != nil {
41
+ extra["clientInfo"] = clientInfo
42
+ }
43
+ result := c.Initialize(clientID, extra)
44
+ var out []string
45
+ for _, s := range result.Snapshots {
46
+ out = append(out, s.Resource)
47
+ }
48
+ return out
49
+ }
50
+
51
+ func TestClientURIDialectsOverTheWire(t *testing.T) {
52
+ f := startHydrated(t, hydratedOptions{})
53
+
54
+ twin.Run(t, "client-workarounds", "answers VS Code at the URIs it computes for itself", func(t *testing.T) {
55
+ client := f.connectAsVSCode()
56
+ providerSession := "pi:/" + f.sessionID
57
+ derived := derivedChatURI(providerSession)
58
+ session := client.Subscribe(providerSession)
59
+ if session.Snapshot.Resource != providerSession {
60
+ t.Fatalf("snapshot resource = %s", session.Snapshot.Resource)
61
+ }
62
+ state := snapshotStateOf[ahptypes.SessionState](t, session)
63
+ if state.DefaultChat == nil || *state.DefaultChat != derived {
64
+ t.Fatalf("defaultChat = %v, want %s", state.DefaultChat, derived)
65
+ }
66
+ result := client.Subscribe(derived)
67
+ chat := snapshotStateOf[ahptypes.ChatState](t, result)
68
+ if result.Snapshot.Resource != derived {
69
+ t.Fatalf("chat resource = %s", result.Snapshot.Resource)
70
+ }
71
+ if len(chat.Turns) == 0 {
72
+ t.Fatal("the transcript must come back, not an empty chat")
73
+ }
74
+ testkit.AssertValid(t, "state", "ChatState", chat)
75
+ })
76
+
77
+ twin.Run(t, "client-workarounds", "applies the VS Code dialect to initialize-time subscriptions", func(t *testing.T) {
78
+ providerSession := "pi:/" + f.sessionID
79
+ subscriptions := []string{providerSession, derivedChatURI(providerSession)}
80
+ got := initialSnapshotResources(t, f, "initial-subscriptions-VS Code", subscriptions, obj{"name": "vscode-editor-window"})
81
+ sameJSON(t, got, subscriptions, "initial snapshots")
82
+ })
83
+
84
+ twin.Run(t, "client-workarounds", "applies the unnamed provider-alias client dialect to initialize-time subscriptions", func(t *testing.T) {
85
+ providerSession := "pi:/" + f.sessionID
86
+ subscriptions := []string{providerSession, wire.ChatURI(f.sessionID)}
87
+ got := initialSnapshotResources(t, f, "initial-subscriptions-unnamed", subscriptions, nil)
88
+ sameJSON(t, got, subscriptions, "initial snapshots")
89
+ })
90
+ }
91
+
92
+ func TestVSCodeSessionDisposalWorkaround(t *testing.T) {
93
+ twin.Run(t, "client-workarounds", "protects a durable session that this connection did not create", func(t *testing.T) {
94
+ f := startHydrated(t, hydratedOptions{})
95
+ client := f.connectAsVSCode()
96
+ resource := "pi:/" + f.sessionID
97
+ expectVSCodeDisposalRefusal(t, client, resource)
98
+ if got := f.deletedFiles(); len(got) != 0 {
99
+ t.Fatalf("deleted = %v", got)
100
+ }
101
+ var listed struct{ Items []obj }
102
+ client.Decode(client.Must("listSessions", obj{"channel": wire.RootChannel}), &listed)
103
+ found := false
104
+ for _, item := range listed.Items {
105
+ found = found || item["resource"] == resource
106
+ }
107
+ if !found {
108
+ t.Fatalf("the protected session vanished from the listing: %v", listed.Items)
109
+ }
110
+ })
111
+
112
+ twin.Run(t, "client-workarounds", "still disposes an empty session created by this VS Code connection", func(t *testing.T) {
113
+ f := startHydrated(t, hydratedOptions{})
114
+ client := f.connectAsVSCode()
115
+ id := newID()
116
+ resource := "pi:/" + id
117
+ client.Must("createSession", obj{"channel": resource})
118
+ client.Must("disposeSession", obj{"channel": resource})
119
+ if f.host.Store().Has(wire.SessionURI(id)) {
120
+ t.Fatal("the session channel remains")
121
+ }
122
+ })
123
+
124
+ twin.Run(t, "client-workarounds", "restores an empty session after disposal fails so VS Code can retry", func(t *testing.T) {
125
+ attempts := 0
126
+ f := startHydrated(t, hydratedOptions{deleteFile: func(string) (pi.SessionFileDeletionResult, error) {
127
+ attempts++
128
+ if attempts == 1 {
129
+ return pi.SessionFileDeletionResult{Error: "temporary failure"}, nil
130
+ }
131
+ return pi.SessionFileDeletionResult{OK: true}, nil
132
+ }})
133
+ client := f.connectAsVSCode()
134
+ id := newID()
135
+ resource := "pi:/" + id
136
+ client.Must("createSession", obj{"channel": resource})
137
+ e := client.ExpectError("disposeSession", obj{"channel": resource}, wire.CodeInternalError)
138
+ if !strings.Contains(e.Message, "temporary failure") {
139
+ t.Fatalf("message = %q", e.Message)
140
+ }
141
+ client.Must("disposeSession", obj{"channel": resource})
142
+ if attempts != 2 || f.host.Store().Has(wire.SessionURI(id)) {
143
+ t.Fatalf("attempts = %d, channel present = %v", attempts, f.host.Store().Has(wire.SessionURI(id)))
144
+ }
145
+ })
146
+
147
+ twin.Run(t, "client-workarounds", "protects a session after its first turn starts", func(t *testing.T) {
148
+ f := startHydrated(t, hydratedOptions{})
149
+ client := f.connectAsVSCode()
150
+ id := newID()
151
+ resource := "pi:/" + id
152
+ client.Must("createSession", obj{"channel": resource})
153
+ client.Dispatch(derivedChatURI(resource), obj{"type": "chat/turnStarted", "turnId": "materialized-turn", "startedAt": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), "message": userMessage("keep this")})
154
+ testkit.Eventually(t, "the turn to be accepted", func() bool {
155
+ c := f.host.Store().Chat(wire.ChatURI(id))
156
+ return c != nil && (c.ActiveTurn != nil || len(c.Turns) > 0)
157
+ })
158
+ expectVSCodeDisposalRefusal(t, client, resource)
159
+ if !f.host.Store().Has(wire.SessionURI(id)) {
160
+ t.Fatal("the protected session was disposed")
161
+ }
162
+ })
163
+ }
164
+
165
+ func TestSessionURIWorkaroundsOverTheWire(t *testing.T) {
166
+ twin.Run(t, "client-workarounds", "does not hydrate or dispose a durable session through VS Code's terminal URI", func(t *testing.T) {
167
+ f := startHydrated(t, hydratedOptions{})
168
+ client := f.connectAsVSCode()
169
+ channel := "agenthost-terminal:/" + f.sessionID
170
+ if _, err := client.Request("subscribe", obj{"channel": channel}); err == nil {
171
+ t.Fatal("an uncreated terminal must not open a matching session")
172
+ }
173
+ if _, err := client.Request("disposeSession", obj{"channel": channel}); err == nil {
174
+ t.Fatal("a terminal URI must not delete a matching session")
175
+ }
176
+ if f.host.Store().Has(channel) {
177
+ t.Fatal("the terminal channel exists")
178
+ }
179
+ if got := f.deletedFiles(); len(got) != 0 {
180
+ t.Fatalf("deleted = %v", got)
181
+ }
182
+ })
183
+
184
+ twin.Run(t, "client-workarounds", "accepts the provider scheme but rejects an undeclared session scheme", func(t *testing.T) {
185
+ f := startHydrated(t, hydratedOptions{})
186
+ if _, err := f.client.Request("createSession", obj{"channel": "custom:/" + newID()}); err == nil {
187
+ t.Fatal("an undeclared session scheme must be rejected")
188
+ }
189
+ id := strings.ToUpper(newID())
190
+ uri := "pi:/" + id
191
+ f.client.Must("createSession", obj{"channel": uri})
192
+ result := f.client.Subscribe(uri)
193
+ if result.Snapshot == nil || result.Snapshot.Resource != uri {
194
+ t.Fatalf("snapshot = %+v", result.Snapshot)
195
+ }
196
+ state := snapshotStateOf[ahptypes.SessionState](t, result)
197
+ if state.DefaultChat == nil || *state.DefaultChat != wire.ChatURI(id) {
198
+ t.Fatalf("defaultChat = %v", state.DefaultChat)
199
+ }
200
+ if !f.host.Store().Has(wire.SessionURI(id)) || f.host.Store().Has(uri) {
201
+ t.Fatal("core state stays canonical")
202
+ }
203
+ f.client.Must("disposeSession", obj{"channel": uri})
204
+ if f.host.Store().Has(wire.SessionURI(id)) {
205
+ t.Fatal("the session remains after disposal")
206
+ }
207
+ })
208
+ }
@@ -0,0 +1,32 @@
1
+ package pisession
2
+
3
+ import "testing"
4
+
5
+ func TestFromEntriesBuildsATreeWithASynthesisedHeader(t *testing.T) {
6
+ entries := []Entry{
7
+ {"type": "message", "id": "a", "parentId": nil, "timestamp": "2025-01-01T00:00:00.000Z", "message": map[string]any{"role": "user", "content": "hi"}},
8
+ {"type": "message", "id": "b", "parentId": "a", "timestamp": "2025-01-01T00:00:01.000Z", "message": map[string]any{"role": "assistant", "content": "yo"}},
9
+ }
10
+ m, err := FromEntries("/work", "sid", "/f.jsonl", entries, "")
11
+ if err != nil {
12
+ t.Fatal(err)
13
+ }
14
+ if m.SessionID() != "sid" || m.Cwd() != "/work" || m.File() != "/f.jsonl" || m.LeafEntry().ID() != "b" {
15
+ t.Fatalf("%s %s %s %v", m.SessionID(), m.Cwd(), m.File(), m.LeafEntry())
16
+ }
17
+ if got := len(m.Branch("")); got != 2 {
18
+ t.Fatalf("branch of %d", got)
19
+ }
20
+ // an explicit leaf moves it
21
+ at, err := FromEntries("/work", "sid", "", entries, "a")
22
+ if err != nil || at.LeafEntry().ID() != "a" {
23
+ t.Fatalf("%v %v", at, err)
24
+ }
25
+ if _, err := FromEntries("/work", "sid", "", entries, "missing"); err == nil {
26
+ t.Fatal("an unknown leaf must be an error")
27
+ }
28
+ empty, err := FromEntries("/work", "sid", "", nil, "")
29
+ if err != nil || empty.LeafEntry() != nil {
30
+ t.Fatalf("an empty session has no leaf: %v %v", empty, err)
31
+ }
32
+ }