@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,619 @@
1
+ /**
2
+ * The event mapper, exercised as a pure function.
3
+ *
4
+ * These tests feed minimal synthetic pi event sequences through the mapper and
5
+ * reduce the result with the protocol's own `chatReducer`, so they assert the
6
+ * resulting client state without a socket, model, or clock. Recorded streams
7
+ * are covered separately by `mapper-fixtures.test.ts`.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "node:test";
12
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ ActionType,
15
+ type ChatState,
16
+ chatReducer,
17
+ MessageAttachmentKind,
18
+ ResponsePartKind,
19
+ SessionStatus,
20
+ type StateAction,
21
+ ToolCallStatus,
22
+ TurnState,
23
+ } from "@microsoft/agent-host-protocol";
24
+ import { initialChatState } from "../src/channels/chat.ts";
25
+ import { TurnMapper, userTurnStarted } from "../src/pi/event-mapper.ts";
26
+ import { must, turnError } from "./support/assertions.ts";
27
+ import { ONE_PIXEL_PNG } from "./support/images.ts";
28
+ import { assertValid } from "./support/schema.ts";
29
+
30
+ const CHAT_URI = "ahp-chat:/c1";
31
+ const TURN = "turn-1";
32
+
33
+ /**
34
+ * pi's event types are fully specified (a real `AssistantMessage` carries
35
+ * provider, model, usage, …). These fixtures deliberately supply only the
36
+ * fields the mapper reads, which is also the point: the mapper must not depend
37
+ * on anything else.
38
+ */
39
+ function event(value: object): AgentSessionEvent {
40
+ return value as unknown as AgentSessionEvent;
41
+ }
42
+
43
+ /** Shorthands for the pi events the mapper cares about. */
44
+ const pi = {
45
+ agentStart: () => event({ type: "agent_start" }),
46
+ agentEnd: (willRetry = false) => event({ type: "agent_end", messages: [], willRetry }),
47
+ settled: () => event({ type: "agent_settled" }),
48
+ assistantStart: () => event({ type: "message_start", message: { role: "assistant", content: [] } }),
49
+ assistantEnd: (usage?: Record<string, number>) =>
50
+ event({
51
+ type: "message_end",
52
+ message: { role: "assistant", content: [], ...(usage ? { usage, model: "test-model" } : {}) },
53
+ }),
54
+ text: (contentIndex: number, delta: string) =>
55
+ event({
56
+ type: "message_update",
57
+ message: { role: "assistant" },
58
+ assistantMessageEvent: { type: "text_delta", contentIndex, delta },
59
+ }),
60
+ thinking: (contentIndex: number, delta: string) =>
61
+ event({
62
+ type: "message_update",
63
+ message: { role: "assistant" },
64
+ assistantMessageEvent: { type: "thinking_delta", contentIndex, delta },
65
+ }),
66
+ textEnd: (contentIndex: number, content: string) =>
67
+ event({
68
+ type: "message_update",
69
+ message: { role: "assistant" },
70
+ assistantMessageEvent: { type: "text_end", contentIndex, content },
71
+ }),
72
+ thinkingEnd: (contentIndex: number, content: string) =>
73
+ event({
74
+ type: "message_update",
75
+ message: { role: "assistant" },
76
+ assistantMessageEvent: { type: "thinking_end", contentIndex, content },
77
+ }),
78
+ toolStart: (contentIndex: number, id: string, name: string) =>
79
+ event({
80
+ type: "message_update",
81
+ message: { role: "assistant" },
82
+ assistantMessageEvent: {
83
+ type: "toolcall_start",
84
+ contentIndex,
85
+ partial: {
86
+ content: Array.from({ length: contentIndex + 1 }, (_, i) =>
87
+ i === contentIndex ? { type: "toolCall", id, name } : {},
88
+ ),
89
+ },
90
+ },
91
+ }),
92
+ toolDelta: (contentIndex: number, delta: string) =>
93
+ event({
94
+ type: "message_update",
95
+ message: { role: "assistant" },
96
+ assistantMessageEvent: { type: "toolcall_delta", contentIndex, delta },
97
+ }),
98
+ toolEnd: (contentIndex: number, id: string, name: string, args: unknown) =>
99
+ event({
100
+ type: "message_update",
101
+ message: { role: "assistant" },
102
+ assistantMessageEvent: {
103
+ type: "toolcall_end",
104
+ contentIndex,
105
+ toolCall: { id, name, arguments: args },
106
+ },
107
+ }),
108
+ execUpdate: (toolCallId: string, toolName: string, text: string) =>
109
+ event({
110
+ type: "tool_execution_update",
111
+ toolCallId,
112
+ toolName,
113
+ partialResult: { content: [{ type: "text", text }] },
114
+ }),
115
+ execEnd: (toolCallId: string, toolName: string, text: string, isError = false) =>
116
+ event({
117
+ type: "tool_execution_end",
118
+ toolCallId,
119
+ toolName,
120
+ result: { content: [{ type: "text", text }] },
121
+ isError,
122
+ }),
123
+ streamError: (message: string, aborted = false) =>
124
+ event({
125
+ type: "message_update",
126
+ message: { role: "assistant" },
127
+ assistantMessageEvent: {
128
+ type: "error",
129
+ reason: aborted ? "aborted" : "error",
130
+ error: { errorMessage: message, stopReason: aborted ? "aborted" : "error" },
131
+ },
132
+ }),
133
+ };
134
+
135
+ /** Runs a turn end-to-end and returns both the actions and the reduced state. */
136
+ function runTurn(events: AgentSessionEvent[], text = "Hello"): { actions: StateAction[]; state: ChatState } {
137
+ const mapper = new TurnMapper(TURN, 0);
138
+ const actions: StateAction[] = [userTurnStarted(TURN, text, "1970-01-01T00:00:00.000Z")];
139
+ for (const event of events) {
140
+ actions.push(...mapper.handle(event));
141
+ }
142
+
143
+ let state = initialChatState(CHAT_URI, "Test chat");
144
+ for (const action of actions) {
145
+ state = chatReducer(state, action as never);
146
+ }
147
+ return { actions, state };
148
+ }
149
+
150
+ describe("event mapper — turn boundary", () => {
151
+ it("spans several agent runs in a single turn", () => {
152
+ // An auto-retry produces a second agent_start/agent_end pair. Closing
153
+ // the turn on the first agent_end would make every later action a
154
+ // silent no-op in the reducer.
155
+ const { state } = runTurn([
156
+ pi.agentStart(),
157
+ pi.assistantStart(),
158
+ pi.text(0, "first attempt"),
159
+ pi.agentEnd(true),
160
+ pi.agentStart(),
161
+ pi.assistantStart(),
162
+ pi.text(0, "second attempt"),
163
+ pi.assistantEnd(),
164
+ pi.agentEnd(false),
165
+ pi.settled(),
166
+ ]);
167
+
168
+ assert.equal(state.activeTurn, undefined);
169
+ assert.equal(state.turns.length, 1);
170
+ assert.equal(state.turns[0]?.state, TurnState.Complete);
171
+ // Both attempts survive as separate parts — the protocol has no action
172
+ // that removes one, so the retry cannot rewrite history.
173
+ const markdown = must(state.turns[0]).responseParts.filter((p) => p.kind === ResponsePartKind.Markdown);
174
+ assert.deepEqual(
175
+ markdown.map((p) => (p as { content: string }).content),
176
+ ["first attempt", "second attempt"],
177
+ );
178
+ });
179
+
180
+ it("only closes the turn on agent_settled", () => {
181
+ const mapper = new TurnMapper(TURN, 0);
182
+ const beforeSettle = [pi.agentStart(), pi.assistantStart(), pi.text(0, "hi"), pi.agentEnd()].flatMap((event) =>
183
+ mapper.handle(event),
184
+ );
185
+
186
+ assert.equal(
187
+ beforeSettle.some((action) => action.type === ActionType.ChatTurnComplete),
188
+ false,
189
+ );
190
+ // The terminator now travels behind an activity clear, so look for it
191
+ // rather than assuming it is first.
192
+ const closing = mapper.handle(pi.settled());
193
+ assert.ok(closing.some((action) => action.type === ActionType.ChatTurnComplete));
194
+ });
195
+
196
+ it("reports an aborted run as cancelled, not complete", () => {
197
+ const { state } = runTurn([
198
+ pi.agentStart(),
199
+ pi.assistantStart(),
200
+ pi.text(0, "partial"),
201
+ pi.streamError("aborted by user", true),
202
+ pi.settled(),
203
+ ]);
204
+
205
+ assert.equal(state.turns[0]?.state, TurnState.Cancelled);
206
+ });
207
+
208
+ it("reports a failed run as an error and marks the chat", () => {
209
+ const { actions, state } = runTurn([
210
+ pi.agentStart(),
211
+ pi.assistantStart(),
212
+ pi.streamError("overloaded_error"),
213
+ pi.settled(),
214
+ ]);
215
+
216
+ const errorAction = must(
217
+ actions.find((action) => action.type === ActionType.ChatError),
218
+ "chat/error action",
219
+ );
220
+ assert.deepEqual(errorAction.part, {
221
+ kind: ResponsePartKind.Error,
222
+ error: { errorType: "agentRunFailed", message: "overloaded_error" },
223
+ });
224
+ assertValid("actions", "StateAction", errorAction);
225
+ assert.equal(state.turns[0]?.state, TurnState.Error);
226
+ assert.equal(turnError(state.turns[0])?.message, "overloaded_error");
227
+ assert.ok(state.status & SessionStatus.Error);
228
+ });
229
+
230
+ it("ignores events after the turn has closed", () => {
231
+ const mapper = new TurnMapper(TURN, 0);
232
+ mapper.handle(pi.settled());
233
+
234
+ assert.deepEqual(mapper.handle(pi.text(0, "late")), []);
235
+ assert.deepEqual(mapper.finish(), []);
236
+ });
237
+
238
+ it("can be closed without agent_settled", () => {
239
+ // An extension command handled entirely by pi never reaches the model,
240
+ // so no agent event ever arrives; the turn must still terminate.
241
+ const mapper = new TurnMapper(TURN, 0);
242
+ const actions = mapper.finish("complete");
243
+
244
+ assert.equal(actions[0]?.type, ActionType.ChatTurnComplete);
245
+ });
246
+ });
247
+
248
+ describe("event mapper — part identity", () => {
249
+ it("keeps contentIndex collisions across messages apart", () => {
250
+ // pi restarts contentIndex at 0 for every assistant message. Using it
251
+ // directly as a partId would append the second message's text to the
252
+ // first message's paragraph.
253
+ const { state } = runTurn([
254
+ pi.agentStart(),
255
+ pi.assistantStart(),
256
+ pi.text(0, "first message"),
257
+ pi.assistantEnd(),
258
+ pi.assistantStart(),
259
+ pi.text(0, "second message"),
260
+ pi.assistantEnd(),
261
+ pi.settled(),
262
+ ]);
263
+
264
+ const markdown = must(state.turns[0]).responseParts.filter((p) => p.kind === ResponsePartKind.Markdown);
265
+ assert.equal(markdown.length, 2);
266
+ assert.deepEqual(
267
+ markdown.map((p) => (p as { content: string }).content),
268
+ ["first message", "second message"],
269
+ );
270
+ });
271
+
272
+ it("appends consecutive deltas into one part", () => {
273
+ const { state } = runTurn([
274
+ pi.agentStart(),
275
+ pi.assistantStart(),
276
+ pi.text(0, "Hello"),
277
+ pi.text(0, ", "),
278
+ pi.text(0, "world"),
279
+ pi.settled(),
280
+ ]);
281
+
282
+ const [part] = must(state.turns[0]).responseParts;
283
+ assert.equal((part as { content: string }).content, "Hello, world");
284
+ });
285
+
286
+ it("routes thinking into a reasoning part, separate from markdown", () => {
287
+ const { state } = runTurn([
288
+ pi.agentStart(),
289
+ pi.assistantStart(),
290
+ pi.thinking(0, "let me think"),
291
+ pi.thinking(0, " harder"),
292
+ pi.text(1, "the answer"),
293
+ pi.settled(),
294
+ ]);
295
+
296
+ const parts = must(state.turns[0]).responseParts;
297
+ assert.equal(parts[0]?.kind, ResponsePartKind.Reasoning);
298
+ assert.equal((parts[0] as { content: string }).content, "let me think harder");
299
+ assert.equal(parts[1]?.kind, ResponsePartKind.Markdown);
300
+ });
301
+
302
+ it("creates parts in arrival order so interleaving survives", () => {
303
+ const { state } = runTurn([
304
+ pi.agentStart(),
305
+ pi.assistantStart(),
306
+ pi.text(0, "before"),
307
+ pi.toolStart(1, "tc-1", "bash"),
308
+ pi.toolEnd(1, "tc-1", "bash", { command: "ls" }),
309
+ pi.text(2, "after"),
310
+ pi.settled(),
311
+ ]);
312
+
313
+ assert.deepEqual(
314
+ must(state.turns[0]).responseParts.map((part) => part.kind),
315
+ [ResponsePartKind.Markdown, ResponsePartKind.ToolCall, ResponsePartKind.Markdown],
316
+ );
317
+ });
318
+
319
+ it("recovers a block a provider never streamed", () => {
320
+ // Observed live: some models emit `thinking_start` / `thinking_end`
321
+ // with no `thinking_delta` in between. Creating parts only from deltas
322
+ // would drop the content silently, exactly like targeting an unknown
323
+ // partId does.
324
+ const { state } = runTurn([
325
+ pi.agentStart(),
326
+ pi.assistantStart(),
327
+ pi.thinkingEnd(0, "reasoned without streaming"),
328
+ pi.textEnd(1, "answered without streaming"),
329
+ pi.settled(),
330
+ ]);
331
+
332
+ const parts = must(state.turns[0]).responseParts;
333
+ assert.equal(parts[0]?.kind, ResponsePartKind.Reasoning);
334
+ assert.equal((parts[0] as { content: string }).content, "reasoned without streaming");
335
+ assert.equal(parts[1]?.kind, ResponsePartKind.Markdown);
336
+ assert.equal((parts[1] as { content: string }).content, "answered without streaming");
337
+ });
338
+
339
+ it("does not duplicate a block that was streamed", () => {
340
+ const { state } = runTurn([
341
+ pi.agentStart(),
342
+ pi.assistantStart(),
343
+ pi.text(0, "Hello"),
344
+ pi.text(0, " world"),
345
+ pi.textEnd(0, "Hello world"),
346
+ pi.settled(),
347
+ ]);
348
+
349
+ const parts = must(state.turns[0]).responseParts;
350
+ assert.equal(parts.length, 1);
351
+ assert.equal((parts[0] as { content: string }).content, "Hello world");
352
+ });
353
+
354
+ it("emits no part for an empty delta", () => {
355
+ const { state } = runTurn([pi.agentStart(), pi.assistantStart(), pi.text(0, ""), pi.settled()]);
356
+
357
+ assert.deepEqual(must(state.turns[0]).responseParts, []);
358
+ });
359
+ });
360
+
361
+ describe("event mapper — tool calls", () => {
362
+ it("drives a tool call from streaming to completed without confirmation", () => {
363
+ const { state } = runTurn([
364
+ pi.agentStart(),
365
+ pi.assistantStart(),
366
+ pi.toolStart(0, "tc-1", "bash"),
367
+ pi.toolDelta(0, '{"command":'),
368
+ pi.toolDelta(0, '"ls"}'),
369
+ pi.toolEnd(0, "tc-1", "bash", { command: "ls" }),
370
+ pi.assistantEnd(),
371
+ pi.execEnd("tc-1", "bash", "file-a\nfile-b"),
372
+ pi.settled(),
373
+ ]);
374
+
375
+ const part = must(state.turns[0]).responseParts.find((p) => p.kind === ResponsePartKind.ToolCall);
376
+ assert.ok(part);
377
+ const toolCall = (part as { toolCall: { status: string; toolName: string } }).toolCall;
378
+ assert.equal(toolCall.status, ToolCallStatus.Completed);
379
+ assert.equal(toolCall.toolName, "bash");
380
+ });
381
+
382
+ it("publishes partial output while a tool is still running", () => {
383
+ const { state } = runTurn([
384
+ pi.agentStart(),
385
+ pi.assistantStart(),
386
+ pi.toolStart(0, "tc-1", "bash"),
387
+ pi.toolEnd(0, "tc-1", "bash", { command: "long-command" }),
388
+ pi.execUpdate("tc-1", "bash", "partial output"),
389
+ ]);
390
+
391
+ const part = must(state.activeTurn).responseParts.find((candidate) => candidate.kind === ResponsePartKind.ToolCall);
392
+ const call = (must(part) as { toolCall: { status: string; content?: unknown } }).toolCall;
393
+ assert.equal(call.status, ToolCallStatus.Running);
394
+ assert.deepEqual(call.content, [{ type: "text", text: "partial output" }]);
395
+ });
396
+
397
+ it("never enters pending-confirmation", () => {
398
+ // pi has no permission system, so there is nothing for a client to
399
+ // approve. Auto-confirming is the protocol's `confirmed` path, and it
400
+ // means no conforming client renders approve/deny UI for these calls.
401
+ const { actions } = runTurn([
402
+ pi.agentStart(),
403
+ pi.assistantStart(),
404
+ pi.toolStart(0, "tc-1", "bash"),
405
+ pi.toolEnd(0, "tc-1", "bash", { command: "ls" }),
406
+ pi.execEnd("tc-1", "bash", "ok"),
407
+ pi.settled(),
408
+ ]);
409
+
410
+ const ready = actions.find((action) => action.type === ActionType.ChatToolCallReady);
411
+ assert.ok(ready);
412
+ assert.equal((ready as { confirmed?: string }).confirmed, "setting");
413
+ });
414
+
415
+ it("marks a failed tool call as unsuccessful", () => {
416
+ const { state } = runTurn([
417
+ pi.agentStart(),
418
+ pi.assistantStart(),
419
+ pi.toolStart(0, "tc-1", "bash"),
420
+ pi.toolEnd(0, "tc-1", "bash", { command: "false" }),
421
+ pi.execEnd("tc-1", "bash", "boom", true),
422
+ pi.settled(),
423
+ ]);
424
+
425
+ const part = must(state.turns[0]).responseParts.find((p) => p.kind === ResponsePartKind.ToolCall);
426
+ const toolCall = (part as { toolCall: { status: string; success?: boolean } }).toolCall;
427
+ assert.equal(toolCall.status, ToolCallStatus.Completed);
428
+ assert.equal(toolCall.success, false);
429
+ });
430
+
431
+ it("force-cancels a tool call still running when the turn ends", () => {
432
+ // The reducer does this for us; asserting it here pins the behaviour
433
+ // our mapper relies on when an abort lands mid-tool.
434
+ const { state } = runTurn([
435
+ pi.agentStart(),
436
+ pi.assistantStart(),
437
+ pi.toolStart(0, "tc-1", "bash"),
438
+ pi.toolEnd(0, "tc-1", "bash", { command: "sleep 100" }),
439
+ pi.settled(),
440
+ ]);
441
+
442
+ const part = must(state.turns[0]).responseParts.find((p) => p.kind === ResponsePartKind.ToolCall);
443
+ assert.equal((part as { toolCall: { status: string } }).toolCall.status, ToolCallStatus.Cancelled);
444
+ });
445
+
446
+ it("ignores tool events for a call it never saw", () => {
447
+ const mapper = new TurnMapper(TURN, 0);
448
+ const actions = mapper.handle(pi.execEnd("unknown", "bash", "x"));
449
+
450
+ // No tool-call action: there is nothing to complete. The activity
451
+ // change still goes out — it is display state, not bookkeeping.
452
+ assert.equal(
453
+ actions.some((action) => action.type === ActionType.ChatToolCallComplete),
454
+ false,
455
+ );
456
+ });
457
+ });
458
+
459
+ describe("event mapper — usage and schema", () => {
460
+ it("carries token usage onto the turn", () => {
461
+ const { state } = runTurn([
462
+ pi.agentStart(),
463
+ pi.assistantStart(),
464
+ pi.text(0, "hi"),
465
+ pi.assistantEnd({ input: 100, output: 20, cacheRead: 5 }),
466
+ pi.settled(),
467
+ ]);
468
+
469
+ assert.deepEqual(state.turns[0]?.usage, {
470
+ inputTokens: 100,
471
+ outputTokens: 20,
472
+ cacheReadTokens: 5,
473
+ model: "test-model",
474
+ });
475
+ });
476
+
477
+ it("keeps the usage fields the protocol has no slot for", () => {
478
+ // pi reports cache writes, a reasoning-token breakdown and computed
479
+ // cost; `UsageInfo` has fields for none of them. Dropping them would
480
+ // lose exactly the numbers a client needs to show what a turn cost.
481
+ const { state } = runTurn([
482
+ pi.agentStart(),
483
+ pi.assistantStart(),
484
+ pi.text(0, "hi"),
485
+ pi.assistantEnd({ input: 3, output: 49, cacheRead: 0, cacheWrite: 2699, reasoning: 20, totalTokens: 2751 }),
486
+ pi.settled(),
487
+ ]);
488
+
489
+ assert.deepEqual(state.turns[0]?.usage?._meta, {
490
+ cacheWriteTokens: 2699,
491
+ reasoningTokens: 20,
492
+ totalTokens: 2751,
493
+ });
494
+ });
495
+
496
+ it("emits only schema-conforming actions", () => {
497
+ const { actions } = runTurn([
498
+ pi.agentStart(),
499
+ pi.assistantStart(),
500
+ pi.thinking(0, "hmm"),
501
+ pi.text(1, "answer"),
502
+ pi.toolStart(2, "tc-1", "read"),
503
+ pi.toolDelta(2, '{"path":"a"}'),
504
+ pi.toolEnd(2, "tc-1", "read", { path: "a" }),
505
+ pi.assistantEnd({ input: 1, output: 2, cacheRead: 0 }),
506
+ pi.execEnd("tc-1", "read", "contents"),
507
+ pi.settled(),
508
+ ]);
509
+
510
+ for (const action of actions) {
511
+ assertValid("actions", "StateAction", action, `bad action: ${action.type}`);
512
+ }
513
+ });
514
+ });
515
+
516
+ describe("event mapper — outcome from stopReason", () => {
517
+ it("treats an aborted assistant message as a cancelled turn", () => {
518
+ // Aborting before anything streams produces no `error` delta at all:
519
+ // the only signal is the finished message's stopReason. Observed live in
520
+ // the `abort` fixture.
521
+ const { state } = runTurn([
522
+ pi.agentStart(),
523
+ pi.assistantStart(),
524
+ event({
525
+ type: "message_end",
526
+ message: { role: "assistant", stopReason: "aborted", errorMessage: "Request aborted" },
527
+ }),
528
+ pi.settled(),
529
+ ]);
530
+
531
+ assert.equal(state.turns[0]?.state, TurnState.Cancelled);
532
+ });
533
+
534
+ it("treats a failed assistant message as an error turn", () => {
535
+ const { state } = runTurn([
536
+ pi.agentStart(),
537
+ pi.assistantStart(),
538
+ event({
539
+ type: "message_end",
540
+ message: { role: "assistant", stopReason: "error", errorMessage: "upstream exploded" },
541
+ }),
542
+ pi.settled(),
543
+ ]);
544
+
545
+ assert.equal(state.turns[0]?.state, TurnState.Error);
546
+ assert.equal(turnError(state.turns[0])?.message, "upstream exploded");
547
+ });
548
+ });
549
+
550
+ describe("event mapper — injected messages", () => {
551
+ it("gives an injected user message its own turn", () => {
552
+ // pi stores a steering message as an ordinary user message with nothing
553
+ // marking it as steering, so a rebuild from disk necessarily makes it a
554
+ // turn. The live path has to match or the same conversation renders
555
+ // differently before and after a reload.
556
+ const injectedContent = [
557
+ { type: "text", text: "Stop counting." },
558
+ { type: "image", data: ONE_PIXEL_PNG, mimeType: "image/png" },
559
+ ];
560
+ const { state } = runTurn([
561
+ pi.agentStart(),
562
+ pi.assistantStart(),
563
+ pi.text(0, "1\n2\n3"),
564
+ pi.assistantEnd(),
565
+ event({ type: "message_start", message: { role: "user", content: injectedContent } }),
566
+ event({ type: "message_end", message: { role: "user", content: injectedContent } }),
567
+ pi.assistantStart(),
568
+ pi.text(0, "STOPPED"),
569
+ pi.assistantEnd(),
570
+ pi.settled(),
571
+ ]);
572
+
573
+ assert.equal(state.turns.length, 2);
574
+ assert.equal(state.turns[0]?.message.text, "Hello");
575
+ assert.equal(state.turns[1]?.message.text, "Stop counting.");
576
+ assert.deepEqual(state.turns[1]?.message.attachments, [
577
+ {
578
+ type: MessageAttachmentKind.EmbeddedResource,
579
+ label: "Image 1",
580
+ displayKind: "image",
581
+ data: ONE_PIXEL_PNG,
582
+ contentType: "image/png",
583
+ },
584
+ ]);
585
+ assert.equal((must(state.turns[1]).responseParts[0] as { content: string }).content, "STOPPED");
586
+ });
587
+
588
+ it("does not split on the run's own opening prompt", () => {
589
+ // The first user message is the prompt the client already opened the
590
+ // turn with; splitting there would produce an empty leading turn.
591
+ const { state } = runTurn([
592
+ pi.agentStart(),
593
+ event({ type: "message_start", message: { role: "user", content: "Hello" } }),
594
+ event({ type: "message_end", message: { role: "user", content: "Hello" } }),
595
+ pi.assistantStart(),
596
+ pi.text(0, "hi"),
597
+ pi.settled(),
598
+ ]);
599
+
600
+ assert.equal(state.turns.length, 1);
601
+ });
602
+
603
+ it("keeps part ids unique across a split", () => {
604
+ const { actions } = runTurn([
605
+ pi.agentStart(),
606
+ pi.assistantStart(),
607
+ pi.text(0, "before"),
608
+ event({ type: "message_start", message: { role: "user", content: "switch" } }),
609
+ pi.assistantStart(),
610
+ pi.text(0, "after"),
611
+ pi.settled(),
612
+ ]);
613
+
614
+ const ids = actions
615
+ .filter((action) => action.type === ActionType.ChatResponsePart)
616
+ .map((action) => ("id" in action.part ? action.part.id : undefined));
617
+ assert.equal(new Set(ids).size, ids.length, "part ids collided across the split");
618
+ });
619
+ });