@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,197 @@
1
+ package host_test
2
+
3
+ import (
4
+ "encoding/json"
5
+ "reflect"
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
10
+
11
+ "github.com/MichaelKinsy/pigpen/ahp/internal/channels"
12
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
15
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
16
+ )
17
+
18
+ const reconnectClientID = "reconnecting-client"
19
+
20
+ func bumpActiveSessions(h *host.Host, n int64) {
21
+ h.DispatchServerAction(wire.RootChannel, rootActiveSessions(n))
22
+ }
23
+
24
+ type replayResult struct {
25
+ Type string `json:"type"`
26
+ Actions []ahptypes.ActionEnvelope `json:"actions"`
27
+ Missing []string `json:"missing"`
28
+ Snaps []ahptypes.Snapshot `json:"snapshots"`
29
+ }
30
+
31
+ func reconnect(t *testing.T, c *testkit.Client, clientID string, lastSeen int64, subs []string) replayResult {
32
+ t.Helper()
33
+ var r replayResult
34
+ c.Decode(c.Must("reconnect", rootParams(map[string]any{"clientId": clientID, "lastSeenServerSeq": lastSeen, "subscriptions": subs})), &r)
35
+ return r
36
+ }
37
+
38
+ // Twins of upstream test/reconnect.test.ts, describe "reconnect".
39
+ func TestReconnect(t *testing.T) {
40
+ // A tiny buffer makes the eviction path cheap to exercise.
41
+ h := testkit.NewHost(host.Options{ReplayBufferCapacity: 4})
42
+
43
+ twin.Run(t, "reconnect", "keeps non-VS Code and explicitly misrouted reconnects strict", func(t *testing.T) {
44
+ c := testkit.Connect(t, h)
45
+ for _, tc := range []struct {
46
+ context string
47
+ params map[string]any
48
+ }{
49
+ {"non-VS Code reconnect without a channel", map[string]any{"clientId": reconnectClientID + "-missing-channel", "lastSeenServerSeq": 0, "subscriptions": []string{wire.RootChannel}}},
50
+ {"VS Code reconnect with the wrong channel", map[string]any{
51
+ "channel": wire.SessionURI("wrong-channel"), "clientId": reconnectClientID + "-wrong-channel", "lastSeenServerSeq": 0,
52
+ "subscriptions": []string{wire.RootChannel}, "_meta": map[string]any{"vscode.telemetryLevel": "off"},
53
+ }},
54
+ } {
55
+ e := c.ExpectError("reconnect", tc.params, wire.CodeInvalidParams)
56
+ if !strings.HasSuffix(e.Message, "reconnect requires channel ahp-root://") {
57
+ t.Errorf("%s: message %q", tc.context, e.Message)
58
+ }
59
+ }
60
+ })
61
+
62
+ twin.Run(t, "reconnect", "replays only the actions the client missed", func(t *testing.T) {
63
+ first := testkit.Connect(t, h)
64
+ init := first.Initialize(reconnectClientID, map[string]any{"initialSubscriptions": []string{wire.RootChannel}})
65
+ bumpActiveSessions(h, 1)
66
+ bumpActiveSessions(h, 2)
67
+ first.Close()
68
+
69
+ r := reconnect(t, testkit.Connect(t, h), reconnectClientID, init.ServerSeq, []string{wire.RootChannel})
70
+ if r.Type != "replay" || len(r.Actions) != 2 {
71
+ t.Fatalf("result = %+v", r)
72
+ }
73
+ if r.Actions[0].ServerSeq != init.ServerSeq+1 || r.Actions[1].ServerSeq != init.ServerSeq+2 {
74
+ t.Fatalf("serverSeqs = %d, %d (from %d)", r.Actions[0].ServerSeq, r.Actions[1].ServerSeq, init.ServerSeq)
75
+ }
76
+ if len(r.Missing) != 0 {
77
+ t.Fatalf("missing = %v", r.Missing)
78
+ }
79
+ })
80
+
81
+ twin.Run(t, "reconnect", "falls back to snapshots when the gap predates the replay buffer", func(t *testing.T) {
82
+ c := testkit.Connect(t, h)
83
+ c.Initialize(reconnectClientID+"-gap", map[string]any{"initialSubscriptions": []string{wire.RootChannel}})
84
+ stale := h.ServerSeq()
85
+ // Overflow the 4-entry buffer so stale+1 is evicted.
86
+ for i := int64(0); i < 6; i++ {
87
+ bumpActiveSessions(h, i)
88
+ }
89
+ c.Close()
90
+ r := reconnect(t, testkit.Connect(t, h), reconnectClientID+"-gap", stale, []string{wire.RootChannel})
91
+ if r.Type != "snapshot" || len(r.Snaps) != 1 || r.Snaps[0].Resource != wire.RootChannel || r.Snaps[0].FromSeq != h.ServerSeq() {
92
+ t.Fatalf("result = %+v (serverSeq %d)", r, h.ServerSeq())
93
+ }
94
+ })
95
+
96
+ twin.Run(t, "reconnect", "reports subscriptions it cannot resume as missing", func(t *testing.T) {
97
+ c := testkit.Connect(t, h)
98
+ init := c.Initialize(reconnectClientID+"-missing", map[string]any{"initialSubscriptions": []string{wire.RootChannel}})
99
+ disposed := wire.SessionURI("already-gone")
100
+ c.Close()
101
+ r := reconnect(t, testkit.Connect(t, h), reconnectClientID+"-missing", init.ServerSeq, []string{wire.RootChannel, disposed})
102
+ if r.Type != "replay" || !reflect.DeepEqual(r.Missing, []string{disposed}) {
103
+ t.Fatalf("result = %+v", r)
104
+ }
105
+ })
106
+
107
+ twin.Run(t, "reconnect", "returns an empty replay for a client that is already current", func(t *testing.T) {
108
+ c := testkit.Connect(t, h)
109
+ c.Initialize(reconnectClientID+"-current", map[string]any{"initialSubscriptions": []string{wire.RootChannel}})
110
+ c.Close()
111
+ r := reconnect(t, testkit.Connect(t, h), reconnectClientID+"-current", h.ServerSeq(), []string{wire.RootChannel})
112
+ if r.Type != "replay" || r.Actions == nil || len(r.Actions) != 0 {
113
+ t.Fatalf("result = %+v (actions must be an empty array)", r)
114
+ }
115
+ })
116
+
117
+ twin.Run(t, "reconnect", "replaces a half-open connection that reuses a clientId", func(t *testing.T) {
118
+ clientID := reconnectClientID + "-duplicate"
119
+ before := h.SubscriberCount(wire.RootChannel)
120
+ first := testkit.Connect(t, h)
121
+ first.Initialize(clientID, nil)
122
+ first.Subscribe(wire.RootChannel)
123
+ if got := h.SubscriberCount(wire.RootChannel); got != before+1 {
124
+ t.Fatalf("subscribers = %d, want %d", got, before+1)
125
+ }
126
+ replacement := testkit.Connect(t, h)
127
+ replacement.Initialize(clientID, nil)
128
+ replacement.Subscribe(wire.RootChannel)
129
+ if got := h.SubscriberCount(wire.RootChannel); got != before+1 {
130
+ t.Fatalf("subscribers = %d, want %d", got, before+1)
131
+ }
132
+ // The host closes the replaced transport on its own goroutine (a Close that blocks must not stall the handshake).
133
+ testkit.Eventually(t, "the half-open connection to be closed", first.ServerClosed)
134
+ })
135
+
136
+ twin.Run(t, "reconnect", "retains clientInfo when a replacement initialize omits it", func(t *testing.T) {
137
+ clientID := reconnectClientID + "-reinitialize"
138
+ id := "vscode-reinitialize"
139
+ mustCreate(t, h, wire.SessionURI(id), channels.InitialSessionState("pi", "Reinitialize", "/tmp"), wire.KindSession)
140
+ first := testkit.Connect(t, h)
141
+ first.Must("initialize", rootParams(map[string]any{"clientId": clientID, "protocolVersions": testkit.SupportedVersions(), "clientInfo": map[string]any{"name": "vscode-editor-window"}}))
142
+ first.Close()
143
+ replacement := testkit.Connect(t, h)
144
+ replacement.Initialize(clientID, nil)
145
+ clientURI := "pi:/" + id
146
+ if got := replacement.Subscribe(clientURI); got.Snapshot == nil || got.Snapshot.Resource != clientURI {
147
+ t.Fatalf("subscribe(%s) = %+v", clientURI, got)
148
+ }
149
+ })
150
+
151
+ twin.Run(t, "reconnect", "infers the provider URI dialect without clientInfo", func(t *testing.T) {
152
+ clientID := reconnectClientID + "-provider-alias"
153
+ id := "provider-alias-reconnect"
154
+ canonical, alias := wire.SessionURI(id), "pi:/"+id
155
+ mustCreate(t, h, canonical, channels.InitialSessionState("pi", "Reconnect", "/tmp"), wire.KindSession)
156
+ first := testkit.Connect(t, h)
157
+ first.Initialize(clientID, nil)
158
+ if got := first.Subscribe(alias); got.Snapshot == nil || got.Snapshot.Resource != alias {
159
+ t.Fatalf("subscribe = %+v", got)
160
+ }
161
+ stale := h.ServerSeq()
162
+ first.Close()
163
+ for i := int64(0); i < 6; i++ {
164
+ bumpActiveSessions(h, i)
165
+ }
166
+ r := reconnect(t, testkit.Connect(t, h), clientID, stale, []string{alias})
167
+ if r.Type != "snapshot" || len(r.Snaps) != 1 || r.Snaps[0].Resource != alias {
168
+ t.Fatalf("result = %+v", r)
169
+ }
170
+ if !h.Store().Has(canonical) || h.Store().Has(alias) {
171
+ t.Fatal("core state stays canonical")
172
+ }
173
+ })
174
+
175
+ twin.Run(t, "reconnect", "retains VS Code's URI dialect across connections", func(t *testing.T) {
176
+ clientID := reconnectClientID + "-vscode"
177
+ id := "vscode-reconnect"
178
+ clientURI := "pi:/" + id
179
+ mustCreate(t, h, wire.SessionURI(id), channels.InitialSessionState("pi", "Reconnect", "/tmp"), wire.KindSession)
180
+ first := testkit.Connect(t, h)
181
+ first.Must("initialize", rootParams(map[string]any{"clientId": clientID, "protocolVersions": testkit.SupportedVersions(), "clientInfo": map[string]any{"name": "vscode-editor-window"}}))
182
+ if got := first.Subscribe(clientURI); got.Snapshot == nil || got.Snapshot.Resource != clientURI {
183
+ t.Fatalf("subscribe = %+v", got)
184
+ }
185
+ stale := h.ServerSeq()
186
+ first.Close()
187
+ for i := int64(0); i < 6; i++ {
188
+ bumpActiveSessions(h, i)
189
+ }
190
+ r := reconnect(t, testkit.Connect(t, h), clientID, stale, []string{clientURI})
191
+ if r.Type != "snapshot" || len(r.Snaps) != 1 || r.Snaps[0].Resource != clientURI {
192
+ t.Fatalf("result = %+v", r)
193
+ }
194
+ })
195
+ }
196
+
197
+ var _ = json.Marshal
@@ -0,0 +1,31 @@
1
+ package host_test
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
7
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
8
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
9
+ )
10
+
11
+ // The replay buffer serves a client only when it still holds every action after lastSeenServerSeq:
12
+ // one action past the edge must fall back to fresh snapshots, never a replay with a hole in it.
13
+ func TestReplayWindowEdge(t *testing.T) {
14
+ h := testkit.NewHost(host.Options{ReplayBufferCapacity: 4})
15
+ c := testkit.Connect(t, h)
16
+ c.Initialize("edge-client", map[string]any{"initialSubscriptions": []string{wire.RootChannel}})
17
+ c.Close()
18
+ seen := h.ServerSeq()
19
+ for i := 0; i < 4; i++ {
20
+ bumpActiveSessions(h, int64(i))
21
+ }
22
+ inside := reconnect(t, testkit.Connect(t, h), "edge-client", seen, []string{wire.RootChannel})
23
+ if inside.Type != "replay" || len(inside.Actions) != 4 {
24
+ t.Fatalf("the buffer still holds every action after lastSeen: type=%s actions=%d", inside.Type, len(inside.Actions))
25
+ }
26
+ bumpActiveSessions(h, 9) // evicts the oldest action the client has not seen
27
+ outside := reconnect(t, testkit.Connect(t, h), "edge-client", seen, []string{wire.RootChannel})
28
+ if outside.Type != "snapshot" || len(outside.Snaps) != 1 {
29
+ t.Fatalf("one action beyond the buffer must fall back to snapshots: type=%s snapshots=%d", outside.Type, len(outside.Snaps))
30
+ }
31
+ }
@@ -0,0 +1,79 @@
1
+ package host_test
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
7
+
8
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
9
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
10
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
11
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
12
+ )
13
+
14
+ // Twins of upstream test/schema.test.ts, describe "wire schema".
15
+ func TestWireSchema(t *testing.T) {
16
+ h := testkit.NewHost(host.Options{})
17
+
18
+ twin.Run(t, "schema", "emits a conforming InitializeResult", func(t *testing.T) {
19
+ c := testkit.Connect(t, h)
20
+ res := c.Initialize(nextClientID(), map[string]any{"initialSubscriptions": []string{wire.RootChannel}})
21
+ testkit.AssertValid(t, "commands", "InitializeResult", res)
22
+ })
23
+
24
+ twin.Run(t, "schema", "emits a conforming RootState snapshot", func(t *testing.T) {
25
+ c := initialized(t, h)
26
+ res := c.Subscribe(wire.RootChannel)
27
+ if res.Snapshot == nil {
28
+ t.Fatal("no snapshot")
29
+ }
30
+ testkit.AssertValid(t, "commands", "Snapshot", res.Snapshot)
31
+ testkit.AssertValid(t, "state", "RootState", res.Snapshot.State.Root)
32
+ })
33
+
34
+ twin.Run(t, "schema", "emits conforming ActionEnvelopes", func(t *testing.T) {
35
+ c := initialized(t, h, wire.RootChannel)
36
+ h.DispatchServerAction(wire.RootChannel, ahptypes.StateAction{Value: &ahptypes.RootActiveSessionsChangedAction{
37
+ Type: ahptypes.ActionTypeRootActiveSessionsChanged, ActiveSessions: 3,
38
+ }})
39
+ env := c.NextEnvelope(wire.RootChannel, 0)
40
+ testkit.AssertValid(t, "actions", "ActionEnvelope", env)
41
+ testkit.AssertValid(t, "actions", "StateAction", env.Action)
42
+ })
43
+
44
+ twin.Run(t, "schema", "fails validation for a structurally wrong action", func(t *testing.T) {
45
+ // A reducer would accept this silently (unknown shape becomes a no-op); only the schema catches it.
46
+ bogus := map[string]any{
47
+ "channel": wire.RootChannel,
48
+ "action": map[string]any{"type": "root/activeSessionsChanged"},
49
+ "serverSeq": 1,
50
+ }
51
+ if testkit.CheckSchema("actions", "ActionEnvelope", bogus) == nil {
52
+ t.Fatal("the schema accepted an action without its required field")
53
+ }
54
+ })
55
+ }
56
+
57
+ // Twins of upstream test/upstream-workarounds.test.ts: they fail once a spec re-sync no longer
58
+ // needs the workarounds the schema validator applies.
59
+ func TestUpstreamWorkarounds(t *testing.T) {
60
+ if err := testkit.LoadSchemas(); err != nil {
61
+ t.Fatal(err)
62
+ }
63
+ twin.Run(t, "upstream-workarounds", "still needs the dangling-$ref strip", func(t *testing.T) {
64
+ if testkit.StrippedDanglingRefs == 0 {
65
+ t.Fatal("the schemas no longer contain a dangling `$ref` to an empty `$defs` name: delete the strip from internal/testkit/schema.go and this test")
66
+ }
67
+ })
68
+ twin.Run(t, "upstream-workarounds", "still needs every KNOWN_BITSET_ENUMS entry", func(t *testing.T) {
69
+ found := false
70
+ for _, e := range testkit.RelaxedBitsetEnums {
71
+ if len(e) >= len("SessionStatus") && e[len(e)-len("SessionStatus"):] == "SessionStatus" {
72
+ found = true
73
+ }
74
+ }
75
+ if !found {
76
+ t.Fatal("SessionStatus is no longer emitted as a closed enum: remove the relaxation from internal/testkit/schema.go")
77
+ }
78
+ })
79
+ }
@@ -0,0 +1,291 @@
1
+ package host
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "sync"
7
+
8
+ "github.com/microsoft/agent-host-protocol/clients/go/ahp"
9
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
10
+
11
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
12
+ )
13
+
14
+ // maxRetainedTerminalChars bounds the output kept for snapshots; live clients still receive every action.
15
+ const maxRetainedTerminalChars = 1_000_000
16
+
17
+ // Store holds the state tree of every state-bearing channel. Every state is mutated only by
18
+ // actions run through the protocol's reducers (the vendored Microsoft Go reducers), and reads
19
+ // return deep copies, so a caller never observes a later mutation. Port of src/core/state-store.ts.
20
+ type Store struct {
21
+ mu sync.RWMutex
22
+ channels map[string]*entry
23
+ }
24
+
25
+ type entry struct {
26
+ kind wire.ChannelKind
27
+ state any // *RootState, *SessionState, *ChatState, *TerminalState or *ResourceWatchState
28
+ }
29
+
30
+ func newStore() *Store { return &Store{channels: map[string]*entry{}} }
31
+
32
+ // Create registers a channel with its initial state (a pointer to the typed state). The kind is
33
+ // inferred from the scheme unless given, which callers do when a command establishes the kind of
34
+ // a client-chosen URI. It replaces any existing entry.
35
+ func (s *Store) Create(uri string, state any, kind ...wire.ChannelKind) error {
36
+ var k wire.ChannelKind
37
+ if len(kind) > 0 && kind[0] != "" {
38
+ k = kind[0]
39
+ } else if inferred, ok := wire.KindOf(uri); ok {
40
+ k = inferred
41
+ } else {
42
+ return fmt.Errorf("cannot create channel with unknown scheme: %s", uri)
43
+ }
44
+ if !stateMatchesKind(state, k) {
45
+ return fmt.Errorf("state %T does not belong to a %s channel", state, k)
46
+ }
47
+ s.mu.Lock()
48
+ s.channels[uri] = &entry{kind: k, state: cloneState(state)}
49
+ s.mu.Unlock()
50
+ return nil
51
+ }
52
+
53
+ // UpdateChat edits a chat's state in place under the store lock, so an edit cannot overwrite an
54
+ // action reduced between reading the state and writing it back. fn reports whether it changed
55
+ // anything; UpdateChat returns false when the chat does not exist or fn made no change.
56
+ func (s *Store) UpdateChat(uri string, fn func(*ahptypes.ChatState) bool) bool {
57
+ s.mu.Lock()
58
+ defer s.mu.Unlock()
59
+ e, ok := s.channels[uri]
60
+ if !ok {
61
+ return false
62
+ }
63
+ chat, ok := e.state.(*ahptypes.ChatState)
64
+ if !ok {
65
+ return false
66
+ }
67
+ return fn(chat)
68
+ }
69
+
70
+ func stateMatchesKind(state any, kind wire.ChannelKind) bool {
71
+ switch state.(type) {
72
+ case *ahptypes.RootState:
73
+ return kind == wire.KindRoot
74
+ case *ahptypes.SessionState:
75
+ return kind == wire.KindSession
76
+ case *ahptypes.ChatState:
77
+ return kind == wire.KindChat
78
+ case *ahptypes.TerminalState:
79
+ return kind == wire.KindTerminal
80
+ case *ahptypes.ChangesetState:
81
+ return kind == wire.KindChangeset
82
+ case *ahptypes.ResourceWatchState:
83
+ return kind == wire.KindResourceWatch
84
+ }
85
+ return false
86
+ }
87
+
88
+ // cloneState deep-copies a typed state through its JSON form (the wire form is the contract).
89
+ func cloneState(state any) any {
90
+ switch v := state.(type) {
91
+ case *ahptypes.RootState:
92
+ return cloneVia(v)
93
+ case *ahptypes.SessionState:
94
+ return cloneVia(v)
95
+ case *ahptypes.ChatState:
96
+ return cloneVia(v)
97
+ case *ahptypes.TerminalState:
98
+ return cloneVia(v)
99
+ case *ahptypes.ChangesetState:
100
+ return cloneVia(v)
101
+ case *ahptypes.ResourceWatchState:
102
+ return cloneVia(v)
103
+ }
104
+ return state
105
+ }
106
+
107
+ func cloneVia[T any](v *T) *T {
108
+ if v == nil {
109
+ return nil
110
+ }
111
+ b, err := json.Marshal(v)
112
+ if err != nil {
113
+ panic(fmt.Sprintf("host: state is not marshalable: %v", err))
114
+ }
115
+ out := new(T)
116
+ if err := json.Unmarshal(b, out); err != nil {
117
+ panic(fmt.Sprintf("host: state does not round-trip: %v", err))
118
+ }
119
+ return out
120
+ }
121
+
122
+ // Delete removes a channel.
123
+ func (s *Store) Delete(uri string) bool {
124
+ s.mu.Lock()
125
+ defer s.mu.Unlock()
126
+ _, ok := s.channels[uri]
127
+ delete(s.channels, uri)
128
+ return ok
129
+ }
130
+
131
+ // Has reports whether a channel exists.
132
+ func (s *Store) Has(uri string) bool {
133
+ s.mu.RLock()
134
+ defer s.mu.RUnlock()
135
+ _, ok := s.channels[uri]
136
+ return ok
137
+ }
138
+
139
+ // KindOf returns the kind of an existing channel.
140
+ func (s *Store) KindOf(uri string) (wire.ChannelKind, bool) {
141
+ s.mu.RLock()
142
+ defer s.mu.RUnlock()
143
+ e, ok := s.channels[uri]
144
+ if !ok {
145
+ return "", false
146
+ }
147
+ return e.kind, true
148
+ }
149
+
150
+ func get[T any](s *Store, uri string) *T {
151
+ s.mu.RLock()
152
+ defer s.mu.RUnlock()
153
+ e, ok := s.channels[uri]
154
+ if !ok {
155
+ return nil
156
+ }
157
+ typed, ok := e.state.(*T)
158
+ if !ok {
159
+ return nil
160
+ }
161
+ return cloneVia(typed)
162
+ }
163
+
164
+ // Root, Session, Chat, Terminal, ResourceWatch return a deep copy of the typed state, nil when
165
+ // the channel is absent or of another kind.
166
+ func (s *Store) Root(uri string) *ahptypes.RootState { return get[ahptypes.RootState](s, uri) }
167
+ func (s *Store) Session(uri string) *ahptypes.SessionState { return get[ahptypes.SessionState](s, uri) }
168
+ func (s *Store) Chat(uri string) *ahptypes.ChatState { return get[ahptypes.ChatState](s, uri) }
169
+ func (s *Store) Terminal(uri string) *ahptypes.TerminalState {
170
+ return get[ahptypes.TerminalState](s, uri)
171
+ }
172
+ func (s *Store) ResourceWatch(uri string) *ahptypes.ResourceWatchState {
173
+ return get[ahptypes.ResourceWatchState](s, uri)
174
+ }
175
+
176
+ // Apply reduces an action into a channel using the reducer for its kind. Reducers are total: an
177
+ // action they do not recognise is a no-op, which is how forward compatibility works. It returns
178
+ // false when the channel does not exist — the spec requires the host to silently ignore actions
179
+ // targeting an unknown channel rather than echoing a rejection.
180
+ func (s *Store) Apply(uri string, action ahptypes.StateAction) (applied bool) {
181
+ s.mu.Lock()
182
+ defer s.mu.Unlock()
183
+ e, ok := s.channels[uri]
184
+ if !ok {
185
+ return false
186
+ }
187
+ // The vendored reducers panic on a malformed timestamp; a hostile or buggy client must not take the host down.
188
+ defer func() {
189
+ if r := recover(); r != nil {
190
+ applied = true
191
+ }
192
+ }()
193
+ switch st := e.state.(type) {
194
+ case *ahptypes.RootState:
195
+ ahp.ApplyActionToRoot(st, action)
196
+ case *ahptypes.SessionState:
197
+ ahp.ApplyActionToSession(st, action)
198
+ case *ahptypes.ChatState:
199
+ ahp.ApplyActionToChat(st, action)
200
+ case *ahptypes.TerminalState:
201
+ ahp.ApplyActionToTerminal(st, action)
202
+ if _, isData := action.Value.(*ahptypes.TerminalDataAction); isData {
203
+ trimTerminal(st)
204
+ }
205
+ case *ahptypes.ChangesetState:
206
+ ahp.ApplyActionToChangeset(st, action)
207
+ case *ahptypes.ResourceWatchState:
208
+ ahp.ApplyActionToResourceWatch(st, action)
209
+ }
210
+ return true
211
+ }
212
+
213
+ // Snapshot builds a subscribe/initialize snapshot for a channel at the given seq.
214
+ func (s *Store) Snapshot(uri string, fromSeq int64) (ahptypes.Snapshot, bool) {
215
+ s.mu.RLock()
216
+ defer s.mu.RUnlock()
217
+ e, ok := s.channels[uri]
218
+ if !ok {
219
+ return ahptypes.Snapshot{}, false
220
+ }
221
+ var state ahptypes.SnapshotState
222
+ switch st := e.state.(type) {
223
+ case *ahptypes.RootState:
224
+ state.Root = cloneVia(st)
225
+ case *ahptypes.SessionState:
226
+ state.Session = cloneVia(st)
227
+ case *ahptypes.ChatState:
228
+ state.Chat = cloneVia(st)
229
+ case *ahptypes.TerminalState:
230
+ state.Terminal = cloneVia(st)
231
+ case *ahptypes.ChangesetState:
232
+ state.Changeset = cloneVia(st)
233
+ case *ahptypes.ResourceWatchState:
234
+ state.ResourceWatch = cloneVia(st)
235
+ }
236
+ return ahptypes.Snapshot{Resource: uri, State: state, FromSeq: fromSeq}, true
237
+ }
238
+
239
+ // trimTerminal drops only the oldest retained output beyond the cap.
240
+ func trimTerminal(st *ahptypes.TerminalState) {
241
+ remaining := maxRetainedTerminalChars
242
+ trimmed := false
243
+ var newestFirst []ahptypes.TerminalContentPart
244
+ for i := len(st.Content) - 1; i >= 0; i-- {
245
+ part := st.Content[i]
246
+ out := terminalOutput(part)
247
+ runes := []rune(out)
248
+ if len(runes) <= remaining {
249
+ newestFirst = append(newestFirst, part)
250
+ remaining -= len(runes)
251
+ continue
252
+ }
253
+ if remaining > 0 {
254
+ newestFirst = append(newestFirst, withTerminalOutput(part, string(runes[len(runes)-remaining:])))
255
+ }
256
+ trimmed = true
257
+ break
258
+ }
259
+ if !trimmed {
260
+ return
261
+ }
262
+ content := make([]ahptypes.TerminalContentPart, 0, len(newestFirst))
263
+ for i := len(newestFirst) - 1; i >= 0; i-- {
264
+ content = append(content, newestFirst[i])
265
+ }
266
+ st.Content = content
267
+ }
268
+
269
+ func terminalOutput(p ahptypes.TerminalContentPart) string {
270
+ switch v := p.Value.(type) {
271
+ case *ahptypes.TerminalUnclassifiedPart:
272
+ return v.Value
273
+ case *ahptypes.TerminalCommandPart:
274
+ return v.Output
275
+ }
276
+ return ""
277
+ }
278
+
279
+ func withTerminalOutput(p ahptypes.TerminalContentPart, out string) ahptypes.TerminalContentPart {
280
+ switch v := p.Value.(type) {
281
+ case *ahptypes.TerminalUnclassifiedPart:
282
+ c := *v
283
+ c.Value = out
284
+ return ahptypes.TerminalContentPart{Value: &c}
285
+ case *ahptypes.TerminalCommandPart:
286
+ c := *v
287
+ c.Output = out
288
+ return ahptypes.TerminalContentPart{Value: &c}
289
+ }
290
+ return p
291
+ }
@@ -0,0 +1,85 @@
1
+ package host_test
2
+
3
+ import (
4
+ "sync"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
9
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
10
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
11
+ ahptypes "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
12
+ )
13
+
14
+ // UpdateChat is a read-modify-write under the store lock: edits racing with reduced actions must
15
+ // never drop either side (a title rewrite that replaced the whole state once erased a running turn).
16
+ func TestUpdateChatDoesNotLoseConcurrentActions(t *testing.T) {
17
+ const turns = 200
18
+ s := testkit.NewHost(host.Options{}).Store()
19
+ uri := "ahp-chat:/00000000-0000-7000-8000-000000000001"
20
+ if err := s.Create(uri, &ahptypes.ChatState{Resource: uri, Title: "t"}, wire.KindChat); err != nil {
21
+ t.Fatal(err)
22
+ }
23
+ var wg sync.WaitGroup
24
+ wg.Add(2)
25
+ go func() {
26
+ defer wg.Done()
27
+ for i := 0; i < turns; i++ {
28
+ s.Apply(uri, ahptypes.StateAction{Value: &ahptypes.ChatTurnStartedAction{Type: ahptypes.ActionTypeChatTurnStarted, TurnId: "x", StartedAt: "2025-01-01T00:00:00.000Z"}})
29
+ s.Apply(uri, ahptypes.StateAction{Value: &ahptypes.ChatTurnCompleteAction{Type: ahptypes.ActionTypeChatTurnComplete, TurnId: "x", Duration: 1}})
30
+ }
31
+ }()
32
+ go func() {
33
+ defer wg.Done()
34
+ for i := 0; i < turns; i++ {
35
+ s.UpdateChat(uri, func(c *ahptypes.ChatState) bool { c.Title = "renamed"; return true })
36
+ }
37
+ }()
38
+ wg.Wait()
39
+ got := s.Chat(uri)
40
+ if len(got.Turns) != turns || got.ActiveTurn != nil {
41
+ t.Fatalf("turns = %d active = %v, want %d complete turns", len(got.Turns), got.ActiveTurn != nil, turns)
42
+ }
43
+ if got.Title != "renamed" {
44
+ t.Fatalf("title = %q", got.Title)
45
+ }
46
+ if s.UpdateChat("ahp-chat:/missing", func(*ahptypes.ChatState) bool { return true }) {
47
+ t.Fatal("updating a missing chat reported a change")
48
+ }
49
+ }
50
+
51
+ // An action reduced while an edit is in flight waits for it: the edit sees, and keeps, whatever
52
+ // came before, and the action lands after (deterministic form of the race above; needs no -race).
53
+ func TestUpdateChatExcludesReducers(t *testing.T) {
54
+ s := testkit.NewHost(host.Options{}).Store()
55
+ uri := "ahp-chat:/00000000-0000-7000-8000-000000000002"
56
+ if err := s.Create(uri, &ahptypes.ChatState{Resource: uri}, wire.KindChat); err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ inside, applied := make(chan struct{}), make(chan struct{})
60
+ done := make(chan struct{})
61
+ go func() {
62
+ defer close(done)
63
+ s.UpdateChat(uri, func(c *ahptypes.ChatState) bool {
64
+ close(inside)
65
+ go func() {
66
+ s.Apply(uri, ahptypes.StateAction{Value: &ahptypes.ChatTurnStartedAction{Type: ahptypes.ActionTypeChatTurnStarted, TurnId: "x", StartedAt: "2025-01-01T00:00:00.000Z"}})
67
+ close(applied)
68
+ }()
69
+ select {
70
+ case <-applied:
71
+ t.Error("an action was reduced while the edit held the chat")
72
+ case <-time.After(50 * time.Millisecond):
73
+ }
74
+ c.Title = "edited"
75
+ return true
76
+ })
77
+ }()
78
+ <-inside
79
+ <-done
80
+ <-applied
81
+ got := s.Chat(uri)
82
+ if got.Title != "edited" || got.ActiveTurn == nil {
83
+ t.Fatalf("title=%q active=%v: the edit and the action must both survive", got.Title, got.ActiveTurn != nil)
84
+ }
85
+ }