@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,512 @@
1
+ package svc
2
+
3
+ import (
4
+ "encoding/json"
5
+ "path/filepath"
6
+ "reflect"
7
+ "strings"
8
+ "sync"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/MichaelKinsy/pigpen/ahp/internal/host"
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
15
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
16
+ ahptypes "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
17
+ )
18
+
19
+ // Twins of upstream test/terminal-service.test.ts: the service against a fake PTY, so nothing here
20
+ // depends on a shell.
21
+
22
+ const expectedScrollbackChars = 1_000_000
23
+
24
+ type fakePty struct {
25
+ mu sync.Mutex
26
+ writes []string
27
+ resizes [][2]int
28
+ kills int
29
+ handlers PtyHandlers
30
+ }
31
+
32
+ func (p *fakePty) Write(data string) error {
33
+ p.mu.Lock()
34
+ defer p.mu.Unlock()
35
+ p.writes = append(p.writes, data)
36
+ return nil
37
+ }
38
+ func (p *fakePty) Resize(cols, rows int) error {
39
+ p.mu.Lock()
40
+ defer p.mu.Unlock()
41
+ p.resizes = append(p.resizes, [2]int{cols, rows})
42
+ return nil
43
+ }
44
+ func (p *fakePty) Kill() error { p.mu.Lock(); defer p.mu.Unlock(); p.kills++; return nil }
45
+ func (p *fakePty) Writes() []string {
46
+ p.mu.Lock()
47
+ defer p.mu.Unlock()
48
+ return append([]string(nil), p.writes...)
49
+ }
50
+ func (p *fakePty) Resizes() [][2]int {
51
+ p.mu.Lock()
52
+ defer p.mu.Unlock()
53
+ return append([][2]int(nil), p.resizes...)
54
+ }
55
+ func (p *fakePty) Kills() int { p.mu.Lock(); defer p.mu.Unlock(); return p.kills }
56
+ func (p *fakePty) emitData(data string) { p.handlers.OnData(data) }
57
+ func (p *fakePty) emitExit(code int) { p.handlers.OnExit(code) }
58
+
59
+ type spawnCall struct {
60
+ file string
61
+ args []string
62
+ options PtyOptions
63
+ pty *fakePty
64
+ }
65
+
66
+ type fakeSpawner struct {
67
+ mu sync.Mutex
68
+ calls []spawnCall
69
+ }
70
+
71
+ func (f *fakeSpawner) spawn(file string, args []string, opts PtyOptions, h PtyHandlers) (PtyProcess, error) {
72
+ pty := &fakePty{handlers: h}
73
+ f.mu.Lock()
74
+ f.calls = append(f.calls, spawnCall{file, args, opts, pty})
75
+ f.mu.Unlock()
76
+ return pty, nil
77
+ }
78
+
79
+ func (f *fakeSpawner) Calls() []spawnCall {
80
+ f.mu.Lock()
81
+ defer f.mu.Unlock()
82
+ return append([]spawnCall(nil), f.calls...)
83
+ }
84
+
85
+ type terminalFixture struct {
86
+ t *testing.T
87
+ directory string
88
+ host *host.Host
89
+ spawner *fakeSpawner
90
+ terminals *TerminalService
91
+ }
92
+
93
+ const ownerID = "terminal-owner"
94
+
95
+ func startTerminalFixture(t *testing.T) (*terminalFixture, *testkit.Client) {
96
+ t.Helper()
97
+ dir, _ := filepath.EvalSymlinks(t.TempDir())
98
+ h := testkit.NewHost(host.Options{ReplayBufferCapacity: 4})
99
+ spawner := &fakeSpawner{}
100
+ terminals := NewTerminalService(h, TerminalOptions{DefaultWorkingDirectory: dir, Shell: "test-shell", Spawn: spawner.spawn})
101
+ h.Serve(host.Capabilities{Terminals: terminals})
102
+ f := &terminalFixture{t: t, directory: dir, host: h, spawner: spawner, terminals: terminals}
103
+ t.Cleanup(terminals.Shutdown)
104
+ owner := f.connect(ownerID)
105
+ return f, owner
106
+ }
107
+
108
+ func (f *terminalFixture) connect(clientID string) *testkit.Client {
109
+ c := testkit.Connect(f.t, f.host)
110
+ f.t.Cleanup(c.Close)
111
+ c.Initialize(clientID, nil)
112
+ return c
113
+ }
114
+
115
+ func (f *terminalFixture) state(channel string) *ahptypes.TerminalState {
116
+ f.t.Helper()
117
+ s := f.host.Store().Terminal(channel)
118
+ if s == nil {
119
+ f.t.Fatalf("missing terminal state for %s", channel)
120
+ }
121
+ return s
122
+ }
123
+
124
+ func (f *terminalFixture) root() *ahptypes.RootState { return f.host.Store().Root(wire.RootChannel) }
125
+
126
+ func clientClaim(id string) map[string]any { return map[string]any{"kind": "client", "clientId": id} }
127
+
128
+ var terminalCounter int
129
+
130
+ func newTerminalChannel() string {
131
+ terminalCounter++
132
+ return "ahp-terminal:/" + newUUID()
133
+ }
134
+
135
+ func (f *terminalFixture) createTerminal(owner *testkit.Client, params map[string]any) (string, *fakePty) {
136
+ f.t.Helper()
137
+ channel, _ := params["channel"].(string)
138
+ if channel == "" {
139
+ channel = "agenthost-terminal:/" + newUUID()
140
+ }
141
+ req := map[string]any{"channel": channel, "claim": clientClaim(ownerID)}
142
+ for k, v := range params {
143
+ req[k] = v
144
+ }
145
+ owner.Must("createTerminal", req)
146
+ calls := f.spawner.Calls()
147
+ if len(calls) == 0 {
148
+ f.t.Fatal("no PTY was spawned")
149
+ }
150
+ return channel, calls[len(calls)-1].pty
151
+ }
152
+
153
+ func contentValues(s *ahptypes.TerminalState) []string {
154
+ var out []string
155
+ for _, part := range s.Content {
156
+ if u, ok := part.Value.(*ahptypes.TerminalUnclassifiedPart); ok {
157
+ out = append(out, u.Value)
158
+ } else {
159
+ out = append(out, "?")
160
+ }
161
+ }
162
+ return out
163
+ }
164
+
165
+ func lifecycleJSON(s *ahptypes.TerminalState) string {
166
+ raw, _ := json.Marshal(s.Lifecycle)
167
+ return string(raw)
168
+ }
169
+
170
+ func TestTerminalService(t *testing.T) {
171
+ twin.Run(t, "terminal-service", "creates a client-owned terminal and publishes its authoritative state", func(t *testing.T) {
172
+ f, owner := startTerminalFixture(t)
173
+ cwd := wire.PathToFileURI(f.directory)
174
+ channel, _ := f.createTerminal(owner, map[string]any{"name": "Build", "cwd": cwd, "cols": 100, "rows": 30})
175
+ calls := f.spawner.Calls()
176
+ call := calls[0]
177
+ if call.file != "test-shell" || len(call.args) != 0 || call.options.Name != "xterm-256color" || call.options.Cwd != f.directory || call.options.Cols != 100 || call.options.Rows != 30 {
178
+ t.Fatalf("spawn call %+v", call)
179
+ }
180
+ state := f.state(channel)
181
+ raw, _ := json.Marshal(state)
182
+ var got any
183
+ _ = json.Unmarshal(raw, &got)
184
+ want := map[string]any{"title": "Build", "cwd": cwd, "cols": float64(100), "rows": float64(30), "content": []any{},
185
+ "lifecycle": map[string]any{"status": "running"}, "claim": clientClaim(ownerID), "isPty": true}
186
+ if !reflect.DeepEqual(got, want) {
187
+ t.Fatalf("state\n got %v\n want %v", got, want)
188
+ }
189
+ testkit.AssertValid(t, "state", "TerminalState", got)
190
+ rootRaw, _ := json.Marshal(f.root().Terminals)
191
+ wantRoot := `[{"resource":"` + channel + `","title":"Build","claim":{"kind":"client","clientId":"terminal-owner"},"lifecycle":{"status":"running"}}]`
192
+ if string(rootRaw) != wantRoot {
193
+ t.Fatalf("root terminals %s\nwant %s", rootRaw, wantRoot)
194
+ }
195
+ var rootGeneric any
196
+ rr, _ := json.Marshal(f.root())
197
+ _ = json.Unmarshal(rr, &rootGeneric)
198
+ testkit.AssertValid(t, "state", "RootState", rootGeneric)
199
+
200
+ sr := owner.Must("subscribe", map[string]any{"channel": channel})
201
+ var snapWrapper struct {
202
+ Snapshot struct {
203
+ Resource string
204
+ State any
205
+ }
206
+ }
207
+ _ = json.Unmarshal(sr, &snapWrapper)
208
+ snapGeneric := snapWrapper.Snapshot
209
+ if snapGeneric.Resource != channel || !reflect.DeepEqual(snapGeneric.State, want) {
210
+ t.Fatalf("snapshot %s", sr)
211
+ }
212
+ })
213
+
214
+ twin.Run(t, "terminal-service", "forwards output, input, resize, title, and clear without interpreting VT data", func(t *testing.T) {
215
+ f, owner := startTerminalFixture(t)
216
+ channel, pty := f.createTerminal(owner, nil)
217
+ subscribeRaw(owner, channel)
218
+ if f.state(channel).Title != "test-shell" {
219
+ t.Fatalf("title %q", f.state(channel).Title)
220
+ }
221
+ pty.emitData("hello\x1b[31m red")
222
+ testkit.Eventually(t, "terminal output to be published", func() bool { return len(f.state(channel).Content) > 0 })
223
+ if got := contentValues(f.state(channel)); !reflect.DeepEqual(got, []string{"hello\x1b[31m red"}) {
224
+ t.Fatalf("%q", got)
225
+ }
226
+
227
+ owner.Dispatch(channel, map[string]any{"type": "terminal/input", "data": "echo hi\r"})
228
+ testkit.Eventually(t, "terminal input to reach the PTY", func() bool { return len(pty.Writes()) == 1 })
229
+ if got := pty.Writes(); !reflect.DeepEqual(got, []string{"echo hi\r"}) {
230
+ t.Fatalf("%q", got)
231
+ }
232
+
233
+ owner.Dispatch(channel, map[string]any{"type": "terminal/resized", "cols": 120, "rows": 40})
234
+ testkit.Eventually(t, "terminal resize to reach the PTY", func() bool { return len(pty.Resizes()) == 1 })
235
+ if got := pty.Resizes(); !reflect.DeepEqual(got, [][2]int{{120, 40}}) {
236
+ t.Fatalf("%v", got)
237
+ }
238
+ if s := f.state(channel); *s.Cols != 120 || *s.Rows != 40 {
239
+ t.Fatalf("state size %d x %d", *s.Cols, *s.Rows)
240
+ }
241
+
242
+ owner.Dispatch(channel, map[string]any{"type": "terminal/titleChanged", "title": "Tests"})
243
+ testkit.Eventually(t, "the terminal title to reach root state", func() bool {
244
+ r := f.root()
245
+ return len(r.Terminals) > 0 && r.Terminals[0].Title == "Tests"
246
+ })
247
+ if f.state(channel).Title != "Tests" {
248
+ t.Fatalf("title %q", f.state(channel).Title)
249
+ }
250
+
251
+ pty.emitData("stale pending output")
252
+ clear := owner.Dispatch(channel, map[string]any{"type": "terminal/cleared"})
253
+ owner.NextEnvelope(channel, clear)
254
+ time.Sleep(20 * time.Millisecond)
255
+ if got := f.state(channel).Content; len(got) != 0 {
256
+ t.Fatalf("content after clear: %v", contentValues(f.state(channel)))
257
+ }
258
+ })
259
+
260
+ twin.Run(t, "terminal-service", "batches burst output and flushes pending data before exit", func(t *testing.T) {
261
+ f, owner := startTerminalFixture(t)
262
+ channel, pty := f.createTerminal(owner, nil)
263
+ before := f.host.ServerSeq()
264
+ for i := 0; i < 100; i++ {
265
+ pty.emitData("x")
266
+ }
267
+ testkit.Eventually(t, "terminal output to be published", func() bool { return len(f.state(channel).Content) > 0 })
268
+ if f.host.ServerSeq() != before+1 {
269
+ t.Fatalf("serverSeq %d, want %d: a burst must be one action", f.host.ServerSeq(), before+1)
270
+ }
271
+ if got := contentValues(f.state(channel)); !reflect.DeepEqual(got, []string{strings.Repeat("x", 100)}) {
272
+ t.Fatalf("%q", got)
273
+ }
274
+ pty.emitData("tail")
275
+ pty.emitExit(0)
276
+ if got := contentValues(f.state(channel)); !reflect.DeepEqual(got, []string{strings.Repeat("x", 100) + "tail"}) {
277
+ t.Fatalf("%q", got)
278
+ }
279
+ if got := lifecycleJSON(f.state(channel)); got != `{"status":"exited","exitCode":0}` {
280
+ t.Fatalf("%s", got)
281
+ }
282
+ })
283
+
284
+ twin.Run(t, "terminal-service", "restricts interaction and claim transfer without preventing cleanup", func(t *testing.T) {
285
+ f, owner := startTerminalFixture(t)
286
+ channel, pty := f.createTerminal(owner, nil)
287
+ observer := f.connect("terminal-observer")
288
+ subscribeRaw(owner, channel)
289
+ subscribeRaw(observer, channel)
290
+
291
+ input := observer.Dispatch(channel, map[string]any{"type": "terminal/input", "data": "nope"})
292
+ rejected := nextFrom(t, observer, channel, "terminal-observer", input)
293
+ if rejected.RejectionReason == nil || !strings.Contains(*rejected.RejectionReason, "claimed by another client") {
294
+ t.Fatalf("%v", rejected.RejectionReason)
295
+ }
296
+ if len(pty.Writes()) != 0 {
297
+ t.Fatalf("writes %q", pty.Writes())
298
+ }
299
+
300
+ claim := owner.Dispatch(channel, map[string]any{"type": "terminal/claimed", "claim": clientClaim("terminal-observer")})
301
+ // A rejection is broadcast to the channel's subscribers, and both clients count clientSeq
302
+ // from 1: match on the originating client as well.
303
+ rejectedClaim := nextFrom(t, owner, channel, ownerID, claim)
304
+ if rejectedClaim.RejectionReason == nil || !strings.Contains(*rejectedClaim.RejectionReason, "does not support transferring terminal claims") {
305
+ t.Fatalf("%v", *rejectedClaim.RejectionReason)
306
+ }
307
+ raw, _ := json.Marshal(f.state(channel).Claim)
308
+ if string(raw) != `{"kind":"client","clientId":"terminal-owner"}` {
309
+ t.Fatalf("claim %s", raw)
310
+ }
311
+
312
+ observer.Must("disposeTerminal", map[string]any{"channel": channel})
313
+ if pty.Kills() != 1 || f.host.Store().Has(channel) || len(f.root().Terminals) != 0 {
314
+ t.Fatalf("kills=%d exists=%v terminals=%v", pty.Kills(), f.host.Store().Has(channel), f.root().Terminals)
315
+ }
316
+ })
317
+
318
+ twin.Run(t, "terminal-service", "spawns only once when duplicate creates race", func(t *testing.T) {
319
+ f, owner := startTerminalFixture(t)
320
+ params := map[string]any{"channel": newTerminalChannel(), "claim": clientClaim(ownerID)}
321
+ var wg sync.WaitGroup
322
+ errs := make([]*testkit.RPCError, 2)
323
+ for i := range errs {
324
+ wg.Add(1)
325
+ go func() { defer wg.Done(); _, errs[i] = owner.Request("createTerminal", params) }()
326
+ }
327
+ wg.Wait()
328
+ failed := 0
329
+ for _, e := range errs {
330
+ if e != nil {
331
+ failed++
332
+ if e.Code != wire.CodeAlreadyExists {
333
+ t.Fatalf("code %d", e.Code)
334
+ }
335
+ }
336
+ }
337
+ if failed != 1 || len(f.spawner.Calls()) != 1 {
338
+ t.Fatalf("failed=%d spawns=%d", failed, len(f.spawner.Calls()))
339
+ }
340
+ })
341
+
342
+ twin.Run(t, "terminal-service", "rejects unsupported claims and invalid creation parameters before spawning", func(t *testing.T) {
343
+ f, owner := startTerminalFixture(t)
344
+ channel := newTerminalChannel()
345
+ for _, params := range []map[string]any{
346
+ {"channel": channel, "claim": map[string]any{"kind": "session", "session": "ahp-session:/s", "chat": "ahp-chat:/s"}},
347
+ {"channel": channel, "claim": clientClaim("someone-else")},
348
+ {"channel": channel, "claim": clientClaim(ownerID), "cols": 0},
349
+ {"channel": channel, "claim": clientClaim(ownerID), "cwd": "https://example.com"},
350
+ {"channel": channel, "claim": clientClaim(ownerID), "cwd": "file://remote/share"},
351
+ } {
352
+ owner.ExpectError("createTerminal", params, wire.CodeInvalidParams)
353
+ }
354
+ if n := len(f.spawner.Calls()); n != 0 {
355
+ t.Fatalf("%d PTYs spawned", n)
356
+ }
357
+ })
358
+
359
+ twin.Run(t, "terminal-service", "reserves the provider session scheme from terminal creation", func(t *testing.T) {
360
+ f, owner := startTerminalFixture(t)
361
+ owner.ExpectError("createTerminal", map[string]any{"channel": "pi:/reserved-for-sessions", "claim": clientClaim(ownerID)}, wire.CodeInvalidParams)
362
+ if n := len(f.spawner.Calls()); n != 0 {
363
+ t.Fatalf("%d PTYs spawned", n)
364
+ }
365
+ })
366
+
367
+ twin.Run(t, "terminal-service", "does not let terminal disposal target another channel kind", func(t *testing.T) {
368
+ f, owner := startTerminalFixture(t)
369
+ owner.ExpectError("disposeTerminal", map[string]any{"channel": wire.RootChannel}, wire.CodeInvalidParams)
370
+ if !f.host.Store().Has(wire.RootChannel) {
371
+ t.Fatal("the root channel was disposed")
372
+ }
373
+ })
374
+
375
+ twin.Run(t, "terminal-service", "records natural exit and disposes exited or running terminals", func(t *testing.T) {
376
+ f, owner := startTerminalFixture(t)
377
+ first, firstPty := f.createTerminal(owner, map[string]any{"name": "First"})
378
+ firstPty.emitExit(7)
379
+ if got := lifecycleJSON(f.state(first)); got != `{"status":"exited","exitCode":7}` {
380
+ t.Fatalf("%s", got)
381
+ }
382
+ if _, exited := f.root().Terminals[0].Lifecycle.Value.(*ahptypes.TerminalExitedLifecycleState); !exited {
383
+ t.Fatal("root catalogue still shows the terminal as running")
384
+ }
385
+ owner.Must("disposeTerminal", map[string]any{"channel": first})
386
+ if f.host.Store().Has(first) || firstPty.Kills() != 0 || len(f.root().Terminals) != 0 {
387
+ t.Fatalf("exists=%v kills=%d terminals=%v", f.host.Store().Has(first), firstPty.Kills(), f.root().Terminals)
388
+ }
389
+
390
+ second, secondPty := f.createTerminal(owner, map[string]any{"name": "Second"})
391
+ owner.Must("disposeTerminal", map[string]any{"channel": second})
392
+ if secondPty.Kills() != 1 || f.host.Store().Has(second) {
393
+ t.Fatalf("kills=%d exists=%v", secondPty.Kills(), f.host.Store().Has(second))
394
+ }
395
+ owner.Must("disposeTerminal", map[string]any{"channel": second})
396
+ })
397
+
398
+ twin.Run(t, "terminal-service", "keeps the PTY attached across a same-host reconnect", func(t *testing.T) {
399
+ f, owner := startTerminalFixture(t)
400
+ channel, pty := f.createTerminal(owner, nil)
401
+ subscribeRaw(owner, channel)
402
+ lastSeen := f.host.ServerSeq()
403
+ owner.Close()
404
+ pty.emitData("offline output")
405
+ testkit.Eventually(t, "offline terminal output to be sequenced", func() bool { return f.host.ServerSeq() > lastSeen })
406
+
407
+ resumed := testkit.Connect(t, f.host)
408
+ t.Cleanup(resumed.Close)
409
+ var result struct {
410
+ Type string
411
+ Actions []struct{ Action map[string]any }
412
+ }
413
+ resumed.Decode(resumed.Must("reconnect", map[string]any{"channel": wire.RootChannel, "clientId": ownerID, "lastSeenServerSeq": lastSeen, "subscriptions": []string{channel}}), &result)
414
+ if result.Type != "replay" || len(result.Actions) != 1 || !reflect.DeepEqual(result.Actions[0].Action, map[string]any{"type": "terminal/data", "data": "offline output"}) {
415
+ t.Fatalf("%+v", result)
416
+ }
417
+ if len(f.spawner.Calls()) != 1 {
418
+ t.Fatal("the reconnect spawned a second PTY")
419
+ }
420
+ resumed.Dispatch(channel, map[string]any{"type": "terminal/input", "data": "continued"})
421
+ testkit.Eventually(t, "reconnected terminal input to reach the PTY", func() bool { return contains(strings.Join(pty.Writes(), ""), "continued") })
422
+ })
423
+
424
+ twin.Run(t, "terminal-service", "falls back to a live terminal snapshot after replay eviction", func(t *testing.T) {
425
+ f, owner := startTerminalFixture(t)
426
+ channel, pty := f.createTerminal(owner, nil)
427
+ subscribeRaw(owner, channel)
428
+ lastSeen := f.host.ServerSeq()
429
+ owner.Close()
430
+ chunks := []string{"zero", "one", "two", "three", "four", "five"}
431
+ for _, chunk := range chunks {
432
+ before := f.host.ServerSeq()
433
+ pty.emitData(chunk)
434
+ testkit.Eventually(t, "terminal output to be sequenced", func() bool { return f.host.ServerSeq() > before })
435
+ }
436
+ resumed := testkit.Connect(t, f.host)
437
+ t.Cleanup(resumed.Close)
438
+ var result struct {
439
+ Type string
440
+ Snapshots []struct {
441
+ Resource string
442
+ FromSeq int64
443
+ State map[string]any
444
+ }
445
+ }
446
+ resumed.Decode(resumed.Must("reconnect", map[string]any{"channel": wire.RootChannel, "clientId": ownerID, "lastSeenServerSeq": lastSeen, "subscriptions": []string{channel}}), &result)
447
+ if result.Type != "snapshot" || len(result.Snapshots) != 1 {
448
+ t.Fatalf("%+v", result)
449
+ }
450
+ snap := result.Snapshots[0]
451
+ if snap.Resource != channel || snap.FromSeq != f.host.ServerSeq() {
452
+ t.Fatalf("%+v", snap)
453
+ }
454
+ want := map[string]any{"type": "unclassified", "value": strings.Join(chunks, "")}
455
+ if !reflect.DeepEqual(snap.State["content"], []any{want}) || !reflect.DeepEqual(snap.State["lifecycle"], map[string]any{"status": "running"}) ||
456
+ !reflect.DeepEqual(snap.State["claim"], clientClaim(ownerID)) || snap.State["cols"] != float64(80) || snap.State["rows"] != float64(24) {
457
+ t.Fatalf("snapshot state %v", snap.State)
458
+ }
459
+ resumed.Dispatch(channel, map[string]any{"type": "terminal/input", "data": "after snapshot"})
460
+ testkit.Eventually(t, "post-snapshot input to reach the PTY", func() bool { return contains(strings.Join(pty.Writes(), ""), "after snapshot") })
461
+ })
462
+
463
+ twin.Run(t, "terminal-service", "bounds snapshot scrollback while retaining the newest VT stream", func(t *testing.T) {
464
+ f, owner := startTerminalFixture(t)
465
+ channel, pty := f.createTerminal(owner, nil)
466
+ pty.emitData("discard" + strings.Repeat("x", expectedScrollbackChars))
467
+ testkit.Eventually(t, "terminal output to be published", func() bool { return len(f.state(channel).Content) > 0 })
468
+ got := contentValues(f.state(channel))
469
+ if len(got) != 1 || got[0] != strings.Repeat("x", expectedScrollbackChars) {
470
+ t.Fatalf("scrollback holds %d parts, first of %d chars", len(got), len(got[0]))
471
+ }
472
+ })
473
+
474
+ twin.Run(t, "terminal-service", "kills running PTYs during service shutdown", func(t *testing.T) {
475
+ f, owner := startTerminalFixture(t)
476
+ channel, pty := f.createTerminal(owner, nil)
477
+ f.terminals.Shutdown()
478
+ if pty.Kills() != 1 || f.host.Store().Has(channel) || len(f.root().Terminals) != 0 {
479
+ t.Fatalf("kills=%d exists=%v terminals=%v", pty.Kills(), f.host.Store().Has(channel), f.root().Terminals)
480
+ }
481
+ f.terminals.Shutdown()
482
+ })
483
+ }
484
+
485
+ func TestTerminalNotWired(t *testing.T) {
486
+ twin.Run(t, "terminal-service", "returns MethodNotFound when terminal support is not wired", func(t *testing.T) {
487
+ h := testkit.NewHost(host.Options{})
488
+ c := testkit.Connect(t, h)
489
+ defer c.Close()
490
+ c.Initialize("no-terminals", nil)
491
+ c.ExpectError("createTerminal", map[string]any{"channel": "ahp-terminal:/none", "claim": clientClaim("no-terminals")}, wire.CodeMethodNotFound)
492
+ })
493
+ }
494
+
495
+ // subscribeRaw subscribes without decoding the snapshot: the vendored typed SnapshotState cannot
496
+ // tell a terminal state from a session state (see the FRICTION notes).
497
+ func subscribeRaw(c *testkit.Client, channel string) json.RawMessage {
498
+ return c.Must("subscribe", map[string]any{"channel": channel})
499
+ }
500
+
501
+ func nextFrom(t *testing.T, c *testkit.Client, channel, clientID string, clientSeq int64) ahptypes.ActionEnvelope {
502
+ t.Helper()
503
+ n, ok := c.Await(func(n testkit.Notification) bool {
504
+ env, isAction := n.Envelope()
505
+ return isAction && env.Channel == channel && env.Origin != nil && env.Origin.ClientId == clientID && env.Origin.ClientSeq == clientSeq
506
+ }, testkit.Timeout)
507
+ if !ok {
508
+ t.Fatalf("timed out waiting for %s's action %d on %s", clientID, clientSeq, channel)
509
+ }
510
+ env, _ := n.Envelope()
511
+ return env
512
+ }