@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,209 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "os"
7
+ "reflect"
8
+ "sync"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
13
+
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
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-hydration.test.ts: opening a session that only exists on disk.
22
+ // The catalogue is backed by Pi's session files, most of which no live host has ever touched;
23
+ // without lazy loading every session the catalogue advertises would answer NotFound on subscribe.
24
+
25
+ func snapshotState[T any](t *testing.T, client *testkit.Client, channel string) T {
26
+ t.Helper()
27
+ result := client.Subscribe(channel)
28
+ if result.Snapshot == nil {
29
+ t.Fatalf("no snapshot for %s", channel)
30
+ }
31
+ raw, _ := json.Marshal(result.Snapshot.State)
32
+ var state T
33
+ if err := json.Unmarshal(raw, &state); err != nil {
34
+ t.Fatal(err)
35
+ }
36
+ return state
37
+ }
38
+
39
+ func TestOpeningASessionFromTheCatalogue(t *testing.T) {
40
+ f := startHydrated(t, hydratedOptions{})
41
+
42
+ twin.Run(t, "session-hydration", "hydrates a catalogued session and counts it as active", func(t *testing.T) {
43
+ state := snapshotState[ahptypes.SessionState](t, f.client, f.session())
44
+ if state.Lifecycle != ahptypes.SessionLifecycleReady || len(state.Chats) != 1 {
45
+ t.Fatalf("state = %+v", state)
46
+ }
47
+ if state.DefaultChat == nil || *state.DefaultChat != f.chat() {
48
+ t.Fatalf("defaultChat = %v", state.DefaultChat)
49
+ }
50
+ if !reflect.DeepEqual(state.WorkingDirectories, []ahptypes.URI{wire.PathToFileURI(f.workspace)}) {
51
+ t.Fatalf("workingDirectories = %v", state.WorkingDirectories)
52
+ }
53
+ testkit.AssertValid(t, "state", "SessionState", state)
54
+ if got := f.host.Store().Root(wire.RootChannel).ActiveSessions; got == nil || *got != 1 {
55
+ t.Fatalf("activeSessions = %v", got)
56
+ }
57
+ })
58
+
59
+ twin.Run(t, "session-hydration", "rebuilds the transcript onto the chat channel", func(t *testing.T) {
60
+ chat := snapshotState[ahptypes.ChatState](t, f.client, f.chat())
61
+ if len(chat.Turns) != 2 || chat.Turns[0].Message.Text != "Read note.txt" || chat.Turns[0].State != ahptypes.TurnStateComplete {
62
+ t.Fatalf("turns = %+v", turnShapes(chat.Turns))
63
+ }
64
+ testkit.AssertValid(t, "state", "ChatState", chat)
65
+ })
66
+
67
+ twin.Run(t, "session-hydration", "pairs each tool call with the result that followed it", func(t *testing.T) {
68
+ chat := snapshotState[ahptypes.ChatState](t, f.client, f.chat())
69
+ parts := chat.Turns[0].ResponseParts
70
+ var kinds []ahptypes.ResponsePartKind
71
+ for _, p := range parts {
72
+ raw, _ := json.Marshal(p)
73
+ var k struct{ Kind ahptypes.ResponsePartKind }
74
+ _ = json.Unmarshal(raw, &k)
75
+ kinds = append(kinds, k.Kind)
76
+ }
77
+ want := []ahptypes.ResponsePartKind{ahptypes.ResponsePartKindReasoning, ahptypes.ResponsePartKindToolCall, ahptypes.ResponsePartKindMarkdown}
78
+ if !reflect.DeepEqual(kinds, want) {
79
+ t.Fatalf("kinds = %v", kinds)
80
+ }
81
+ call := parts[1].Value.(*ahptypes.ToolCallResponsePart).ToolCall.Value.(*ahptypes.ToolCallCompletedState)
82
+ if call.Status != ahptypes.ToolCallStatusCompleted || call.ToolName != "read" {
83
+ t.Fatalf("toolCall = %+v", call)
84
+ }
85
+ sameJSON(t, call.Content, []any{obj{"type": "text", "text": "ALPHA"}}, "tool content")
86
+ })
87
+
88
+ twin.Run(t, "session-hydration", "still answers NotFound for a session that really does not exist", func(t *testing.T) {
89
+ f.client.ExpectError("subscribe", obj{"channel": wire.SessionURI(newID())}, -32008)
90
+ })
91
+
92
+ twin.Run(t, "session-hydration", "restores images stored in pi user messages", func(t *testing.T) {
93
+ fresh := startHydrated(t, hydratedOptions{includeImage: true})
94
+ chat := snapshotState[ahptypes.ChatState](t, fresh.client, fresh.chat())
95
+ sameJSON(t, chat.Turns[0].Message.Attachments, []any{obj{
96
+ "type": "embeddedResource", "label": "Image 1", "displayKind": "image", "data": onePixelPNG, "contentType": "image/png",
97
+ }}, "attachments")
98
+ testkit.AssertValid(t, "state", "ChatState", chat)
99
+ })
100
+
101
+ twin.Run(t, "session-hydration", "hydrates from either half of the pair", func(t *testing.T) {
102
+ fresh := startHydrated(t, hydratedOptions{})
103
+ if r := fresh.client.Subscribe(fresh.chat()); r.Snapshot == nil {
104
+ t.Fatal("no snapshot")
105
+ }
106
+ if !fresh.host.Store().Has(fresh.session()) {
107
+ t.Fatal("the session must load alongside its chat")
108
+ }
109
+ })
110
+
111
+ twin.Run(t, "session-hydration", "coalesces concurrent session and chat hydration", func(t *testing.T) {
112
+ source := startHydrated(t, hydratedOptions{})
113
+ catalogue := &blockingCatalogue{inner: pi.NewCatalogue(source.root), started: make(chan struct{}), gate: make(chan struct{})}
114
+ h := testkit.NewHost(host.Options{})
115
+ var mu sync.Mutex
116
+ live := map[string]bool{}
117
+ var adopted []string
118
+ hydrator := pi.NewHydrator(pi.HydratorOptions{
119
+ Host: h, Catalogue: catalogue,
120
+ IsLive: func(s string) bool { mu.Lock(); defer mu.Unlock(); return live[s] },
121
+ IsDisposing: func(string) bool { return false },
122
+ Adopt: func(a pi.AdoptedSession) {
123
+ mu.Lock()
124
+ live[a.URI] = true
125
+ adopted = append(adopted, a.URI)
126
+ mu.Unlock()
127
+ },
128
+ })
129
+ session, chat := wire.SessionURI(source.sessionID), wire.ChatURI(source.sessionID)
130
+ results := make(chan bool, 2)
131
+ go func() { ok, _ := hydrator.Hydrate(context.Background(), session); results <- ok }()
132
+ <-catalogue.started
133
+ go func() { ok, _ := hydrator.Hydrate(context.Background(), chat); results <- ok }()
134
+ time.Sleep(20 * time.Millisecond) // let the second caller reach the coalescing point
135
+ close(catalogue.gate)
136
+ if a, b := <-results, <-results; !a || !b {
137
+ t.Fatalf("hydrate results = %v %v", a, b)
138
+ }
139
+ if catalogue.lookupCount() != 1 {
140
+ t.Fatalf("lookups = %d", catalogue.lookupCount())
141
+ }
142
+ if !reflect.DeepEqual(adopted, []string{session}) || !h.Store().Has(session) || !h.Store().Has(chat) {
143
+ t.Fatalf("adopted %v, session %v chat %v", adopted, h.Store().Has(session), h.Store().Has(chat))
144
+ }
145
+ })
146
+
147
+ twin.Run(t, "session-hydration", "preserves catalogue modifiedAt when a disk session becomes a live overlay", func(t *testing.T) {
148
+ fixture := startHydrated(t, hydratedOptions{})
149
+ file, err := pi.NewCatalogue(fixture.root).FindSessionFile(fixture.sessionID)
150
+ if err != nil || file == "" {
151
+ t.Fatalf("file %q err %v", file, err)
152
+ }
153
+ stamp := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC)
154
+ if err := os.Chtimes(file, stamp, stamp); err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ iso := "2025-06-01T12:00:00.000Z"
158
+ listItems := func() []obj {
159
+ var r struct{ Items []obj }
160
+ fixture.client.Decode(fixture.client.Must("listSessions", obj{"channel": wire.RootChannel}), &r)
161
+ return r.Items
162
+ }
163
+ if got := listItems(); len(got) != 1 || got[0]["modifiedAt"] != iso {
164
+ t.Fatalf("before = %v", got)
165
+ }
166
+ chat := snapshotState[ahptypes.ChatState](t, fixture.client, fixture.chat())
167
+ if chat.ModifiedAt != iso {
168
+ t.Fatalf("chat modifiedAt = %s", chat.ModifiedAt)
169
+ }
170
+ if got := listItems(); len(got) != 1 || got[0]["modifiedAt"] != iso {
171
+ t.Fatalf("after = %v", got)
172
+ }
173
+ })
174
+ }
175
+
176
+ // blockingCatalogue holds the first lookup until the test releases it.
177
+ type blockingCatalogue struct {
178
+ inner *pi.Catalogue
179
+ started chan struct{}
180
+ gate chan struct{}
181
+ once sync.Once
182
+ mu sync.Mutex
183
+ lookups int
184
+ }
185
+
186
+ func (b *blockingCatalogue) FindSessionFile(id string) (string, error) {
187
+ b.mu.Lock()
188
+ b.lookups++
189
+ b.mu.Unlock()
190
+ b.once.Do(func() { close(b.started) })
191
+ <-b.gate
192
+ return b.inner.FindSessionFile(id)
193
+ }
194
+
195
+ func (b *blockingCatalogue) lookupCount() int { b.mu.Lock(); defer b.mu.Unlock(); return b.lookups }
196
+
197
+ // snapshotStateOf decodes the state of an already-received subscribe result.
198
+ func snapshotStateOf[T any](t *testing.T, result ahptypes.SubscribeResult) T {
199
+ t.Helper()
200
+ if result.Snapshot == nil {
201
+ t.Fatal("no snapshot")
202
+ }
203
+ raw, _ := json.Marshal(result.Snapshot.State)
204
+ var state T
205
+ if err := json.Unmarshal(raw, &state); err != nil {
206
+ t.Fatal(err)
207
+ }
208
+ return state
209
+ }
@@ -0,0 +1,212 @@
1
+ package pi
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "os"
7
+ "strings"
8
+ "sync"
9
+
10
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
11
+
12
+ "github.com/MichaelKinsy/pigpen/ahp/internal/channels"
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
15
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pisession"
16
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
17
+ )
18
+
19
+ // FileFinder locates the file behind a session id ("" when there is none).
20
+ type FileFinder interface {
21
+ FindSessionFile(sessionID string) (string, error)
22
+ }
23
+
24
+ // HydratorOptions configure a [Hydrator].
25
+ type HydratorOptions struct {
26
+ Host *host.Host
27
+ Catalogue FileFinder
28
+ IsLive func(sessionChannel string) bool
29
+ // IsDisposing reports a disposal in flight, which must not be raced by a load.
30
+ IsDisposing func(sessionChannel string) bool
31
+ FallbackSelection func() *ahptypes.ModelSelection
32
+ // Adopt registers the loaded session with the registry, without a backend.
33
+ Adopt func(AdoptedSession)
34
+ // WrapStore may replace the store a session file was opened into (the running session's
35
+ // store reads the host's live mirror instead of the file); nil keeps the file-backed store.
36
+ WrapStore func(sessionID string, opened SessionStore) SessionStore
37
+ Log func(format string, args ...any)
38
+ }
39
+
40
+ // Hydrator loads a session that exists only on disk into protocol state, on first subscription
41
+ // (port of src/pi/session-hydrator.ts). A chat and its session share one id, so either URI loads
42
+ // the pair. The session becomes ready, since its transcript is genuinely available; it simply has
43
+ // no agent attached until someone starts a turn.
44
+ type Hydrator struct {
45
+ opts HydratorOptions
46
+
47
+ mu sync.Mutex
48
+ hydrations map[string]chan struct{}
49
+ }
50
+
51
+ // NewHydrator creates a hydrator.
52
+ func NewHydrator(opts HydratorOptions) *Hydrator {
53
+ return &Hydrator{opts: opts, hydrations: map[string]chan struct{}{}}
54
+ }
55
+
56
+ func (h *Hydrator) logf(format string, args ...any) {
57
+ if h.opts.Log != nil {
58
+ h.opts.Log(format, args...)
59
+ }
60
+ }
61
+
62
+ // Hydrate makes channel exist in the store if a session file backs it; it reports whether the
63
+ // channel now exists.
64
+ func (h *Hydrator) Hydrate(_ context.Context, channel string) (bool, error) {
65
+ var sessionID string
66
+ var ok bool
67
+ if wire.IsChatChannel(channel) {
68
+ sessionID, ok = wire.ChatIDFromURI(channel)
69
+ } else {
70
+ // The provider alias is explicit: an unknown channel scheme must not become a session
71
+ // merely because its path resembles one.
72
+ sessionID, ok = wire.SessionIDFromURI(channel, Provider)
73
+ }
74
+ if !ok || sessionID == "" {
75
+ return false, nil
76
+ }
77
+ // Register the session under the URI the client actually used, so a non-standard scheme
78
+ // resolves to the same channel it subscribed to.
79
+ session := channel
80
+ if wire.IsChatChannel(channel) {
81
+ session = wire.SessionURI(sessionID)
82
+ }
83
+ if h.opts.IsLive(session) {
84
+ return h.opts.Host.Store().Has(channel), nil
85
+ }
86
+ if h.opts.IsDisposing(session) {
87
+ return false, nil
88
+ }
89
+
90
+ h.mu.Lock()
91
+ if pending, ok := h.hydrations[sessionID]; ok {
92
+ h.mu.Unlock()
93
+ <-pending
94
+ return h.opts.Host.Store().Has(channel), nil
95
+ }
96
+ done := make(chan struct{})
97
+ h.hydrations[sessionID] = done
98
+ h.mu.Unlock()
99
+ h.hydrateSession(sessionID, session)
100
+ h.mu.Lock()
101
+ delete(h.hydrations, sessionID)
102
+ h.mu.Unlock()
103
+ close(done)
104
+ return h.opts.Host.Store().Has(channel), nil
105
+ }
106
+
107
+ func firstUserText(turns []ahptypes.Turn) string {
108
+ for _, t := range turns {
109
+ if strings.TrimSpace(t.Message.Text) != "" {
110
+ return t.Message.Text
111
+ }
112
+ }
113
+ return ""
114
+ }
115
+
116
+ func (h *Hydrator) hydrateSession(sessionID, session string) {
117
+ chat := wire.ChatURI(sessionID)
118
+ file, err := h.opts.Catalogue.FindSessionFile(sessionID)
119
+ if err != nil {
120
+ h.logf("cannot search for session %s: %v", sessionID, err)
121
+ return
122
+ }
123
+ if file == "" {
124
+ return
125
+ }
126
+ manager, err := pisession.Open(file)
127
+ if err != nil {
128
+ h.logf("cannot open %s: %v", file, err)
129
+ return
130
+ }
131
+
132
+ turns := RebuildHistoryFromSession(manager, RebuildOptions{TurnIDPrefix: sessionID}).Turns
133
+ // Present only when the window really is a tail: its presence is the protocol's signal that
134
+ // more history can be paged in.
135
+ nextCursor := InitialTurnsCursor(manager, sessionID, turns)
136
+ modifiedAt := nowISO()
137
+ if len(turns) > 0 && turns[len(turns)-1].StartedAt != nil {
138
+ modifiedAt = *turns[len(turns)-1].StartedAt
139
+ }
140
+ if info, err := os.Stat(file); err == nil {
141
+ // The file may disappear after it was located; the reconstructed transcript timestamp
142
+ // remains a valid fallback for this snapshot.
143
+ modifiedAt = info.ModTime().UTC().Format("2006-01-02T15:04:05.000Z")
144
+ }
145
+ workingDirectory := manager.Cwd()
146
+ title := mapper.SessionDisplayTitle(manager.SessionName(), firstUserText(turns))
147
+
148
+ // Another create/adopt or disposal may have won during file I/O: live state must not be
149
+ // replaced by this disk snapshot.
150
+ if h.opts.IsLive(session) || h.opts.IsDisposing(session) {
151
+ return
152
+ }
153
+
154
+ // Read, for the same reason the catalogue reports read; the reducer still clears the bit if a
155
+ // turn starts.
156
+ status := ahptypes.SessionStatusIdle | ahptypes.SessionStatusIsRead
157
+
158
+ // Which model this conversation was last using. The agent starts only on the first new turn,
159
+ // so without seeding it here the client's model picker is empty until the user has already
160
+ // sent something. The recorded model is checked field by field: a session file can carry a
161
+ // partially populated entry, and the wire id needs both halves of Pi's (provider, modelId).
162
+ level, provider, modelID := manager.ContextSettings()
163
+ var selection *ahptypes.ModelSelection
164
+ if provider != "" && modelID != "" {
165
+ thinking, _ := json.Marshal(level)
166
+ selection = &ahptypes.ModelSelection{Id: ModelSelectionID(provider, modelID), Config: map[string]json.RawMessage{ThinkingConfigKey: thinking}}
167
+ } else if h.opts.FallbackSelection != nil {
168
+ selection = h.opts.FallbackSelection()
169
+ }
170
+
171
+ chatState := &ahptypes.ChatState{Resource: chat, Title: title, Status: status, ModifiedAt: modifiedAt, Turns: turns}
172
+ if nextCursor != "" {
173
+ chatState.TurnsNextCursor = &nextCursor
174
+ }
175
+ if selection != nil {
176
+ chatState.Draft = &ahptypes.Message{Text: "", Origin: ahptypes.MessageOrigin{Kind: ahptypes.MessageKindUser}, Model: selection}
177
+ }
178
+ if err := h.opts.Host.Store().Create(chat, chatState); err != nil {
179
+ h.logf("cannot create %s: %v", chat, err)
180
+ return
181
+ }
182
+ fileMeta, _ := json.Marshal(file)
183
+ sessionState := &ahptypes.SessionState{
184
+ Provider: Provider, Title: title, Status: status,
185
+ // The transcript is genuinely available, so the session is ready.
186
+ Lifecycle: ahptypes.SessionLifecycleReady, ActiveClients: []ahptypes.SessionActiveClient{},
187
+ Chats: []ahptypes.ChatSummary{channels.ChatSummaryOf(chatState)}, DefaultChat: &chat,
188
+ Meta: map[string]json.RawMessage{"piSessionFile": fileMeta, "hydrated": json.RawMessage("true")},
189
+ }
190
+ if workingDirectory != "" {
191
+ sessionState.WorkingDirectories = []ahptypes.URI{wire.PathToFileURI(workingDirectory)}
192
+ }
193
+ if err := h.opts.Host.Store().Create(session, sessionState, wire.KindSession); err != nil {
194
+ h.logf("cannot create %s: %v", session, err)
195
+ return
196
+ }
197
+ createdAt := modifiedAt
198
+ if len(turns) > 0 && turns[0].StartedAt != nil {
199
+ createdAt = *turns[0].StartedAt
200
+ }
201
+ var store SessionStore = manager
202
+ if h.opts.WrapStore != nil {
203
+ store = h.opts.WrapStore(sessionID, store)
204
+ }
205
+ if h.opts.Adopt != nil {
206
+ h.opts.Adopt(AdoptedSession{
207
+ URI: session, SessionID: sessionID, WorkingDirectory: workingDirectory, SessionManager: store,
208
+ ChatChannel: chat, CreatedAt: createdAt,
209
+ })
210
+ }
211
+ h.logf("hydrated %s with %d turns", session, len(turns))
212
+ }
@@ -0,0 +1,224 @@
1
+ package pi
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/base64"
6
+ "encoding/binary"
7
+ "errors"
8
+ "fmt"
9
+ "image"
10
+ "image/color"
11
+ "image/draw"
12
+ "image/gif"
13
+ "image/jpeg"
14
+ "image/png"
15
+
16
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
17
+ )
18
+
19
+ // Port of image-input.ts. Upstream calls Pi's convertToPng / resizeImage (WASM codecs); those are
20
+ // not part of the PiG SDK, so the standard library's PNG, JPEG and GIF codecs plus a small BMP
21
+ // decoder stand in. WebP cannot be decoded here: it passes through unchanged when the header is
22
+ // valid and is never resized (see PORT.md).
23
+
24
+ const (
25
+ maxImageDimension = 2000
26
+ maxImageBytes = 4_500_000
27
+ )
28
+
29
+ var supportedInlineTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true}
30
+
31
+ // PrepareImagesForPi validates, converts and (when autoResize) bounds client images before they
32
+ // enter the agent. It returns nil for no images.
33
+ func PrepareImagesForPi(images []mapper.Image, autoResize bool) ([]mapper.Image, error) {
34
+ if len(images) == 0 {
35
+ return nil, nil
36
+ }
37
+ out := make([]mapper.Image, 0, len(images))
38
+ for i, img := range images {
39
+ prepared, err := prepareImage(img, autoResize, i)
40
+ if err != nil {
41
+ return nil, err
42
+ }
43
+ out = append(out, prepared)
44
+ }
45
+ return out, nil
46
+ }
47
+
48
+ func prepareImage(img mapper.Image, autoResize bool, index int) (mapper.Image, error) {
49
+ data := img.Data
50
+ mimeType := mapper.NormalizeImageMimeType(img.MimeType)
51
+ if mimeType == "" {
52
+ return mapper.Image{}, fmt.Errorf("Image %d has an invalid MIME type: %s", index+1, img.MimeType)
53
+ }
54
+ if !supportedInlineTypes[mimeType] {
55
+ converted, err := convertToPNG(data, mimeType)
56
+ if err != nil {
57
+ return mapper.Image{}, fmt.Errorf("Image %d could not be converted from %s: %w", index+1, mimeType, err)
58
+ }
59
+ data, mimeType = converted, "image/png"
60
+ }
61
+ if !autoResize {
62
+ return mapper.Image{Type: "image", Data: data, MimeType: mimeType}, nil
63
+ }
64
+ resized, resizedType, err := resizeImage(data, mimeType)
65
+ if err != nil {
66
+ return mapper.Image{}, fmt.Errorf("Image %d could not be prepared for inline model input: %w", index+1, err)
67
+ }
68
+ return mapper.Image{Type: "image", Data: resized, MimeType: resizedType}, nil
69
+ }
70
+
71
+ func decodeAny(raw []byte, mimeType string) (image.Image, error) {
72
+ switch mimeType {
73
+ case "image/png":
74
+ return png.Decode(bytes.NewReader(raw))
75
+ case "image/jpeg":
76
+ return jpeg.Decode(bytes.NewReader(raw))
77
+ case "image/gif":
78
+ return gif.Decode(bytes.NewReader(raw))
79
+ case "image/bmp", "image/x-ms-bmp":
80
+ return decodeBMP(raw)
81
+ }
82
+ return nil, fmt.Errorf("no decoder for %s", mimeType)
83
+ }
84
+
85
+ func encodePNG(img image.Image) (string, error) {
86
+ var buf bytes.Buffer
87
+ if err := png.Encode(&buf, img); err != nil {
88
+ return "", err
89
+ }
90
+ return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
91
+ }
92
+
93
+ func convertToPNG(data, mimeType string) (string, error) {
94
+ raw, err := base64.StdEncoding.DecodeString(data)
95
+ if err != nil {
96
+ return "", err
97
+ }
98
+ img, err := decodeAny(raw, mimeType)
99
+ if err != nil {
100
+ return "", err
101
+ }
102
+ return encodePNG(img)
103
+ }
104
+
105
+ func resizeImage(data, mimeType string) (string, string, error) {
106
+ raw, err := base64.StdEncoding.DecodeString(data)
107
+ if err != nil {
108
+ return "", "", err
109
+ }
110
+ if mimeType == "image/webp" {
111
+ if len(raw) < 12 || string(raw[:4]) != "RIFF" || string(raw[8:12]) != "WEBP" {
112
+ return "", "", errors.New("not a WebP image")
113
+ }
114
+ return data, mimeType, nil
115
+ }
116
+ img, err := decodeAny(raw, mimeType)
117
+ if err != nil {
118
+ return "", "", err
119
+ }
120
+ b := img.Bounds()
121
+ if b.Dx() <= maxImageDimension && b.Dy() <= maxImageDimension && len(raw) <= maxImageBytes {
122
+ return data, mimeType, nil
123
+ }
124
+ scale := 1.0
125
+ if b.Dx() > maxImageDimension {
126
+ scale = float64(maxImageDimension) / float64(b.Dx())
127
+ }
128
+ if s := float64(maxImageDimension) / float64(b.Dy()); s < scale {
129
+ scale = s
130
+ }
131
+ w, h := max(1, int(float64(b.Dx())*scale)), max(1, int(float64(b.Dy())*scale))
132
+ for {
133
+ small := boxResize(img, w, h)
134
+ if mimeType == "image/jpeg" {
135
+ var buf bytes.Buffer
136
+ if err := jpeg.Encode(&buf, small, &jpeg.Options{Quality: 85}); err != nil {
137
+ return "", "", err
138
+ }
139
+ if buf.Len() <= maxImageBytes || w <= 1 {
140
+ return base64.StdEncoding.EncodeToString(buf.Bytes()), "image/jpeg", nil
141
+ }
142
+ } else {
143
+ encoded, err := encodePNG(small)
144
+ if err != nil {
145
+ return "", "", err
146
+ }
147
+ if base64.StdEncoding.DecodedLen(len(encoded)) <= maxImageBytes || w <= 1 {
148
+ return encoded, "image/png", nil
149
+ }
150
+ }
151
+ w, h = max(1, w*3/4), max(1, h*3/4)
152
+ }
153
+ }
154
+
155
+ // boxResize averages source pixels into each destination pixel.
156
+ func boxResize(src image.Image, w, h int) image.Image {
157
+ b := src.Bounds()
158
+ dst := image.NewNRGBA(image.Rect(0, 0, w, h))
159
+ for y := 0; y < h; y++ {
160
+ y0, y1 := b.Min.Y+y*b.Dy()/h, b.Min.Y+(y+1)*b.Dy()/h
161
+ if y1 <= y0 {
162
+ y1 = y0 + 1
163
+ }
164
+ for x := 0; x < w; x++ {
165
+ x0, x1 := b.Min.X+x*b.Dx()/w, b.Min.X+(x+1)*b.Dx()/w
166
+ if x1 <= x0 {
167
+ x1 = x0 + 1
168
+ }
169
+ var r, g, bl, a, n uint64
170
+ for yy := y0; yy < y1; yy++ {
171
+ for xx := x0; xx < x1; xx++ {
172
+ pr, pg, pb, pa := src.At(xx, yy).RGBA()
173
+ r, g, bl, a, n = r+uint64(pr), g+uint64(pg), bl+uint64(pb), a+uint64(pa), n+1
174
+ }
175
+ }
176
+ dst.Set(x, y, color.RGBA64{uint16(r / n), uint16(g / n), uint16(bl / n), uint16(a / n)})
177
+ }
178
+ }
179
+ return dst
180
+ }
181
+
182
+ // decodeBMP reads uncompressed 24- and 32-bit BMPs (BITMAPINFOHEADER), the shapes clients send.
183
+ func decodeBMP(raw []byte) (image.Image, error) {
184
+ if len(raw) < 54 || raw[0] != 'B' || raw[1] != 'M' {
185
+ return nil, errors.New("not a BMP")
186
+ }
187
+ offset := int(binary.LittleEndian.Uint32(raw[10:14]))
188
+ width := int(int32(binary.LittleEndian.Uint32(raw[18:22])))
189
+ height := int(int32(binary.LittleEndian.Uint32(raw[22:26])))
190
+ bpp := int(binary.LittleEndian.Uint16(raw[28:30]))
191
+ compression := binary.LittleEndian.Uint32(raw[30:34])
192
+ topDown := height < 0
193
+ if topDown {
194
+ height = -height
195
+ }
196
+ if width <= 0 || height <= 0 || width > 1<<15 || height > 1<<15 || (compression != 0 && compression != 3) || (bpp != 24 && bpp != 32) {
197
+ return nil, fmt.Errorf("unsupported BMP (bpp=%d compression=%d)", bpp, compression)
198
+ }
199
+ stride := (width*bpp/8 + 3) &^ 3
200
+ if offset < 0 || offset+stride*height > len(raw) {
201
+ return nil, errors.New("truncated BMP")
202
+ }
203
+ img := image.NewNRGBA(image.Rect(0, 0, width, height))
204
+ draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
205
+ for y := 0; y < height; y++ {
206
+ row := y
207
+ if !topDown {
208
+ row = height - 1 - y
209
+ }
210
+ line := raw[offset+row*stride:]
211
+ for x := 0; x < width; x++ {
212
+ p := line[x*bpp/8:]
213
+ a := uint8(255)
214
+ if bpp == 32 {
215
+ a = p[3]
216
+ if a == 0 && compression == 0 {
217
+ a = 255 // BI_RGB 32-bit files rarely carry a real alpha channel
218
+ }
219
+ }
220
+ img.SetNRGBA(x, y, color.NRGBA{R: p[2], G: p[1], B: p[0], A: a})
221
+ }
222
+ }
223
+ return img, nil
224
+ }
@@ -0,0 +1,57 @@
1
+ package pi
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/base64"
6
+ "strings"
7
+ "testing"
8
+
9
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
10
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
11
+ )
12
+
13
+ // Twins of upstream test/image-input.test.ts (support/images.ts fixtures).
14
+
15
+ const twoPixelBMP = "Qk1GAAAAAAAAADYAAAAoAAAAAgAAAAIAAAABABgAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/AAD/AAAAAP8AAP8AAA=="
16
+
17
+ const onePixelPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
18
+
19
+ func TestImageInput(t *testing.T) {
20
+ twin.Run(t, "image-input", "converts an unsupported image type even when auto-resize is disabled", func(t *testing.T) {
21
+ prepared, err := PrepareImagesForPi([]mapper.Image{{Type: "image", Data: twoPixelBMP, MimeType: "image/bmp"}}, false)
22
+ if err != nil || len(prepared) != 1 {
23
+ t.Fatalf("%v %v", prepared, err)
24
+ }
25
+ if prepared[0].MimeType != "image/png" {
26
+ t.Fatalf("mime %s", prepared[0].MimeType)
27
+ }
28
+ raw, _ := base64.StdEncoding.DecodeString(prepared[0].Data)
29
+ if !bytes.HasPrefix(raw, []byte("\x89PNG\r\n\x1a\n")) {
30
+ t.Fatalf("not a PNG: %x", raw[:8])
31
+ }
32
+ })
33
+
34
+ twin.Run(t, "image-input", "fails when image bytes cannot be prepared", func(t *testing.T) {
35
+ _, err := PrepareImagesForPi([]mapper.Image{{Type: "image", Data: base64.StdEncoding.EncodeToString([]byte("not an image")), MimeType: "image/png"}}, true)
36
+ if err == nil || !strings.Contains(err.Error(), "could not be prepared") {
37
+ t.Fatalf("%v", err)
38
+ }
39
+ })
40
+ }
41
+
42
+ func TestImageInputAdditions(t *testing.T) {
43
+ // Additions: the resize and validation paths the standard-library codecs stand in for.
44
+ if got, err := PrepareImagesForPi(nil, true); got != nil || err != nil {
45
+ t.Fatalf("no images: %v %v", got, err)
46
+ }
47
+ same, err := PrepareImagesForPi([]mapper.Image{{Type: "image", Data: onePixelPNG, MimeType: "IMAGE/PNG; charset=x"}}, true)
48
+ if err != nil || same[0].Data != onePixelPNG || same[0].MimeType != "image/png" {
49
+ t.Fatalf("a small PNG must pass through: %+v %v", same, err)
50
+ }
51
+ if _, err := PrepareImagesForPi([]mapper.Image{{Type: "image", Data: onePixelPNG, MimeType: "text/plain"}}, true); err == nil {
52
+ t.Fatal("a non-image MIME type must be rejected")
53
+ }
54
+ if _, err := PrepareImagesForPi([]mapper.Image{{Type: "image", Data: "!!!", MimeType: "image/tiff"}}, false); err == nil {
55
+ t.Fatal("an undecodable conversion must fail")
56
+ }
57
+ }