@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,272 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "regexp"
10
+ "sort"
11
+ "testing"
12
+
13
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
14
+
15
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
16
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
17
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
18
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
19
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
20
+ )
21
+
22
+ // Twins of upstream test/completions.test.ts: inline completions for a message being composed.
23
+
24
+ const completionsChat = "ahp-chat:/completions"
25
+
26
+ func TestMentionParsing(t *testing.T) {
27
+ twin.Run(t, "completions", "finds a mention the cursor sits in", func(t *testing.T) {
28
+ got, ok := pi.FindMention("look at @foo", 12)
29
+ if !ok || got != (pi.Mention{Start: 8, End: 12, Query: "foo"}) {
30
+ t.Fatalf("got %+v %v", got, ok)
31
+ }
32
+ })
33
+ twin.Run(t, "completions", "finds a bare mention at the start of the text", func(t *testing.T) {
34
+ if got, ok := pi.FindMention("@src", 4); !ok || got.Query != "src" {
35
+ t.Fatalf("got %+v %v", got, ok)
36
+ }
37
+ })
38
+ twin.Run(t, "completions", "ignores an `@` that is not preceded by whitespace", func(t *testing.T) {
39
+ // Otherwise every email address and decorator opens the picker.
40
+ if _, ok := pi.FindMention("mail me at a@b.com", 18); ok {
41
+ t.Fatal("an email address opened the picker")
42
+ }
43
+ })
44
+ twin.Run(t, "completions", "ignores a cursor that is past the end of the mention", func(t *testing.T) {
45
+ if _, ok := pi.FindMention("@foo bar", 8); ok {
46
+ t.Fatal("the cursor is past the mention")
47
+ }
48
+ })
49
+ twin.Run(t, "completions", "returns the empty query for a lone trigger", func(t *testing.T) {
50
+ if got, ok := pi.FindMention("@", 1); !ok || got.Query != "" {
51
+ t.Fatalf("got %+v %v", got, ok)
52
+ }
53
+ })
54
+ }
55
+
56
+ func touch(t *testing.T, path string) {
57
+ t.Helper()
58
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ }
65
+
66
+ func complete(t *testing.T, s *pi.CompletionService, text string, offset int) ahptypes.CompletionsResult {
67
+ t.Helper()
68
+ r, err := s.Complete(context.Background(), ahptypes.CompletionsParams{Kind: ahptypes.CompletionItemKindUserMessage, Channel: completionsChat, Text: text, Offset: int64(offset)})
69
+ if err != nil {
70
+ t.Fatal(err)
71
+ }
72
+ return r
73
+ }
74
+
75
+ func inserted(r ahptypes.CompletionsResult) []string {
76
+ out := []string{}
77
+ for _, i := range r.Items {
78
+ out = append(out, i.InsertText)
79
+ }
80
+ return out
81
+ }
82
+
83
+ func TestCompletions(t *testing.T) {
84
+ workspace := t.TempDir()
85
+ for _, f := range []string{"readme.md", "report.txt", "other.js", ".hidden", "src/index.ts", "src/lib/deep.ts", "node_modules/reactive.js", "dist/generated.js", "build/artifact.js"} {
86
+ touch(t, filepath.Join(workspace, f))
87
+ }
88
+ service := pi.NewCompletionService(pi.CompletionServiceOptions{WorkingDirectoryFor: func(string) string { return workspace }})
89
+ at := func(text string) ahptypes.CompletionsResult { return complete(t, service, text, len([]rune(text))) }
90
+
91
+ twin.Run(t, "completions", "offers files matching the typed prefix", func(t *testing.T) {
92
+ got := inserted(at("look at @re"))
93
+ sort.Strings(got)
94
+ sameJSON(t, got, []string{"@readme.md", "@report.txt"}, "insert texts")
95
+ })
96
+
97
+ twin.Run(t, "completions", "attaches the resource each item refers to", func(t *testing.T) {
98
+ r := at("@readme")
99
+ if len(r.Items) == 0 {
100
+ t.Fatal("no items")
101
+ }
102
+ att, ok := r.Items[0].Attachment.Value.(*ahptypes.MessageResourceAttachment)
103
+ if !ok || att.Type != ahptypes.MessageAttachmentKindResource || att.Label != "readme.md" || !regexp.MustCompile(`readme\.md$`).MatchString(att.Uri) {
104
+ t.Fatalf("attachment = %+v", r.Items[0].Attachment.Value)
105
+ }
106
+ testkit.AssertValid(t, "commands", "CompletionItem", r.Items[0])
107
+ })
108
+
109
+ twin.Run(t, "completions", "marks the range the client should replace", func(t *testing.T) {
110
+ r := at("look at @re")
111
+ // The span covers the `@` through the cursor, so accepting replaces the half-typed mention
112
+ // rather than appending to it.
113
+ if *r.Items[0].RangeStart != 8 || *r.Items[0].RangeEnd != 11 {
114
+ t.Fatalf("range = %d..%d", *r.Items[0].RangeStart, *r.Items[0].RangeEnd)
115
+ }
116
+ })
117
+
118
+ twin.Run(t, "completions", "returns nested files instead of directory navigation items", func(t *testing.T) {
119
+ r := at("@sr")
120
+ sameJSON(t, inserted(r), []string{"@src/index.ts", "@src/lib/deep.ts"}, "insert texts")
121
+ for _, i := range r.Items {
122
+ if k := i.Attachment.Value.(*ahptypes.MessageResourceAttachment).DisplayKind; k == nil || *k != "document" {
123
+ t.Fatalf("displayKind = %v", k)
124
+ }
125
+ }
126
+ })
127
+
128
+ twin.Run(t, "completions", "matches a typed relative-path prefix", func(t *testing.T) {
129
+ sameJSON(t, inserted(at("@src/in")), []string{"@src/index.ts"}, "insert texts")
130
+ })
131
+
132
+ twin.Run(t, "completions", "matches a nested file by basename", func(t *testing.T) {
133
+ sameJSON(t, inserted(at("@deep")), []string{"@src/lib/deep.ts"}, "insert texts")
134
+ })
135
+
136
+ twin.Run(t, "completions", "keeps the temporary directory skip list narrow", func(t *testing.T) {
137
+ got := inserted(at("@"))
138
+ has := func(s string) bool {
139
+ for _, g := range got {
140
+ if g == s {
141
+ return true
142
+ }
143
+ }
144
+ return false
145
+ }
146
+ for _, g := range got {
147
+ if regexp.MustCompile(`node_modules|\.hidden`).MatchString(g) {
148
+ t.Fatalf("offered %s", g)
149
+ }
150
+ }
151
+ if !has("@dist/generated.js") || !has("@build/artifact.js") {
152
+ t.Fatalf("build output must be offered: %v", got)
153
+ }
154
+ })
155
+
156
+ twin.Run(t, "completions", "does not interpret a query as path traversal", func(t *testing.T) {
157
+ if got := at("@../../etc/pass").Items; len(got) != 0 {
158
+ t.Fatalf("items = %v", got)
159
+ }
160
+ })
161
+
162
+ twin.Run(t, "completions", "returns nothing for a completion kind it does not implement", func(t *testing.T) {
163
+ r, err := service.Complete(context.Background(), ahptypes.CompletionsParams{Kind: "futureKind", Channel: completionsChat, Text: "@not", Offset: 4})
164
+ if err != nil || len(r.Items) != 0 {
165
+ t.Fatalf("items = %v (%v)", r.Items, err)
166
+ }
167
+ })
168
+
169
+ twin.Run(t, "completions", "returns nothing when the cursor is not in a mention", func(t *testing.T) {
170
+ if got := at("plain text").Items; len(got) != 0 {
171
+ t.Fatalf("items = %v", got)
172
+ }
173
+ })
174
+
175
+ twin.Run(t, "completions", "returns nothing for a chat it has no directory for", func(t *testing.T) {
176
+ orphan := pi.NewCompletionService(pi.CompletionServiceOptions{WorkingDirectoryFor: func(string) string { return "" }})
177
+ if got := complete(t, orphan, "@re", 3).Items; len(got) != 0 {
178
+ t.Fatalf("items = %v", got)
179
+ }
180
+ })
181
+
182
+ twin.Run(t, "completions", "caps how many items it returns", func(t *testing.T) {
183
+ crowded := t.TempDir()
184
+ for i := 0; i < 40; i++ {
185
+ touch(t, filepath.Join(crowded, fmt.Sprintf("file-%d.txt", i)))
186
+ }
187
+ capped := pi.NewCompletionService(pi.CompletionServiceOptions{WorkingDirectoryFor: func(string) string { return crowded }, MaxItems: 5})
188
+ if got := len(complete(t, capped, "@file", 5).Items); got != 5 {
189
+ t.Fatalf("%d items", got)
190
+ }
191
+ })
192
+ }
193
+
194
+ func TestCompletionsOverTheWire(t *testing.T) {
195
+ workspace := t.TempDir()
196
+ touch(t, filepath.Join(workspace, "notes.md"))
197
+ h := testkit.NewHost(host.Options{CompletionTriggerCharacters: []string{pi.MentionTrigger}})
198
+ h.Serve(host.Capabilities{Completions: pi.NewCompletionService(pi.CompletionServiceOptions{
199
+ WorkingDirectoryFor: func(channel string) string {
200
+ if channel == completionsChat {
201
+ return workspace
202
+ }
203
+ return ""
204
+ },
205
+ })})
206
+ client := testkit.Connect(t, h)
207
+ t.Cleanup(client.Close)
208
+ initialization := client.Initialize("completions-client", nil)
209
+ params := func(channel string) obj {
210
+ p := obj{"kind": "userMessage", "text": "@not", "offset": 4}
211
+ if channel != "" {
212
+ p["channel"] = channel
213
+ }
214
+ return p
215
+ }
216
+
217
+ twin.Run(t, "completions", "advertises only the trigger it can answer", func(t *testing.T) {
218
+ // "/" is deliberately absent: Pi's slash commands attach nothing, and every completion
219
+ // item must carry an attachment.
220
+ sameJSON(t, initialization.CompletionTriggerCharacters, []string{"@"}, "triggers")
221
+ testkit.AssertValid(t, "commands", "InitializeResult", initialization)
222
+ })
223
+
224
+ twin.Run(t, "completions", "serves a schema-conforming result", func(t *testing.T) {
225
+ p := params(completionsChat)
226
+ p["text"], p["offset"] = "see @not", 8
227
+ raw := client.Must("completions", p)
228
+ var result struct{ Items []obj }
229
+ if err := json.Unmarshal(raw, &result); err != nil {
230
+ t.Fatal(err)
231
+ }
232
+ if len(result.Items) != 1 || result.Items[0]["insertText"] != "@notes.md" {
233
+ t.Fatalf("result = %s", raw)
234
+ }
235
+ testkit.AssertValid(t, "commands", "CompletionsResult", testkit.Normalize(t, raw))
236
+ })
237
+
238
+ twin.Run(t, "completions", "rejects non-chat completion targets", func(t *testing.T) {
239
+ for _, channel := range []string{"", "ahp-session:/completions"} {
240
+ client.ExpectError("completions", params(channel), wire.CodeInvalidParams)
241
+ }
242
+ })
243
+
244
+ twin.Run(t, "completions", "maps provider-alias completion targets onto the default chat", func(t *testing.T) {
245
+ vscode := testkit.Connect(t, h)
246
+ t.Cleanup(vscode.Close)
247
+ vscode.Initialize("vscode-completions-client", obj{"clientInfo": obj{"name": "vscode-editor-window"}})
248
+ for _, c := range []*testkit.Client{vscode, client} {
249
+ var result struct{ Items []obj }
250
+ c.Decode(c.Must("completions", params("pi:/completions")), &result)
251
+ if len(result.Items) != 1 || result.Items[0]["insertText"] != "@notes.md" {
252
+ t.Fatalf("result = %v", result.Items)
253
+ }
254
+ }
255
+ })
256
+ }
257
+
258
+ func TestCompletionsWithoutAHandler(t *testing.T) {
259
+ twin.Run(t, "completions", "answers with an empty list rather than MethodNotFound", func(t *testing.T) {
260
+ // The client debounces keystrokes into this call; an error per keypress would be worse
261
+ // than nothing to show.
262
+ h := testkit.NewHost(host.Options{})
263
+ client := testkit.Connect(t, h)
264
+ t.Cleanup(client.Close)
265
+ client.Initialize("bare-client", nil)
266
+ var result struct{ Items []obj }
267
+ client.Decode(client.Must("completions", obj{"kind": "userMessage", "channel": completionsChat, "text": "@x", "offset": 2}), &result)
268
+ if len(result.Items) != 0 {
269
+ t.Fatalf("items = %v", result.Items)
270
+ }
271
+ })
272
+ }
@@ -0,0 +1,269 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+
10
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
11
+
12
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/pi"
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
15
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
16
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
17
+ )
18
+
19
+ // Twins of upstream test/project-trust.test.ts and test/session-config.test.ts.
20
+ //
21
+ // Project trust is a security default, not a convenience: Pi's SDK trusts a working directory
22
+ // unless told otherwise, and a host takes that directory from a client over the network.
23
+
24
+ func projectWithResources(t *testing.T) string {
25
+ t.Helper()
26
+ dir := t.TempDir()
27
+ // A project-level extension is exactly the thing trust gates: loading it executes code from
28
+ // the directory.
29
+ if err := os.MkdirAll(filepath.Join(dir, ".pi", "extensions"), 0o755); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ if err := os.WriteFile(filepath.Join(dir, ".pi", "extensions", "ext.ts"), []byte("export default () => {};\n"), 0o644); err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ return dir
36
+ }
37
+
38
+ func resolveTrust(t *testing.T, cwd string, policy pi.ProjectTrustPolicy, agentDir string) pi.TrustDecision {
39
+ t.Helper()
40
+ // An isolated HOME so a developer's own ~/.agents/skills cannot leak into the result.
41
+ t.Setenv("HOME", t.TempDir())
42
+ d, err := pi.ResolveProjectTrust(cwd, policy, agentDir)
43
+ if err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ return d
47
+ }
48
+
49
+ func TestProjectTrust(t *testing.T) {
50
+ bare, withResources := t.TempDir(), projectWithResources(t)
51
+ agentDir := func() string { return t.TempDir() } // every policy case starts without a recorded decision
52
+ tp := func(b bool) *bool { return &b }
53
+
54
+ twin.Run(t, "project-trust", "trusts by default, matching raw pi SDK construction", func(t *testing.T) {
55
+ if got := resolveTrust(t, withResources, "", agentDir()); got != (pi.TrustDecision{Trusted: true, Reason: "policy"}) {
56
+ t.Fatalf("got %+v", got)
57
+ }
58
+ })
59
+
60
+ twin.Run(t, "project-trust", "trusts a directory with nothing to gate", func(t *testing.T) {
61
+ if got := resolveTrust(t, bare, pi.TrustPolicyInherit, agentDir()); got != (pi.TrustDecision{Trusted: true, Reason: "no-project-resources"}) {
62
+ t.Fatalf("got %+v", got)
63
+ }
64
+ })
65
+
66
+ twin.Run(t, "project-trust", "declines a project nobody has approved, under `inherit`", func(t *testing.T) {
67
+ // The important case: the SDK's own default would return true here.
68
+ if got := resolveTrust(t, withResources, pi.TrustPolicyInherit, agentDir()); got != (pi.TrustDecision{Trusted: false, Reason: "unknown-project"}) {
69
+ t.Fatalf("got %+v", got)
70
+ }
71
+ })
72
+
73
+ twin.Run(t, "project-trust", "inherits a decision the user made with pi's CLI", func(t *testing.T) {
74
+ dir := agentDir()
75
+ if err := pi.NewProjectTrustStore(dir).Set(withResources, tp(true)); err != nil {
76
+ t.Fatal(err)
77
+ }
78
+ // Reusing Pi's store means trusting once covers both tools, and revoking in either
79
+ // revokes in both.
80
+ if got := resolveTrust(t, withResources, pi.TrustPolicyInherit, dir); got != (pi.TrustDecision{Trusted: true, Reason: "user-trusted"}) {
81
+ t.Fatalf("got %+v", got)
82
+ }
83
+ })
84
+
85
+ twin.Run(t, "project-trust", "honours an explicit distrust from pi's CLI", func(t *testing.T) {
86
+ dir := agentDir()
87
+ if err := pi.NewProjectTrustStore(dir).Set(withResources, tp(false)); err != nil {
88
+ t.Fatal(err)
89
+ }
90
+ if got := resolveTrust(t, withResources, pi.TrustPolicyInherit, dir); got != (pi.TrustDecision{Trusted: false, Reason: "user-untrusted"}) {
91
+ t.Fatalf("got %+v", got)
92
+ }
93
+ })
94
+
95
+ twin.Run(t, "project-trust", "the `never` policy overrides a stored trust decision", func(t *testing.T) {
96
+ dir := agentDir()
97
+ if err := pi.NewProjectTrustStore(dir).Set(withResources, tp(true)); err != nil {
98
+ t.Fatal(err)
99
+ }
100
+ if got := resolveTrust(t, withResources, pi.TrustPolicyNever, dir); got != (pi.TrustDecision{Trusted: false, Reason: "policy"}) {
101
+ t.Fatalf("got %+v", got)
102
+ }
103
+ })
104
+
105
+ twin.Skip(t, "project-trust", "applies the decision before pi loads project extensions",
106
+ "needs an embedded Pi that loads project extensions from a client-chosen directory; this host serves the live PiG session only and never loads extensions itself (see PORT.md)")
107
+ }
108
+
109
+ func TestResolveSessionConfig(t *testing.T) {
110
+ bare, withResources := t.TempDir(), projectWithResources(t)
111
+ t.Setenv("HOME", t.TempDir())
112
+ service := pi.NewSessionConfigService(pi.SessionConfigOptions{DefaultWorkingDirectory: bare, AgentDir: t.TempDir()})
113
+ resolve := func(p ahptypes.ResolveSessionConfigParams) ahptypes.ResolveSessionConfigResult {
114
+ t.Helper()
115
+ r, err := service.Resolve(context.Background(), p)
116
+ if err != nil {
117
+ t.Fatal(err)
118
+ }
119
+ return r
120
+ }
121
+ dir := func(path string) *string { u := wire.PathToFileURI(path); return &u }
122
+
123
+ twin.Run(t, "session-config", "returns a schema-conforming result", func(t *testing.T) {
124
+ testkit.AssertValid(t, "commands", "ResolveSessionConfigResult", resolve(ahptypes.ResolveSessionConfigParams{Channel: wire.RootChannel}))
125
+ })
126
+
127
+ twin.Run(t, "session-config", "reports what a directory with no project resources would do", func(t *testing.T) {
128
+ result := resolve(ahptypes.ResolveSessionConfigParams{Channel: wire.RootChannel, WorkingDirectory: dir(bare)})
129
+ if string(result.Values[pi.ProjectTrustKey]) != "true" {
130
+ t.Fatalf("values = %v", result.Values)
131
+ }
132
+ if p := result.Schema.Properties[pi.ProjectTrustKey]; p.ReadOnly == nil || !*p.ReadOnly {
133
+ t.Fatalf("property = %+v", p)
134
+ }
135
+ })
136
+
137
+ twin.Run(t, "session-config", "re-resolves against whatever directory the client is asking about", func(t *testing.T) {
138
+ // The exchange is iterative: the answer changes as the user picks a directory, which is
139
+ // the whole reason the command exists.
140
+ a := resolve(ahptypes.ResolveSessionConfigParams{Channel: wire.RootChannel, WorkingDirectory: dir(bare)})
141
+ b := resolve(ahptypes.ResolveSessionConfigParams{Channel: wire.RootChannel, WorkingDirectory: dir(withResources)})
142
+ if *a.Schema.Properties[pi.ProjectTrustKey].Description == *b.Schema.Properties[pi.ProjectTrustKey].Description {
143
+ t.Fatal("the description must change with the directory")
144
+ }
145
+ })
146
+
147
+ twin.Run(t, "session-config", "offers no dynamic completions", func(t *testing.T) {
148
+ result, err := service.Completions(context.Background(), ahptypes.SessionConfigCompletionsParams{Channel: wire.RootChannel, Property: pi.ProjectTrustKey})
149
+ if err != nil || len(result.Items) != 0 {
150
+ t.Fatalf("items = %v (%v)", result.Items, err)
151
+ }
152
+ })
153
+ }
154
+
155
+ // selectionBackend records the model selection each turn ran with.
156
+ type selectionBackend struct {
157
+ *recordingBackend
158
+ mu2 chan struct{}
159
+ selections []ahptypes.ModelSelection
160
+ current ahptypes.ModelSelection
161
+ }
162
+
163
+ func newSelectionBackend() *selectionBackend {
164
+ return &selectionBackend{
165
+ recordingBackend: &recordingBackend{}, mu2: make(chan struct{}, 1),
166
+ current: ahptypes.ModelSelection{Id: "default-model", Config: map[string]json.RawMessage{pi.ThinkingConfigKey: json.RawMessage(`"medium"`)}},
167
+ }
168
+ }
169
+
170
+ func (b *selectionBackend) SelectModel(_ context.Context, s ahptypes.ModelSelection) error {
171
+ b.recordingBackend.mu.Lock()
172
+ defer b.recordingBackend.mu.Unlock()
173
+ b.selections = append(b.selections, s)
174
+ b.current = s
175
+ return nil
176
+ }
177
+
178
+ func (b *selectionBackend) CurrentSelection() *ahptypes.ModelSelection {
179
+ b.recordingBackend.mu.Lock()
180
+ defer b.recordingBackend.mu.Unlock()
181
+ c := b.current
182
+ return &c
183
+ }
184
+
185
+ func (b *selectionBackend) selectionCount() int {
186
+ b.recordingBackend.mu.Lock()
187
+ defer b.recordingBackend.mu.Unlock()
188
+ return len(b.selections)
189
+ }
190
+
191
+ func TestModelSelection(t *testing.T) {
192
+ backend := newSelectionBackend()
193
+ h := testkit.NewHost(host.Options{})
194
+ reg := pi.NewRegistry(pi.RegistryOptions{
195
+ Host: h, CreateSessionManager: pi.InMemoryStorage,
196
+ CreateBackend: func(*pi.LiveSession) (pi.Backend, error) { return backend, nil },
197
+ // The backend is authoritative once it starts, including config values.
198
+ DefaultSelection: func() *ahptypes.ModelSelection {
199
+ return &ahptypes.ModelSelection{Id: "default-model", Config: map[string]json.RawMessage{pi.ThinkingConfigKey: json.RawMessage(`"low"`)}}
200
+ },
201
+ })
202
+ h.Serve(host.Capabilities{Sessions: reg})
203
+ client := testkit.Connect(t, h)
204
+ t.Cleanup(client.Close)
205
+ client.Initialize("model-client", nil)
206
+ id := newID()
207
+ chat := wire.ChatURI(id)
208
+ client.Must("createSession", obj{"channel": wire.SessionURI(id)})
209
+ client.Subscribe(chat)
210
+ draft := func() *ahptypes.Message { return h.Store().Chat(chat).Draft }
211
+ testkit.Eventually(t, "the backend model to reach the chat draft", func() bool {
212
+ d := draft()
213
+ return d != nil && d.Model != nil && d.Model.Id == "default-model" && string(d.Model.Config[pi.ThinkingConfigKey]) == `"medium"`
214
+ })
215
+
216
+ twin.Run(t, "session-config", "has a model selected before the agent has even started", func(t *testing.T) {
217
+ // Starting an agent takes seconds. A client that subscribes in the meantime would
218
+ // otherwise find an empty picker and be unable to send.
219
+ h2 := testkit.NewHost(host.Options{})
220
+ registry := pi.NewRegistry(pi.RegistryOptions{
221
+ Host: h2, CreateSessionManager: pi.InMemoryStorage,
222
+ DefaultSelection: func() *ahptypes.ModelSelection {
223
+ return &ahptypes.ModelSelection{Id: "seeded-model", Config: map[string]json.RawMessage{pi.ThinkingConfigKey: json.RawMessage(`"medium"`)}}
224
+ },
225
+ })
226
+ id := newID()
227
+ if err := registry.Create(context.Background(), ahptypes.CreateSessionParams{Channel: wire.SessionURI(id)}); err != nil {
228
+ t.Fatal(err)
229
+ }
230
+ if d := h2.Store().Chat(wire.ChatURI(id)).Draft; d == nil || d.Model == nil || d.Model.Id != "seeded-model" {
231
+ t.Fatalf("draft = %+v", d)
232
+ }
233
+ })
234
+
235
+ twin.Run(t, "session-config", "publishes the model and config actually in effect as the chat draft", func(t *testing.T) {
236
+ // There is no protocol field for "the default model"; a client initialises its input from
237
+ // draft, so this is how a host answers. The backend must also correct a stale config for
238
+ // the same model id.
239
+ d := draft()
240
+ if d.Model.Id != "default-model" || string(d.Model.Config[pi.ThinkingConfigKey]) != `"medium"` {
241
+ t.Fatalf("draft model = %+v", d.Model)
242
+ }
243
+ })
244
+
245
+ twin.Run(t, "session-config", "applies the model a client picked, before the prompt runs", func(t *testing.T) {
246
+ promptsBefore, selectionsBefore := len(backend.promptList()), backend.selectionCount()
247
+ message := userMessage("hi")
248
+ message["model"] = obj{"id": "picked-model", "config": obj{pi.ThinkingConfigKey: "high"}}
249
+ client.Dispatch(chat, obj{"type": "chat/turnStarted", "turnId": "t-model", "startedAt": "2025-01-01T00:00:00.000Z", "message": message})
250
+ testkit.Eventually(t, "the prompt to reach the backend", func() bool { return len(backend.promptList()) == promptsBefore+1 })
251
+ if backend.selectionCount() != selectionsBefore+1 {
252
+ t.Fatalf("selections = %d, want %d", backend.selectionCount(), selectionsBefore+1)
253
+ }
254
+ backend.recordingBackend.mu.Lock()
255
+ last := backend.selections[len(backend.selections)-1]
256
+ backend.recordingBackend.mu.Unlock()
257
+ sameJSON(t, last, obj{"id": "picked-model", "config": obj{pi.ThinkingConfigKey: "high"}}, "selection")
258
+ testkit.Eventually(t, "the turn to settle", func() bool { return h.Store().Chat(chat).ActiveTurn == nil })
259
+ })
260
+
261
+ twin.Run(t, "session-config", "runs on the current model when the message carries no selection", func(t *testing.T) {
262
+ selectionsBefore, promptsBefore := backend.selectionCount(), len(backend.promptList())
263
+ client.Dispatch(chat, obj{"type": "chat/turnStarted", "turnId": "t-plain", "startedAt": "2025-01-01T00:00:00.000Z", "message": userMessage("again")})
264
+ testkit.Eventually(t, "the prompt to reach the backend", func() bool { return len(backend.promptList()) == promptsBefore+1 })
265
+ if backend.selectionCount() != selectionsBefore {
266
+ t.Fatal("no selection means no switch")
267
+ }
268
+ })
269
+ }
@@ -0,0 +1,69 @@
1
+ package pi
2
+
3
+ import (
4
+ "errors"
5
+ "os"
6
+ "os/exec"
7
+ "strings"
8
+ "syscall"
9
+ )
10
+
11
+ // DeleteResult is the outcome of deleting a session file.
12
+ type DeleteResult struct {
13
+ OK bool
14
+ // Method is "trash", "unlink" or "missing".
15
+ Method string
16
+ Error string
17
+ }
18
+
19
+ func isMissing(path string) bool {
20
+ _, err := os.Lstat(path)
21
+ return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR)
22
+ }
23
+
24
+ // DeleteSessionFile removes a session file the way Pi's own /resume delete does: through the
25
+ // system trash when there is one, else by unlinking. Success is judged by the file being gone,
26
+ // not by any exit status: a wrapper or broken installation can exit successfully without moving
27
+ // the file, and a trash that moved it may still exit non-zero (port of src/pi/delete-session.ts).
28
+ func DeleteSessionFile(path string) DeleteResult {
29
+ if isMissing(path) {
30
+ return DeleteResult{OK: true, Method: "missing"}
31
+ }
32
+ // "--" guards against a path that looks like a flag.
33
+ args := []string{path}
34
+ if strings.HasPrefix(path, "-") {
35
+ args = []string{"--", path}
36
+ }
37
+ if bin, err := exec.LookPath("trash"); err == nil {
38
+ _ = exec.Command(bin, args...).Run()
39
+ }
40
+ if isMissing(path) {
41
+ return DeleteResult{OK: true, Method: "trash"}
42
+ }
43
+ if err := unlink(path); err != nil {
44
+ // Another process deleting the same session is still the desired outcome.
45
+ if isMissing(path) {
46
+ return DeleteResult{OK: true, Method: "missing"}
47
+ }
48
+ return DeleteResult{OK: false, Method: "unlink", Error: err.Error()}
49
+ }
50
+ return DeleteResult{OK: true, Method: "unlink"}
51
+ }
52
+
53
+ // unlink removes a file but never a directory (os.Remove would also rmdir an empty one).
54
+ func unlink(path string) error {
55
+ info, err := os.Lstat(path)
56
+ if err != nil {
57
+ return err
58
+ }
59
+ if info.IsDir() {
60
+ return &os.PathError{Op: "unlink", Path: path, Err: errors.New("is a directory")}
61
+ }
62
+ return os.Remove(path)
63
+ }
64
+
65
+ // FileDeleter adapts DeleteSessionFile to the registry's deletion boundary.
66
+ func FileDeleter(path string) (SessionFileDeletionResult, error) {
67
+ r := DeleteSessionFile(path)
68
+ return SessionFileDeletionResult{OK: r.OK, Error: r.Error}, nil
69
+ }