@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,370 @@
1
+ package pi
2
+
3
+ import (
4
+ "context"
5
+ "encoding/base64"
6
+ "encoding/json"
7
+ "io"
8
+ "math"
9
+ "os"
10
+ "path/filepath"
11
+ "sort"
12
+ "strconv"
13
+ "strings"
14
+ "sync"
15
+ "time"
16
+
17
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
18
+
19
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
20
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pisession"
21
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
22
+ )
23
+
24
+ // The session catalogue (port of src/pi/session-catalogue.ts): the set of Pi session files under
25
+ // a root, most recently modified first, merged with live sessions the host is running.
26
+ //
27
+ // The catalogue is read-only. It opens files only to summarise them and never rewrites or
28
+ // migrates one; Pi owns those files.
29
+
30
+ const (
31
+ defaultCataloguePage = 30
32
+ maxCataloguePage = 100
33
+ headerScanBytes = 8192
34
+ )
35
+
36
+ type sessionFile struct {
37
+ path string
38
+ mtimeMs float64
39
+ }
40
+
41
+ type catalogueEntry struct {
42
+ sessionFile
43
+ file string
44
+ onDisk bool
45
+ summary *ahptypes.SessionSummary
46
+ }
47
+
48
+ func mtimeMs(info os.FileInfo) float64 { return float64(info.ModTime().UnixNano()) / 1e6 }
49
+
50
+ func listSessionFiles(root string) ([]sessionFile, error) {
51
+ dirs, err := os.ReadDir(root)
52
+ if err != nil {
53
+ // No sessions directory yet is an empty catalogue, not an error.
54
+ if os.IsNotExist(err) {
55
+ return nil, nil
56
+ }
57
+ return nil, err
58
+ }
59
+ var files []sessionFile
60
+ for _, d := range dirs {
61
+ dirPath := filepath.Join(root, d.Name())
62
+ entries, err := os.ReadDir(dirPath)
63
+ if err != nil {
64
+ continue // a file at the top level, or a race with deletion
65
+ }
66
+ for _, e := range entries {
67
+ if !strings.HasSuffix(e.Name(), ".jsonl") {
68
+ continue
69
+ }
70
+ path := filepath.Join(dirPath, e.Name())
71
+ info, err := os.Stat(path)
72
+ if err != nil {
73
+ continue // deleted between the directory read and the stat
74
+ }
75
+ files = append(files, sessionFile{path, mtimeMs(info)})
76
+ }
77
+ }
78
+ sortFiles(files)
79
+ return files, nil
80
+ }
81
+
82
+ // sortFiles orders by recency; ties break by path so the keyset cursor is total and stable.
83
+ func sortFiles(files []sessionFile) {
84
+ sort.Slice(files, func(i, j int) bool {
85
+ if files[i].mtimeMs != files[j].mtimeMs {
86
+ return files[i].mtimeMs > files[j].mtimeMs
87
+ }
88
+ return files[i].path < files[j].path
89
+ })
90
+ }
91
+
92
+ func encodeCursor(f sessionFile) string {
93
+ raw := strconv.FormatFloat(f.mtimeMs, 'f', -1, 64) + "\x00" + f.path
94
+ return base64.RawURLEncoding.EncodeToString([]byte(raw))
95
+ }
96
+
97
+ func decodeCursor(cursor string) (sessionFile, error) {
98
+ raw, err := base64.RawURLEncoding.DecodeString(cursor)
99
+ bad := wire.InvalidParams("Unrecognised listSessions cursor")
100
+ if err != nil {
101
+ return sessionFile{}, bad
102
+ }
103
+ s := string(raw)
104
+ sep := strings.IndexByte(s, 0)
105
+ if sep < 0 {
106
+ return sessionFile{}, bad
107
+ }
108
+ ms, err := strconv.ParseFloat(s[:sep], 64)
109
+ path := s[sep+1:]
110
+ if err != nil || math.IsInf(ms, 0) || math.IsNaN(ms) || path == "" {
111
+ return sessionFile{}, bad
112
+ }
113
+ return sessionFile{path, ms}, nil
114
+ }
115
+
116
+ // readSessionID reads the id from a session file's header line without loading the file.
117
+ func readSessionID(path string) (string, bool) {
118
+ f, err := os.Open(path)
119
+ if err != nil {
120
+ return "", false
121
+ }
122
+ defer f.Close()
123
+ buf := make([]byte, headerScanBytes)
124
+ n, err := io.ReadFull(f, buf)
125
+ if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
126
+ return "", false
127
+ }
128
+ line := buf[:n]
129
+ if i := strings.IndexByte(string(line), '\n'); i >= 0 {
130
+ line = line[:i]
131
+ }
132
+ var header struct {
133
+ Type string `json:"type"`
134
+ ID string `json:"id"`
135
+ }
136
+ if json.Unmarshal(line, &header) != nil || header.Type != "session" || header.ID == "" {
137
+ return "", false
138
+ }
139
+ return header.ID, true
140
+ }
141
+
142
+ func isoMs(ms float64) string {
143
+ return time.UnixMilli(int64(ms)).UTC().Format("2006-01-02T15:04:05.000Z")
144
+ }
145
+
146
+ // SessionName mirrors Pi: the latest session_info name.
147
+ func sessionNameOf(entries []pisession.Entry) string {
148
+ for i := len(entries) - 1; i >= 0; i-- {
149
+ if entries[i].Type() == "session_info" {
150
+ s, _ := entries[i]["name"].(string)
151
+ return strings.TrimSpace(s)
152
+ }
153
+ }
154
+ return ""
155
+ }
156
+
157
+ // firstUserMessage is the first non-blank user text in a session.
158
+ func firstUserMessage(entries []pisession.Entry) string {
159
+ for _, e := range entries {
160
+ if msg := e.Message(); msg != nil && msg["role"] == "user" {
161
+ if text := strings.TrimSpace(mapper.TextFromPiUserContent(msg["content"])); text != "" {
162
+ return text
163
+ }
164
+ }
165
+ }
166
+ return ""
167
+ }
168
+
169
+ func readSessionSummary(f sessionFile) (ahptypes.SessionSummary, bool) {
170
+ all, err := pisession.LoadEntries(f.path)
171
+ if err != nil || len(all) == 0 {
172
+ return ahptypes.SessionSummary{}, false
173
+ }
174
+ header, entries := all[0], all[1:]
175
+ sessionID, _ := header["id"].(string)
176
+ if sessionID == "" {
177
+ return ahptypes.SessionSummary{}, false
178
+ }
179
+ createdAt := ""
180
+ if len(entries) > 0 {
181
+ createdAt = entries[0].Timestamp()
182
+ }
183
+ if createdAt == "" {
184
+ createdAt = isoMs(f.mtimeMs)
185
+ }
186
+ meta, _ := json.Marshal(f.path)
187
+ summary := ahptypes.SessionSummary{
188
+ Resource: wire.SessionURI(sessionID), Provider: Provider,
189
+ Title: mapper.SessionDisplayTitle(sessionNameOf(entries), firstUserMessage(entries)),
190
+ // Idle by definition, and reported as read. Read/unread is not modelled: Pi has no such
191
+ // concept, so tracking it would mean this host inventing durable state of its own, and
192
+ // without archiving to go with it, a catalogue where everything is permanently unread is
193
+ // worse than one that is quiet.
194
+ Status: ahptypes.SessionStatusIdle | ahptypes.SessionStatusIsRead,
195
+ CreatedAt: createdAt,
196
+ ModifiedAt: isoMs(f.mtimeMs),
197
+ Meta: map[string]json.RawMessage{"piSessionFile": meta},
198
+ }
199
+ if cwd, _ := header["cwd"].(string); cwd != "" {
200
+ summary.WorkingDirectories = []ahptypes.URI{wire.PathToFileURI(cwd)}
201
+ }
202
+ return summary, true
203
+ }
204
+
205
+ // Catalogue lists Pi session files under a root.
206
+ type Catalogue struct {
207
+ root string
208
+
209
+ mu sync.Mutex
210
+ index map[string]string // session URI -> file
211
+ }
212
+
213
+ // NewCatalogue creates a catalogue over a sessions root (one sub-directory per working directory).
214
+ func NewCatalogue(root string) *Catalogue { return &Catalogue{root: root, index: map[string]string{}} }
215
+
216
+ // FileFor is the file last seen for a session URI.
217
+ func (c *Catalogue) FileFor(uri string) (string, bool) {
218
+ c.mu.Lock()
219
+ defer c.mu.Unlock()
220
+ f, ok := c.index[uri]
221
+ return f, ok
222
+ }
223
+
224
+ // FindSessionFile locates the file behind a session id, "" if there is none.
225
+ func (c *Catalogue) FindSessionFile(sessionID string) (string, error) {
226
+ uri := wire.SessionURI(sessionID)
227
+ c.mu.Lock()
228
+ cached, ok := c.index[uri]
229
+ c.mu.Unlock()
230
+ if ok {
231
+ // The cache is only an index hint: deletion by this host, Pi, or the user must invalidate
232
+ // a path before it is returned again.
233
+ if id, ok := readSessionID(cached); ok && id == sessionID {
234
+ return cached, nil
235
+ }
236
+ c.mu.Lock()
237
+ delete(c.index, uri)
238
+ c.mu.Unlock()
239
+ }
240
+ files, err := listSessionFiles(c.root)
241
+ if err != nil {
242
+ return "", err
243
+ }
244
+ for _, f := range files {
245
+ id, ok := readSessionID(f.path)
246
+ if ok {
247
+ c.mu.Lock()
248
+ c.index[wire.SessionURI(id)] = f.path
249
+ c.mu.Unlock()
250
+ }
251
+ if ok && id == sessionID {
252
+ return f.path, nil
253
+ }
254
+ }
255
+ return "", nil
256
+ }
257
+
258
+ // List serves listSessions: one page of the catalogue, live sessions overriding what is on disk.
259
+ func (c *Catalogue) List(_ context.Context, limit *int64, cursor *string, live func() []LiveCatalogueEntry) (ahptypes.ListSessionsResult, error) {
260
+ pageSize := int64(defaultCataloguePage)
261
+ if limit != nil {
262
+ pageSize = *limit
263
+ }
264
+ if pageSize < 1 {
265
+ pageSize = 1
266
+ }
267
+ if pageSize > maxCataloguePage {
268
+ pageSize = maxCataloguePage
269
+ }
270
+ files, err := listSessionFiles(c.root)
271
+ if err != nil {
272
+ return ahptypes.ListSessionsResult{}, err
273
+ }
274
+ entries := map[string]*catalogueEntry{}
275
+ for _, f := range files {
276
+ entries[f.path] = &catalogueEntry{sessionFile: f, file: f.path, onDisk: true}
277
+ }
278
+ // Live state is read after disk discovery, and the page is built without further waiting, so a
279
+ // turn transition cannot make the returned page older than a notification sent before it.
280
+ if live != nil {
281
+ for _, item := range live() {
282
+ ms := 0.0
283
+ if t, err := time.Parse(time.RFC3339Nano, item.Summary.ModifiedAt); err == nil {
284
+ ms = float64(t.UnixNano()) / 1e6
285
+ }
286
+ path := item.File
287
+ if path == "" {
288
+ path = "live:" + item.Summary.Resource
289
+ }
290
+ summary := item.Summary
291
+ entry := &catalogueEntry{sessionFile: sessionFile{path, ms}, file: item.File, summary: &summary}
292
+ if existing := entries[path]; existing != nil && existing.onDisk {
293
+ entry.onDisk = true
294
+ }
295
+ entries[path] = entry
296
+ }
297
+ }
298
+ ordered := make([]*catalogueEntry, 0, len(entries))
299
+ for _, e := range entries {
300
+ ordered = append(ordered, e)
301
+ }
302
+ sort.Slice(ordered, func(i, j int) bool {
303
+ if ordered[i].mtimeMs != ordered[j].mtimeMs {
304
+ return ordered[i].mtimeMs > ordered[j].mtimeMs
305
+ }
306
+ return ordered[i].path < ordered[j].path
307
+ })
308
+
309
+ start := 0
310
+ if cursor != nil {
311
+ after, err := decodeCursor(*cursor)
312
+ if err != nil {
313
+ return ahptypes.ListSessionsResult{}, err
314
+ }
315
+ index := -1
316
+ for i, e := range ordered {
317
+ if e.path == after.path {
318
+ index = i
319
+ break
320
+ }
321
+ }
322
+ if index < 0 {
323
+ // The entry the cursor named is gone (deleted between pages): fall back to the
324
+ // position it would have occupied rather than failing the whole call.
325
+ for i, e := range ordered {
326
+ if e.mtimeMs < after.mtimeMs || (e.mtimeMs == after.mtimeMs && e.path > after.path) {
327
+ index = i
328
+ break
329
+ }
330
+ }
331
+ if index < 0 {
332
+ return ahptypes.ListSessionsResult{Items: []ahptypes.SessionSummary{}}, nil
333
+ }
334
+ start = index
335
+ } else {
336
+ start = index + 1
337
+ }
338
+ }
339
+ end := start + int(pageSize)
340
+ if end > len(ordered) {
341
+ end = len(ordered)
342
+ }
343
+ page := ordered[start:end]
344
+ items := []ahptypes.SessionSummary{}
345
+ for _, e := range page {
346
+ var summary ahptypes.SessionSummary
347
+ ok := false
348
+ switch {
349
+ case e.summary != nil:
350
+ summary, ok = *e.summary, true
351
+ case e.file != "":
352
+ summary, ok = readSessionSummary(sessionFile{e.file, e.mtimeMs})
353
+ }
354
+ if !ok {
355
+ continue
356
+ }
357
+ if e.file != "" && e.onDisk {
358
+ c.mu.Lock()
359
+ c.index[summary.Resource] = e.file
360
+ c.mu.Unlock()
361
+ }
362
+ items = append(items, summary)
363
+ }
364
+ result := ahptypes.ListSessionsResult{Items: items}
365
+ if end < len(ordered) && len(page) > 0 {
366
+ next := encodeCursor(page[len(page)-1].sessionFile)
367
+ result.NextCursor = &next
368
+ }
369
+ return result, nil
370
+ }
@@ -0,0 +1,271 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "os"
8
+ "path/filepath"
9
+ "reflect"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
14
+
15
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
16
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
17
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
18
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
19
+ )
20
+
21
+ // Twins of upstream test/session-catalogue.test.ts: ordering, pagination, title derivation, live
22
+ // overlays, over a synthetic sessions directory.
23
+
24
+ func list(t *testing.T, c *pi.Catalogue, limit int64, cursor *string, live func() []pi.LiveCatalogueEntry) ahptypes.ListSessionsResult {
25
+ t.Helper()
26
+ var l *int64
27
+ if limit > 0 {
28
+ l = &limit
29
+ }
30
+ result, err := c.List(context.Background(), l, cursor, live)
31
+ if err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ return result
35
+ }
36
+
37
+ func resources(items []ahptypes.SessionSummary) []string {
38
+ out := []string{}
39
+ for _, i := range items {
40
+ out = append(out, i.Resource)
41
+ }
42
+ return out
43
+ }
44
+
45
+ func TestSessionCatalogue(t *testing.T) {
46
+ root := t.TempDir()
47
+ var ids []string
48
+ // Interleave two working directories so the walk covers more than one session dir.
49
+ for i := 0; i < 5; i++ {
50
+ id := newID()
51
+ ids = append(ids, id)
52
+ cwd := "/tmp/project-b"
53
+ if i%2 == 0 {
54
+ cwd = "/tmp/project a"
55
+ }
56
+ writeFakeSession(t, root, id, fakeSession{cwd: cwd, firstUserMessage: "Message number " + itoa(i), mtimeSeconds: 1_700_000_000 + int64(i)})
57
+ }
58
+ catalogue := pi.NewCatalogue(root)
59
+
60
+ twin.Run(t, "session-catalogue", "returns most-recently-modified first, across working directories", func(t *testing.T) {
61
+ result := list(t, catalogue, 0, nil, nil)
62
+ if len(result.Items) != 5 {
63
+ t.Fatalf("%d items", len(result.Items))
64
+ }
65
+ var want []string
66
+ for i := len(ids) - 1; i >= 0; i-- {
67
+ want = append(want, wire.SessionURI(ids[i]))
68
+ }
69
+ if got := resources(result.Items); !reflect.DeepEqual(got, want) {
70
+ t.Fatalf("order = %v", got)
71
+ }
72
+ if got := result.Items[0].WorkingDirectories; !reflect.DeepEqual(got, []ahptypes.URI{wire.PathToFileURI("/tmp/project a")}) {
73
+ t.Fatalf("workingDirectories = %v", got)
74
+ }
75
+ })
76
+
77
+ twin.Run(t, "session-catalogue", "paginates with an opaque cursor and stops at the end", func(t *testing.T) {
78
+ first := list(t, catalogue, 2, nil, nil)
79
+ if len(first.Items) != 2 || first.NextCursor == nil {
80
+ t.Fatalf("first page = %+v", first)
81
+ }
82
+ second := list(t, catalogue, 2, first.NextCursor, nil)
83
+ if len(second.Items) != 2 || second.NextCursor == nil {
84
+ t.Fatalf("second page = %+v", second)
85
+ }
86
+ third := list(t, catalogue, 2, second.NextCursor, nil)
87
+ // A missing nextCursor is what signals the end of the catalogue.
88
+ if len(third.Items) != 1 || third.NextCursor != nil {
89
+ t.Fatalf("third page = %+v", third)
90
+ }
91
+ seen := map[string]bool{}
92
+ for _, page := range [][]ahptypes.SessionSummary{first.Items, second.Items, third.Items} {
93
+ for _, item := range page {
94
+ seen[item.Resource] = true
95
+ }
96
+ }
97
+ if len(seen) != 5 {
98
+ t.Fatalf("pages overlap: %d distinct", len(seen))
99
+ }
100
+ })
101
+
102
+ twin.Run(t, "session-catalogue", "rejects a malformed cursor with InvalidParams", func(t *testing.T) {
103
+ bad := "not-a-cursor"
104
+ _, err := catalogue.List(context.Background(), ptr(int64(2)), &bad, nil)
105
+ var werr *wire.Error
106
+ if !errors.As(err, &werr) || werr.Code != wire.CodeInvalidParams {
107
+ t.Fatalf("err = %v", err)
108
+ }
109
+ })
110
+
111
+ twin.Run(t, "session-catalogue", "titles a session by its name, falling back to the first user message", func(t *testing.T) {
112
+ writeFakeSession(t, root, newID(), fakeSession{cwd: "/tmp/project-c", firstUserMessage: "This should lose to the explicit name", name: "Nightly refactor", mtimeSeconds: 1_700_001_000})
113
+ result := list(t, catalogue, 1, nil, nil)
114
+ // Same rule Pi's own /resume picker uses: name ?? firstMessage.
115
+ if result.Items[0].Title != "Nightly refactor" {
116
+ t.Fatalf("title = %q", result.Items[0].Title)
117
+ }
118
+ unnamed := list(t, catalogue, 2, nil, nil)
119
+ if unnamed.Items[1].Title != "Message number 4" {
120
+ t.Fatalf("title = %q", unnamed.Items[1].Title)
121
+ }
122
+ })
123
+
124
+ twin.Run(t, "session-catalogue", "skips files that are not readable pi sessions", func(t *testing.T) {
125
+ before := list(t, catalogue, 0, nil, nil)
126
+ dir := filepath.Join(root, "--tmp-project-broken--")
127
+ if err := os.MkdirAll(dir, 0o755); err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ if err := os.WriteFile(filepath.Join(dir, "1700002000_broken.jsonl"), []byte("not json at all\n"), 0o644); err != nil {
131
+ t.Fatal(err)
132
+ }
133
+ // One corrupt file must neither appear nor take down the catalogue.
134
+ if after := list(t, catalogue, 0, nil, nil); !reflect.DeepEqual(after, before) {
135
+ t.Fatalf("catalogue changed: %+v vs %+v", after, before)
136
+ }
137
+ })
138
+
139
+ twin.Run(t, "session-catalogue", "returns an empty catalogue when nothing exists yet", func(t *testing.T) {
140
+ empty := pi.NewCatalogue(filepath.Join(root, "does-not-exist"))
141
+ if got := list(t, empty, 0, nil, nil); len(got.Items) != 0 || got.NextCursor != nil {
142
+ t.Fatalf("got %+v", got)
143
+ }
144
+ })
145
+
146
+ twin.Run(t, "session-catalogue", "does not return a cached path after its session file is removed", func(t *testing.T) {
147
+ isolatedRoot := t.TempDir()
148
+ id := newID()
149
+ file := writeFakeSession(t, isolatedRoot, id, fakeSession{cwd: "/tmp/cached", firstUserMessage: "cache me", mtimeSeconds: 1_700_003_000})
150
+ isolated := pi.NewCatalogue(isolatedRoot)
151
+ list(t, isolated, 0, nil, nil)
152
+ if got, _ := isolated.FindSessionFile(id); got != file {
153
+ t.Fatalf("found %q, want %q", got, file)
154
+ }
155
+ if err := os.Remove(file); err != nil {
156
+ t.Fatal(err)
157
+ }
158
+ if got, _ := isolated.FindSessionFile(id); got != "" {
159
+ t.Fatalf("found a removed file: %q", got)
160
+ }
161
+ })
162
+ }
163
+
164
+ func ptr[T any](v T) *T { return &v }
165
+
166
+ func liveSummary(id, title string, status ahptypes.SessionStatus, modified time.Time) ahptypes.SessionSummary {
167
+ return ahptypes.SessionSummary{
168
+ Resource: wire.SessionURI(id), Provider: "pi", Title: title, Status: status,
169
+ CreatedAt: time.UnixMilli(0).UTC().Format("2006-01-02T15:04:05.000Z"), ModifiedAt: modified.UTC().Format("2006-01-02T15:04:05.000Z"),
170
+ }
171
+ }
172
+
173
+ func TestLiveSessionCatalogueOverlays(t *testing.T) {
174
+ twin.Run(t, "session-catalogue", "reads live status after disk discovery rather than freezing request-start state", func(t *testing.T) {
175
+ // Upstream races an async scan against a state change. The Go scan is synchronous, so the
176
+ // property kept is that live state is read exactly once, when the page is built.
177
+ root := t.TempDir()
178
+ current := liveSummary(newID(), "Live", ahptypes.SessionStatusInProgress, time.UnixMilli(1000))
179
+ reads := 0
180
+ source := func() []pi.LiveCatalogueEntry {
181
+ reads++
182
+ return []pi.LiveCatalogueEntry{{Summary: current}}
183
+ }
184
+ current.Status, current.ModifiedAt = ahptypes.SessionStatusIdle, "1970-01-01T00:00:02.000Z"
185
+ got := list(t, pi.NewCatalogue(root), 1, nil, source)
186
+ if !reflect.DeepEqual(got.Items, []ahptypes.SessionSummary{current}) || got.NextCursor != nil {
187
+ t.Fatalf("got %+v", got)
188
+ }
189
+ if reads != 1 {
190
+ t.Fatalf("live source read %d times", reads)
191
+ }
192
+ })
193
+
194
+ twin.Run(t, "session-catalogue", "keeps a live entry stable as its file appears, without caching a nonexistent file", func(t *testing.T) {
195
+ root := t.TempDir()
196
+ id := newID()
197
+ options := fakeSession{cwd: "/tmp/live", firstUserMessage: "disk", mtimeSeconds: 100}
198
+ file := writeFakeSession(t, root, id, options)
199
+ if err := os.Remove(file); err != nil {
200
+ t.Fatal(err)
201
+ }
202
+ live := liveSummary(id, "Live", ahptypes.SessionStatusInProgress, time.UnixMilli(200000))
203
+ catalogue := pi.NewCatalogue(root)
204
+ source := func() []pi.LiveCatalogueEntry { return []pi.LiveCatalogueEntry{{File: file, Summary: live}} }
205
+ if got := list(t, catalogue, 1, nil, source); !reflect.DeepEqual(got.Items, []ahptypes.SessionSummary{live}) {
206
+ t.Fatalf("got %+v", got)
207
+ }
208
+ if _, cached := catalogue.FileFor(live.Resource); cached {
209
+ t.Fatal("cached a nonexistent file")
210
+ }
211
+ writeFakeSession(t, root, id, options)
212
+ if got := list(t, catalogue, 1, nil, source); !reflect.DeepEqual(got.Items, []ahptypes.SessionSummary{live}) {
213
+ t.Fatalf("got %+v", got)
214
+ }
215
+ if got, _ := catalogue.FileFor(live.Resource); got != file {
216
+ t.Fatalf("cached %q, want %q", got, file)
217
+ }
218
+ })
219
+
220
+ twin.Run(t, "session-catalogue", "orders and paginates live summaries without duplicating their files", func(t *testing.T) {
221
+ root := t.TempDir()
222
+ replacedID, olderID, liveOnlyID := newID(), newID(), newID()
223
+ replacedFile := writeFakeSession(t, root, replacedID, fakeSession{cwd: "/tmp/replaced", firstUserMessage: "stale disk title", mtimeSeconds: 100})
224
+ writeFakeSession(t, root, olderID, fakeSession{cwd: "/tmp/older", firstUserMessage: "older", mtimeSeconds: 200})
225
+ summary := func(id, title string, seconds int64) ahptypes.SessionSummary {
226
+ return liveSummary(id, title, ahptypes.SessionStatusInProgress, time.Unix(seconds, 0))
227
+ }
228
+ liveEntries := []pi.LiveCatalogueEntry{
229
+ {File: replacedFile, Summary: summary(replacedID, "live replacement", 400)},
230
+ {Summary: summary(liveOnlyID, "not on disk", 300)},
231
+ }
232
+ source := func() []pi.LiveCatalogueEntry { return liveEntries }
233
+ catalogue := pi.NewCatalogue(root)
234
+ first := list(t, catalogue, 2, nil, source)
235
+ if len(first.Items) != 2 || first.NextCursor == nil {
236
+ t.Fatalf("first = %+v", first)
237
+ }
238
+ second := list(t, catalogue, 2, first.NextCursor, source)
239
+ items := append(append([]ahptypes.SessionSummary{}, first.Items...), second.Items...)
240
+ want := []string{wire.SessionURI(replacedID), wire.SessionURI(liveOnlyID), wire.SessionURI(olderID)}
241
+ if got := resources(items); !reflect.DeepEqual(got, want) {
242
+ t.Fatalf("order = %v", got)
243
+ }
244
+ if items[0].Title != "live replacement" || items[0].Status != ahptypes.SessionStatusInProgress {
245
+ t.Fatalf("first item = %+v", items[0])
246
+ }
247
+ if second.NextCursor != nil {
248
+ t.Fatal("expected the end of the catalogue")
249
+ }
250
+ })
251
+ }
252
+
253
+ func TestListSessionsOverTheWire(t *testing.T) {
254
+ twin.Run(t, "session-catalogue", "serves a schema-conforming page", func(t *testing.T) {
255
+ root := t.TempDir()
256
+ writeFakeSession(t, root, newID(), fakeSession{cwd: "/tmp/wire", firstUserMessage: "Explain this repository", mtimeSeconds: 1_700_100_000})
257
+ h := startHarness(t, harnessOptions{sessionRoot: root})
258
+ client := testkit.Connect(t, h.host)
259
+ t.Cleanup(client.Close)
260
+ client.Initialize(nextClientID(), nil)
261
+ raw := client.Must("listSessions", obj{"channel": wire.RootChannel})
262
+ var result struct{ Items []obj }
263
+ if err := json.Unmarshal(raw, &result); err != nil {
264
+ t.Fatal(err)
265
+ }
266
+ if len(result.Items) != 1 || result.Items[0]["title"] != "Explain this repository" {
267
+ t.Fatalf("result = %s", raw)
268
+ }
269
+ testkit.AssertValid(t, "commands", "ListSessionsResult", testkit.Normalize(t, raw))
270
+ })
271
+ }