@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,128 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "encoding/json"
5
+ "reflect"
6
+ "testing"
7
+
8
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
9
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
10
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
11
+ )
12
+
13
+ // Twins of upstream test/models.test.ts: mapping Pi's model catalogue onto protocol agent/model
14
+ // descriptions.
15
+
16
+ func testModel(mutate ...func(*pi.Model)) pi.Model {
17
+ m := pi.Model{
18
+ ID: "claude-sonnet-4", Name: "Claude Sonnet 4", API: "anthropic-messages", Provider: "anthropic",
19
+ BaseURL: "https://api.anthropic.com", Reasoning: true, Input: []string{"text", "image"},
20
+ Cost: json.RawMessage(`{"input":3,"output":15,"cacheRead":0.3,"cacheWrite":3.75}`),
21
+ ContextWindow: 200_000, MaxTokens: 16_384,
22
+ }
23
+ for _, f := range mutate {
24
+ f(&m)
25
+ }
26
+ return m
27
+ }
28
+
29
+ func TestModelMapping(t *testing.T) {
30
+ twin.Run(t, "models", "offers the standard thinking levels when no extended levels are declared", func(t *testing.T) {
31
+ if got := pi.SupportedThinkingLevels(testModel()); !reflect.DeepEqual(got, []string{"off", "minimal", "low", "medium", "high"}) {
32
+ t.Fatalf("levels = %v", got)
33
+ }
34
+ })
35
+
36
+ twin.Run(t, "models", "offers only `off` for a model without reasoning", func(t *testing.T) {
37
+ got := pi.SupportedThinkingLevels(testModel(func(m *pi.Model) { m.Reasoning = false }))
38
+ if !reflect.DeepEqual(got, []string{"off"}) {
39
+ t.Fatalf("levels = %v", got)
40
+ }
41
+ })
42
+
43
+ twin.Run(t, "models", "drops unsupported levels and opts into extended levels exactly as pi does", func(t *testing.T) {
44
+ m := testModel(func(m *pi.Model) {
45
+ m.ThinkingLevelMap = map[string]json.RawMessage{"off": json.RawMessage("null"), "minimal": json.RawMessage("null"), "xhigh": json.RawMessage(`"xhigh"`), "max": json.RawMessage("null")}
46
+ })
47
+ if got := pi.SupportedThinkingLevels(m); !reflect.DeepEqual(got, []string{"low", "medium", "high", "xhigh"}) {
48
+ t.Fatalf("levels = %v", got)
49
+ }
50
+ })
51
+
52
+ twin.Run(t, "models", "uses pi's provider-qualified model ids on the wire", func(t *testing.T) {
53
+ if got := pi.ModelSelectionID("anthropic", "claude-sonnet-4"); got != "anthropic/claude-sonnet-4" {
54
+ t.Fatalf("id = %s", got)
55
+ }
56
+ m := testModel(func(m *pi.Model) { m.Provider, m.ID = "openrouter", "anthropic/claude-sonnet-4" })
57
+ if got := m.SelectionID(); got != "openrouter/anthropic/claude-sonnet-4" {
58
+ t.Fatalf("id = %s", got)
59
+ }
60
+ })
61
+
62
+ twin.Run(t, "models", "resolves qualified ids without confusing providers", func(t *testing.T) {
63
+ direct := testModel(func(m *pi.Model) { m.Provider, m.ID, m.Name = "openai", "gpt-6-astra", "GPT-6 Astra (API)" })
64
+ codex := testModel(func(m *pi.Model) { m.Provider, m.ID, m.Name = "openai-codex", "gpt-6-astra", "GPT-6 Astra (Codex)" })
65
+ models := []pi.Model{direct, codex}
66
+ if got, ok := pi.FindModelBySelectionID(models, codex.SelectionID(), nil); !ok || got.Name != codex.Name {
67
+ t.Fatalf("qualified lookup = %+v %v", got, ok)
68
+ }
69
+ if _, ok := pi.FindModelBySelectionID(models, "gpt-6-astra", nil); ok {
70
+ t.Fatal("an ambiguous bare id must not resolve")
71
+ }
72
+ if got, ok := pi.FindModelBySelectionID(models, "gpt-6-astra", &codex); !ok || got.Name != codex.Name {
73
+ t.Fatalf("bare id with the current model = %+v %v", got, ok)
74
+ }
75
+ })
76
+
77
+ twin.Run(t, "models", "accepts an unambiguous legacy bare model id", func(t *testing.T) {
78
+ only := testModel()
79
+ other := testModel(func(m *pi.Model) { m.ID = "other-model" })
80
+ if got, ok := pi.FindModelBySelectionID([]pi.Model{only, other}, only.ID, nil); !ok || got.ID != only.ID {
81
+ t.Fatalf("got %+v %v", got, ok)
82
+ }
83
+ })
84
+
85
+ twin.Run(t, "models", "exposes thinking level as a model configSchema", func(t *testing.T) {
86
+ info := pi.ToSessionModelInfo(testModel())
87
+ if info.ConfigSchema == nil {
88
+ t.Fatal("no configSchema")
89
+ }
90
+ property, ok := info.ConfigSchema.Properties[pi.ThinkingConfigKey]
91
+ if !ok || property.Type != "string" || string(*property.Default) != `"medium"` {
92
+ t.Fatalf("property = %+v", property)
93
+ }
94
+ sameJSON(t, property.Enum, []string{"off", "minimal", "low", "medium", "high"}, "enum")
95
+ if info.Id != "anthropic/claude-sonnet-4" || info.Provider != pi.Provider || info.MaxContextWindow == nil || *info.MaxContextWindow != 200_000 {
96
+ t.Fatalf("info = %+v", info)
97
+ }
98
+ })
99
+
100
+ twin.Run(t, "models", "maps pi input modalities onto the vision capability", func(t *testing.T) {
101
+ if v := pi.ToSessionModelInfo(testModel()).SupportsVision; v == nil || !*v {
102
+ t.Fatal("an image-capable model must support vision")
103
+ }
104
+ if v := pi.ToSessionModelInfo(testModel(func(m *pi.Model) { m.Input = []string{"text"} })).SupportsVision; v == nil || *v {
105
+ t.Fatal("a text-only model must not support vision")
106
+ }
107
+ })
108
+
109
+ twin.Run(t, "models", "omits the configSchema when there is nothing to choose", func(t *testing.T) {
110
+ // A single-option picker is worse than no picker.
111
+ if pi.ToSessionModelInfo(testModel(func(m *pi.Model) { m.Reasoning = false })).ConfigSchema != nil {
112
+ t.Fatal("configSchema must be omitted")
113
+ }
114
+ })
115
+
116
+ twin.Run(t, "models", "produces a schema-conforming AgentInfo", func(t *testing.T) {
117
+ agent := pi.BuildAgentInfo([]pi.Model{testModel(), testModel(func(m *pi.Model) { m.ID, m.Name, m.Reasoning = "gpt-5", "GPT-5", false })})
118
+ if agent.Provider != pi.Provider || len(agent.Models) != 2 {
119
+ t.Fatalf("agent = %+v", agent)
120
+ }
121
+ // No capabilities declared: one chat, one working directory. Their absence is what tells a
122
+ // client not to attempt those calls.
123
+ if agent.Capabilities != nil || len(agent.ProtectedResources) != 0 || agent.Customizations != nil {
124
+ t.Fatalf("agent declares more than it supports: %+v", agent)
125
+ }
126
+ testkit.AssertValid(t, "state", "AgentInfo", agent)
127
+ })
128
+ }
@@ -0,0 +1,95 @@
1
+ package pi
2
+
3
+ import (
4
+ "fmt"
5
+
6
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
7
+
8
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
9
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
10
+ )
11
+
12
+ const defaultPageSize = 20
13
+
14
+ // OlderTurnsPage is a page of history older than what the client already has.
15
+ type OlderTurnsPage struct {
16
+ Turns []ahptypes.Turn
17
+ NextCursor string
18
+ }
19
+
20
+ func fullBranchTurns(store SessionStore, prefix string) []ahptypes.Turn {
21
+ return RebuildHistory(store.Branch(""), RebuildOptions{TurnIDPrefix: prefix}).Turns
22
+ }
23
+
24
+ func indexOfTurn(turns []ahptypes.Turn, id string) int {
25
+ for i, t := range turns {
26
+ if t.Id == id {
27
+ return i
28
+ }
29
+ }
30
+ return -1
31
+ }
32
+
33
+ // InitialTurnsCursor is the cursor a client needs to fetch what precedes the visible window, ""
34
+ // when the window already starts at the beginning. The cursor is the id of the oldest visible turn.
35
+ func InitialTurnsCursor(store SessionStore, prefix string, visible []ahptypes.Turn) string {
36
+ if len(visible) == 0 {
37
+ return ""
38
+ }
39
+ if indexOfTurn(fullBranchTurns(store, prefix), visible[0].Id) > 0 {
40
+ return visible[0].Id
41
+ }
42
+ return ""
43
+ }
44
+
45
+ // LoadOlderTurns returns the page of turns ending just before cursor.
46
+ func LoadOlderTurns(store SessionStore, prefix, cursor string, limit int) OlderTurnsPage {
47
+ if cursor == "" {
48
+ return OlderTurnsPage{Turns: []ahptypes.Turn{}}
49
+ }
50
+ if limit <= 0 {
51
+ limit = defaultPageSize
52
+ }
53
+ full := fullBranchTurns(store, prefix)
54
+ boundary := indexOfTurn(full, cursor)
55
+ if boundary <= 0 {
56
+ return OlderTurnsPage{Turns: []ahptypes.Turn{}}
57
+ }
58
+ start := boundary - limit
59
+ if start < 0 {
60
+ start = 0
61
+ }
62
+ page := OlderTurnsPage{Turns: append([]ahptypes.Turn{}, full[start:boundary]...)}
63
+ // More remains only if this page did not reach the beginning.
64
+ if start > 0 && len(page.Turns) > 0 {
65
+ page.NextCursor = page.Turns[0].Id
66
+ }
67
+ return page
68
+ }
69
+
70
+ // DispatchOlderTurns serves fetchTurns: the page is dispatched to the chat before it returns.
71
+ func DispatchOlderTurns(h *host.Host, channel string, source HistorySource, cursor *string) error {
72
+ state := h.Store().Chat(channel)
73
+ if cursor != nil {
74
+ var next string
75
+ if state != nil && state.TurnsNextCursor != nil {
76
+ next = *state.TurnsNextCursor
77
+ }
78
+ if state == nil || state.TurnsNextCursor == nil || *cursor != next {
79
+ return wire.InvalidParams(fmt.Sprintf("Unrecognised fetchTurns cursor for %s", channel))
80
+ }
81
+ }
82
+ c := ""
83
+ if cursor != nil {
84
+ c = *cursor
85
+ }
86
+ page := LoadOlderTurns(source.Store(), source.ID(), c, 0)
87
+ // Dispatched even when empty: it is what clears a cursor that has no more history behind it, so
88
+ // the client stops asking.
89
+ action := &ahptypes.ChatTurnsLoadedAction{Type: ahptypes.ActionTypeChatTurnsLoaded, Turns: page.Turns}
90
+ if page.NextCursor != "" {
91
+ action.TurnsNextCursor = &page.NextCursor
92
+ }
93
+ h.DispatchServerAction(channel, ahptypes.StateAction{Value: action})
94
+ return nil
95
+ }
@@ -0,0 +1,323 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "reflect"
10
+ "regexp"
11
+ "sort"
12
+ "strconv"
13
+ "strings"
14
+ "sync"
15
+ "testing"
16
+
17
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
18
+
19
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
20
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
21
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
22
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pisession"
23
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
24
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
25
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
26
+ )
27
+
28
+ // Twins of upstream test/fetch-turns.test.ts and test/truncate.test.ts.
29
+
30
+ // sessionFileFixture is a host serving one pre-written session file.
31
+ type sessionFileFixture struct {
32
+ t *testing.T
33
+ host *host.Host
34
+ client *testkit.Client
35
+ sessionID string
36
+ root string
37
+ }
38
+
39
+ func (f *sessionFileFixture) chat() string { return wire.ChatURI(f.sessionID) }
40
+ func (f *sessionFileFixture) session() string { return wire.SessionURI(f.sessionID) }
41
+
42
+ func startSessionFile(t *testing.T, write func(root, id, cwd string), backend pi.Backend) *sessionFileFixture {
43
+ t.Helper()
44
+ f := &sessionFileFixture{t: t, root: t.TempDir(), sessionID: newID()}
45
+ workspace := t.TempDir()
46
+ write(f.root, f.sessionID, workspace)
47
+ opts := pi.ServicesOptions{
48
+ SessionRoot: f.root, DefaultWorkingDirectory: workspace,
49
+ CreateSessionManager: pi.PersistentStorage(filepath.Join(f.root, "created")),
50
+ }
51
+ f.host = testkit.NewHost(host.Options{})
52
+ opts.Host = f.host
53
+ if backend != nil {
54
+ opts.CreateBackend = func(*pi.LiveSession) (pi.Backend, error) { return backend, nil }
55
+ }
56
+ services := pi.NewServices(opts)
57
+ f.host.Serve(services.Capabilities())
58
+ f.client = testkit.Connect(t, f.host)
59
+ t.Cleanup(f.client.Close)
60
+ f.client.Initialize("paging-client", nil)
61
+ return f
62
+ }
63
+
64
+ // jsonl builds a session file body: header + entries linked by id/parentId.
65
+ type jsonl struct {
66
+ lines []string
67
+ parent any
68
+ n int
69
+ }
70
+
71
+ func newJSONL(id, cwd string) *jsonl {
72
+ raw, _ := json.Marshal(obj{"type": "session", "id": id, "parentId": nil, "timestamp": "2026-01-01T00:00:00.000Z", "version": 3, "cwd": cwd})
73
+ return &jsonl{lines: []string{string(raw)}}
74
+ }
75
+
76
+ func (j *jsonl) push(entry obj) string {
77
+ j.n++
78
+ id := "n" + strconv.Itoa(j.n)
79
+ entry["id"], entry["parentId"], entry["timestamp"] = id, j.parent, "2026-01-01T00:00:00.000Z"
80
+ raw, _ := json.Marshal(entry)
81
+ j.lines = append(j.lines, string(raw))
82
+ j.parent = id
83
+ return id
84
+ }
85
+
86
+ func (j *jsonl) message(role string, content any) string {
87
+ return j.push(obj{"type": "message", "message": obj{"role": role, "content": content, "timestamp": 0}})
88
+ }
89
+
90
+ func (j *jsonl) write(t testing.TB, root, id, cwd string) {
91
+ dir := fixtureSessionDirectory(t, root, cwd)
92
+ if err := os.WriteFile(filepath.Join(dir, "2026-01-01T00-00-00-000Z_"+id+".jsonl"), []byte(strings.Join(j.lines, "\n")+"\n"), 0o644); err != nil {
93
+ t.Fatal(err)
94
+ }
95
+ }
96
+
97
+ // writeCompactedSession writes a session with `before` exchanges, then a compaction, then `after`.
98
+ // The compaction is what makes the earlier turns invisible to the default window, which is exactly
99
+ // the case paging exists for.
100
+ func writeCompactedSession(before, after int) func(root, id, cwd string) {
101
+ return func(root, id, cwd string) {
102
+ j := newJSONL(id, cwd)
103
+ exchange := func(n int) {
104
+ j.message("user", fmt.Sprintf("question %d", n))
105
+ j.message("assistant", []any{obj{"type": "text", "text": fmt.Sprintf("answer %d", n)}})
106
+ }
107
+ for i := 0; i < before; i++ {
108
+ exchange(i)
109
+ }
110
+ // The compaction keeps nothing before itself: firstKeptEntryId points at the entry that
111
+ // follows it, so the window starts here.
112
+ j.push(obj{"type": "compaction", "summary": "earlier work", "firstKeptEntryId": "none", "tokensBefore": 1000})
113
+ for i := 0; i < after; i++ {
114
+ exchange(before + i)
115
+ }
116
+ j.write(t0{}, root, id, cwd)
117
+ }
118
+ }
119
+
120
+ // t0 lets the writers above (called without a *testing.T) fail loudly.
121
+ type t0 struct{ testing.TB }
122
+
123
+ func (t0) Helper() {}
124
+ func (t0) Fatal(args ...any) { panic(fmt.Sprint(args...)) }
125
+ func (t0) Fatalf(format string, args ...any) { panic(fmt.Sprintf(format, args...)) }
126
+
127
+ func TestFetchTurns(t *testing.T) {
128
+ // 25 pre-compaction exchanges, so a 20-turn page leaves a second one.
129
+ f := startSessionFile(t, writeCompactedSession(25, 2), nil)
130
+ f.client.Subscribe(f.chat())
131
+ state := func() *ahptypes.ChatState { return f.host.Store().Chat(f.chat()) }
132
+
133
+ twin.Run(t, "fetch-turns", "pages backward while preserving a complete oldest-first transcript", func(t *testing.T) {
134
+ initial := state()
135
+ // Its presence is the protocol's signal that turns is a tail window.
136
+ if initial.TurnsNextCursor == nil {
137
+ t.Fatal("a compacted session must offer more history")
138
+ }
139
+ testkit.AssertValid(t, "state", "ChatState", initial)
140
+ newest := initial.Turns[len(initial.Turns)-1].Id
141
+ initialCount := len(initial.Turns)
142
+
143
+ f.client.Must("fetchTurns", obj{"channel": f.chat(), "cursor": *initial.TurnsNextCursor})
144
+ current := state()
145
+ if len(current.Turns) <= initialCount {
146
+ t.Fatalf("no older turns arrived: %d", len(current.Turns))
147
+ }
148
+ if got := current.Turns[len(current.Turns)-1].Id; got != newest {
149
+ t.Fatalf("paging must preserve the visible tail: %s vs %s", got, newest)
150
+ }
151
+ if !regexp.MustCompile(`question \d+`).MatchString(current.Turns[0].Message.Text) {
152
+ t.Fatalf("oldest turn = %q", current.Turns[0].Message.Text)
153
+ }
154
+ for current.TurnsNextCursor != nil {
155
+ f.client.Must("fetchTurns", obj{"channel": f.chat(), "cursor": *current.TurnsNextCursor})
156
+ current = state()
157
+ }
158
+ var numbered []int
159
+ re := regexp.MustCompile(`question (\d+)`)
160
+ for _, turn := range current.Turns {
161
+ if m := re.FindStringSubmatch(turn.Message.Text); m != nil {
162
+ n, _ := strconv.Atoi(m[1])
163
+ numbered = append(numbered, n)
164
+ }
165
+ }
166
+ if !sort.IntsAreSorted(numbered) || len(numbered) != 27 {
167
+ t.Fatalf("questions = %v", numbered)
168
+ }
169
+ // Without this the client would keep asking for pages that do not exist.
170
+ if current.TurnsNextCursor != nil {
171
+ t.Fatal("the cursor must clear once history is exhausted")
172
+ }
173
+ })
174
+
175
+ twin.Run(t, "fetch-turns", "rejects a session channel for this chat-scoped command", func(t *testing.T) {
176
+ f.client.ExpectError("fetchTurns", obj{"channel": f.session()}, wire.CodeInvalidParams)
177
+ })
178
+
179
+ twin.Run(t, "fetch-turns", "rejects a cursor it did not issue", func(t *testing.T) {
180
+ f.client.ExpectError("fetchTurns", obj{"channel": f.chat(), "cursor": "made-up"}, -32602)
181
+ })
182
+
183
+ twin.Run(t, "fetch-turns", "offers no cursor for a session that was never compacted", func(t *testing.T) {
184
+ fresh := startSessionFile(t, writeCompactedSession(0, 3), nil)
185
+ chat := snapshotState[ahptypes.ChatState](t, fresh.client, fresh.chat())
186
+ if chat.TurnsNextCursor != nil {
187
+ t.Fatalf("turnsNextCursor = %v", *chat.TurnsNextCursor)
188
+ }
189
+ })
190
+ }
191
+
192
+ // truncatingBackend records the entries it was asked to move back to.
193
+ type truncatingBackend struct {
194
+ mu sync.Mutex
195
+ truncated []string
196
+ accept bool
197
+ }
198
+
199
+ func (b *truncatingBackend) Subscribe(func(mapper.Event)) func() { return func() {} }
200
+ func (b *truncatingBackend) Prompt(context.Context, string, []mapper.Image) error { return nil }
201
+ func (b *truncatingBackend) Steer(context.Context, string, []mapper.Image) error { return nil }
202
+ func (b *truncatingBackend) Abort(context.Context) error { return nil }
203
+ func (b *truncatingBackend) Truncate(_ context.Context, entryID string) (bool, error) {
204
+ b.mu.Lock()
205
+ defer b.mu.Unlock()
206
+ b.truncated = append(b.truncated, entryID)
207
+ return b.accept, nil
208
+ }
209
+ func (b *truncatingBackend) list() []string {
210
+ b.mu.Lock()
211
+ defer b.mu.Unlock()
212
+ return append([]string(nil), b.truncated...)
213
+ }
214
+
215
+ // writeTwoTurns is upstream truncate.test.ts's writeSession: two complete turns.
216
+ func writeTwoTurns(root, id, cwd string) {
217
+ j := newJSONL(id, cwd)
218
+ j.message("user", "first question")
219
+ j.message("assistant", []any{obj{"type": "text", "text": "first answer"}})
220
+ j.message("user", "second question")
221
+ j.message("assistant", []any{obj{"type": "text", "text": "second answer"}})
222
+ j.write(t0{}, root, id, cwd)
223
+ }
224
+
225
+ func TestTruncationAnchors(t *testing.T) {
226
+ entry := func(id, role, text string) pisession.Entry {
227
+ return pisession.Entry{"type": "message", "id": id, "parentId": nil, "timestamp": "2026-01-01T00:00:00.000Z", "message": obj{"role": role, "content": text}}
228
+ }
229
+ twin.Run(t, "truncate", "anchors a turn on its last entry, not its first", func(t *testing.T) {
230
+ // navigateTree is inclusive for a non-user entry and exclusive for a user one, so pointing
231
+ // at the turn's *last* entry is what expresses "keep turns up to and including this one".
232
+ history := pi.RebuildHistory([]pisession.Entry{entry("u1", "user", "q1"), entry("a1", "assistant", "a1"), entry("u2", "user", "q2"), entry("a2", "assistant", "a2")}, pi.RebuildOptions{TurnIDPrefix: "s"})
233
+ if len(history.Turns) != 2 {
234
+ t.Fatalf("%d turns", len(history.Turns))
235
+ }
236
+ if history.Anchors[history.Turns[0].Id] != "a1" || history.Anchors[history.Turns[1].Id] != "a2" {
237
+ t.Fatalf("anchors = %v", history.Anchors)
238
+ }
239
+ })
240
+
241
+ twin.Run(t, "truncate", "anchors 'clear everything' on the first user entry", func(t *testing.T) {
242
+ // Navigating to a user entry lands the leaf on its parent, and the first one's parent is
243
+ // null, which is how Pi expresses an empty branch.
244
+ u1 := entry("u1", "user", "q")
245
+ a1 := entry("a1", "assistant", "a")
246
+ a1["parentId"] = "u1"
247
+ history := pi.RebuildHistory([]pisession.Entry{u1, a1}, pi.RebuildOptions{TurnIDPrefix: "s"})
248
+ if history.Anchors[pi.ClearAllAnchor] != "u1" {
249
+ t.Fatalf("anchors = %v", history.Anchors)
250
+ }
251
+ })
252
+ }
253
+
254
+ func TestChatTruncated(t *testing.T) {
255
+ twin.Run(t, "truncate", "moves pi's leaf to the named turn, not just the client's view", func(t *testing.T) {
256
+ backend := &truncatingBackend{accept: true}
257
+ f := startSessionFile(t, writeTwoTurns, backend)
258
+ chat := snapshotState[ahptypes.ChatState](t, f.client, f.chat())
259
+ if len(chat.Turns) != 2 {
260
+ t.Fatalf("%d turns", len(chat.Turns))
261
+ }
262
+ f.client.Dispatch(f.chat(), obj{"type": "chat/truncated", "turnId": chat.Turns[0].Id})
263
+ testkit.Eventually(t, "pi's history to receive the truncation", func() bool { return len(backend.list()) == 1 })
264
+ // Both halves have to move: the reducer drops the later turn, and Pi is told to branch from
265
+ // the kept one.
266
+ if got := len(f.host.Store().Chat(f.chat()).Turns); got != 1 {
267
+ t.Fatalf("%d turns remain", got)
268
+ }
269
+ // The entry Pi is told to move to is the kept turn's last entry (the first answer).
270
+ file, _ := pi.NewCatalogue(f.root).FindSessionFile(f.sessionID)
271
+ m, _ := pisession.Open(file)
272
+ var assistantIDs []string
273
+ for _, e := range m.Entries() {
274
+ if e.Role() == "assistant" {
275
+ assistantIDs = append(assistantIDs, e.ID())
276
+ }
277
+ }
278
+ if !reflect.DeepEqual(backend.list(), assistantIDs[:1]) {
279
+ t.Fatalf("truncated to %v, want %v", backend.list(), assistantIDs[:1])
280
+ }
281
+ })
282
+
283
+ twin.Run(t, "truncate", "maps 'clear everything' onto the first entry", func(t *testing.T) {
284
+ backend := &truncatingBackend{accept: true}
285
+ f := startSessionFile(t, writeTwoTurns, backend)
286
+ f.client.Subscribe(f.chat())
287
+ f.client.Dispatch(f.chat(), obj{"type": "chat/truncated"})
288
+ testkit.Eventually(t, "pi's history to receive the clear-all truncation", func() bool { return len(backend.list()) == 1 })
289
+ if got := len(f.host.Store().Chat(f.chat()).Turns); got != 0 {
290
+ t.Fatalf("%d turns remain", got)
291
+ }
292
+ file, _ := pi.NewCatalogue(f.root).FindSessionFile(f.sessionID)
293
+ m, _ := pisession.Open(file)
294
+ if first := m.Entries()[0].ID(); !reflect.DeepEqual(backend.list(), []string{first}) {
295
+ t.Fatalf("truncated to %v, want the first entry %s", backend.list(), first)
296
+ }
297
+ })
298
+
299
+ twin.Run(t, "truncate", "refuses a turn it cannot anchor, instead of diverging", func(t *testing.T) {
300
+ backend := &truncatingBackend{accept: true}
301
+ f := startSessionFile(t, writeTwoTurns, backend)
302
+ before := len(snapshotState[ahptypes.ChatState](t, f.client, f.chat()).Turns)
303
+ mark := len(f.client.Notifications())
304
+ f.client.Dispatch(f.chat(), obj{"type": "chat/truncated", "turnId": "no-such-turn"})
305
+ // Refused before the reducer runs: accepting would truncate the client's view while Pi
306
+ // kept everything.
307
+ n, ok := f.client.Await(func(n testkit.Notification) bool { _, isAction := n.Envelope(); return isAction }, testkit.Timeout)
308
+ _ = mark
309
+ if !ok {
310
+ t.Fatal("no action envelope")
311
+ }
312
+ env, _ := n.Envelope()
313
+ if env.RejectionReason == nil || !strings.Contains(*env.RejectionReason, "Cannot truncate") {
314
+ t.Fatalf("rejectionReason = %v", env.RejectionReason)
315
+ }
316
+ if got := len(f.host.Store().Chat(f.chat()).Turns); got != before {
317
+ t.Fatalf("%d turns, want %d", got, before)
318
+ }
319
+ if got := backend.list(); len(got) != 0 {
320
+ t.Fatalf("truncated = %v", got)
321
+ }
322
+ })
323
+ }