@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,269 @@
1
+ package pi
2
+
3
+ import (
4
+ "strings"
5
+
6
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
7
+
8
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
9
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pisession"
10
+ )
11
+
12
+ // Rebuilds chat history from a pi session file (port of src/pi/history.ts).
13
+ //
14
+ // A session from the catalogue exists only on disk, so subscribing to it has to reconstruct its
15
+ // state, or the client opens an empty conversation. The window matches what pi itself renders:
16
+ // the most recent compaction onward (ContextEntries), which is the transcript pi's own TUI shows
17
+ // on resume; reconstructing more would show the user history their agent has already forgotten.
18
+ //
19
+ // This is a second mapper, distinct from the streaming one: it works from finished messages
20
+ // rather than deltas, so there is no partial state and every tool call already has its result.
21
+
22
+ // ClearAllAnchor is the key under which the whole-conversation anchor is stored.
23
+ const ClearAllAnchor = ""
24
+
25
+ // RebuiltHistory is the result of a rebuild.
26
+ type RebuiltHistory struct {
27
+ Turns []ahptypes.Turn
28
+ // Anchors maps a turn id to the session entry that turn ends on: what truncation needs.
29
+ // chat/truncated keeps turns up to and including the named one, and pointing the agent at a
30
+ // turn's last entry expresses exactly that. The first user entry is recorded under
31
+ // ClearAllAnchor: navigating to it lands the leaf on its parent (null), which is how "clear
32
+ // everything" is expressed.
33
+ Anchors map[string]string
34
+ }
35
+
36
+ // RebuildOptions configure a rebuild.
37
+ type RebuildOptions struct {
38
+ // TurnIDPrefix prefixes generated turn ids; it only needs to be stable within a chat.
39
+ TurnIDPrefix string
40
+ }
41
+
42
+ func blocksOf(message map[string]any) []map[string]any {
43
+ if s, ok := message["content"].(string); ok {
44
+ return []map[string]any{{"type": "text", "text": s}}
45
+ }
46
+ var out []map[string]any
47
+ if arr, ok := message["content"].([]any); ok {
48
+ for _, b := range arr {
49
+ if m, ok := b.(map[string]any); ok {
50
+ out = append(out, m)
51
+ }
52
+ }
53
+ }
54
+ return out
55
+ }
56
+
57
+ func plainText(message map[string]any) string {
58
+ var sb strings.Builder
59
+ for _, b := range blocksOf(message) {
60
+ if b["type"] == "text" {
61
+ if s, ok := b["text"].(string); ok {
62
+ sb.WriteString(s)
63
+ }
64
+ }
65
+ }
66
+ return sb.String()
67
+ }
68
+
69
+ func numberPtr(v any) *int64 {
70
+ switch n := v.(type) {
71
+ case float64:
72
+ i := int64(n)
73
+ return &i
74
+ }
75
+ return nil
76
+ }
77
+
78
+ func storedUsage(message map[string]any) *ahptypes.UsageInfo {
79
+ usage, ok := message["usage"].(map[string]any)
80
+ if !ok {
81
+ return nil
82
+ }
83
+ info := &ahptypes.UsageInfo{
84
+ InputTokens: numberPtr(usage["input"]), OutputTokens: numberPtr(usage["output"]), CacheReadTokens: numberPtr(usage["cacheRead"]),
85
+ }
86
+ if model, _ := message["model"].(string); model != "" {
87
+ info.Model = &model
88
+ }
89
+ return info
90
+ }
91
+
92
+ func textResult(text string) []ahptypes.ToolResultContent {
93
+ return []ahptypes.ToolResultContent{{Value: &ahptypes.ToolResultTextContent{Type: ahptypes.ToolResultContentTypeText, Text: text}}}
94
+ }
95
+
96
+ type pendingTurn struct {
97
+ id, startedAt string
98
+ message ahptypes.Message
99
+ parts []ahptypes.ResponsePart
100
+ usage *ahptypes.UsageInfo
101
+ }
102
+
103
+ func stringOr(v any, def string) string {
104
+ if s, ok := v.(string); ok && s != "" {
105
+ return s
106
+ }
107
+ return def
108
+ }
109
+
110
+ // RebuildHistory converts session entries into completed turns.
111
+ //
112
+ // Turn boundaries come from user messages: each one opens a turn that absorbs every assistant
113
+ // message and tool result until the next. That mirrors the live mapper, where a turn spans
114
+ // everything from one prompt to agent_settled.
115
+ func RebuildHistory(entries []pisession.Entry, opts RebuildOptions) RebuiltHistory {
116
+ prefix := opts.TurnIDPrefix
117
+ if prefix == "" {
118
+ prefix = "turn"
119
+ }
120
+ turns := []ahptypes.Turn{}
121
+ anchors := map[string]string{}
122
+ var lastEntryID string
123
+ var current *pendingTurn
124
+ toolCallParts := map[string]int{}
125
+
126
+ closeTurn := func() {
127
+ if current == nil {
128
+ return
129
+ }
130
+ if lastEntryID != "" {
131
+ anchors[current.id] = lastEntryID
132
+ }
133
+ started := current.startedAt
134
+ turns = append(turns, ahptypes.Turn{
135
+ Id: current.id, StartedAt: &started, Message: current.message,
136
+ ResponseParts: current.parts, Usage: current.usage, State: ahptypes.TurnStateComplete,
137
+ })
138
+ current = nil
139
+ toolCallParts = map[string]int{}
140
+ }
141
+ openTurn := func(id, startedAt string, message ahptypes.Message) {
142
+ closeTurn()
143
+ current = &pendingTurn{id: id, startedAt: startedAt, message: message, parts: []ahptypes.ResponsePart{}}
144
+ }
145
+
146
+ for _, entry := range entries {
147
+ if entry.Role() == "user" {
148
+ if _, set := anchors[ClearAllAnchor]; !set {
149
+ anchors[ClearAllAnchor] = entry.ID()
150
+ }
151
+ }
152
+
153
+ if entry.Type() == "compaction" {
154
+ // The summary replaces everything before it. Surfacing it as a turn of its own tells
155
+ // the user why the history starts where it does.
156
+ closeTurn()
157
+ started := entry.Timestamp()
158
+ summary, _ := entry["summary"].(string)
159
+ turns = append(turns, ahptypes.Turn{
160
+ Id: prefix + "-compaction-" + entry.ID(), StartedAt: &started,
161
+ Message: ahptypes.Message{Text: "", Origin: ahptypes.MessageOrigin{Kind: ahptypes.MessageKindUser}},
162
+ ResponseParts: []ahptypes.ResponsePart{{Value: &ahptypes.SystemNotificationResponsePart{
163
+ Kind: ahptypes.ResponsePartKindSystemNotification,
164
+ Content: ahptypes.NewStringOrMarkdownPlain("Conversation compacted.\n\n" + summary),
165
+ }}},
166
+ State: ahptypes.TurnStateComplete,
167
+ })
168
+ continue
169
+ }
170
+ if entry.Type() != "message" {
171
+ continue // model / thinking-level changes and labels carry no transcript
172
+ }
173
+
174
+ message := entry.Message()
175
+ switch message["role"] {
176
+ case "user":
177
+ openTurn(prefix+"-"+entry.ID(), entry.Timestamp(), mapper.UserMessageFromPiContent(message["content"]))
178
+ case "assistant":
179
+ if current == nil {
180
+ // An assistant message with no preceding user message (a truncated file, or history
181
+ // that starts mid-turn): give it a turn so the content is not dropped.
182
+ openTurn(prefix+"-"+entry.ID(), entry.Timestamp(), mapper.UserMessageFromPiContent(""))
183
+ }
184
+ for index, block := range blocksOf(message) {
185
+ partID := current.id + ":" + entry.ID() + ":" + itoa(index)
186
+ switch {
187
+ case block["type"] == "text" && stringOr(block["text"], "") != "":
188
+ current.parts = append(current.parts, ahptypes.ResponsePart{Value: &ahptypes.MarkdownResponsePart{
189
+ Kind: ahptypes.ResponsePartKindMarkdown, Id: partID, Content: block["text"].(string)}})
190
+ case block["type"] == "thinking" && stringOr(block["thinking"], "") != "":
191
+ current.parts = append(current.parts, ahptypes.ResponsePart{Value: &ahptypes.ReasoningResponsePart{
192
+ Kind: ahptypes.ResponsePartKindReasoning, Id: partID, Content: block["thinking"].(string)}})
193
+ case block["type"] == "toolCall" && stringOr(block["id"], "") != "":
194
+ id := block["id"].(string)
195
+ name := stringOr(block["name"], "tool")
196
+ toolCallParts[id] = len(current.parts)
197
+ running := &ahptypes.ToolCallRunningState{
198
+ Status: ahptypes.ToolCallStatusRunning, ToolCallId: id, ToolName: name, DisplayName: name,
199
+ InvocationMessage: ahptypes.NewStringOrMarkdownPlain(name), Confirmed: ahptypes.ToolCallConfirmationReasonSetting,
200
+ }
201
+ if args, present := block["arguments"]; present {
202
+ if s, err := mapper.StringifyJSON(args, false); err == nil {
203
+ running.ToolInput = &ahptypes.ToolInput{Inline: &s}
204
+ }
205
+ }
206
+ current.parts = append(current.parts, ahptypes.ResponsePart{Value: &ahptypes.ToolCallResponsePart{
207
+ Kind: ahptypes.ResponsePartKindToolCall, ToolCall: ahptypes.ToolCallState{Value: running}}})
208
+ }
209
+ }
210
+ if u := storedUsage(message); u != nil {
211
+ current.usage = u
212
+ }
213
+ case "toolResult":
214
+ callID, _ := message["toolCallId"].(string)
215
+ idx, found := toolCallParts[callID]
216
+ if current == nil || callID == "" || !found {
217
+ break
218
+ }
219
+ part, ok := current.parts[idx].Value.(*ahptypes.ToolCallResponsePart)
220
+ if !ok {
221
+ break
222
+ }
223
+ running, ok := part.ToolCall.Value.(*ahptypes.ToolCallRunningState)
224
+ if !ok {
225
+ break
226
+ }
227
+ name := stringOr(message["toolName"], "tool")
228
+ success := message["isError"] != true
229
+ past := "Ran " + name
230
+ if !success {
231
+ past = name + " failed"
232
+ }
233
+ done := &ahptypes.ToolCallCompletedState{
234
+ Status: ahptypes.ToolCallStatusCompleted, ToolCallId: running.ToolCallId, ToolName: running.ToolName,
235
+ DisplayName: running.DisplayName, InvocationMessage: running.InvocationMessage, ToolInput: running.ToolInput,
236
+ Confirmed: running.Confirmed, Success: success, PastTenseMessage: ahptypes.NewStringOrMarkdownPlain(past),
237
+ }
238
+ if text := plainText(message); text != "" {
239
+ done.Content = textResult(text)
240
+ }
241
+ current.parts[idx] = ahptypes.ResponsePart{Value: &ahptypes.ToolCallResponsePart{
242
+ Kind: ahptypes.ResponsePartKindToolCall, ToolCall: ahptypes.ToolCallState{Value: done}}}
243
+ case "bashExecution":
244
+ // pi records `!command` runs as their own message role; they are part of the transcript
245
+ // even though no model produced them.
246
+ command, _ := message["command"].(string)
247
+ output, _ := message["output"].(string)
248
+ if current == nil {
249
+ openTurn(prefix+"-"+entry.ID(), entry.Timestamp(), mapper.UserMessageFromPiContent("!"+command))
250
+ }
251
+ current.parts = append(current.parts, ahptypes.ResponsePart{Value: &ahptypes.SystemNotificationResponsePart{
252
+ Kind: ahptypes.ResponsePartKindSystemNotification, Content: ahptypes.NewStringOrMarkdownPlain("`" + command + "`\n\n" + output)}})
253
+ }
254
+ // Recorded after the entry has been folded in, so a user message that opens a new turn
255
+ // does not become the previous turn's anchor.
256
+ lastEntryID = entry.ID()
257
+ }
258
+ closeTurn()
259
+ return RebuiltHistory{Turns: turns, Anchors: anchors}
260
+ }
261
+
262
+ // RebuildHistoryFromSession rebuilds the turns of a session with the window pi's TUI shows.
263
+ func RebuildHistoryFromSession(store SessionStore, opts RebuildOptions) RebuiltHistory {
264
+ return RebuildHistory(store.ContextEntries(), opts)
265
+ }
266
+
267
+ func itoa(i int) string {
268
+ return mapper.Itoa(i)
269
+ }
@@ -0,0 +1,171 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "sync"
11
+ "testing"
12
+
13
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
14
+
15
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
16
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
17
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
18
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
19
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
20
+ )
21
+
22
+ // writeSession writes a Pi session file containing one full turn with a tool call, then a second
23
+ // user message (upstream test/support/hydrated-session.ts).
24
+ func writeSession(t testing.TB, root, id, cwd string, includeImage bool) string {
25
+ t.Helper()
26
+ dir := fixtureSessionDirectory(t, root, cwd)
27
+ at := "2026-01-01T00:00:00.000Z"
28
+ line := func(v map[string]any) string { raw, _ := json.Marshal(v); return string(raw) }
29
+ lines := []string{line(map[string]any{"type": "session", "id": id, "parentId": nil, "timestamp": at, "version": 3, "cwd": cwd})}
30
+ var parent any
31
+ n := 0
32
+ push := func(entry map[string]any) {
33
+ n++
34
+ entryID := fmt.Sprintf("e%d", n)
35
+ entry["id"], entry["parentId"], entry["timestamp"] = entryID, parent, at
36
+ lines = append(lines, line(entry))
37
+ parent = entryID
38
+ }
39
+ var userContent any = "Read note.txt"
40
+ if includeImage {
41
+ userContent = []any{obj{"type": "text", "text": "Read note.txt"}, obj{"type": "image", "data": onePixelPNG, "mimeType": "image/png"}}
42
+ }
43
+ push(obj{"type": "message", "message": obj{"role": "user", "content": userContent, "timestamp": 0}})
44
+ push(obj{"type": "message", "message": obj{
45
+ "role": "assistant",
46
+ "content": []any{obj{"type": "thinking", "thinking": "I should read it."},
47
+ obj{"type": "toolCall", "id": "tc-1", "name": "read", "arguments": obj{"path": "note.txt"}}},
48
+ "usage": obj{"input": 10, "output": 5, "cacheRead": 0}, "provider": "fixture", "model": "test-model", "timestamp": 0,
49
+ }})
50
+ push(obj{"type": "message", "message": obj{
51
+ "role": "toolResult", "toolCallId": "tc-1", "toolName": "read", "content": []any{obj{"type": "text", "text": "ALPHA"}}, "timestamp": 0,
52
+ }})
53
+ push(obj{"type": "message", "message": obj{
54
+ "role": "assistant", "content": []any{obj{"type": "text", "text": "It says ALPHA."}}, "provider": "fixture", "model": "test-model", "timestamp": 0,
55
+ }})
56
+ push(obj{"type": "message", "message": obj{"role": "user", "content": "Thanks", "timestamp": 0}})
57
+ path := filepath.Join(dir, "2026-01-01T00-00-00-000Z_"+id+".jsonl")
58
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ return path
62
+ }
63
+
64
+ // recordingBackend records what a resumed session actually asks the agent to do.
65
+ type recordingBackend struct {
66
+ mu sync.Mutex
67
+ prompts []string
68
+ listeners []func(mapper.Event)
69
+ }
70
+
71
+ func (b *recordingBackend) Subscribe(l func(mapper.Event)) func() {
72
+ b.mu.Lock()
73
+ b.listeners = append(b.listeners, l)
74
+ b.mu.Unlock()
75
+ return func() {}
76
+ }
77
+
78
+ func (b *recordingBackend) Prompt(_ context.Context, text string, _ []mapper.Image) error {
79
+ b.mu.Lock()
80
+ b.prompts = append(b.prompts, text)
81
+ ls := append([]func(mapper.Event){}, b.listeners...)
82
+ b.mu.Unlock()
83
+ for _, typ := range []string{"agent_start", "agent_settled"} {
84
+ for _, l := range ls {
85
+ l(mapper.Event{"type": typ})
86
+ }
87
+ }
88
+ return nil
89
+ }
90
+ func (b *recordingBackend) Steer(context.Context, string, []mapper.Image) error { return nil }
91
+ func (b *recordingBackend) Abort(context.Context) error { return nil }
92
+
93
+ func (b *recordingBackend) promptList() []string {
94
+ b.mu.Lock()
95
+ defer b.mu.Unlock()
96
+ return append([]string(nil), b.prompts...)
97
+ }
98
+
99
+ type hydratedOptions struct {
100
+ deleteFile func(path string) (pi.SessionFileDeletionResult, error)
101
+ includeImage bool
102
+ }
103
+
104
+ type hydratedFixture struct {
105
+ t *testing.T
106
+ host *host.Host
107
+ services *pi.Services
108
+ client *testkit.Client
109
+ sessionID string
110
+ root string
111
+ workspace string
112
+ backend *recordingBackend
113
+ mu sync.Mutex
114
+ deleted []string
115
+ extra int
116
+ }
117
+
118
+ func (f *hydratedFixture) deletedFiles() []string {
119
+ f.mu.Lock()
120
+ defer f.mu.Unlock()
121
+ return append([]string(nil), f.deleted...)
122
+ }
123
+
124
+ func (f *hydratedFixture) session() string { return wire.SessionURI(f.sessionID) }
125
+ func (f *hydratedFixture) chat() string { return wire.ChatURI(f.sessionID) }
126
+
127
+ // connectAsVSCode attaches another client that identifies itself as an editor window.
128
+ func (f *hydratedFixture) connectAsVSCode() *testkit.Client {
129
+ f.t.Helper()
130
+ f.extra++
131
+ c := testkit.Connect(f.t, f.host)
132
+ f.t.Cleanup(c.Close)
133
+ c.Initialize(fmt.Sprintf("vscode-client-%d", f.extra+1), obj{"clientInfo": obj{"name": "vscode-editor-window", "title": "VS Code"}})
134
+ return c
135
+ }
136
+
137
+ func startHydrated(t *testing.T, o hydratedOptions) *hydratedFixture {
138
+ t.Helper()
139
+ f := &hydratedFixture{t: t, root: t.TempDir(), backend: &recordingBackend{}}
140
+ f.workspace = filepath.Join(t.TempDir(), "pi ahp hydrate cwd")
141
+ if err := os.MkdirAll(f.workspace, 0o755); err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ f.sessionID = newID()
145
+ writeSession(t, f.root, f.sessionID, f.workspace, o.includeImage)
146
+ f.host = testkit.NewHost(host.Options{})
147
+ f.host.Store() // root installed by NewHost
148
+ f.services = pi.NewServices(pi.ServicesOptions{
149
+ Host: f.host, SessionRoot: f.root, DefaultWorkingDirectory: f.workspace,
150
+ CreateBackend: func(*pi.LiveSession) (pi.Backend, error) { return f.backend, nil },
151
+ CreateSessionManager: pi.PersistentStorage(filepath.Join(f.root, "created")),
152
+ DefaultSelection: func() *ahptypes.ModelSelection {
153
+ return &ahptypes.ModelSelection{Id: "fallback-model", Config: map[string]json.RawMessage{"thinkingLevel": json.RawMessage(`"medium"`)}}
154
+ },
155
+ DeleteFile: func(path string) (pi.SessionFileDeletionResult, error) {
156
+ f.mu.Lock()
157
+ f.deleted = append(f.deleted, path)
158
+ f.mu.Unlock()
159
+ if o.deleteFile != nil {
160
+ return o.deleteFile(path)
161
+ }
162
+ _ = os.Remove(path)
163
+ return pi.SessionFileDeletionResult{OK: true}, nil
164
+ },
165
+ })
166
+ f.host.Serve(f.services.Capabilities())
167
+ f.client = testkit.Connect(t, f.host)
168
+ t.Cleanup(f.client.Close)
169
+ f.client.Initialize("hydrate-client", nil)
170
+ return f
171
+ }
@@ -0,0 +1,196 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "os"
5
+ "reflect"
6
+ "sync"
7
+ "testing"
8
+ "time"
9
+
10
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
11
+
12
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pisession"
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
15
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
16
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
17
+ )
18
+
19
+ // Twins of upstream test/hydrated-session-lifecycle.test.ts: operations performed after a durable
20
+ // Pi session has been hydrated.
21
+
22
+ func (f *hydratedFixture) listItems() []obj {
23
+ f.t.Helper()
24
+ var r struct{ Items []obj }
25
+ f.client.Decode(f.client.Must("listSessions", obj{"channel": wire.RootChannel}), &r)
26
+ return r.Items
27
+ }
28
+
29
+ func (f *hydratedFixture) turnStarted(turnID, text string) {
30
+ f.client.Dispatch(f.chat(), obj{"type": "chat/turnStarted", "turnId": turnID, "startedAt": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), "message": userMessage(text)})
31
+ }
32
+
33
+ func TestReadStateAfterHydration(t *testing.T) {
34
+ f := startHydrated(t, hydratedOptions{})
35
+ isRead := float64(ahptypes.SessionStatusIsRead)
36
+
37
+ twin.Run(t, "hydrated-session-lifecycle", "reports every catalogue entry as read", func(t *testing.T) {
38
+ items := f.listItems()
39
+ if int64(items[0]["status"].(float64))&int64(isRead) == 0 {
40
+ t.Fatalf("status = %v", items[0]["status"])
41
+ }
42
+ })
43
+
44
+ twin.Run(t, "hydrated-session-lifecycle", "reports a hydrated session as read", func(t *testing.T) {
45
+ state := snapshotState[ahptypes.SessionState](t, f.client, f.session())
46
+ if state.Status&ahptypes.SessionStatusIsRead == 0 {
47
+ t.Fatalf("status = %d", state.Status)
48
+ }
49
+ if state.Title != "Read note.txt" {
50
+ t.Fatalf("title = %q", state.Title)
51
+ }
52
+ })
53
+
54
+ twin.Run(t, "hydrated-session-lifecycle", "keeps session-owned read state when a starting turn marks the chat unread", func(t *testing.T) {
55
+ f.client.Subscribe(f.chat())
56
+ if before := f.host.Store().Chat(f.chat()).Status; before&ahptypes.SessionStatusIsRead == 0 {
57
+ t.Fatalf("chat status before = %d", before)
58
+ }
59
+ f.turnStarted("t-unread", "hi")
60
+ f.client.Ping()
61
+ if got := f.host.Store().Chat(f.chat()).Status & ahptypes.SessionStatusIsRead; got != 0 {
62
+ t.Fatalf("chat still read: %d", got)
63
+ }
64
+ if f.host.Store().Session(f.session()).Status&ahptypes.SessionStatusIsRead == 0 {
65
+ t.Fatal("the session lost its read bit")
66
+ }
67
+ for _, item := range f.listItems() {
68
+ if item["resource"] == f.session() && int64(item["status"].(float64))&int64(isRead) == 0 {
69
+ t.Fatalf("listed status = %v", item["status"])
70
+ }
71
+ }
72
+ })
73
+ }
74
+
75
+ func TestDisposingAHydratedSession(t *testing.T) {
76
+ twin.Run(t, "hydrated-session-lifecycle", "removes it from the catalogue and deletes the file", func(t *testing.T) {
77
+ f := startHydrated(t, hydratedOptions{})
78
+ f.client.Subscribe(f.session())
79
+ f.client.Must("disposeSession", obj{"channel": f.session()})
80
+ if f.host.Store().Has(f.session()) {
81
+ t.Fatal("the session channel remains")
82
+ }
83
+ if len(f.deletedFiles()) != 1 {
84
+ t.Fatalf("deleted = %v", f.deletedFiles())
85
+ }
86
+ if got := f.listItems(); len(got) != 0 {
87
+ t.Fatalf("a disposed session must not come back on the next listing: %v", got)
88
+ }
89
+ })
90
+
91
+ twin.Run(t, "hydrated-session-lifecycle", "prevents an unloaded session from being recreated or hydrated during deletion", func(t *testing.T) {
92
+ started := make(chan struct{})
93
+ finish := make(chan bool)
94
+ var once sync.Once
95
+ f := startHydrated(t, hydratedOptions{deleteFile: func(path string) (pi.SessionFileDeletionResult, error) {
96
+ once.Do(func() { close(started) })
97
+ ok := <-finish
98
+ if ok {
99
+ _ = os.Remove(path)
100
+ }
101
+ return pi.SessionFileDeletionResult{OK: ok}, nil
102
+ }})
103
+ defer func() {
104
+ select {
105
+ case finish <- false:
106
+ default:
107
+ }
108
+ }()
109
+ uri := f.session()
110
+ disposed := make(chan *testkit.RPCError, 1)
111
+ go func() { _, err := f.client.Request("disposeSession", obj{"channel": uri}); disposed <- err }()
112
+ select {
113
+ case <-started:
114
+ case <-time.After(testkit.Timeout):
115
+ t.Fatal("timed out waiting for durable deletion to start")
116
+ }
117
+ f.client.ExpectError("subscribe", obj{"channel": uri}, wire.CodeNotFound)
118
+ f.client.ExpectError("createSession", obj{"channel": uri}, wire.CodeSessionAlreadyExists)
119
+
120
+ finish <- true
121
+ if err := <-disposed; err != nil {
122
+ t.Fatal(err)
123
+ }
124
+ deleted := f.deletedFiles()
125
+ if len(deleted) != 1 {
126
+ t.Fatalf("deleted = %v", deleted)
127
+ }
128
+ if _, err := os.Stat(deleted[0]); !os.IsNotExist(err) {
129
+ t.Fatalf("the deleted session file remains: %v", err)
130
+ }
131
+ for _, item := range f.listItems() {
132
+ if item["resource"] == uri {
133
+ t.Fatal("the disposed session is still listed")
134
+ }
135
+ }
136
+ })
137
+ }
138
+
139
+ func TestResumingAHydratedSession(t *testing.T) {
140
+ twin.Run(t, "hydrated-session-lifecycle", "starts an agent on the first turn and prompts it", func(t *testing.T) {
141
+ f := startHydrated(t, hydratedOptions{})
142
+ f.client.Subscribe(f.chat())
143
+ if got := f.backend.promptList(); len(got) != 0 {
144
+ t.Fatalf("browsing history must not start an agent: %v", got)
145
+ }
146
+ f.turnStarted("t-resume", "continue please")
147
+ testkit.Eventually(t, "the resumed prompt to reach the backend", func() bool { return len(f.backend.promptList()) == 1 })
148
+ if got := f.backend.promptList(); !reflect.DeepEqual(got, []string{"continue please"}) {
149
+ t.Fatalf("prompts = %v", got)
150
+ }
151
+ testkit.Eventually(t, "the resumed turn to settle", func() bool { return f.host.Store().Chat(f.chat()).ActiveTurn == nil })
152
+ })
153
+
154
+ twin.Run(t, "hydrated-session-lifecycle", "resumes onto the existing transcript rather than a fresh one", func(t *testing.T) {
155
+ f := startHydrated(t, hydratedOptions{})
156
+ f.client.Subscribe(f.chat())
157
+ before := len(f.host.Store().Chat(f.chat()).Turns)
158
+ f.turnStarted("t-append", "and again")
159
+ testkit.Eventually(t, "the resumed prompt to reach the backend", func() bool { return len(f.backend.promptList()) == 1 })
160
+ testkit.Eventually(t, "the resumed turn to append after history", func() bool { return len(f.host.Store().Chat(f.chat()).Turns) == before+1 })
161
+ turns := f.host.Store().Chat(f.chat()).Turns
162
+ if turns[0].Message.Text != "Read note.txt" || turns[len(turns)-1].Message.Text != "and again" {
163
+ t.Fatalf("turns = %+v", turnShapes(turns))
164
+ }
165
+ })
166
+ }
167
+
168
+ func TestModelSelectionOnAHydratedSession(t *testing.T) {
169
+ twin.Run(t, "hydrated-session-lifecycle", "seeds the picker from the model the session was using", func(t *testing.T) {
170
+ f := startHydrated(t, hydratedOptions{})
171
+ chat := snapshotState[ahptypes.ChatState](t, f.client, f.chat())
172
+ if chat.Draft == nil || chat.Draft.Model == nil || chat.Draft.Model.Id != "fixture/test-model" {
173
+ t.Fatalf("draft = %+v", chat.Draft)
174
+ }
175
+ })
176
+ }
177
+
178
+ func TestRenamingAHydratedSession(t *testing.T) {
179
+ twin.Run(t, "hydrated-session-lifecycle", "persists the name into the session file", func(t *testing.T) {
180
+ f := startHydrated(t, hydratedOptions{})
181
+ f.client.Subscribe(f.session())
182
+ f.client.Dispatch(f.session(), obj{"type": "session/titleChanged", "title": "Archived work"})
183
+ f.client.Ping()
184
+ if got := f.host.Store().Session(f.session()).Title; got != "Archived work" {
185
+ t.Fatalf("title = %q", got)
186
+ }
187
+ testkit.Eventually(t, "the name to reach the file", func() bool {
188
+ file, _ := pi.NewCatalogue(f.root).FindSessionFile(f.sessionID)
189
+ if file == "" {
190
+ return false
191
+ }
192
+ m, err := pisession.Open(file)
193
+ return err == nil && m.SessionName() == "Archived work"
194
+ })
195
+ })
196
+ }