@cloudcli-ai/cloudcli 1.37.2 → 1.37.4

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 (453) hide show
  1. package/README.md +269 -0
  2. package/dist/assets/{abnfDiagram-N423BO3Z-C4UAoHJb.js → abnfDiagram-N423BO3Z-Dmkwx38H.js} +1 -1
  3. package/dist/assets/{arc-B8jF-ja9.js → arc-DLe9AYuO.js} +1 -1
  4. package/dist/assets/{architectureDiagram-T3A2C74G-C_Wur_CU.js → architectureDiagram-T3A2C74G-CBbbbNUW.js} +1 -1
  5. package/dist/assets/{blockDiagram-VBNYF7ZC-Cq4P1i8t.js → blockDiagram-VBNYF7ZC-CaMEaWu4.js} +1 -1
  6. package/dist/assets/{c4Diagram-5PPSVZJV-C1wG4eUj.js → c4Diagram-5PPSVZJV-DhjMgNnR.js} +1 -1
  7. package/dist/assets/channel-CWrmwsir.js +1 -0
  8. package/dist/assets/{chunk-2GRJ4B5K-B8fnBG6E.js → chunk-2GRJ4B5K-yjl3-F-i.js} +1 -1
  9. package/dist/assets/{chunk-2Q5K7J3B-WdfPooPJ.js → chunk-2Q5K7J3B-DsAYaWEP.js} +1 -1
  10. package/dist/assets/{chunk-5RXB4S5H-DhZJuG2E.js → chunk-5RXB4S5H-Bt5qmqOX.js} +1 -1
  11. package/dist/assets/{chunk-5VM5RSS4-B0rjslal.js → chunk-5VM5RSS4-yWrWVvUt.js} +1 -1
  12. package/dist/assets/{chunk-6Q2QTUOP-CG2Oj4jL.js → chunk-6Q2QTUOP-Dt66K5xb.js} +1 -1
  13. package/dist/assets/{chunk-GF5L2VYU-rrl2lRxY.js → chunk-GF5L2VYU-DA0vzaj4.js} +1 -1
  14. package/dist/assets/{chunk-JWPE2WC7-CjVbrVMA.js → chunk-JWPE2WC7-CUkbx96V.js} +1 -1
  15. package/dist/assets/{chunk-KBJHAD2P-CRzKgU8S.js → chunk-KBJHAD2P-DWKxx1WH.js} +1 -1
  16. package/dist/assets/{chunk-RYQCIY6F-DBz4GTZF.js → chunk-RYQCIY6F-BqluplN0.js} +1 -1
  17. package/dist/assets/{chunk-XXDRQBXY-wHxalsXk.js → chunk-XXDRQBXY-DelKg3Xf.js} +1 -1
  18. package/dist/assets/classDiagram-JCYQIIEL-COAGCC6w.js +1 -0
  19. package/dist/assets/classDiagram-v2-OCEON4UE-COAGCC6w.js +1 -0
  20. package/dist/assets/{cose-bilkent-JH36ORCC-s1hygY8e.js → cose-bilkent-JH36ORCC-DHV93-Em.js} +1 -1
  21. package/dist/assets/{cynefin-VYW2F7L2-C4LGV15i.js → cynefin-VYW2F7L2-B9HZgoGX.js} +1 -1
  22. package/dist/assets/{cynefinDiagram-MW4NZA55-C5uhu7B3.js → cynefinDiagram-MW4NZA55-hFjjEQg8.js} +1 -1
  23. package/dist/assets/{dagre-VZM6K2ZE-B-OBTewm.js → dagre-VZM6K2ZE-CpeVOC7V.js} +1 -1
  24. package/dist/assets/{diagram-7IWD3JNH-BjGKQxih.js → diagram-7IWD3JNH-D0H1UoRA.js} +1 -1
  25. package/dist/assets/{diagram-B4RE2ZJO-BMKz1X2L.js → diagram-B4RE2ZJO-6Y7BQUup.js} +1 -1
  26. package/dist/assets/{diagram-LBJQPF4R-DNh-GhKC.js → diagram-LBJQPF4R-C0nNOwht.js} +1 -1
  27. package/dist/assets/{diagram-Q27KOJAE-BaTG9jPT.js → diagram-Q27KOJAE-En6QlOVX.js} +1 -1
  28. package/dist/assets/{diagram-UB23O5K3-BUwo2OvJ.js → diagram-UB23O5K3-CmPiNeGD.js} +1 -1
  29. package/dist/assets/{ebnfDiagram-BXEA7PRR-DAZgfzX2.js → ebnfDiagram-BXEA7PRR-Bygzp1Qt.js} +1 -1
  30. package/dist/assets/{erDiagram-JOGREHBK-1w1CcGEb.js → erDiagram-JOGREHBK-CwScRfzl.js} +1 -1
  31. package/dist/assets/{flowDiagram-UKHOOZJN-D4EII8Jj.js → flowDiagram-UKHOOZJN-bKX-0xP6.js} +1 -1
  32. package/dist/assets/{ganttDiagram-PKOTCBZU-ChvJZAgB.js → ganttDiagram-PKOTCBZU-DtZPKXGd.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-DS77QQ5N-DtOn41S8.js → gitGraphDiagram-DS77QQ5N-CUOtdL1o.js} +1 -1
  34. package/dist/assets/index-CdjIFxM2.js +5004 -0
  35. package/dist/assets/index-DgafhGTF.css +32 -0
  36. package/dist/assets/{infoDiagram-6WML65LV-DN_PVEJZ.js → infoDiagram-6WML65LV-ciRQTS0h.js} +1 -1
  37. package/dist/assets/{ishikawaDiagram-WSZJBQD7-DbkF1kXb.js → ishikawaDiagram-WSZJBQD7-CgjhQZ84.js} +1 -1
  38. package/dist/assets/{journeyDiagram-NVQOT4AX-pJwmhddE.js → journeyDiagram-NVQOT4AX-BsbQSeIW.js} +1 -1
  39. package/dist/assets/{kanban-definition-27J2QSJJ-PPAPgNp5.js → kanban-definition-27J2QSJJ-CrwzY9Jw.js} +1 -1
  40. package/dist/assets/{linear-iPR-J9f7.js → linear-BUaCDr6l.js} +1 -1
  41. package/dist/assets/{mermaid.core-BEUL86Z6.js → mermaid.core-ClgdUU0j.js} +5 -5
  42. package/dist/assets/{mindmap-definition-FAOFIHXS-BvZoBib7.js → mindmap-definition-FAOFIHXS-BictljY1.js} +1 -1
  43. package/dist/assets/{pegDiagram-VL7TDLO6-DhYreaUd.js → pegDiagram-VL7TDLO6-BXdByy_U.js} +1 -1
  44. package/dist/assets/{pieDiagram-7S7Q4E2Y-CrOHlyD0.js → pieDiagram-7S7Q4E2Y-uZsMfkRX.js} +1 -1
  45. package/dist/assets/{quadrantDiagram-CIZ2JOQS-D4g5yH3t.js → quadrantDiagram-CIZ2JOQS-7lCv4QIj.js} +1 -1
  46. package/dist/assets/{railroadDiagram-AXF67PYL-Dj39lHlf.js → railroadDiagram-AXF67PYL-07dxIWbi.js} +1 -1
  47. package/dist/assets/{requirementDiagram-LRYGKXZP-1AS4Zgm_.js → requirementDiagram-LRYGKXZP-Cb31Yv1_.js} +1 -1
  48. package/dist/assets/{sankeyDiagram-W5VNT64P-CKlMn19P.js → sankeyDiagram-W5VNT64P-X0YPSyF9.js} +1 -1
  49. package/dist/assets/{sequenceDiagram-SI44F4Z6-B_j3qyvo.js → sequenceDiagram-SI44F4Z6-BdBxSIlP.js} +1 -1
  50. package/dist/assets/server.browser-CtX3Hdl5.js +27 -0
  51. package/dist/assets/{sizeCapture-X5ZJPWSS-Bp2Op16v.js → sizeCapture-X5ZJPWSS-DQM5xAyD.js} +1 -1
  52. package/dist/assets/{stateDiagram-OKZ733FA-C0cN6DoY.js → stateDiagram-OKZ733FA-DJ3xkLUF.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-UEYNNEHI-vAaE4zNo.js +1 -0
  54. package/dist/assets/{swimlanes-SLNWSIFB-BEvXOV9b.js → swimlanes-SLNWSIFB-rjfYdW0w.js} +2 -2
  55. package/dist/assets/swimlanesDiagram-ULZ7WXOC-TV2IF4Nq.js +8 -0
  56. package/dist/assets/{timeline-definition-Z64GVDOM-zdJQinvZ.js → timeline-definition-Z64GVDOM-DZMra5do.js} +1 -1
  57. package/dist/assets/{vendor-codemirror-OwyKSvPE.js → vendor-codemirror-CYrSt7-U.js} +18 -18
  58. package/dist/assets/{vennDiagram-T6HMQDX7-DuF1uZMY.js → vennDiagram-T6HMQDX7-BevD8uu0.js} +1 -1
  59. package/dist/assets/{wardleyDiagram-T6FBY63Y-B9AlLQup.js → wardleyDiagram-T6FBY63Y-DsIyXB7z.js} +1 -1
  60. package/dist/assets/{xychartDiagram-ELKLHX3M-DTJX48Qg.js → xychartDiagram-ELKLHX3M-G3PlF3ko.js} +1 -1
  61. package/dist/index.html +3 -3
  62. package/dist-server/server/index.js +11 -4
  63. package/dist-server/server/index.js.map +1 -1
  64. package/dist-server/server/modules/agent/agent.module.js +9 -2
  65. package/dist-server/server/modules/agent/agent.module.js.map +1 -1
  66. package/dist-server/server/modules/agent/agent.routes.js +177 -130
  67. package/dist-server/server/modules/agent/agent.routes.js.map +1 -1
  68. package/dist-server/server/modules/agent/tests/agent-run-registration.test.js +306 -0
  69. package/dist-server/server/modules/agent/tests/agent-run-registration.test.js.map +1 -0
  70. package/dist-server/server/modules/agent/tests/agent.routes.test.js +48 -2
  71. package/dist-server/server/modules/agent/tests/agent.routes.test.js.map +1 -1
  72. package/dist-server/server/modules/browser-use/browser-use.routes.js +39 -33
  73. package/dist-server/server/modules/browser-use/browser-use.routes.js.map +1 -1
  74. package/dist-server/server/modules/database/connection.js +1 -1
  75. package/dist-server/server/modules/database/connection.js.map +1 -1
  76. package/dist-server/server/modules/database/index.js +5 -0
  77. package/dist-server/server/modules/database/index.js.map +1 -1
  78. package/dist-server/server/modules/database/migrations.js +45 -1
  79. package/dist-server/server/modules/database/migrations.js.map +1 -1
  80. package/dist-server/server/modules/database/repositories/provider-models.js +60 -6
  81. package/dist-server/server/modules/database/repositories/provider-models.js.map +1 -1
  82. package/dist-server/server/modules/database/repositories/scheduled-messages.db.js +71 -0
  83. package/dist-server/server/modules/database/repositories/scheduled-messages.db.js.map +1 -0
  84. package/dist-server/server/modules/database/repositories/session-drafts.db.js +105 -0
  85. package/dist-server/server/modules/database/repositories/session-drafts.db.js.map +1 -0
  86. package/dist-server/server/modules/database/repositories/sessions.db.js +144 -5
  87. package/dist-server/server/modules/database/repositories/sessions.db.js.map +1 -1
  88. package/dist-server/server/modules/database/repositories/user-preferences.db.js +65 -0
  89. package/dist-server/server/modules/database/repositories/user-preferences.db.js.map +1 -0
  90. package/dist-server/server/modules/database/schema.js +99 -0
  91. package/dist-server/server/modules/database/schema.js.map +1 -1
  92. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js +99 -0
  93. package/dist-server/server/modules/database/tests/provider-models.db.integration.test.js.map +1 -1
  94. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js +39 -0
  95. package/dist-server/server/modules/database/tests/sessions.db.integration.test.js.map +1 -1
  96. package/dist-server/server/modules/database/tests/user-preferences.db.integration.test.js +105 -0
  97. package/dist-server/server/modules/database/tests/user-preferences.db.integration.test.js.map +1 -0
  98. package/dist-server/server/modules/file-tree/file-tree.module.js +2 -1
  99. package/dist-server/server/modules/file-tree/file-tree.module.js.map +1 -1
  100. package/dist-server/server/modules/file-tree/file-tree.service.js +44 -7
  101. package/dist-server/server/modules/file-tree/file-tree.service.js.map +1 -1
  102. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js +126 -1
  103. package/dist-server/server/modules/file-tree/tests/file-tree.service.test.js.map +1 -1
  104. package/dist-server/server/modules/git/git-branch-diff.service.js +142 -0
  105. package/dist-server/server/modules/git/git-branch-diff.service.js.map +1 -0
  106. package/dist-server/server/modules/git/git-branch.service.js.map +1 -1
  107. package/dist-server/server/modules/git/git.routes.js +92 -5
  108. package/dist-server/server/modules/git/git.routes.js.map +1 -1
  109. package/dist-server/server/modules/git/tests/git-branch-diff.service.test.js +160 -0
  110. package/dist-server/server/modules/git/tests/git-branch-diff.service.test.js.map +1 -0
  111. package/dist-server/server/modules/projects/project-clone.routes.js +108 -0
  112. package/dist-server/server/modules/projects/project-clone.routes.js.map +1 -0
  113. package/dist-server/server/modules/projects/projects.routes.js +13 -66
  114. package/dist-server/server/modules/projects/projects.routes.js.map +1 -1
  115. package/dist-server/server/modules/projects/services/project-clone-request.service.js +36 -0
  116. package/dist-server/server/modules/projects/services/project-clone-request.service.js.map +1 -0
  117. package/dist-server/server/modules/projects/services/project-clone.service.js +86 -32
  118. package/dist-server/server/modules/projects/services/project-clone.service.js.map +1 -1
  119. package/dist-server/server/modules/projects/services/project-delete.service.js +12 -1
  120. package/dist-server/server/modules/projects/services/project-delete.service.js.map +1 -1
  121. package/dist-server/server/modules/projects/services/project-star.service.js +2 -2
  122. package/dist-server/server/modules/projects/services/project-star.service.js.map +1 -1
  123. package/dist-server/server/modules/projects/tests/project-clone.routes.test.js +191 -0
  124. package/dist-server/server/modules/projects/tests/project-clone.routes.test.js.map +1 -0
  125. package/dist-server/server/modules/projects/tests/project-clone.service.test.js +275 -0
  126. package/dist-server/server/modules/projects/tests/project-clone.service.test.js.map +1 -1
  127. package/dist-server/server/modules/providers/index.js +3 -0
  128. package/dist-server/server/modules/providers/index.js.map +1 -1
  129. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +62 -9
  130. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js.map +1 -1
  131. package/dist-server/server/modules/providers/list/claude/claude-fork.provider.js +46 -0
  132. package/dist-server/server/modules/providers/list/claude/claude-fork.provider.js.map +1 -0
  133. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js +31 -12
  134. package/dist-server/server/modules/providers/list/claude/claude-models.provider.js.map +1 -1
  135. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js +472 -53
  136. package/dist-server/server/modules/providers/list/claude/claude-runtime.provider.js.map +1 -1
  137. package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js +38 -11
  138. package/dist-server/server/modules/providers/list/claude/claude-session-synchronizer.provider.js.map +1 -1
  139. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js +1007 -60
  140. package/dist-server/server/modules/providers/list/claude/claude-sessions.provider.js.map +1 -1
  141. package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js +4 -1
  142. package/dist-server/server/modules/providers/list/claude/claude-skills.provider.js.map +1 -1
  143. package/dist-server/server/modules/providers/list/claude/claude.provider.js +2 -0
  144. package/dist-server/server/modules/providers/list/claude/claude.provider.js.map +1 -1
  145. package/dist-server/server/modules/providers/list/codex/codex-app-server.client.js +204 -0
  146. package/dist-server/server/modules/providers/list/codex/codex-app-server.client.js.map +1 -0
  147. package/dist-server/server/modules/providers/list/codex/codex-fork.provider.js +32 -0
  148. package/dist-server/server/modules/providers/list/codex/codex-fork.provider.js.map +1 -0
  149. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js +47 -0
  150. package/dist-server/server/modules/providers/list/codex/codex-models.provider.js.map +1 -1
  151. package/dist-server/server/modules/providers/list/codex/codex-runtime.provider.js +71 -53
  152. package/dist-server/server/modules/providers/list/codex/codex-runtime.provider.js.map +1 -1
  153. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js +8 -0
  154. package/dist-server/server/modules/providers/list/codex/codex-session-synchronizer.provider.js.map +1 -1
  155. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js +1621 -431
  156. package/dist-server/server/modules/providers/list/codex/codex-sessions.provider.js.map +1 -1
  157. package/dist-server/server/modules/providers/list/codex/codex.provider.js +2 -0
  158. package/dist-server/server/modules/providers/list/codex/codex.provider.js.map +1 -1
  159. package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js +9 -2
  160. package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js.map +1 -1
  161. package/dist-server/server/modules/providers/list/cursor/cursor-runtime.provider.js +16 -8
  162. package/dist-server/server/modules/providers/list/cursor/cursor-runtime.provider.js.map +1 -1
  163. package/dist-server/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.js.map +1 -1
  164. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js +274 -9
  165. package/dist-server/server/modules/providers/list/opencode/opencode-models.provider.js.map +1 -1
  166. package/dist-server/server/modules/providers/list/opencode/opencode-runtime.provider.js +5 -2
  167. package/dist-server/server/modules/providers/list/opencode/opencode-runtime.provider.js.map +1 -1
  168. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js +19 -2
  169. package/dist-server/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.js.map +1 -1
  170. package/dist-server/server/modules/providers/provider.routes.js +134 -2
  171. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  172. package/dist-server/server/modules/providers/services/claude-cli-liveness.service.js +100 -0
  173. package/dist-server/server/modules/providers/services/claude-cli-liveness.service.js.map +1 -0
  174. package/dist-server/server/modules/providers/services/claude-settings-permissions.service.js +97 -0
  175. package/dist-server/server/modules/providers/services/claude-settings-permissions.service.js.map +1 -0
  176. package/dist-server/server/modules/providers/services/provider-capabilities.service.js +23 -1
  177. package/dist-server/server/modules/providers/services/provider-capabilities.service.js.map +1 -1
  178. package/dist-server/server/modules/providers/services/provider-models.service.js +59 -5
  179. package/dist-server/server/modules/providers/services/provider-models.service.js.map +1 -1
  180. package/dist-server/server/modules/providers/services/provider-runtime.service.js +8 -0
  181. package/dist-server/server/modules/providers/services/provider-runtime.service.js.map +1 -1
  182. package/dist-server/server/modules/providers/services/provider-token-usage.service.js +139 -35
  183. package/dist-server/server/modules/providers/services/provider-token-usage.service.js.map +1 -1
  184. package/dist-server/server/modules/providers/services/session-conversations-search.service.js +2 -4
  185. package/dist-server/server/modules/providers/services/session-conversations-search.service.js.map +1 -1
  186. package/dist-server/server/modules/providers/services/session-history-cache.service.js +91 -0
  187. package/dist-server/server/modules/providers/services/session-history-cache.service.js.map +1 -0
  188. package/dist-server/server/modules/providers/services/session-synchronizer.service.js +105 -32
  189. package/dist-server/server/modules/providers/services/session-synchronizer.service.js.map +1 -1
  190. package/dist-server/server/modules/providers/services/sessions-watcher.service.js +3 -60
  191. package/dist-server/server/modules/providers/services/sessions-watcher.service.js.map +1 -1
  192. package/dist-server/server/modules/providers/services/sessions.service.js +252 -12
  193. package/dist-server/server/modules/providers/services/sessions.service.js.map +1 -1
  194. package/dist-server/server/modules/providers/tests/claude-auth.test.js +183 -0
  195. package/dist-server/server/modules/providers/tests/claude-auth.test.js.map +1 -1
  196. package/dist-server/server/modules/providers/tests/claude-background-work.test.js +172 -0
  197. package/dist-server/server/modules/providers/tests/claude-background-work.test.js.map +1 -0
  198. package/dist-server/server/modules/providers/tests/claude-compaction.test.js +84 -0
  199. package/dist-server/server/modules/providers/tests/claude-compaction.test.js.map +1 -0
  200. package/dist-server/server/modules/providers/tests/claude-message-model.test.js +123 -0
  201. package/dist-server/server/modules/providers/tests/claude-message-model.test.js.map +1 -0
  202. package/dist-server/server/modules/providers/tests/claude-models.test.js +39 -0
  203. package/dist-server/server/modules/providers/tests/claude-models.test.js.map +1 -0
  204. package/dist-server/server/modules/providers/tests/claude-runtime-hold.test.js +265 -0
  205. package/dist-server/server/modules/providers/tests/claude-runtime-hold.test.js.map +1 -0
  206. package/dist-server/server/modules/providers/tests/claude-sessions.test.js +1815 -33
  207. package/dist-server/server/modules/providers/tests/claude-sessions.test.js.map +1 -1
  208. package/dist-server/server/modules/providers/tests/claude-settings-permissions.test.js +228 -0
  209. package/dist-server/server/modules/providers/tests/claude-settings-permissions.test.js.map +1 -0
  210. package/dist-server/server/modules/providers/tests/claude-subagent-echo.test.js +16 -0
  211. package/dist-server/server/modules/providers/tests/claude-subagent-echo.test.js.map +1 -0
  212. package/dist-server/server/modules/providers/tests/claude-token-budget.test.js +85 -0
  213. package/dist-server/server/modules/providers/tests/claude-token-budget.test.js.map +1 -0
  214. package/dist-server/server/modules/providers/tests/codex-message-editing.test.js +338 -0
  215. package/dist-server/server/modules/providers/tests/codex-message-editing.test.js.map +1 -0
  216. package/dist-server/server/modules/providers/tests/codex-models.test.js +25 -0
  217. package/dist-server/server/modules/providers/tests/codex-models.test.js.map +1 -0
  218. package/dist-server/server/modules/providers/tests/codex-runtime.test.js +83 -0
  219. package/dist-server/server/modules/providers/tests/codex-runtime.test.js.map +1 -0
  220. package/dist-server/server/modules/providers/tests/codex-session-fork.test.js +54 -0
  221. package/dist-server/server/modules/providers/tests/codex-session-fork.test.js.map +1 -0
  222. package/dist-server/server/modules/providers/tests/codex-sessions.test.js +354 -15
  223. package/dist-server/server/modules/providers/tests/codex-sessions.test.js.map +1 -1
  224. package/dist-server/server/modules/providers/tests/cursor-auth.test.js +167 -0
  225. package/dist-server/server/modules/providers/tests/cursor-auth.test.js.map +1 -0
  226. package/dist-server/server/modules/providers/tests/opencode-models.test.js +196 -4
  227. package/dist-server/server/modules/providers/tests/opencode-models.test.js.map +1 -1
  228. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js +19 -1
  229. package/dist-server/server/modules/providers/tests/opencode-sessions.test.js.map +1 -1
  230. package/dist-server/server/modules/providers/tests/provider-capabilities.service.test.js +30 -0
  231. package/dist-server/server/modules/providers/tests/provider-capabilities.service.test.js.map +1 -0
  232. package/dist-server/server/modules/providers/tests/provider-models.service.test.js +79 -1
  233. package/dist-server/server/modules/providers/tests/provider-models.service.test.js.map +1 -1
  234. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js +164 -1
  235. package/dist-server/server/modules/providers/tests/provider-token-usage.service.test.js.map +1 -1
  236. package/dist-server/server/modules/providers/tests/provider.routes.test.js +190 -1
  237. package/dist-server/server/modules/providers/tests/provider.routes.test.js.map +1 -1
  238. package/dist-server/server/modules/providers/tests/session-fork.test.js +113 -0
  239. package/dist-server/server/modules/providers/tests/session-fork.test.js.map +1 -0
  240. package/dist-server/server/modules/providers/tests/session-history-cache.service.test.js +139 -0
  241. package/dist-server/server/modules/providers/tests/session-history-cache.service.test.js.map +1 -0
  242. package/dist-server/server/modules/providers/tests/session-orphan-prune.test.js +90 -0
  243. package/dist-server/server/modules/providers/tests/session-orphan-prune.test.js.map +1 -0
  244. package/dist-server/server/modules/providers/tests/session-synchronizer-coalescing.test.js +122 -0
  245. package/dist-server/server/modules/providers/tests/session-synchronizer-coalescing.test.js.map +1 -0
  246. package/dist-server/server/modules/providers/tests/sessions.service.test.js +219 -1
  247. package/dist-server/server/modules/providers/tests/sessions.service.test.js.map +1 -1
  248. package/dist-server/server/modules/providers/tests/skills.test.js +15 -3
  249. package/dist-server/server/modules/providers/tests/skills.test.js.map +1 -1
  250. package/dist-server/server/modules/scheduled-messages/index.js +5 -0
  251. package/dist-server/server/modules/scheduled-messages/index.js.map +1 -0
  252. package/dist-server/server/modules/scheduled-messages/scheduled-messages.routes.js +47 -0
  253. package/dist-server/server/modules/scheduled-messages/scheduled-messages.routes.js.map +1 -0
  254. package/dist-server/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.js +175 -0
  255. package/dist-server/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.js.map +1 -0
  256. package/dist-server/server/modules/scheduled-messages/services/scheduled-messages.service.js +95 -0
  257. package/dist-server/server/modules/scheduled-messages/services/scheduled-messages.service.js.map +1 -0
  258. package/dist-server/server/modules/scheduled-messages/tests/scheduled-messages.test.js +290 -0
  259. package/dist-server/server/modules/scheduled-messages/tests/scheduled-messages.test.js.map +1 -0
  260. package/dist-server/server/modules/taskmaster/taskmaster.routes.js +23 -23
  261. package/dist-server/server/modules/taskmaster/taskmaster.routes.js.map +1 -1
  262. package/dist-server/server/modules/taskmaster/tests/taskmaster.routes.test.js +48 -0
  263. package/dist-server/server/modules/taskmaster/tests/taskmaster.routes.test.js.map +1 -1
  264. package/dist-server/server/modules/user/tests/user.service.test.js +70 -0
  265. package/dist-server/server/modules/user/tests/user.service.test.js.map +1 -1
  266. package/dist-server/server/modules/user/user.module.js +10 -1
  267. package/dist-server/server/modules/user/user.module.js.map +1 -1
  268. package/dist-server/server/modules/user/user.routes.js +44 -0
  269. package/dist-server/server/modules/user/user.routes.js.map +1 -1
  270. package/dist-server/server/modules/user/user.service.js +75 -0
  271. package/dist-server/server/modules/user/user.service.js.map +1 -1
  272. package/dist-server/server/modules/voice/tests/voice.service.test.js +3 -2
  273. package/dist-server/server/modules/voice/tests/voice.service.test.js.map +1 -1
  274. package/dist-server/server/modules/websocket/index.js +7 -0
  275. package/dist-server/server/modules/websocket/index.js.map +1 -1
  276. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js +37 -49
  277. package/dist-server/server/modules/websocket/services/chat-run-registry.service.js.map +1 -1
  278. package/dist-server/server/modules/websocket/services/chat-session-writer.service.js +35 -5
  279. package/dist-server/server/modules/websocket/services/chat-session-writer.service.js.map +1 -1
  280. package/dist-server/server/modules/websocket/services/chat-websocket.service.js +204 -10
  281. package/dist-server/server/modules/websocket/services/chat-websocket.service.js.map +1 -1
  282. package/dist-server/server/modules/websocket/services/session-upsert-broadcast.service.js +94 -0
  283. package/dist-server/server/modules/websocket/services/session-upsert-broadcast.service.js.map +1 -0
  284. package/dist-server/server/modules/websocket/services/shell-websocket.service.js +236 -20
  285. package/dist-server/server/modules/websocket/services/shell-websocket.service.js.map +1 -1
  286. package/dist-server/server/modules/websocket/tests/chat-background-subscribe.test.js +137 -0
  287. package/dist-server/server/modules/websocket/tests/chat-background-subscribe.test.js.map +1 -0
  288. package/dist-server/server/modules/websocket/tests/chat-edit-send.test.js +263 -0
  289. package/dist-server/server/modules/websocket/tests/chat-edit-send.test.js.map +1 -0
  290. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js +29 -3
  291. package/dist-server/server/modules/websocket/tests/chat-run-registry.test.js.map +1 -1
  292. package/dist-server/server/modules/websocket/tests/chat-stop-task.test.js +96 -0
  293. package/dist-server/server/modules/websocket/tests/chat-stop-task.test.js.map +1 -0
  294. package/dist-server/server/modules/websocket/tests/session-upsert-broadcast.test.js +106 -0
  295. package/dist-server/server/modules/websocket/tests/session-upsert-broadcast.test.js.map +1 -0
  296. package/dist-server/server/modules/websocket/tests/shell-websocket.service.test.js +500 -0
  297. package/dist-server/server/modules/websocket/tests/shell-websocket.service.test.js.map +1 -1
  298. package/dist-server/server/shared/claude-cli-path.js +22 -5
  299. package/dist-server/server/shared/claude-cli-path.js.map +1 -1
  300. package/dist-server/server/shared/index.js +3 -0
  301. package/dist-server/server/shared/index.js.map +1 -0
  302. package/dist-server/server/shared/message-unification.js +376 -0
  303. package/dist-server/server/shared/message-unification.js.map +1 -0
  304. package/dist-server/server/shared/tests/claude-cli-path.test.js +45 -1
  305. package/dist-server/server/shared/tests/claude-cli-path.test.js.map +1 -1
  306. package/dist-server/server/shared/tests/message-unification.test.js +174 -0
  307. package/dist-server/server/shared/tests/message-unification.test.js.map +1 -0
  308. package/dist-server/server/shared/tests/read-only-roots.test.js +124 -0
  309. package/dist-server/server/shared/tests/read-only-roots.test.js.map +1 -0
  310. package/dist-server/server/shared/tests/strip-ansi-sequences.test.js +31 -0
  311. package/dist-server/server/shared/tests/strip-ansi-sequences.test.js.map +1 -0
  312. package/dist-server/server/shared/utils.js +160 -0
  313. package/dist-server/server/shared/utils.js.map +1 -1
  314. package/electron/desktopWindow.js +6 -0
  315. package/electron/viewHost.js +25 -2
  316. package/package.json +20 -17
  317. package/server/index.ts +16 -5
  318. package/server/modules/agent/agent.module.ts +11 -1
  319. package/server/modules/agent/agent.routes.ts +200 -142
  320. package/server/modules/agent/tests/agent-run-registration.test.ts +348 -0
  321. package/server/modules/agent/tests/agent.routes.test.ts +51 -2
  322. package/server/modules/browser-use/browser-use.routes.ts +96 -33
  323. package/server/modules/database/connection.ts +2 -1
  324. package/server/modules/database/index.ts +10 -0
  325. package/server/modules/database/migrations.ts +51 -0
  326. package/server/modules/database/repositories/provider-models.ts +79 -6
  327. package/server/modules/database/repositories/scheduled-messages.db.ts +127 -0
  328. package/server/modules/database/repositories/session-drafts.db.ts +171 -0
  329. package/server/modules/database/repositories/sessions.db.ts +204 -3
  330. package/server/modules/database/repositories/user-preferences.db.ts +80 -0
  331. package/server/modules/database/schema.ts +103 -0
  332. package/server/modules/database/tests/provider-models.db.integration.test.ts +113 -0
  333. package/server/modules/database/tests/sessions.db.integration.test.ts +52 -0
  334. package/server/modules/database/tests/user-preferences.db.integration.test.ts +135 -0
  335. package/server/modules/file-tree/file-tree.module.ts +2 -1
  336. package/server/modules/file-tree/file-tree.service.ts +49 -7
  337. package/server/modules/file-tree/tests/file-tree.service.test.ts +163 -1
  338. package/server/modules/git/git-branch-diff.service.ts +204 -0
  339. package/server/modules/git/git-branch.service.ts +2 -12
  340. package/server/modules/git/git.routes.ts +100 -6
  341. package/server/modules/git/tests/git-branch-diff.service.test.ts +210 -0
  342. package/server/modules/projects/project-clone.routes.ts +142 -0
  343. package/server/modules/projects/projects.routes.ts +16 -81
  344. package/server/modules/projects/services/project-clone-request.service.ts +56 -0
  345. package/server/modules/projects/services/project-clone.service.ts +102 -34
  346. package/server/modules/projects/services/project-delete.service.ts +13 -1
  347. package/server/modules/projects/services/project-star.service.ts +2 -2
  348. package/server/modules/projects/tests/project-clone.routes.test.ts +246 -0
  349. package/server/modules/projects/tests/project-clone.service.test.ts +376 -0
  350. package/server/modules/providers/index.ts +4 -0
  351. package/server/modules/providers/list/claude/claude-auth.provider.ts +81 -11
  352. package/server/modules/providers/list/claude/claude-fork.provider.ts +57 -0
  353. package/server/modules/providers/list/claude/claude-models.provider.ts +37 -17
  354. package/server/modules/providers/list/claude/claude-runtime.provider.js +510 -54
  355. package/server/modules/providers/list/claude/claude-session-synchronizer.provider.ts +40 -14
  356. package/server/modules/providers/list/claude/claude-sessions.provider.ts +1195 -63
  357. package/server/modules/providers/list/claude/claude-skills.provider.ts +4 -1
  358. package/server/modules/providers/list/claude/claude.provider.ts +3 -0
  359. package/server/modules/providers/list/codex/codex-app-server.client.ts +243 -0
  360. package/server/modules/providers/list/codex/codex-fork.provider.ts +40 -0
  361. package/server/modules/providers/list/codex/codex-models.provider.ts +47 -0
  362. package/server/modules/providers/list/codex/{codex-runtime.provider.js → codex-runtime.provider.ts} +105 -70
  363. package/server/modules/providers/list/codex/codex-session-synchronizer.provider.ts +9 -0
  364. package/server/modules/providers/list/codex/codex-sessions.provider.ts +1847 -455
  365. package/server/modules/providers/list/codex/codex.provider.ts +3 -0
  366. package/server/modules/providers/list/cursor/cursor-auth.provider.ts +10 -2
  367. package/server/modules/providers/list/cursor/cursor-runtime.provider.js +17 -8
  368. package/server/modules/providers/list/cursor/cursor-session-synchronizer.provider.ts +0 -1
  369. package/server/modules/providers/list/opencode/opencode-models.provider.ts +297 -9
  370. package/server/modules/providers/list/opencode/opencode-runtime.provider.js +5 -2
  371. package/server/modules/providers/list/opencode/opencode-session-synchronizer.provider.ts +30 -2
  372. package/server/modules/providers/provider.routes.ts +162 -2
  373. package/server/modules/providers/services/claude-cli-liveness.service.ts +121 -0
  374. package/server/modules/providers/services/claude-settings-permissions.service.ts +127 -0
  375. package/server/modules/providers/services/provider-capabilities.service.ts +40 -1
  376. package/server/modules/providers/services/provider-models.service.ts +71 -4
  377. package/server/modules/providers/services/provider-runtime.service.ts +11 -0
  378. package/server/modules/providers/services/provider-token-usage.service.ts +166 -39
  379. package/server/modules/providers/services/session-conversations-search.service.ts +2 -5
  380. package/server/modules/providers/services/session-history-cache.service.ts +150 -0
  381. package/server/modules/providers/services/session-synchronizer.service.ts +125 -38
  382. package/server/modules/providers/services/sessions-watcher.service.ts +3 -64
  383. package/server/modules/providers/services/sessions.service.ts +309 -17
  384. package/server/modules/providers/tests/claude-auth.test.ts +208 -0
  385. package/server/modules/providers/tests/claude-background-work.test.ts +213 -0
  386. package/server/modules/providers/tests/claude-compaction.test.ts +101 -0
  387. package/server/modules/providers/tests/claude-message-model.test.ts +160 -0
  388. package/server/modules/providers/tests/claude-models.test.ts +87 -0
  389. package/server/modules/providers/tests/claude-runtime-hold.test.ts +309 -0
  390. package/server/modules/providers/tests/claude-sessions.test.ts +2167 -37
  391. package/server/modules/providers/tests/claude-settings-permissions.test.ts +271 -0
  392. package/server/modules/providers/tests/claude-subagent-echo.test.ts +25 -0
  393. package/server/modules/providers/tests/claude-token-budget.test.ts +104 -0
  394. package/server/modules/providers/tests/codex-message-editing.test.ts +419 -0
  395. package/server/modules/providers/tests/codex-models.test.ts +37 -0
  396. package/server/modules/providers/tests/codex-runtime.test.ts +94 -0
  397. package/server/modules/providers/tests/codex-session-fork.test.ts +59 -0
  398. package/server/modules/providers/tests/codex-sessions.test.ts +396 -15
  399. package/server/modules/providers/tests/cursor-auth.test.ts +187 -0
  400. package/server/modules/providers/tests/opencode-models.test.ts +266 -8
  401. package/server/modules/providers/tests/opencode-sessions.test.ts +40 -1
  402. package/server/modules/providers/tests/provider-capabilities.service.test.ts +37 -0
  403. package/server/modules/providers/tests/provider-models.service.test.ts +99 -1
  404. package/server/modules/providers/tests/provider-token-usage.service.test.ts +188 -1
  405. package/server/modules/providers/tests/provider.routes.test.ts +230 -1
  406. package/server/modules/providers/tests/session-fork.test.ts +173 -0
  407. package/server/modules/providers/tests/session-history-cache.service.test.ts +165 -0
  408. package/server/modules/providers/tests/session-orphan-prune.test.ts +107 -0
  409. package/server/modules/providers/tests/session-synchronizer-coalescing.test.ts +136 -0
  410. package/server/modules/providers/tests/sessions.service.test.ts +303 -1
  411. package/server/modules/providers/tests/skills.test.ts +21 -5
  412. package/server/modules/scheduled-messages/index.ts +8 -0
  413. package/server/modules/scheduled-messages/scheduled-messages.routes.ts +68 -0
  414. package/server/modules/scheduled-messages/services/scheduled-message-dispatcher.service.ts +216 -0
  415. package/server/modules/scheduled-messages/services/scheduled-messages.service.ts +121 -0
  416. package/server/modules/scheduled-messages/tests/scheduled-messages.test.ts +354 -0
  417. package/server/modules/taskmaster/taskmaster.routes.ts +23 -33
  418. package/server/modules/taskmaster/tests/taskmaster.routes.test.ts +57 -0
  419. package/server/modules/user/tests/user.service.test.ts +90 -0
  420. package/server/modules/user/user.module.ts +10 -1
  421. package/server/modules/user/user.routes.ts +44 -0
  422. package/server/modules/user/user.service.ts +105 -0
  423. package/server/modules/voice/tests/voice.service.test.ts +3 -2
  424. package/server/modules/websocket/README.md +4 -2
  425. package/server/modules/websocket/index.ts +8 -0
  426. package/server/modules/websocket/services/chat-run-registry.service.ts +45 -54
  427. package/server/modules/websocket/services/chat-session-writer.service.ts +36 -6
  428. package/server/modules/websocket/services/chat-websocket.service.ts +302 -16
  429. package/server/modules/websocket/services/session-upsert-broadcast.service.ts +108 -0
  430. package/server/modules/websocket/services/shell-websocket.service.ts +276 -22
  431. package/server/modules/websocket/tests/chat-background-subscribe.test.ts +167 -0
  432. package/server/modules/websocket/tests/chat-edit-send.test.ts +309 -0
  433. package/server/modules/websocket/tests/chat-run-registry.test.ts +34 -3
  434. package/server/modules/websocket/tests/chat-stop-task.test.ts +127 -0
  435. package/server/modules/websocket/tests/session-upsert-broadcast.test.ts +138 -0
  436. package/server/modules/websocket/tests/shell-websocket.service.test.ts +629 -0
  437. package/server/shared/claude-cli-path.ts +24 -6
  438. package/server/shared/index.ts +3 -0
  439. package/server/shared/interfaces.ts +91 -0
  440. package/server/shared/message-unification.ts +449 -0
  441. package/server/shared/tests/claude-cli-path.test.ts +58 -1
  442. package/server/shared/tests/message-unification.test.ts +218 -0
  443. package/server/shared/tests/read-only-roots.test.ts +139 -0
  444. package/server/shared/tests/strip-ansi-sequences.test.ts +42 -0
  445. package/server/shared/types.ts +382 -7
  446. package/server/shared/utils.ts +176 -0
  447. package/dist/assets/channel-_falYbU1.js +0 -1
  448. package/dist/assets/classDiagram-JCYQIIEL-Dn-3CfpR.js +0 -1
  449. package/dist/assets/classDiagram-v2-OCEON4UE-Dn-3CfpR.js +0 -1
  450. package/dist/assets/index-CzPLBvXT.css +0 -32
  451. package/dist/assets/index-DFkBBuSA.js +0 -1549
  452. package/dist/assets/stateDiagram-v2-UEYNNEHI-BvBxUUrZ.js +0 -1
  453. package/dist/assets/swimlanesDiagram-ULZ7WXOC-CsE-x4YK.js +0 -8
@@ -1,61 +1,2191 @@
1
1
  import assert from 'node:assert/strict';
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
2
5
  import test from 'node:test';
3
6
 
7
+ import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
4
8
  import { ClaudeSessionsProvider } from '@/modules/providers/list/claude/claude-sessions.provider.js';
9
+ import { ClaudeSessionSynchronizer } from '@/modules/providers/list/claude/claude-session-synchronizer.provider.js';
10
+ import { buildLookupMap } from '@/shared/utils.js';
5
11
 
6
- const SESSION_ID = 'session-1';
12
+ async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
13
+ const previousDatabasePath = process.env.DATABASE_PATH;
14
+ const tempDirectory = await mkdtemp(path.join(os.tmpdir(), 'claude-provider-db-'));
7
15
 
8
- const SKILL_BODY = [
9
- 'Base directory for this skill: /tmp/claude/bundled-skills/2.1.220/abc123/claude-api',
10
- '',
11
- '# Building LLM-Powered Applications with Claude',
12
- '',
13
- 'This skill helps you build LLM-powered applications with Claude.',
14
- ].join('\n');
16
+ closeConnection();
17
+ process.env.DATABASE_PATH = path.join(tempDirectory, 'auth.db');
18
+ await initializeDatabase();
15
19
 
16
- test('claude: injected skill bodies are hidden even without the isMeta flag', () => {
17
- const provider = new ClaudeSessionsProvider();
20
+ try {
21
+ await runTest();
22
+ } finally {
23
+ closeConnection();
24
+ if (previousDatabasePath === undefined) {
25
+ delete process.env.DATABASE_PATH;
26
+ } else {
27
+ process.env.DATABASE_PATH = previousDatabasePath;
28
+ }
29
+ await rm(tempDirectory, { recursive: true, force: true });
30
+ }
31
+ }
32
+
33
+ const patchHomeDir = (nextHomeDir: string) => {
34
+ const original = os.homedir;
35
+ (os as any).homedir = () => nextHomeDir;
36
+ return () => {
37
+ (os as any).homedir = original;
38
+ };
39
+ };
40
+
41
+ /**
42
+ * Writes a minimal valid Claude JSONL session file with enough fields for
43
+ * `extractFirstValidJsonlData` to parse `sessionId` and `cwd`.
44
+ */
45
+ async function writeSessionJsonl(
46
+ dirPath: string,
47
+ fileName: string,
48
+ lines: string[],
49
+ ): Promise<string> {
50
+ const filePath = path.join(dirPath, fileName);
51
+ const head = [
52
+ JSON.stringify({ type: 'mode', mode: 'normal', sessionId: 'test-session-1' }),
53
+ JSON.stringify({ type: 'permission-mode', permissionMode: 'default', sessionId: 'test-session-1' }),
54
+ JSON.stringify({
55
+ parentUuid: null,
56
+ isSidechain: false,
57
+ type: 'user',
58
+ message: { role: 'user', content: 'first prompt' },
59
+ uuid: 'msg-1',
60
+ timestamp: '2026-07-10T00:00:00.000Z',
61
+ cwd: '/workspace/demo',
62
+ sessionId: 'test-session-1',
63
+ }),
64
+ ];
65
+ const content = [...head, ...lines, ''].join('\n');
66
+ await writeFile(filePath, content, 'utf8');
67
+ return filePath;
68
+ }
69
+
70
+ const SESSION_ID = 'claude-session-1';
71
+ const AGENT_ID = 'a1b2c3d4e5f60718';
72
+ const AGENT_TOOL_USE_ID = 'toolu_agent_1';
73
+
74
+ /**
75
+ * Writes the transcript pair current Claude versions produce for one async
76
+ * subagent: the parent session, and the agent's own transcript plus sidecar
77
+ * metadata under `<session>/subagents/`.
78
+ */
79
+ async function writeClaudeSubagentSession(projectDirectory: string): Promise<string> {
80
+ const parentPath = path.join(projectDirectory, `${SESSION_ID}.jsonl`);
81
+ const subagentDirectory = path.join(projectDirectory, SESSION_ID, 'subagents');
82
+ await mkdir(subagentDirectory, { recursive: true });
18
83
 
19
- // The live SDK stream omits `isMeta`, so the payload has to be recognised by
20
- // its content or it renders as a giant user bubble mid-run.
21
- const live = provider.normalizeMessage(
84
+ const parentLines = [
22
85
  {
23
- uuid: 'u1',
24
- timestamp: '2026-07-28T10:00:00.000Z',
25
- message: { role: 'user', content: [{ type: 'text', text: SKILL_BODY }] },
86
+ type: 'assistant',
87
+ uuid: 'assistant-1',
88
+ sessionId: SESSION_ID,
89
+ timestamp: '2026-08-21T10:00:00.000Z',
90
+ message: {
91
+ role: 'assistant',
92
+ content: [{
93
+ type: 'tool_use',
94
+ id: AGENT_TOOL_USE_ID,
95
+ name: 'Agent',
96
+ input: { subagent_type: 'Explore', description: 'Survey the repo', prompt: 'Look around' },
97
+ }],
98
+ },
26
99
  },
27
- SESSION_ID,
28
- );
29
- assert.deepEqual(live, []);
100
+ {
101
+ type: 'user',
102
+ uuid: 'launch-ack-1',
103
+ sessionId: SESSION_ID,
104
+ timestamp: '2026-08-21T10:00:01.000Z',
105
+ message: {
106
+ role: 'user',
107
+ content: [{
108
+ type: 'tool_result',
109
+ tool_use_id: AGENT_TOOL_USE_ID,
110
+ content: 'Async agent launched successfully. agentId: internal bookkeeping',
111
+ }],
112
+ },
113
+ toolUseResult: {
114
+ isAsync: true,
115
+ status: 'async_launched',
116
+ agentId: AGENT_ID,
117
+ description: 'Survey the repo',
118
+ resolvedModel: 'claude-opus-5',
119
+ },
120
+ },
121
+ {
122
+ type: 'user',
123
+ uuid: 'notification-1',
124
+ sessionId: SESSION_ID,
125
+ timestamp: '2026-08-21T10:05:00.000Z',
126
+ message: {
127
+ role: 'user',
128
+ content: [{
129
+ type: 'text',
130
+ text: [
131
+ '<task-notification>',
132
+ `<task-id>${AGENT_ID}</task-id>`,
133
+ `<tool-use-id>${AGENT_TOOL_USE_ID}</tool-use-id>`,
134
+ '<status>completed</status>',
135
+ '<summary>Agent "Survey the repo" finished</summary>',
136
+ '<result>The repo has two packages.</result>',
137
+ '</task-notification>',
138
+ ].join('\n'),
139
+ }],
140
+ },
141
+ },
142
+ ];
143
+ await writeFile(parentPath, `${parentLines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8');
30
144
 
31
- const persisted = provider.normalizeMessage(
145
+ const agentLines = [
146
+ {
147
+ type: 'assistant',
148
+ isSidechain: true,
149
+ agentId: AGENT_ID,
150
+ timestamp: '2026-08-21T10:00:30.000Z',
151
+ message: {
152
+ role: 'assistant',
153
+ model: 'claude-opus-5',
154
+ content: [
155
+ { type: 'text', text: 'Starting the survey.' },
156
+ { type: 'tool_use', id: 'toolu_child_1', name: 'Read', input: { file_path: '/repo/package.json' } },
157
+ ],
158
+ },
159
+ },
32
160
  {
33
- uuid: 'u2',
34
- timestamp: '2026-07-28T10:00:00.000Z',
35
- isMeta: true,
36
- message: { role: 'user', content: [{ type: 'text', text: SKILL_BODY }] },
161
+ type: 'user',
162
+ isSidechain: true,
163
+ agentId: AGENT_ID,
164
+ timestamp: '2026-08-21T10:00:31.000Z',
165
+ message: {
166
+ role: 'user',
167
+ content: [{ type: 'tool_result', tool_use_id: 'toolu_child_1', content: '{"name":"repo"}' }],
168
+ },
37
169
  },
38
- SESSION_ID,
170
+ ];
171
+ await writeFile(
172
+ path.join(subagentDirectory, `agent-${AGENT_ID}.jsonl`),
173
+ `${agentLines.map((line) => JSON.stringify(line)).join('\n')}\n`,
174
+ 'utf8',
175
+ );
176
+ await writeFile(
177
+ path.join(subagentDirectory, `agent-${AGENT_ID}.meta.json`),
178
+ JSON.stringify({ agentType: 'Explore', description: 'Survey the repo', toolUseId: AGENT_TOOL_USE_ID, spawnDepth: 1 }),
179
+ 'utf8',
39
180
  );
40
- assert.deepEqual(persisted, []);
181
+
182
+ return parentPath;
183
+ }
184
+
185
+ test('Claude history attaches a subagent transcript stored under the session directory', { concurrency: false }, async () => {
186
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-subagent-'));
187
+
188
+ try {
189
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
190
+
191
+ await withIsolatedDatabase(async () => {
192
+ const now = new Date().toISOString();
193
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
194
+
195
+ const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
196
+ providerSessionId: SESSION_ID,
197
+ });
198
+ const agentRow = history.messages.find(
199
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
200
+ );
201
+
202
+ assert.ok(agentRow, 'the Agent call must be in the transcript');
203
+ assert.equal(agentRow.subagent?.id, AGENT_ID);
204
+ assert.equal(agentRow.subagent?.type, 'Explore');
205
+ assert.equal(agentRow.subagent?.description, 'Survey the repo');
206
+ assert.equal(agentRow.subagent?.status, 'completed');
207
+
208
+ // The agent's own work — prose and tool calls — comes from its separate
209
+ // transcript, which is the file the previous lookup never found.
210
+ assert.equal(agentRow.subagentTools?.length, 2);
211
+ assert.equal(agentRow.subagentTools?.[0].kind, 'text');
212
+ assert.equal(agentRow.subagentTools?.[1].toolName, 'Read');
213
+ assert.equal(agentRow.subagentTools?.[1].toolResult?.content, '{"name":"repo"}');
214
+ });
215
+ } finally {
216
+ await rm(tempRoot, { recursive: true, force: true });
217
+ }
41
218
  });
42
219
 
43
- test('claude: the Skill tool result itself still reaches the UI', () => {
44
- const provider = new ClaudeSessionsProvider();
220
+ test('Claude history folds an agent task notification into the call that spawned it', { concurrency: false }, async () => {
221
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-notification-'));
222
+
223
+ try {
224
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
225
+
226
+ await withIsolatedDatabase(async () => {
227
+ const now = new Date().toISOString();
228
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
229
+
230
+ const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
231
+ providerSessionId: SESSION_ID,
232
+ });
233
+ const agentRow = history.messages.find(
234
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
235
+ );
236
+
237
+ // The launch acknowledgement is internal bookkeeping; the agent's answer
238
+ // is what belongs on its card.
239
+ assert.equal(agentRow?.toolResult?.content, 'The repo has two packages.');
240
+
241
+ const strayNotification = history.messages.find(
242
+ (message) => typeof message.content === 'string' && message.content.includes('<task-notification>'),
243
+ );
244
+ assert.equal(strayNotification, undefined, 'the folded notification must not also render on its own');
245
+ });
246
+ } finally {
247
+ await rm(tempRoot, { recursive: true, force: true });
248
+ }
249
+ });
250
+
251
+ /**
252
+ * Rewrites the notification turn into the shape the harness writes when it
253
+ * consumes the report inline, while the parent turn is still running: a
254
+ * top-level `queue-operation` record with the payload in `content`, no
255
+ * `message`, no `uuid`, and no user-role turn anywhere in the file.
256
+ */
257
+ async function useQueueOperationNotification(
258
+ parentPath: string,
259
+ operation: 'remove' | 'enqueue',
260
+ ): Promise<void> {
261
+ const lines = (await readFile(parentPath, 'utf8')).split('\n').filter(Boolean);
262
+ const rewritten = lines.map((line) => {
263
+ const row = JSON.parse(line) as Record<string, any>;
264
+ if (!line.includes('task-notification')) {
265
+ return line;
266
+ }
267
+
268
+ return JSON.stringify({
269
+ type: 'queue-operation',
270
+ operation,
271
+ timestamp: row.timestamp,
272
+ sessionId: row.sessionId,
273
+ content: row.message.content[0].text,
274
+ });
275
+ });
276
+ await writeFile(parentPath, `${rewritten.join('\n')}\n`, 'utf8');
277
+ }
278
+
279
+ for (const operation of ['remove', 'enqueue'] as const) {
280
+ test(`Claude history folds a ${operation} queue-operation notification onto the agent card`, { concurrency: false }, async () => {
281
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-queued-notification-'));
282
+
283
+ try {
284
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
285
+ await useQueueOperationNotification(parentPath, operation);
286
+
287
+ await withIsolatedDatabase(async () => {
288
+ const now = new Date().toISOString();
289
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
290
+
291
+ const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
292
+ providerSessionId: SESSION_ID,
293
+ });
294
+ const agentRow = history.messages.find(
295
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
296
+ );
297
+
298
+ // The report is in the file, just in the shape the collector used to
299
+ // skip — so the card has to read exactly as it does for a user-role
300
+ // notification: the agent's answer on the card, and a settled status.
301
+ assert.equal(agentRow?.toolResult?.content, 'The repo has two packages.');
302
+ assert.equal(agentRow?.subagent?.status, 'completed');
303
+
304
+ const strayNotification = history.messages.find(
305
+ (message) => typeof message.content === 'string' && message.content.includes('<task-notification>'),
306
+ );
307
+ assert.equal(strayNotification, undefined, 'a folded notification must never also render on its own');
308
+ });
309
+ } finally {
310
+ await rm(tempRoot, { recursive: true, force: true });
311
+ }
312
+ });
313
+ }
314
+
315
+ test('Claude history keeps rows that have no uuid when a queue-operation notification is folded', { concurrency: false }, async () => {
316
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-uuidless-row-'));
317
+
318
+ try {
319
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
320
+ await useQueueOperationNotification(parentPath, 'remove');
321
+ // A queue-operation record carries no `uuid`, so folding it must not put an
322
+ // empty string in the drop set — that would match every other uuid-less row
323
+ // and delete it from the transcript.
324
+ await writeFile(
325
+ parentPath,
326
+ `${(await readFile(parentPath, 'utf8')).trim()}\n${JSON.stringify({
327
+ type: 'assistant',
328
+ sessionId: SESSION_ID,
329
+ timestamp: '2026-08-21T10:06:00.000Z',
330
+ message: { role: 'assistant', content: [{ type: 'text', text: 'A row with no uuid.' }] },
331
+ })}\n`,
332
+ 'utf8',
333
+ );
334
+
335
+ await withIsolatedDatabase(async () => {
336
+ const now = new Date().toISOString();
337
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
338
+
339
+ const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
340
+ providerSessionId: SESSION_ID,
341
+ });
342
+
343
+ assert.ok(
344
+ history.messages.some((message) => message.content === 'A row with no uuid.'),
345
+ 'a row without a uuid must survive the fold',
346
+ );
347
+ });
348
+ } finally {
349
+ await rm(tempRoot, { recursive: true, force: true });
350
+ }
351
+ });
352
+
353
+ test('Claude history drops every notification row of a task that reported more than once', { concurrency: false }, async () => {
354
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-renotified-agent-'));
355
+
356
+ try {
357
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
358
+ // A resumed session first reports the task as stopped ("no completion
359
+ // record was found"), then the task finishes and reports again. The last
360
+ // word is what the card shows; the earlier row is superseded bookkeeping
361
+ // and must not linger as a bubble of its own.
362
+ const lines = (await readFile(parentPath, 'utf8')).split('\n').filter(Boolean);
363
+ const [notificationLine] = lines.filter((line) => line.includes('task-notification'));
364
+ const completed = JSON.parse(notificationLine) as Record<string, any>;
365
+ const stopped = {
366
+ ...completed,
367
+ uuid: 'notification-0',
368
+ timestamp: '2026-08-21T10:04:00.000Z',
369
+ message: {
370
+ role: 'user',
371
+ content: [{
372
+ type: 'text',
373
+ text: completed.message.content[0].text
374
+ .replace('<status>completed</status>', '<status>stopped</status>')
375
+ .replace(/<result>[\s\S]*<\/result>/, '')
376
+ .replace(/<summary>[^<]*<\/summary>/, '<summary>No completion record was found for this agent</summary>'),
377
+ }],
378
+ },
379
+ };
380
+ const rewritten = lines.flatMap((line) => (line === notificationLine ? [JSON.stringify(stopped), line] : [line]));
381
+ await writeFile(parentPath, `${rewritten.join('\n')}\n`, 'utf8');
382
+
383
+ await withIsolatedDatabase(async () => {
384
+ const now = new Date().toISOString();
385
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
386
+
387
+ const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
388
+ providerSessionId: SESSION_ID,
389
+ });
390
+ const agentRow = history.messages.find(
391
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
392
+ );
393
+
394
+ assert.equal(agentRow?.subagent?.status, 'completed');
395
+ assert.equal(agentRow?.toolResult?.content, 'The repo has two packages.');
396
+ assert.ok(
397
+ !history.messages.some((message) => (message.content ?? '').includes('<task-notification>')),
398
+ 'no notification row may render on its own once the task is folded',
399
+ );
400
+ });
401
+ } finally {
402
+ await rm(tempRoot, { recursive: true, force: true });
403
+ }
404
+ });
405
+
406
+ /** Strips the `<task-notification>` turn so the agent has no reported outcome. */
407
+ async function dropTaskNotification(parentPath: string): Promise<void> {
408
+ const raw = await readFile(parentPath, 'utf8');
409
+ await writeFile(
410
+ parentPath,
411
+ `${raw.split('\n').filter((line) => line && !line.includes('task-notification')).join('\n')}\n`,
412
+ 'utf8',
413
+ );
414
+ }
415
+
416
+ test('Claude history keeps a background agent running until its outcome is reported', { concurrency: false }, async () => {
417
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-live-agent-'));
418
+
419
+ try {
420
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
421
+ await dropTaskNotification(parentPath);
422
+
423
+ await withIsolatedDatabase(async () => {
424
+ const now = new Date().toISOString();
425
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
426
+
427
+ // The run that launched the agent is still up, so it can still report.
428
+ const liveRunStartedAt = Date.parse('2026-08-21T09:59:00.000Z');
429
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => liveRunStartedAt }).fetchHistory(SESSION_ID, {
430
+ providerSessionId: SESSION_ID,
431
+ });
432
+ const agentRow = history.messages.find(
433
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
434
+ );
435
+
436
+ // This agent's transcript ends on a resolved tool call, which used to be
437
+ // read as proof it had finished. A background agent routinely stops there
438
+ // while its work is outstanding — it says it is waiting on something and
439
+ // ends its turn — so the transcript proves nothing and only the
440
+ // notification does. Reporting `completed` here dropped the spinner and
441
+ // collapsed the card while the agent was still working.
442
+ assert.equal(agentRow?.subagent?.status, 'running');
443
+ assert.equal(agentRow?.toolResult?.content, '', 'the launch acknowledgement must never show as a result');
444
+ });
445
+ } finally {
446
+ await rm(tempRoot, { recursive: true, force: true });
447
+ }
448
+ });
449
+
450
+ test('Claude history reports a background agent stopped once its session process is gone', { concurrency: false }, async () => {
451
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-stopped-agent-'));
452
+
453
+ try {
454
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
455
+ await dropTaskNotification(parentPath);
456
+
457
+ await withIsolatedDatabase(async () => {
458
+ const now = new Date().toISOString();
459
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
460
+
461
+ // A background agent runs inside the session's CLI process. With that
462
+ // process gone — the run was stopped, or crashed — an agent that never
463
+ // reported never will, and "still running" would pin a spinner on the
464
+ // card forever.
465
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(SESSION_ID, {
466
+ providerSessionId: SESSION_ID,
467
+ });
468
+ const agentRow = history.messages.find(
469
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
470
+ );
471
+
472
+ assert.equal(agentRow?.subagent?.status, 'stopped');
473
+ assert.equal(agentRow?.toolResult?.content, '', 'the launch acknowledgement must never show as a result');
474
+ });
475
+ } finally {
476
+ await rm(tempRoot, { recursive: true, force: true });
477
+ }
478
+ });
479
+
480
+ test('Claude history reports a background agent stopped when a later run of the session is live', { concurrency: false }, async () => {
481
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-orphaned-agent-'));
482
+
483
+ try {
484
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
485
+ await dropTaskNotification(parentPath);
486
+
487
+ await withIsolatedDatabase(async () => {
488
+ const now = new Date().toISOString();
489
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
490
+
491
+ // Each turn of a session is its own CLI process. The agent was launched
492
+ // at 10:00:01 by a process that has since exited; the run up now started
493
+ // an hour later and cannot deliver that agent's report. Reading it as
494
+ // `running` made an orphaned card flicker back to a spinner on every
495
+ // later turn.
496
+ const laterRunStartedAt = Date.parse('2026-08-21T11:00:00.000Z');
497
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => laterRunStartedAt }).fetchHistory(SESSION_ID, {
498
+ providerSessionId: SESSION_ID,
499
+ });
500
+ const agentRow = history.messages.find(
501
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
502
+ );
503
+
504
+ assert.equal(agentRow?.subagent?.status, 'stopped');
505
+ });
506
+ } finally {
507
+ await rm(tempRoot, { recursive: true, force: true });
508
+ }
509
+ });
510
+
511
+ test('Claude history asks after the session process by both the app and the provider session id', { concurrency: false }, async () => {
512
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-liveness-keys-'));
513
+ const appSessionId = 'app-session-1';
514
+
515
+ try {
516
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
517
+ await dropTaskNotification(parentPath);
45
518
 
46
- const messages = provider.normalizeMessage(
519
+ await withIsolatedDatabase(async () => {
520
+ const now = new Date().toISOString();
521
+ // The DB row carries the app-facing id; the transcript rows carry the
522
+ // provider-native one. The runtime keys its process map by whichever
523
+ // id started the run — the app id from the chat gateway, the provider
524
+ // id from the agent API — so a probe that knows only one of them must
525
+ // still find the run.
526
+ sessionsDb.createSession(appSessionId, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
527
+
528
+ for (const liveId of [appSessionId, SESSION_ID]) {
529
+ const history = await new ClaudeSessionsProvider({
530
+ getLiveRunStartTime: (sessionId) => (sessionId === liveId ? Date.parse('2026-08-21T09:59:00.000Z') : null),
531
+ }).fetchHistory(appSessionId, { providerSessionId: SESSION_ID });
532
+ const agentRow = history.messages.find(
533
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
534
+ );
535
+
536
+ assert.equal(agentRow?.subagent?.status, 'running', `a run keyed by ${liveId} must read as live`);
537
+ }
538
+ });
539
+ } finally {
540
+ await rm(tempRoot, { recursive: true, force: true });
541
+ }
542
+ });
543
+
544
+ /** Removes the agent's own transcript and sidecar, leaving only the parent's rows. */
545
+ async function dropSubagentTranscript(projectDirectory: string): Promise<void> {
546
+ await rm(path.join(projectDirectory, SESSION_ID, 'subagents'), { recursive: true, force: true });
547
+ }
548
+
549
+ for (const reported of ['completed', 'failed'] as const) {
550
+ test(`Claude history settles a ${reported} background agent from its notification when its transcript is missing`, { concurrency: false }, async () => {
551
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-transcriptless-agent-'));
552
+
553
+ try {
554
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
555
+ await useQueueOperationNotification(parentPath, 'remove');
556
+ await writeFile(
557
+ parentPath,
558
+ (await readFile(parentPath, 'utf8')).replace('<status>completed</status>', `<status>${reported}</status>`),
559
+ 'utf8',
560
+ );
561
+ await dropSubagentTranscript(tempRoot);
562
+
563
+ await withIsolatedDatabase(async () => {
564
+ const now = new Date().toISOString();
565
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
566
+
567
+ // The notification is the only record of the outcome, and it is
568
+ // enough: the agent's own transcript names its timeline, not whether
569
+ // it finished. Folding the answer onto the card while leaving the
570
+ // status unset read as `running` on a card whose result said "done".
571
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(SESSION_ID, {
572
+ providerSessionId: SESSION_ID,
573
+ });
574
+ const agentRow = history.messages.find(
575
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
576
+ );
577
+
578
+ assert.equal(agentRow?.toolResult?.content, 'The repo has two packages.');
579
+ assert.equal(agentRow?.subagent?.status, reported);
580
+ assert.equal(agentRow?.subagent?.id, AGENT_ID);
581
+ assert.equal(agentRow?.subagent?.description, 'Survey the repo');
582
+ assert.equal(agentRow?.subagent?.model, 'claude-opus-5');
583
+ assert.equal(agentRow?.subagentTools, undefined, 'there is no timeline to show without a transcript');
584
+ });
585
+ } finally {
586
+ await rm(tempRoot, { recursive: true, force: true });
587
+ }
588
+ });
589
+ }
590
+
591
+ test('Claude history reports a forked background agent stopped: no transcript, no notification, no process', { concurrency: false }, async () => {
592
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-forked-agent-'));
593
+
594
+ try {
595
+ // A fork copies the parent's `.jsonl` rows and nothing else — no
596
+ // `subagents/` directory and no queue-operation records — and the new
597
+ // session has no process of its own yet.
598
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
599
+ await dropTaskNotification(parentPath);
600
+ await dropSubagentTranscript(tempRoot);
601
+
602
+ await withIsolatedDatabase(async () => {
603
+ const now = new Date().toISOString();
604
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
605
+
606
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(SESSION_ID, {
607
+ providerSessionId: SESSION_ID,
608
+ });
609
+ const agentRow = history.messages.find(
610
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
611
+ );
612
+
613
+ // Without `subagent` the card falls back to "async launch, therefore
614
+ // running", which is the spinner that never went away.
615
+ assert.equal(agentRow?.subagent?.status, 'stopped');
616
+ });
617
+ } finally {
618
+ await rm(tempRoot, { recursive: true, force: true });
619
+ }
620
+ });
621
+
622
+ test('a live SDK tool result keeps its launch metadata under the stream key', () => {
623
+ // The transcript spells it `toolUseResult`; the SDK stream spells it
624
+ // `tool_use_result`. Reading only the former sent every live agent launch to
625
+ // the client without `isAsync`, so the card could not tell the launch
626
+ // acknowledgement apart from an answer and settled the agent at launch.
627
+ const [normalized] = new ClaudeSessionsProvider().normalizeMessage({
628
+ type: 'user',
629
+ session_id: SESSION_ID,
630
+ parent_tool_use_id: null,
631
+ uuid: 'launch-ack-live',
632
+ message: {
633
+ role: 'user',
634
+ content: [{
635
+ type: 'tool_result',
636
+ tool_use_id: AGENT_TOOL_USE_ID,
637
+ content: 'Async agent launched successfully. agentId: internal bookkeeping',
638
+ }],
639
+ },
640
+ tool_use_result: { isAsync: true, status: 'async_launched', agentId: AGENT_ID },
641
+ }, SESSION_ID);
642
+
643
+ assert.equal(normalized?.kind, 'tool_result');
644
+ assert.equal((normalized?.toolUseResult as { isAsync?: boolean } | undefined)?.isAsync, true);
645
+ });
646
+
647
+ test('a live SDK tool result caps the strings inside its structured output', () => {
648
+ // The stream's `tool_use_result` is the tool's whole structured output — an
649
+ // Edit's carries the entire file it edited — and it is sent to every client
650
+ // and kept in the run's replay buffer. The transcript path caps each nested
651
+ // string at the same limit before it leaves the server; the live path must
652
+ // not be the one place that forwards it whole.
653
+ const [normalized] = new ClaudeSessionsProvider().normalizeMessage({
654
+ type: 'user',
655
+ session_id: SESSION_ID,
656
+ parent_tool_use_id: null,
657
+ uuid: 'edit-result-live',
658
+ message: {
659
+ role: 'user',
660
+ content: [{ type: 'tool_result', tool_use_id: 'toolu_edit', content: 'The file has been updated.' }],
661
+ },
662
+ tool_use_result: { filePath: '/repo/big.ts', originalFile: 'x'.repeat(50_000) },
663
+ }, SESSION_ID);
664
+
665
+ const forwarded = normalized?.toolUseResult as { filePath?: string; originalFile?: string } | undefined;
666
+ assert.equal(forwarded?.filePath, '/repo/big.ts');
667
+ assert.ok(forwarded?.originalFile && forwarded.originalFile.length < 50_000, 'the file body must be capped');
668
+ assert.match(forwarded?.originalFile ?? '', /… 10000 more characters$/);
669
+ });
670
+
671
+ const WORKFLOW_SESSION_ID = 'claude-workflow-session';
672
+ const WORKFLOW_TOOL_USE_ID = 'toolu_workflow_1';
673
+ const WORKFLOW_RUN_ID = 'wf_16fbf852-274';
674
+ const WORKFLOW_TASK_ID = 'wxkj4kcvd';
675
+ const WORKFLOW_SCRIPT = [
676
+ 'export const meta = {',
677
+ " name: 'frontend-architecture-audit',",
678
+ " description: 'Evidence-based audit of the frontend',",
679
+ " phases: [{ title: 'Audit', detail: 'parallel deep-dives per module cluster' }],",
680
+ '}',
681
+ ].join('\n');
682
+
683
+ /**
684
+ * The `<task-notification>` a finished workflow reports through, as the
685
+ * harness writes it — the real one in session 4820c6b8 carries a JSON
686
+ * `<result>` and names the tool call that launched the run.
687
+ */
688
+ const workflowNotificationText = (status: string) => [
689
+ '<task-notification>',
690
+ `<task-id>${WORKFLOW_TASK_ID}</task-id>`,
691
+ `<tool-use-id>${WORKFLOW_TOOL_USE_ID}</tool-use-id>`,
692
+ `<output-file>/tmp/claude-1000/tasks/${WORKFLOW_TASK_ID}.output</output-file>`,
693
+ `<status>${status}</status>`,
694
+ '<summary>Dynamic workflow "Evidence-based audit of the frontend" completed</summary>',
695
+ '<result>{"audits":[{"area":"src/modules/chat","summary":"three large hooks"}]}</result>',
696
+ '</task-notification>',
697
+ ].join('\n');
698
+
699
+ type WorkflowSessionOptions = {
700
+ /** How the completion report is recorded, or `none` for a run still out. */
701
+ notification: 'user' | 'remove' | 'enqueue' | 'none';
702
+ /** Whether the run left a journal behind; a fork copies none. */
703
+ journal: boolean;
704
+ };
705
+
706
+ /**
707
+ * Writes the rows a `Workflow` launch leaves in a session transcript, copied
708
+ * from session 4820c6b8-b23b-468f-b462-74162ecf0f24 (lines 107, 108 and 294),
709
+ * plus the run's journal under `<session>/subagents/workflows/<runId>/`.
710
+ *
711
+ * The journal records three agents: one finished, one failed, one still
712
+ * going — the shape a run has partway through its second phase.
713
+ */
714
+ async function writeClaudeWorkflowSession(
715
+ projectDirectory: string,
716
+ { notification, journal }: WorkflowSessionOptions,
717
+ ): Promise<string> {
718
+ const parentPath = path.join(projectDirectory, `${WORKFLOW_SESSION_ID}.jsonl`);
719
+ const transcriptDir = path.join(projectDirectory, WORKFLOW_SESSION_ID, 'subagents', 'workflows', WORKFLOW_RUN_ID);
720
+ const scriptPath = path.join(projectDirectory, WORKFLOW_SESSION_ID, 'workflows', 'scripts', `frontend-architecture-audit-${WORKFLOW_RUN_ID}.js`);
721
+
722
+ const parentLines: Record<string, unknown>[] = [
723
+ {
724
+ type: 'assistant',
725
+ uuid: 'assistant-wf-1',
726
+ sessionId: WORKFLOW_SESSION_ID,
727
+ timestamp: '2026-08-21T10:32:10.000Z',
728
+ message: {
729
+ role: 'assistant',
730
+ content: [{
731
+ type: 'tool_use',
732
+ id: WORKFLOW_TOOL_USE_ID,
733
+ name: 'Workflow',
734
+ input: { script: WORKFLOW_SCRIPT, description: 'Parallel frontend architecture audit' },
735
+ }],
736
+ },
737
+ },
47
738
  {
48
- uuid: 'u3',
49
- timestamp: '2026-07-28T10:00:00.000Z',
739
+ type: 'user',
740
+ uuid: 'workflow-ack-1',
741
+ sessionId: WORKFLOW_SESSION_ID,
742
+ timestamp: '2026-08-21T10:32:15.657Z',
50
743
  message: {
51
744
  role: 'user',
52
- content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'Launching skill: claude-api' }],
745
+ content: [{
746
+ type: 'tool_result',
747
+ tool_use_id: WORKFLOW_TOOL_USE_ID,
748
+ content: `Workflow launched in background. Task ID: ${WORKFLOW_TASK_ID}\nSummary: Evidence-based audit of the frontend\nTranscript dir: ${transcriptDir}\nScript file: ${scriptPath}`,
749
+ }],
750
+ },
751
+ toolUseResult: {
752
+ status: 'async_launched',
753
+ taskId: WORKFLOW_TASK_ID,
754
+ taskType: 'local_workflow',
755
+ workflowName: 'frontend-architecture-audit',
756
+ runId: WORKFLOW_RUN_ID,
757
+ summary: 'Evidence-based audit of the frontend',
758
+ transcriptDir,
759
+ scriptPath,
53
760
  },
54
761
  },
55
- SESSION_ID,
56
- );
762
+ ];
763
+
764
+ if (notification === 'user') {
765
+ parentLines.push({
766
+ type: 'user',
767
+ uuid: 'workflow-notification-1',
768
+ sessionId: WORKFLOW_SESSION_ID,
769
+ timestamp: '2026-08-21T11:27:00.000Z',
770
+ message: { role: 'user', content: [{ type: 'text', text: workflowNotificationText('completed') }] },
771
+ });
772
+ } else if (notification !== 'none') {
773
+ parentLines.push({
774
+ type: 'queue-operation',
775
+ operation: notification,
776
+ timestamp: '2026-08-21T11:27:00.000Z',
777
+ sessionId: WORKFLOW_SESSION_ID,
778
+ content: workflowNotificationText('completed'),
779
+ });
780
+ }
781
+
782
+ await writeFile(parentPath, `${parentLines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8');
783
+
784
+ if (journal) {
785
+ await mkdir(transcriptDir, { recursive: true });
786
+ const journalLines = [
787
+ { type: 'launched' },
788
+ { type: 'started', key: 'v2:one', agentId: 'aa1e064cf8bd159d6', label: 'audit:chat', phase: 'Audit' },
789
+ { type: 'started', key: 'v2:two', agentId: 'a9cfe29aa8f2afcbf', label: 'audit:sidebar', phase: 'Audit' },
790
+ { type: 'result', key: 'v2:one', agentId: 'aa1e064cf8bd159d6', result: { area: 'chat' } },
791
+ { type: 'failed', key: 'v2:two', agentId: 'a9cfe29aa8f2afcbf' },
792
+ { type: 'started', key: 'v2:three', agentId: 'ab89f2cde612a51b1', label: 'synthesize', phase: 'Synthesize' },
793
+ ];
794
+ await writeFile(
795
+ path.join(transcriptDir, 'journal.jsonl'),
796
+ `${journalLines.map((line) => JSON.stringify(line)).join('\n')}\n`,
797
+ 'utf8',
798
+ );
799
+ }
800
+
801
+ return parentPath;
802
+ }
803
+
804
+ for (const notification of ['user', 'remove', 'enqueue'] as const) {
805
+ test(`Claude history folds a workflow's ${notification} notification onto the call that launched it`, { concurrency: false }, async () => {
806
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-notification-'));
807
+
808
+ try {
809
+ const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification, journal: true });
810
+
811
+ await withIsolatedDatabase(async () => {
812
+ const now = new Date().toISOString();
813
+ sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
814
+
815
+ // The run is over, so no process is up — and that must not matter:
816
+ // the notification is the outcome.
817
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(WORKFLOW_SESSION_ID, {
818
+ providerSessionId: WORKFLOW_SESSION_ID,
819
+ });
820
+ const workflowRow = history.messages.find(
821
+ (message) => message.kind === 'tool_use' && message.toolId === WORKFLOW_TOOL_USE_ID,
822
+ );
823
+
824
+ // A Workflow launch has no `agentId`, so the fold used to skip it: the
825
+ // card kept "Workflow launched in background. Task ID: …" as its result
826
+ // for good while the report rendered as a raw user bubble — or, for a
827
+ // queue-operation record, nowhere.
828
+ assert.equal(
829
+ workflowRow?.toolResult?.content,
830
+ '{"audits":[{"area":"src/modules/chat","summary":"three large hooks"}]}',
831
+ );
832
+ assert.equal(workflowRow?.workflow?.status, 'completed');
833
+ assert.equal(workflowRow?.workflow?.name, 'frontend-architecture-audit');
834
+ assert.equal(workflowRow?.workflow?.runId, WORKFLOW_RUN_ID);
835
+ assert.equal(workflowRow?.workflow?.description, 'Evidence-based audit of the frontend');
836
+ assert.match(workflowRow?.workflow?.scriptPath ?? '', /frontend-architecture-audit-wf_16fbf852-274\.js$/);
837
+
838
+ const strayNotification = history.messages.find(
839
+ (message) => typeof message.content === 'string' && message.content.includes('<task-notification>'),
840
+ );
841
+ assert.equal(strayNotification, undefined, 'the folded notification must not also render on its own');
842
+ });
843
+ } finally {
844
+ await rm(tempRoot, { recursive: true, force: true });
845
+ }
846
+ });
847
+ }
848
+
849
+ test('Claude history reads a workflow\'s agents and their progress from its journal', { concurrency: false }, async () => {
850
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-journal-'));
851
+
852
+ try {
853
+ const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'none', journal: true });
854
+
855
+ await withIsolatedDatabase(async () => {
856
+ const now = new Date().toISOString();
857
+ sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
858
+
859
+ // The run that launched the workflow is still up, so it is still going.
860
+ const liveRunStartedAt = Date.parse('2026-08-21T10:30:00.000Z');
861
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => liveRunStartedAt }).fetchHistory(WORKFLOW_SESSION_ID, {
862
+ providerSessionId: WORKFLOW_SESSION_ID,
863
+ });
864
+ const workflowRow = history.messages.find(
865
+ (message) => message.kind === 'tool_use' && message.toolId === WORKFLOW_TOOL_USE_ID,
866
+ );
867
+
868
+ assert.equal(workflowRow?.workflow?.status, 'running');
869
+ assert.equal(workflowRow?.toolResult?.content, '', 'the launch acknowledgement must never show as a result');
870
+ // An agent with a `started` record and no `result` or `failed` is the
871
+ // one still working; the journal is the only place that says so.
872
+ assert.deepEqual(workflowRow?.workflow?.agents, [
873
+ { id: 'aa1e064cf8bd159d6', label: 'audit:chat', phase: 'Audit', status: 'completed' },
874
+ { id: 'a9cfe29aa8f2afcbf', label: 'audit:sidebar', phase: 'Audit', status: 'failed' },
875
+ { id: 'ab89f2cde612a51b1', label: 'synthesize', phase: 'Synthesize', status: 'running' },
876
+ ]);
877
+ assert.deepEqual(workflowRow?.workflow?.agentCounts, { total: 3, completed: 1, failed: 1, running: 1, stopped: 0 });
878
+ });
879
+ } finally {
880
+ await rm(tempRoot, { recursive: true, force: true });
881
+ }
882
+ });
883
+
884
+ test('Claude history does not keep a journal agent running once the workflow itself has settled', { concurrency: false }, async () => {
885
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-orphan-agent-'));
886
+
887
+ try {
888
+ // The journal has a `started` with no `result` or `failed` for the
889
+ // synthesize step, but the run has reported completed — a resume re-ran
890
+ // that step under a new id, or the run was stopped under it. Either way
891
+ // nothing is still going, and a pulsing dot on a finished card said
892
+ // otherwise.
893
+ const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'user', journal: true });
894
+
895
+ await withIsolatedDatabase(async () => {
896
+ const now = new Date().toISOString();
897
+ sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
898
+
899
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(WORKFLOW_SESSION_ID, {
900
+ providerSessionId: WORKFLOW_SESSION_ID,
901
+ });
902
+ const workflowRow = history.messages.find(
903
+ (message) => message.kind === 'tool_use' && message.toolId === WORKFLOW_TOOL_USE_ID,
904
+ );
905
+
906
+ assert.equal(workflowRow?.workflow?.status, 'completed');
907
+ assert.deepEqual(
908
+ workflowRow?.workflow?.agents.map((agent) => agent.status),
909
+ ['completed', 'failed', 'stopped'],
910
+ );
911
+ assert.deepEqual(workflowRow?.workflow?.agentCounts, { total: 3, completed: 1, failed: 1, running: 0, stopped: 1 });
912
+ });
913
+ } finally {
914
+ await rm(tempRoot, { recursive: true, force: true });
915
+ }
916
+ });
917
+
918
+ test('Claude history reports a workflow stopped, with no agents, when its run and journal are both gone', { concurrency: false }, async () => {
919
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-forked-'));
920
+
921
+ try {
922
+ // A fork copies the parent's `.jsonl` rows and nothing else: no
923
+ // `subagents/workflows/` directory, no queue-operation records, and no
924
+ // process of its own.
925
+ const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'none', journal: false });
926
+
927
+ await withIsolatedDatabase(async () => {
928
+ const now = new Date().toISOString();
929
+ sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
930
+
931
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(WORKFLOW_SESSION_ID, {
932
+ providerSessionId: WORKFLOW_SESSION_ID,
933
+ });
934
+ const workflowRow = history.messages.find(
935
+ (message) => message.kind === 'tool_use' && message.toolId === WORKFLOW_TOOL_USE_ID,
936
+ );
937
+
938
+ assert.equal(workflowRow?.workflow?.status, 'stopped');
939
+ assert.deepEqual(workflowRow?.workflow?.agents, []);
940
+ assert.deepEqual(workflowRow?.workflow?.agentCounts, { total: 0, completed: 0, failed: 0, running: 0, stopped: 0 });
941
+ });
942
+ } finally {
943
+ await rm(tempRoot, { recursive: true, force: true });
944
+ }
945
+ });
946
+
947
+ test('Claude history folds a backgrounded Bash command\'s notification onto its call', { concurrency: false }, async () => {
948
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-background-bash-'));
949
+ const sessionId = 'claude-bash-session';
950
+ const toolUseId = 'toolu_bash_bg_1';
951
+
952
+ try {
953
+ // Copied from session 30ee5a44 (lines 167 and 234): the shell's own
954
+ // acknowledgement, and the report the harness later queues for it. A
955
+ // shell report carries no `<result>`, only its summary.
956
+ const rows = [
957
+ {
958
+ type: 'assistant',
959
+ uuid: 'assistant-bash-1',
960
+ sessionId,
961
+ timestamp: '2026-08-21T10:00:00.000Z',
962
+ message: {
963
+ role: 'assistant',
964
+ content: [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'npm run dev', run_in_background: true } }],
965
+ },
966
+ },
967
+ {
968
+ type: 'user',
969
+ uuid: 'bash-ack-1',
970
+ sessionId,
971
+ timestamp: '2026-08-21T10:00:01.000Z',
972
+ message: {
973
+ role: 'user',
974
+ content: [{
975
+ type: 'tool_result',
976
+ tool_use_id: toolUseId,
977
+ content: 'Command running in background with ID: b5xsbzu5k. Output is being written to: /tmp/tasks/b5xsbzu5k.output. You will be notified when it completes.',
978
+ }],
979
+ },
980
+ toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false, noOutputExpected: false, backgroundTaskId: 'b5xsbzu5k' },
981
+ },
982
+ {
983
+ type: 'queue-operation',
984
+ operation: 'enqueue',
985
+ timestamp: '2026-08-21T10:05:00.000Z',
986
+ sessionId,
987
+ content: [
988
+ '<task-notification>',
989
+ '<task-id>b5xsbzu5k</task-id>',
990
+ `<tool-use-id>${toolUseId}</tool-use-id>`,
991
+ '<output-file>/tmp/tasks/b5xsbzu5k.output</output-file>',
992
+ '<status>completed</status>',
993
+ '<summary>Background command "Start the dev server" completed (exit code 0)</summary>',
994
+ '</task-notification>',
995
+ ].join('\n'),
996
+ },
997
+ ];
998
+ const transcriptPath = path.join(tempRoot, `${sessionId}.jsonl`);
999
+ await writeFile(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1000
+
1001
+ await withIsolatedDatabase(async () => {
1002
+ const now = new Date().toISOString();
1003
+ sessionsDb.createSession(sessionId, 'claude', tempRoot, 'Bash session', now, now, transcriptPath);
1004
+
1005
+ const history = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(sessionId, {
1006
+ providerSessionId: sessionId,
1007
+ });
1008
+ const bashRow = history.messages.find(
1009
+ (message) => message.kind === 'tool_use' && message.toolId === toolUseId,
1010
+ );
1011
+
1012
+ assert.equal(bashRow?.toolResult?.content, 'Background command "Start the dev server" completed (exit code 0)');
1013
+
1014
+ // Without the report, the shell's acknowledgement stays: unlike an
1015
+ // agent's or a workflow's, it names the output file, which is the only
1016
+ // handle on the command's output until it reports.
1017
+ await writeFile(transcriptPath, `${rows.slice(0, 2).map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1018
+ const unreported = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null }).fetchHistory(sessionId, {
1019
+ providerSessionId: sessionId,
1020
+ });
1021
+ const unreportedRow = unreported.messages.find(
1022
+ (message) => message.kind === 'tool_use' && message.toolId === toolUseId,
1023
+ );
1024
+ assert.match(unreportedRow?.toolResult?.content ?? '', /Output is being written to: \/tmp\/tasks\/b5xsbzu5k\.output/);
1025
+ });
1026
+ } finally {
1027
+ await rm(tempRoot, { recursive: true, force: true });
1028
+ }
1029
+ });
1030
+
1031
+ test('a long workflow agent timeline keeps its newest steps within the transport cap', { concurrency: false }, async () => {
1032
+ // The timeline is polled while the agent runs for what it is doing now; a
1033
+ // cap that kept the first 200 steps would freeze it there, and the card's
1034
+ // "N earlier steps are not included" would be the wrong way round.
1035
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-agent-long-'));
1036
+
1037
+ try {
1038
+ const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'none', journal: true });
1039
+ const runDir = path.join(tempRoot, WORKFLOW_SESSION_ID, 'subagents', 'workflows', WORKFLOW_RUN_ID);
1040
+ const agentId = 'ab89f2cde612a51b1';
1041
+ const rows = [
1042
+ { isSidechain: true, agentId, type: 'user', uuid: 'wa-u1', timestamp: '2026-08-21T10:40:00.000Z', message: { role: 'user', content: 'Synthesize the audits.' } },
1043
+ ...Array.from({ length: 205 }, (_, index) => ({
1044
+ isSidechain: true,
1045
+ agentId,
1046
+ type: 'assistant',
1047
+ uuid: `wa-a${index + 1}`,
1048
+ timestamp: new Date(Date.parse('2026-08-21T10:40:05.000Z') + index * 1_000).toISOString(),
1049
+ message: { role: 'assistant', model: 'claude-opus-4-1', content: [{ type: 'thinking', thinking: `step ${index + 1}` }] },
1050
+ })),
1051
+ ];
1052
+ await writeFile(path.join(runDir, `agent-${agentId}.jsonl`), `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1053
+
1054
+ await withIsolatedDatabase(async () => {
1055
+ const now = new Date().toISOString();
1056
+ sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
1057
+
1058
+ const live = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => Date.parse('2026-08-21T10:30:00.000Z') })
1059
+ .readWorkflowAgentActivity(WORKFLOW_SESSION_ID, WORKFLOW_RUN_ID, agentId);
1060
+ assert.equal(live?.activityCount, 205);
1061
+ assert.equal(live?.activity.length, 200);
1062
+ assert.equal(live?.activity[0]?.content, 'step 6');
1063
+ assert.equal(live?.activity.at(-1)?.content, 'step 205');
1064
+ });
1065
+ } finally {
1066
+ await rm(tempRoot, { recursive: true, force: true });
1067
+ }
1068
+ });
1069
+
1070
+ test('a workflow agent the journal has not settled is running only while the run that spawned it is', { concurrency: false }, async () => {
1071
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-workflow-agent-activity-'));
1072
+
1073
+ try {
1074
+ const parentPath = await writeClaudeWorkflowSession(tempRoot, { notification: 'none', journal: true });
1075
+ // The synthesize step's transcript: the journal says `started` and nothing
1076
+ // more, so only the process tells whether it is still working.
1077
+ const runDir = path.join(tempRoot, WORKFLOW_SESSION_ID, 'subagents', 'workflows', WORKFLOW_RUN_ID);
1078
+ const agentId = 'ab89f2cde612a51b1';
1079
+ const rows = [
1080
+ { isSidechain: true, agentId, type: 'user', uuid: 'wa-u1', timestamp: '2026-08-21T10:40:00.000Z', message: { role: 'user', content: 'Synthesize the audits.' } },
1081
+ { isSidechain: true, agentId, type: 'assistant', uuid: 'wa-a1', timestamp: '2026-08-21T10:40:05.000Z', message: { role: 'assistant', model: 'claude-opus-4-1', content: [{ type: 'thinking', thinking: 'Two audits to merge.' }, { type: 'tool_use', id: 'toolu_wa_1', name: 'Read', input: { file_path: '/repo/audits.json' } }] } },
1082
+ ];
1083
+ await writeFile(path.join(runDir, `agent-${agentId}.jsonl`), `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1084
+
1085
+ await withIsolatedDatabase(async () => {
1086
+ const now = new Date().toISOString();
1087
+ sessionsDb.createSession(WORKFLOW_SESSION_ID, 'claude', tempRoot, 'Workflow session', now, now, parentPath);
1088
+
1089
+ // The run that spawned the agent is still up: it started before the
1090
+ // agent's first row was written.
1091
+ const live = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => Date.parse('2026-08-21T10:30:00.000Z') })
1092
+ .readWorkflowAgentActivity(WORKFLOW_SESSION_ID, WORKFLOW_RUN_ID, agentId);
1093
+ assert.deepEqual(live?.agent, { id: agentId, label: 'synthesize', model: 'claude-opus-4-1', status: 'running' });
1094
+ assert.equal(live?.activityCount, 2);
1095
+ assert.deepEqual(live?.activity.map((entry) => entry.kind), ['thinking', 'tool']);
1096
+ assert.equal(live?.activity[0]?.content, 'Two audits to merge.');
1097
+ assert.equal(live?.activity[1]?.toolName, 'Read');
1098
+
1099
+ // A later run of the session is a new process; the agent's own is gone.
1100
+ const orphaned = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => Date.parse('2026-08-21T11:00:00.000Z') })
1101
+ .readWorkflowAgentActivity(WORKFLOW_SESSION_ID, WORKFLOW_RUN_ID, agentId);
1102
+ assert.equal(orphaned?.agent.status, 'stopped');
1103
+
1104
+ // No transcript for this agent: nothing to show, which the route turns
1105
+ // into a 404 rather than an empty timeline.
1106
+ const missing = await new ClaudeSessionsProvider({ getLiveRunStartTime: () => null })
1107
+ .readWorkflowAgentActivity(WORKFLOW_SESSION_ID, WORKFLOW_RUN_ID, 'aa1e064cf8bd159d6');
1108
+ assert.equal(missing, null);
1109
+ });
1110
+ } finally {
1111
+ await rm(tempRoot, { recursive: true, force: true });
1112
+ }
1113
+ });
1114
+
1115
+ test('the live stream\'s background-task events normalize to task_status', () => {
1116
+ // Verified against a real query: the SDK reports on a launched workflow,
1117
+ // agent or shell command through four `system` subtypes the transcript
1118
+ // never records. They used to fall through every branch of the normalizer
1119
+ // and vanish, so a running workflow's card could not move off its launch
1120
+ // acknowledgement until the whole run had finished and history reloaded.
1121
+ const provider = new ClaudeSessionsProvider();
1122
+ const normalize = (raw: Record<string, unknown>) =>
1123
+ provider.normalizeMessage({ type: 'system', session_id: WORKFLOW_SESSION_ID, uuid: `evt-${String(raw.subtype)}`, ...raw }, WORKFLOW_SESSION_ID);
1124
+
1125
+ const [started] = normalize({
1126
+ subtype: 'task_started',
1127
+ task_id: WORKFLOW_TASK_ID,
1128
+ tool_use_id: WORKFLOW_TOOL_USE_ID,
1129
+ description: 'Parallel frontend architecture audit',
1130
+ task_type: 'local_workflow',
1131
+ workflow_name: 'frontend-architecture-audit',
1132
+ });
1133
+ assert.equal(started?.kind, 'task_status');
1134
+ assert.equal(started?.event, 'started');
1135
+ assert.equal(started?.taskId, WORKFLOW_TASK_ID);
1136
+ assert.equal(started?.toolUseId, WORKFLOW_TOOL_USE_ID);
1137
+ assert.equal(started?.taskType, 'local_workflow');
1138
+ assert.equal(started?.workflowName, 'frontend-architecture-audit');
1139
+ assert.equal(started?.description, 'Parallel frontend architecture audit');
1140
+
1141
+ const [progress] = normalize({
1142
+ subtype: 'task_progress',
1143
+ task_id: WORKFLOW_TASK_ID,
1144
+ tool_use_id: WORKFLOW_TOOL_USE_ID,
1145
+ description: 'Parallel frontend architecture audit',
1146
+ usage: { total_tokens: 12_345, tool_uses: 12, duration_ms: 65_000 },
1147
+ summary: 'Verify 3/6',
1148
+ });
1149
+ assert.equal(progress?.kind, 'task_status');
1150
+ assert.equal(progress?.event, 'progress');
1151
+ assert.deepEqual(progress?.usage, { totalTokens: 12_345, toolUses: 12, durationMs: 65_000 });
1152
+ assert.equal(progress?.summary, 'Verify 3/6');
1153
+
1154
+ // `task_updated` names no tool call — the client has to remember the task
1155
+ // id from the start event — and spells a stop `killed`.
1156
+ const [updated] = normalize({
1157
+ subtype: 'task_updated',
1158
+ task_id: WORKFLOW_TASK_ID,
1159
+ patch: { status: 'killed', end_time: 1_700_000_000_000 },
1160
+ });
1161
+ assert.equal(updated?.kind, 'task_status');
1162
+ assert.equal(updated?.event, 'updated');
1163
+ assert.equal(updated?.taskId, WORKFLOW_TASK_ID);
1164
+ assert.equal(updated?.toolUseId, undefined);
1165
+ assert.equal(updated?.status, 'stopped');
1166
+
1167
+ const [notification] = normalize({
1168
+ subtype: 'task_notification',
1169
+ task_id: WORKFLOW_TASK_ID,
1170
+ tool_use_id: WORKFLOW_TOOL_USE_ID,
1171
+ status: 'completed',
1172
+ summary: 'Dynamic workflow "Evidence-based audit of the frontend" completed',
1173
+ output_file: `/tmp/claude-1000/tasks/${WORKFLOW_TASK_ID}.output`,
1174
+ });
1175
+ assert.equal(notification?.kind, 'task_status');
1176
+ assert.equal(notification?.event, 'notification');
1177
+ assert.equal(notification?.status, 'completed');
1178
+ assert.equal(notification?.summary, 'Dynamic workflow "Evidence-based audit of the frontend" completed');
1179
+ assert.equal(notification?.outputFile, `/tmp/claude-1000/tasks/${WORKFLOW_TASK_ID}.output`);
1180
+
1181
+ // Every other `system` subtype still normalizes to nothing.
1182
+ assert.deepEqual(normalize({ subtype: 'hook_started', task_id: 'irrelevant' }), []);
1183
+ assert.deepEqual(normalize({ subtype: 'init' }), []);
1184
+ });
1185
+
1186
+ test('a workflow\'s progress event reports where each of its agents stands', () => {
1187
+ // Verified against a real query (SDK 0.3.165 / CLI 2.1.274): a workflow's
1188
+ // `task_progress` carries an undocumented `workflow_progress` with one entry
1189
+ // per agent the script spawned, and its task-level `last_tool_name` is the
1190
+ // current agent's label, not a tool — on every batch, with or without the
1191
+ // agent list. Read as a tool, the card's usage line said
1192
+ // "32 tool uses · 3m 30s · You are the analysis step of…".
1193
+ const provider = new ClaudeSessionsProvider();
1194
+ const [progress] = provider.normalizeMessage({
1195
+ type: 'system',
1196
+ subtype: 'task_progress',
1197
+ session_id: WORKFLOW_SESSION_ID,
1198
+ uuid: 'evt-workflow-progress',
1199
+ task_id: WORKFLOW_TASK_ID,
1200
+ tool_use_id: WORKFLOW_TOOL_USE_ID,
1201
+ description: 'audit:sidebar',
1202
+ summary: 'Evidence-based audit of the frontend',
1203
+ usage: { total_tokens: 12_345, tool_uses: 32, duration_ms: 210_000 },
1204
+ last_tool_name: 'audit:sidebar',
1205
+ workflow_progress: [
1206
+ {
1207
+ type: 'workflow_agent',
1208
+ index: 0,
1209
+ label: 'audit:chat',
1210
+ phaseIndex: 0,
1211
+ phaseTitle: 'Audit',
1212
+ agentId: 'aa1e064cf8bd159d6',
1213
+ model: 'claude-opus-4-1',
1214
+ state: 'done',
1215
+ startedAt: 1_700_000_000_000,
1216
+ tokens: 8_000,
1217
+ toolCalls: 20,
1218
+ durationMs: 120_000,
1219
+ resultPreview: 'Three large hooks carry most of the module.',
1220
+ },
1221
+ {
1222
+ type: 'workflow_agent',
1223
+ index: 1,
1224
+ label: 'audit:sidebar',
1225
+ phaseIndex: 0,
1226
+ phaseTitle: 'Audit',
1227
+ agentId: 'a9cfe29aa8f2afcbf',
1228
+ state: 'progress',
1229
+ startedAt: 1_700_000_120_000,
1230
+ lastToolName: 'Grep',
1231
+ lastToolSummary: 'useSidebar',
1232
+ promptPreview: 'You are the analysis step of a code-quality audit',
1233
+ lastProgressAt: 1_700_000_200_000,
1234
+ tokens: 4_000,
1235
+ toolCalls: 12,
1236
+ },
1237
+ // Started but not yet spawned: no transcript, so no id.
1238
+ { type: 'workflow_agent', index: 2, label: 'synthesize', state: 'start', queuedAt: 1_700_000_200_000 },
1239
+ { type: 'workflow_agent', index: 3, label: 'audit:files', agentId: 'ab89f2cde612a51b1', state: 'start', startedAt: 1_700_000_201_000 },
1240
+ { type: 'workflow_agent', index: 4, label: 'audit:terminal', agentId: 'ac89f2cde612a51b2', state: 'error', attempt: 2 },
1241
+ ],
1242
+ }, WORKFLOW_SESSION_ID);
1243
+
1244
+ assert.equal(progress?.kind, 'task_status');
1245
+ assert.equal(progress?.event, 'progress');
1246
+ assert.deepEqual(progress?.usage, { totalTokens: 12_345, toolUses: 32, durationMs: 210_000 });
1247
+ assert.equal('lastToolName' in (progress ?? {}), false, 'the current agent\'s label must not pass as a tool name');
1248
+ assert.deepEqual(progress?.agents, [
1249
+ {
1250
+ index: 0,
1251
+ label: 'audit:chat',
1252
+ phase: 'Audit',
1253
+ agentId: 'aa1e064cf8bd159d6',
1254
+ model: 'claude-opus-4-1',
1255
+ state: 'done',
1256
+ startedAt: 1_700_000_000_000,
1257
+ lastToolName: undefined,
1258
+ lastToolSummary: undefined,
1259
+ promptPreview: undefined,
1260
+ tokens: 8_000,
1261
+ toolCalls: 20,
1262
+ durationMs: 120_000,
1263
+ resultPreview: 'Three large hooks carry most of the module.',
1264
+ },
1265
+ {
1266
+ index: 1,
1267
+ label: 'audit:sidebar',
1268
+ phase: 'Audit',
1269
+ agentId: 'a9cfe29aa8f2afcbf',
1270
+ model: undefined,
1271
+ state: 'running',
1272
+ startedAt: 1_700_000_120_000,
1273
+ lastToolName: 'Grep',
1274
+ lastToolSummary: 'useSidebar',
1275
+ promptPreview: 'You are the analysis step of a code-quality audit',
1276
+ tokens: 4_000,
1277
+ toolCalls: 12,
1278
+ durationMs: undefined,
1279
+ resultPreview: undefined,
1280
+ },
1281
+ {
1282
+ index: 2,
1283
+ label: 'synthesize',
1284
+ phase: undefined,
1285
+ agentId: undefined,
1286
+ model: undefined,
1287
+ state: 'queued',
1288
+ startedAt: undefined,
1289
+ lastToolName: undefined,
1290
+ lastToolSummary: undefined,
1291
+ promptPreview: undefined,
1292
+ tokens: undefined,
1293
+ toolCalls: undefined,
1294
+ durationMs: undefined,
1295
+ resultPreview: undefined,
1296
+ },
1297
+ {
1298
+ index: 3,
1299
+ label: 'audit:files',
1300
+ phase: undefined,
1301
+ agentId: 'ab89f2cde612a51b1',
1302
+ model: undefined,
1303
+ state: 'running',
1304
+ startedAt: 1_700_000_201_000,
1305
+ lastToolName: undefined,
1306
+ lastToolSummary: undefined,
1307
+ promptPreview: undefined,
1308
+ tokens: undefined,
1309
+ toolCalls: undefined,
1310
+ durationMs: undefined,
1311
+ resultPreview: undefined,
1312
+ },
1313
+ {
1314
+ index: 4,
1315
+ label: 'audit:terminal',
1316
+ phase: undefined,
1317
+ agentId: 'ac89f2cde612a51b2',
1318
+ model: undefined,
1319
+ state: 'failed',
1320
+ startedAt: undefined,
1321
+ lastToolName: undefined,
1322
+ lastToolSummary: undefined,
1323
+ promptPreview: undefined,
1324
+ tokens: undefined,
1325
+ toolCalls: undefined,
1326
+ durationMs: undefined,
1327
+ resultPreview: undefined,
1328
+ },
1329
+ ]);
1330
+
1331
+ // A throttled batch — agents mid-step only — comes without the agent list
1332
+ // but still names the current agent as the tool.
1333
+ const [throttled] = provider.normalizeMessage({
1334
+ type: 'system',
1335
+ subtype: 'task_progress',
1336
+ session_id: WORKFLOW_SESSION_ID,
1337
+ uuid: 'evt-workflow-progress-throttled',
1338
+ task_id: WORKFLOW_TASK_ID,
1339
+ tool_use_id: WORKFLOW_TOOL_USE_ID,
1340
+ description: 'Audit: audit:sidebar',
1341
+ usage: { total_tokens: 12_400, tool_uses: 33, duration_ms: 212_000 },
1342
+ last_tool_name: 'audit:sidebar',
1343
+ }, WORKFLOW_SESSION_ID);
1344
+ assert.equal('lastToolName' in (throttled ?? {}), false);
1345
+ assert.equal(throttled?.agents, undefined);
1346
+
1347
+ // An agent's own progress carries no agent list.
1348
+ const [agentProgress] = provider.normalizeMessage({
1349
+ type: 'system',
1350
+ subtype: 'task_progress',
1351
+ session_id: WORKFLOW_SESSION_ID,
1352
+ uuid: 'evt-agent-progress',
1353
+ task_id: 'agent-task-1',
1354
+ tool_use_id: 'toolu_agent_1',
1355
+ description: 'Survey the repo',
1356
+ usage: { total_tokens: 1, tool_uses: 3, duration_ms: 1 },
1357
+ last_tool_name: 'Read',
1358
+ }, WORKFLOW_SESSION_ID);
1359
+ assert.equal(agentProgress?.agents, undefined);
1360
+ });
1361
+
1362
+ test('Claude history still settles a synchronous agent that stops mid tool call', { concurrency: false }, async () => {
1363
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-agent-'));
1364
+
1365
+ try {
1366
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
1367
+ await dropTaskNotification(parentPath);
1368
+
1369
+ // A synchronous agent hands its answer back on its own tool result and
1370
+ // never sends a notification, so "no notification" must not read as
1371
+ // "still running" for one — not even with a dangling tool call, the
1372
+ // strongest possible hint that it stopped mid-flight.
1373
+ const parentRaw = await readFile(parentPath, 'utf8');
1374
+ await writeFile(parentPath, parentRaw.replace('"isAsync":true', '"isAsync":false'), 'utf8');
1375
+
1376
+ const agentPath = path.join(tempRoot, SESSION_ID, 'subagents', `agent-${AGENT_ID}.jsonl`);
1377
+ const agentRaw = await readFile(agentPath, 'utf8');
1378
+ await writeFile(
1379
+ agentPath,
1380
+ `${agentRaw.split('\n').filter((line) => line && !line.includes('tool_result')).join('\n')}\n`,
1381
+ 'utf8',
1382
+ );
1383
+
1384
+ await withIsolatedDatabase(async () => {
1385
+ const now = new Date().toISOString();
1386
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
1387
+
1388
+ const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
1389
+ providerSessionId: SESSION_ID,
1390
+ });
1391
+ const agentRow = history.messages.find(
1392
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
1393
+ );
1394
+
1395
+ assert.equal(agentRow?.subagent?.status, 'completed');
1396
+ });
1397
+ } finally {
1398
+ await rm(tempRoot, { recursive: true, force: true });
1399
+ }
1400
+ });
1401
+
1402
+ test('Claude history trims a subagent timeline down to a preview', { concurrency: false }, async () => {
1403
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-big-agent-'));
1404
+
1405
+ try {
1406
+ const parentPath = await writeClaudeSubagentSession(tempRoot);
1407
+
1408
+ // One child command with a very large output, which is what makes an
1409
+ // agent-heavy session's history payload balloon.
1410
+ const hugeOutput = 'x'.repeat(50_000);
1411
+ const agentPath = path.join(tempRoot, SESSION_ID, 'subagents', `agent-${AGENT_ID}.jsonl`);
1412
+ const agentRaw = await readFile(agentPath, 'utf8');
1413
+ const enlarged = agentRaw
1414
+ .split('\n')
1415
+ .filter(Boolean)
1416
+ .map((line) => {
1417
+ const entry = JSON.parse(line) as { message?: { content?: Array<{ type?: string; content?: string }> } };
1418
+ for (const part of entry.message?.content ?? []) {
1419
+ if (part.type === 'tool_result') {
1420
+ part.content = hugeOutput;
1421
+ }
1422
+ }
1423
+ return JSON.stringify(entry);
1424
+ })
1425
+ .join('\n');
1426
+ await writeFile(agentPath, `${enlarged}\n`, 'utf8');
1427
+
1428
+ await withIsolatedDatabase(async () => {
1429
+ const now = new Date().toISOString();
1430
+ sessionsDb.createSession(SESSION_ID, 'claude', tempRoot, 'Subagent session', now, now, parentPath);
1431
+
1432
+ const history = await new ClaudeSessionsProvider().fetchHistory(SESSION_ID, {
1433
+ providerSessionId: SESSION_ID,
1434
+ });
1435
+ const agentRow = history.messages.find(
1436
+ (message) => message.kind === 'tool_use' && message.toolId === AGENT_TOOL_USE_ID,
1437
+ );
1438
+ const childResult = String(agentRow?.subagentTools?.[1].toolResult?.content ?? '');
1439
+
1440
+ assert.ok(childResult.length < 6000, `nested output must be trimmed, got ${childResult.length}`);
1441
+ assert.match(childResult, /more characters$/, 'the trim must say how much was omitted');
1442
+ });
1443
+ } finally {
1444
+ await rm(tempRoot, { recursive: true, force: true });
1445
+ }
1446
+ });
1447
+
1448
+ const EDIT_SESSION_ID = 'claude-edit-session';
1449
+
1450
+ /**
1451
+ * Writes a transcript where one prompt was edited: the replacement shares a
1452
+ * parent with the original, which is the shape Claude's resume-partway leaves
1453
+ * behind. Nothing is deleted from the file.
1454
+ */
1455
+ async function writeEditedTranscript(projectDirectory: string): Promise<string> {
1456
+ const transcriptPath = path.join(projectDirectory, `${EDIT_SESSION_ID}.jsonl`);
1457
+ const rows = [
1458
+ {
1459
+ type: 'user', uuid: 'u1', parentUuid: null, sessionId: EDIT_SESSION_ID,
1460
+ timestamp: '2026-08-23T10:00:00.000Z',
1461
+ message: { role: 'user', content: [{ type: 'text', text: 'first prompt' }] },
1462
+ },
1463
+ {
1464
+ type: 'assistant', uuid: 'a1', parentUuid: 'u1', sessionId: EDIT_SESSION_ID,
1465
+ timestamp: '2026-08-23T10:00:01.000Z',
1466
+ message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'first answer' }] },
1467
+ },
1468
+ {
1469
+ type: 'user', uuid: 'u2', parentUuid: 'a1', sessionId: EDIT_SESSION_ID,
1470
+ timestamp: '2026-08-23T10:00:02.000Z',
1471
+ message: { role: 'user', content: [{ type: 'text', text: 'original second prompt' }] },
1472
+ },
1473
+ {
1474
+ type: 'assistant', uuid: 'a2', parentUuid: 'u2', sessionId: EDIT_SESSION_ID,
1475
+ timestamp: '2026-08-23T10:00:03.000Z',
1476
+ message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'answer to be replaced' }] },
1477
+ },
1478
+ // The edit: same parent as u2, written later.
1479
+ {
1480
+ type: 'user', uuid: 'u2b', parentUuid: 'a1', sessionId: EDIT_SESSION_ID,
1481
+ timestamp: '2026-08-23T10:00:04.000Z',
1482
+ message: { role: 'user', content: [{ type: 'text', text: 'edited second prompt' }] },
1483
+ },
1484
+ {
1485
+ type: 'assistant', uuid: 'a2b', parentUuid: 'u2b', sessionId: EDIT_SESSION_ID,
1486
+ timestamp: '2026-08-23T10:00:05.000Z',
1487
+ message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'answer to the edit' }] },
1488
+ },
1489
+ ];
1490
+
1491
+ await writeFile(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1492
+ return transcriptPath;
1493
+ }
1494
+
1495
+ test('an edited prompt replaces the one it superseded instead of stacking on it', { concurrency: false }, async () => {
1496
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-edit-history-'));
1497
+
1498
+ try {
1499
+ const transcriptPath = await writeEditedTranscript(tempRoot);
1500
+
1501
+ await withIsolatedDatabase(async () => {
1502
+ const now = new Date().toISOString();
1503
+ sessionsDb.createSession(EDIT_SESSION_ID, 'claude', tempRoot, 'Edited session', now, now, transcriptPath);
1504
+
1505
+ const history = await new ClaudeSessionsProvider().fetchHistory(EDIT_SESSION_ID, {
1506
+ providerSessionId: EDIT_SESSION_ID,
1507
+ });
1508
+ const texts = history.messages.map((message) => message.content);
1509
+
1510
+ assert.deepEqual(texts, [
1511
+ 'first prompt',
1512
+ 'first answer',
1513
+ 'edited second prompt',
1514
+ 'answer to the edit',
1515
+ ]);
1516
+ });
1517
+ } finally {
1518
+ await rm(tempRoot, { recursive: true, force: true });
1519
+ }
1520
+ });
1521
+
1522
+ const CONCURRENT_SESSION_ID = 'claude-concurrent-session';
1523
+
1524
+ /**
1525
+ * Rows two live writers leave behind: Chat and the native `claude` CLI both
1526
+ * branch off the assistant row each of them saw last. `last-prompt` is the row
1527
+ * every CLI process appends when it exits, naming the leaf it was on.
1528
+ */
1529
+ const concurrentRow = {
1530
+ sharedPrompt: {
1531
+ type: 'user', uuid: 'u1', parentUuid: null, sessionId: CONCURRENT_SESSION_ID,
1532
+ timestamp: '2026-08-23T10:00:00.000Z',
1533
+ message: { role: 'user', content: [{ type: 'text', text: 'shared prompt' }] },
1534
+ },
1535
+ sharedAnswer: {
1536
+ type: 'assistant', uuid: 'a1', parentUuid: 'u1', sessionId: CONCURRENT_SESSION_ID,
1537
+ timestamp: '2026-08-23T10:00:01.000Z',
1538
+ message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'shared answer' }] },
1539
+ },
1540
+ chatPrompt: {
1541
+ type: 'user', uuid: 'u2', parentUuid: 'a1', sessionId: CONCURRENT_SESSION_ID,
1542
+ timestamp: '2026-08-23T10:00:02.000Z',
1543
+ message: { role: 'user', content: [{ type: 'text', text: 'chat prompt' }] },
1544
+ },
1545
+ chatAnswer: {
1546
+ type: 'assistant', uuid: 'a2', parentUuid: 'u2', sessionId: CONCURRENT_SESSION_ID,
1547
+ timestamp: '2026-08-23T10:00:03.000Z',
1548
+ message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'chat answer' }] },
1549
+ },
1550
+ // The native CLI had loaded the session before `u2` existed, so its prompt
1551
+ // parents onto `a1` too.
1552
+ terminalPrompt: {
1553
+ type: 'user', uuid: 't1', parentUuid: 'a1', sessionId: CONCURRENT_SESSION_ID,
1554
+ timestamp: '2026-08-23T10:00:04.000Z',
1555
+ message: { role: 'user', content: [{ type: 'text', text: 'terminal prompt' }] },
1556
+ },
1557
+ terminalAnswer: {
1558
+ type: 'assistant', uuid: 't2', parentUuid: 't1', sessionId: CONCURRENT_SESSION_ID,
1559
+ timestamp: '2026-08-23T10:00:05.000Z',
1560
+ message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'terminal answer' }] },
1561
+ },
1562
+ chatFollowUp: {
1563
+ type: 'user', uuid: 'u3', parentUuid: 'a2', sessionId: CONCURRENT_SESSION_ID,
1564
+ timestamp: '2026-08-23T10:00:06.000Z',
1565
+ message: { role: 'user', content: [{ type: 'text', text: 'chat follow-up' }] },
1566
+ },
1567
+ chatFollowUpAnswer: {
1568
+ type: 'assistant', uuid: 'a3', parentUuid: 'u3', sessionId: CONCURRENT_SESSION_ID,
1569
+ timestamp: '2026-08-23T10:00:07.000Z',
1570
+ message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'chat follow-up answer' }] },
1571
+ },
1572
+ };
1573
+
1574
+ const exitStamp = (leafUuid: string) => ({
1575
+ type: 'last-prompt', lastPrompt: 'redacted', leafUuid, sessionId: CONCURRENT_SESSION_ID,
1576
+ });
1577
+
1578
+ async function readConcurrentlyForkedHistory(rows: object[]): Promise<unknown[]> {
1579
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-concurrent-history-'));
1580
+
1581
+ try {
1582
+ const transcriptPath = path.join(tempRoot, `${CONCURRENT_SESSION_ID}.jsonl`);
1583
+ await writeFile(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1584
+
1585
+ let texts: unknown[] = [];
1586
+ await withIsolatedDatabase(async () => {
1587
+ const now = new Date().toISOString();
1588
+ sessionsDb.createSession(
1589
+ CONCURRENT_SESSION_ID, 'claude', tempRoot, 'Concurrently forked session', now, now, transcriptPath,
1590
+ );
1591
+
1592
+ const history = await new ClaudeSessionsProvider().fetchHistory(CONCURRENT_SESSION_ID, {
1593
+ providerSessionId: CONCURRENT_SESSION_ID,
1594
+ });
1595
+ texts = history.messages.map((message) => message.content);
1596
+ });
1597
+ return texts;
1598
+ } finally {
1599
+ await rm(tempRoot, { recursive: true, force: true });
1600
+ }
1601
+ }
1602
+
1603
+ const SHARED_TURN = ['shared prompt', 'shared answer'];
1604
+ const TERMINAL_TURN = ['terminal prompt', 'terminal answer'];
1605
+
1606
+ test('a fork with no exit stamp shows the branch ending at the newest row', { concurrency: false }, async () => {
1607
+ const r = concurrentRow;
1608
+ // With no `last-prompt` in the file, `--resume` falls back to the newest row:
1609
+ // Chat forked first but kept the session going.
1610
+ const texts = await readConcurrentlyForkedHistory([
1611
+ r.sharedPrompt, r.sharedAnswer, r.chatPrompt, r.chatAnswer,
1612
+ r.terminalPrompt, r.terminalAnswer, r.chatFollowUp, r.chatFollowUpAnswer,
1613
+ ]);
1614
+
1615
+ assert.deepEqual(texts, [...SHARED_TURN, 'chat prompt', 'chat answer', 'chat follow-up', 'chat follow-up answer']);
1616
+ });
1617
+
1618
+ test('a fork shows the branch Chat stamped when Chat exited last', { concurrency: false }, async () => {
1619
+ const r = concurrentRow;
1620
+ const texts = await readConcurrentlyForkedHistory([
1621
+ r.sharedPrompt, r.sharedAnswer, r.chatPrompt, r.chatAnswer, exitStamp('a2'),
1622
+ r.terminalPrompt, r.terminalAnswer, r.chatFollowUp, r.chatFollowUpAnswer, exitStamp('a3'),
1623
+ ]);
1624
+
1625
+ assert.deepEqual(texts, [...SHARED_TURN, 'chat prompt', 'chat answer', 'chat follow-up', 'chat follow-up answer']);
1626
+ });
1627
+
1628
+ test('a fork shows the terminal branch once the terminal exits after Chat', { concurrency: false }, async () => {
1629
+ const r = concurrentRow;
1630
+ // Chat wrote the newest rows, but closing the terminal stamps its own leaf
1631
+ // last, and that stamp is what the next resume continues from.
1632
+ const texts = await readConcurrentlyForkedHistory([
1633
+ r.sharedPrompt, r.sharedAnswer, r.chatPrompt, r.chatAnswer, exitStamp('a2'),
1634
+ r.terminalPrompt, r.terminalAnswer, r.chatFollowUp, r.chatFollowUpAnswer, exitStamp('a3'),
1635
+ exitStamp('t2'),
1636
+ ]);
1637
+
1638
+ assert.deepEqual(texts, [...SHARED_TURN, ...TERMINAL_TURN]);
1639
+ });
1640
+
1641
+ test('a fork keeps the stamped branch while the other writer is still open', { concurrency: false }, async () => {
1642
+ const r = concurrentRow;
1643
+ // Chat exited after its turn; the terminal then typed a prompt and is still
1644
+ // open. Its rows are the newest, but they do not descend from Chat's stamp.
1645
+ const texts = await readConcurrentlyForkedHistory([
1646
+ r.sharedPrompt, r.sharedAnswer, r.chatPrompt, r.chatAnswer, exitStamp('a2'),
1647
+ r.terminalPrompt, r.terminalAnswer,
1648
+ ]);
1649
+
1650
+ assert.deepEqual(texts, [...SHARED_TURN, 'chat prompt', 'chat answer']);
1651
+ });
1652
+
1653
+ test('parallel tool calls are not mistaken for an edit', { concurrency: false }, async () => {
1654
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-parallel-tools-'));
1655
+ const sessionId = 'claude-parallel-session';
1656
+
1657
+ try {
1658
+ const transcriptPath = path.join(tempRoot, `${sessionId}.jsonl`);
1659
+ // One assistant turn issuing two tools: each tool_result parents onto the
1660
+ // same row, so this row has two children — a branch point that must not be
1661
+ // pruned, or tool output disappears from every transcript in the app.
1662
+ const rows = [
1663
+ {
1664
+ type: 'user', uuid: 'p1', parentUuid: null, sessionId,
1665
+ timestamp: '2026-08-23T10:00:00.000Z',
1666
+ message: { role: 'user', content: [{ type: 'text', text: 'do two things' }] },
1667
+ },
1668
+ {
1669
+ type: 'assistant', uuid: 'pa1', parentUuid: 'p1', sessionId,
1670
+ timestamp: '2026-08-23T10:00:01.000Z',
1671
+ message: {
1672
+ role: 'assistant', model: 'claude-opus-5',
1673
+ content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: '/a' } }],
1674
+ },
1675
+ },
1676
+ {
1677
+ type: 'user', uuid: 'pr1', parentUuid: 'pa1', sessionId,
1678
+ timestamp: '2026-08-23T10:00:02.000Z',
1679
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'contents of a' }] },
1680
+ },
1681
+ {
1682
+ type: 'user', uuid: 'pr2', parentUuid: 'pa1', sessionId,
1683
+ timestamp: '2026-08-23T10:00:03.000Z',
1684
+ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool-2', content: 'contents of b' }] },
1685
+ },
1686
+ ];
1687
+ await writeFile(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1688
+
1689
+ await withIsolatedDatabase(async () => {
1690
+ const now = new Date().toISOString();
1691
+ sessionsDb.createSession(sessionId, 'claude', tempRoot, 'Parallel tools', now, now, transcriptPath);
1692
+
1693
+ const history = await new ClaudeSessionsProvider().fetchHistory(sessionId, {
1694
+ providerSessionId: sessionId,
1695
+ });
1696
+
1697
+ assert.equal(
1698
+ history.messages.some((message) => message.content === 'do two things'),
1699
+ true,
1700
+ );
1701
+ const toolRow = history.messages.find((message) => message.kind === 'tool_use');
1702
+ assert.ok(toolRow, 'the tool call survives');
1703
+ assert.equal(toolRow?.toolResult?.content, 'contents of a');
1704
+ });
1705
+ } finally {
1706
+ await rm(tempRoot, { recursive: true, force: true });
1707
+ }
1708
+ });
1709
+
1710
+ test('resolving an edit anchor returns the assistant turn before it', { concurrency: false }, async () => {
1711
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-edit-anchor-'));
1712
+
1713
+ try {
1714
+ const transcriptPath = await writeEditedTranscript(tempRoot);
1715
+
1716
+ await withIsolatedDatabase(async () => {
1717
+ const now = new Date().toISOString();
1718
+ sessionsDb.createSession(EDIT_SESSION_ID, 'claude', tempRoot, 'Edited session', now, now, transcriptPath);
1719
+ const provider = new ClaudeSessionsProvider();
1720
+
1721
+ // Resuming is inclusive of the row it names, so replacing `u2b` must
1722
+ // resume through `a1` — naming `u2b` itself would leave the prompt being
1723
+ // replaced in context.
1724
+ assert.deepEqual(
1725
+ await provider.resolveEditAnchor(EDIT_SESSION_ID, 'u2b'),
1726
+ { found: true, resumeThroughId: 'a1' },
1727
+ );
1728
+
1729
+ // Nothing precedes the first prompt, so the conversation starts over.
1730
+ assert.deepEqual(
1731
+ await provider.resolveEditAnchor(EDIT_SESSION_ID, 'u1'),
1732
+ { found: true, resumeThroughId: null },
1733
+ );
1734
+
1735
+ assert.deepEqual(
1736
+ await provider.resolveEditAnchor(EDIT_SESSION_ID, 'not-in-transcript'),
1737
+ { found: false, resumeThroughId: null },
1738
+ );
1739
+ });
1740
+ } finally {
1741
+ await rm(tempRoot, { recursive: true, force: true });
1742
+ }
1743
+ });
1744
+
1745
+ test('user turns carry the transcript uuid so they can be edited', { concurrency: false }, async () => {
1746
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-anchor-ids-'));
1747
+
1748
+ try {
1749
+ const transcriptPath = await writeEditedTranscript(tempRoot);
1750
+
1751
+ await withIsolatedDatabase(async () => {
1752
+ const now = new Date().toISOString();
1753
+ sessionsDb.createSession(EDIT_SESSION_ID, 'claude', tempRoot, 'Edited session', now, now, transcriptPath);
1754
+
1755
+ const history = await new ClaudeSessionsProvider().fetchHistory(EDIT_SESSION_ID, {
1756
+ providerSessionId: EDIT_SESSION_ID,
1757
+ });
1758
+
1759
+ const userRows = history.messages.filter((message) => message.role === 'user');
1760
+ assert.deepEqual(
1761
+ userRows.map((message) => message.transcriptAnchorId),
1762
+ ['u1', 'u2b'],
1763
+ );
1764
+ // Assistant rows are never an anchor: the UI only offers editing on a
1765
+ // turn the user typed.
1766
+ assert.equal(
1767
+ history.messages.some((message) => message.role !== 'user' && message.transcriptAnchorId),
1768
+ false,
1769
+ );
1770
+ });
1771
+ } finally {
1772
+ await rm(tempRoot, { recursive: true, force: true });
1773
+ }
1774
+ });
1775
+
1776
+ test('resolving an edit anchor skips rows that are not conversation turns', { concurrency: false }, async () => {
1777
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'claude-anchor-skip-'));
1778
+ const sessionId = 'claude-anchor-skip-session';
1779
+
1780
+ try {
1781
+ const transcriptPath = path.join(tempRoot, `${sessionId}.jsonl`);
1782
+ // An attachment row sits between the assistant turn and the next prompt.
1783
+ // Resuming names an assistant message, so the walk has to pass over it —
1784
+ // naming the attachment would resume at something the SDK cannot address.
1785
+ const rows = [
1786
+ {
1787
+ type: 'user', uuid: 'su1', parentUuid: null, sessionId,
1788
+ timestamp: '2026-08-23T10:00:00.000Z',
1789
+ message: { role: 'user', content: [{ type: 'text', text: 'hello' }] },
1790
+ },
1791
+ {
1792
+ type: 'assistant', uuid: 'sa1', parentUuid: 'su1', sessionId,
1793
+ timestamp: '2026-08-23T10:00:01.000Z',
1794
+ message: { role: 'assistant', model: 'claude-opus-5', content: [{ type: 'text', text: 'hi' }] },
1795
+ },
1796
+ {
1797
+ type: 'attachment', uuid: 'sat1', parentUuid: 'sa1', sessionId,
1798
+ timestamp: '2026-08-23T10:00:02.000Z',
1799
+ },
1800
+ {
1801
+ type: 'user', uuid: 'su2', parentUuid: 'sat1', sessionId,
1802
+ timestamp: '2026-08-23T10:00:03.000Z',
1803
+ message: { role: 'user', content: [{ type: 'text', text: 'second prompt' }] },
1804
+ },
1805
+ ];
1806
+ await writeFile(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
1807
+
1808
+ await withIsolatedDatabase(async () => {
1809
+ const now = new Date().toISOString();
1810
+ sessionsDb.createSession(sessionId, 'claude', tempRoot, 'Anchor skip', now, now, transcriptPath);
1811
+
1812
+ assert.deepEqual(
1813
+ await new ClaudeSessionsProvider().resolveEditAnchor(sessionId, 'su2'),
1814
+ { found: true, resumeThroughId: 'sa1' },
1815
+ );
1816
+ });
1817
+ } finally {
1818
+ await rm(tempRoot, { recursive: true, force: true });
1819
+ }
1820
+ });
1821
+
1822
+ // ---------------------------------------------------------------------------
1823
+ // buildLookupMap
1824
+ // ---------------------------------------------------------------------------
1825
+
1826
+ test('buildLookupMap returns first-seen value when key appears multiple times', async () => {
1827
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-lookup-'));
1828
+ const filePath = path.join(tmp, 'history.jsonl');
1829
+ try {
1830
+ await writeFile(
1831
+ filePath,
1832
+ [
1833
+ JSON.stringify({ sessionId: 's1', display: 'first-message' }),
1834
+ JSON.stringify({ sessionId: 's1', display: 'second-message' }),
1835
+ JSON.stringify({ sessionId: 's2', display: 'only-message' }),
1836
+ ].join('\n'),
1837
+ 'utf8',
1838
+ );
1839
+
1840
+ const map = await buildLookupMap(filePath, 'sessionId', 'display');
1841
+
1842
+ assert.equal(map.size, 2);
1843
+ assert.equal(map.get('s1'), 'first-message');
1844
+ assert.equal(map.get('s2'), 'only-message');
1845
+ } finally {
1846
+ await rm(tmp, { recursive: true, force: true });
1847
+ }
1848
+ });
1849
+
1850
+ test('buildLookupMap returns empty map for missing file', async () => {
1851
+ const map = await buildLookupMap(path.join(os.tmpdir(), 'does-not-exist.jsonl'), 'k', 'v');
1852
+ assert.equal(map.size, 0);
1853
+ });
1854
+
1855
+ test('buildLookupMap returns empty map for empty file', async () => {
1856
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-lookup-'));
1857
+ const filePath = path.join(tmp, 'empty.jsonl');
1858
+ try {
1859
+ await writeFile(filePath, '', 'utf8');
1860
+ const map = await buildLookupMap(filePath, 'k', 'v');
1861
+ assert.equal(map.size, 0);
1862
+ } finally {
1863
+ await rm(tmp, { recursive: true, force: true });
1864
+ }
1865
+ });
1866
+
1867
+ test('buildLookupMap skips rows with non-string key or value', async () => {
1868
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-lookup-'));
1869
+ const filePath = path.join(tmp, 'history.jsonl');
1870
+ try {
1871
+ await writeFile(
1872
+ filePath,
1873
+ [
1874
+ JSON.stringify({ sessionId: 123, display: 'not-a-string-key' }),
1875
+ JSON.stringify({ sessionId: 's1', display: 456 }),
1876
+ JSON.stringify({ sessionId: 's1', display: 'valid-entry' }),
1877
+ ].join('\n'),
1878
+ 'utf8',
1879
+ );
1880
+
1881
+ const map = await buildLookupMap(filePath, 'sessionId', 'display');
1882
+ assert.equal(map.size, 1);
1883
+ assert.equal(map.get('s1'), 'valid-entry');
1884
+ } finally {
1885
+ await rm(tmp, { recursive: true, force: true });
1886
+ }
1887
+ });
1888
+
1889
+ // ---------------------------------------------------------------------------
1890
+ // extractSessionTitle — tested via synchronizeFile
1891
+ // ---------------------------------------------------------------------------
1892
+
1893
+ test('synchronizeFile uses ai-title from JSONL when no DB custom_name exists', { concurrency: false }, async () => {
1894
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-aititle-'));
1895
+ const workspacePath = path.join(tmp, 'workspace');
1896
+ await mkdir(workspacePath, { recursive: true });
1897
+ const restoreHomeDir = patchHomeDir(tmp);
1898
+
1899
+ try {
1900
+ // Create ~/.claude/history.jsonl with a competing display name.
1901
+ const claudeHome = path.join(tmp, '.claude');
1902
+ await mkdir(claudeHome, { recursive: true });
1903
+ await writeFile(
1904
+ path.join(claudeHome, 'history.jsonl'),
1905
+ JSON.stringify({ sessionId: 'test-session-1', display: 'user-first-prompt-from-history' }) + '\n',
1906
+ 'utf8',
1907
+ );
1908
+
1909
+ // Write session JSONL with ai-title before last-prompt.
1910
+ await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
1911
+ JSON.stringify({ type: 'ai-title', aiTitle: 'AI generated title', sessionId: 'test-session-1' }),
1912
+ JSON.stringify({
1913
+ parentUuid: 'msg-1',
1914
+ isSidechain: false,
1915
+ message: { role: 'assistant', content: [{ type: 'text', text: 'hello' }] },
1916
+ type: 'assistant',
1917
+ uuid: 'msg-2',
1918
+ }),
1919
+ JSON.stringify({ type: 'last-prompt', lastPrompt: 'first prompt', sessionId: 'test-session-1' }),
1920
+ ]);
1921
+
1922
+ await withIsolatedDatabase(async () => {
1923
+ const synchronizer = new ClaudeSessionSynchronizer();
1924
+ const result = await synchronizer.synchronizeFile(
1925
+ path.join(workspacePath, 'test-session-1.jsonl'),
1926
+ );
1927
+
1928
+ assert.ok(result, 'synchronizeFile should return a session id');
1929
+ const session = sessionsDb.getSessionById(result!);
1930
+ assert.equal(session?.custom_name, 'AI generated title');
1931
+ });
1932
+ } finally {
1933
+ restoreHomeDir();
1934
+ await rm(tmp, { recursive: true, force: true });
1935
+ }
1936
+ });
1937
+
1938
+ test('synchronizeFile uses custom-title from JSONL when no DB custom_name and no ai-title', { concurrency: false }, async () => {
1939
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-customtitle-'));
1940
+ const workspacePath = path.join(tmp, 'workspace');
1941
+ await mkdir(workspacePath, { recursive: true });
1942
+ const restoreHomeDir = patchHomeDir(tmp);
1943
+
1944
+ try {
1945
+ const claudeHome = path.join(tmp, '.claude');
1946
+ await mkdir(claudeHome, { recursive: true });
1947
+ await writeFile(path.join(claudeHome, 'history.jsonl'), '', 'utf8');
1948
+
1949
+ await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
1950
+ JSON.stringify({
1951
+ parentUuid: 'msg-1',
1952
+ isSidechain: false,
1953
+ message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
1954
+ type: 'assistant',
1955
+ uuid: 'msg-2',
1956
+ }),
1957
+ JSON.stringify({ type: 'custom-title', customTitle: 'Renamed via cli', sessionId: 'test-session-1' }),
1958
+ JSON.stringify({ type: 'last-prompt', lastPrompt: 'first prompt', sessionId: 'test-session-1' }),
1959
+ ]);
1960
+
1961
+ await withIsolatedDatabase(async () => {
1962
+ const synchronizer = new ClaudeSessionSynchronizer();
1963
+ const result = await synchronizer.synchronizeFile(
1964
+ path.join(workspacePath, 'test-session-1.jsonl'),
1965
+ );
1966
+
1967
+ assert.ok(result);
1968
+ const session = sessionsDb.getSessionById(result!);
1969
+ assert.equal(session?.custom_name, 'Renamed via cli');
1970
+ });
1971
+ } finally {
1972
+ restoreHomeDir();
1973
+ await rm(tmp, { recursive: true, force: true });
1974
+ }
1975
+ });
1976
+
1977
+ test('synchronizeFile falls back to history.jsonl display when JSONL has no title events', { concurrency: false }, async () => {
1978
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-fallback-'));
1979
+ const workspacePath = path.join(tmp, 'workspace');
1980
+ await mkdir(workspacePath, { recursive: true });
1981
+ const restoreHomeDir = patchHomeDir(tmp);
1982
+
1983
+ try {
1984
+ const claudeHome = path.join(tmp, '.claude');
1985
+ await mkdir(claudeHome, { recursive: true });
1986
+ await writeFile(
1987
+ path.join(claudeHome, 'history.jsonl'),
1988
+ JSON.stringify({ sessionId: 'test-session-1', display: 'fallback display name' }) + '\n',
1989
+ 'utf8',
1990
+ );
1991
+
1992
+ // Session JSONL with NO ai-title, custom-title, or last-prompt.
1993
+ await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
1994
+ JSON.stringify({
1995
+ parentUuid: 'msg-1',
1996
+ isSidechain: false,
1997
+ message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
1998
+ type: 'assistant',
1999
+ uuid: 'msg-2',
2000
+ }),
2001
+ ]);
2002
+
2003
+ await withIsolatedDatabase(async () => {
2004
+ const synchronizer = new ClaudeSessionSynchronizer();
2005
+ const result = await synchronizer.synchronizeFile(
2006
+ path.join(workspacePath, 'test-session-1.jsonl'),
2007
+ );
2008
+
2009
+ assert.ok(result);
2010
+ const session = sessionsDb.getSessionById(result!);
2011
+ assert.equal(session?.custom_name, 'fallback display name');
2012
+ });
2013
+ } finally {
2014
+ restoreHomeDir();
2015
+ await rm(tmp, { recursive: true, force: true });
2016
+ }
2017
+ });
2018
+
2019
+ test('synchronizeFile falls back to Untitled Claude Session when all sources are empty', { concurrency: false }, async () => {
2020
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-untitled-'));
2021
+ const workspacePath = path.join(tmp, 'workspace');
2022
+ await mkdir(workspacePath, { recursive: true });
2023
+ const restoreHomeDir = patchHomeDir(tmp);
2024
+
2025
+ try {
2026
+ const claudeHome = path.join(tmp, '.claude');
2027
+ await mkdir(claudeHome, { recursive: true });
2028
+ await writeFile(path.join(claudeHome, 'history.jsonl'), '', 'utf8');
2029
+
2030
+ // Session JSONL with NO title events at all.
2031
+ await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
2032
+ JSON.stringify({
2033
+ parentUuid: 'msg-1',
2034
+ isSidechain: false,
2035
+ message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
2036
+ type: 'assistant',
2037
+ uuid: 'msg-2',
2038
+ }),
2039
+ ]);
2040
+
2041
+ await withIsolatedDatabase(async () => {
2042
+ const synchronizer = new ClaudeSessionSynchronizer();
2043
+ const result = await synchronizer.synchronizeFile(
2044
+ path.join(workspacePath, 'test-session-1.jsonl'),
2045
+ );
2046
+
2047
+ assert.ok(result);
2048
+ const session = sessionsDb.getSessionById(result!);
2049
+ assert.equal(session?.custom_name, 'Untitled Claude Session');
2050
+ });
2051
+ } finally {
2052
+ restoreHomeDir();
2053
+ await rm(tmp, { recursive: true, force: true });
2054
+ }
2055
+ });
2056
+
2057
+ // ---------------------------------------------------------------------------
2058
+ // Priority: DB custom_name > JSONL title > history.jsonl
2059
+ // ---------------------------------------------------------------------------
2060
+
2061
+ test('synchronizeFile preserves existing DB custom_name regardless of JSONL and history.jsonl', { concurrency: false }, async () => {
2062
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-dbwins-'));
2063
+ const workspacePath = path.join(tmp, 'workspace');
2064
+ await mkdir(workspacePath, { recursive: true });
2065
+ const restoreHomeDir = patchHomeDir(tmp);
2066
+
2067
+ try {
2068
+ const claudeHome = path.join(tmp, '.claude');
2069
+ await mkdir(claudeHome, { recursive: true });
2070
+ await writeFile(
2071
+ path.join(claudeHome, 'history.jsonl'),
2072
+ JSON.stringify({ sessionId: 'test-session-1', display: 'history-display-name' }) + '\n',
2073
+ 'utf8',
2074
+ );
2075
+
2076
+ // Write session JSONL with competing ai-title.
2077
+ await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
2078
+ JSON.stringify({ type: 'ai-title', aiTitle: 'JSONL ai title', sessionId: 'test-session-1' }),
2079
+ JSON.stringify({ type: 'last-prompt', lastPrompt: 'first prompt', sessionId: 'test-session-1' }),
2080
+ ]);
2081
+
2082
+ await withIsolatedDatabase(async () => {
2083
+ // Pre-seed the DB with a custom_name set via CloudCLI sidebar rename.
2084
+ sessionsDb.createSession(
2085
+ 'test-session-1',
2086
+ 'claude',
2087
+ workspacePath,
2088
+ 'Sidebar custom name',
2089
+ );
2090
+
2091
+ const synchronizer = new ClaudeSessionSynchronizer();
2092
+ const result = await synchronizer.synchronizeFile(
2093
+ path.join(workspacePath, 'test-session-1.jsonl'),
2094
+ );
2095
+
2096
+ assert.ok(result);
2097
+ const session = sessionsDb.getSessionById(result!);
2098
+ // DB custom_name must win over JSONL ai-title AND history.jsonl display.
2099
+ assert.equal(session?.custom_name, 'Sidebar custom name');
2100
+ });
2101
+ } finally {
2102
+ restoreHomeDir();
2103
+ await rm(tmp, { recursive: true, force: true });
2104
+ }
2105
+ });
2106
+
2107
+ test('synchronizeFile does NOT treat "Untitled Claude Session" in DB as a real custom_name', { concurrency: false }, async () => {
2108
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-untitled-db-'));
2109
+ const workspacePath = path.join(tmp, 'workspace');
2110
+ await mkdir(workspacePath, { recursive: true });
2111
+ const restoreHomeDir = patchHomeDir(tmp);
2112
+
2113
+ try {
2114
+ const claudeHome = path.join(tmp, '.claude');
2115
+ await mkdir(claudeHome, { recursive: true });
2116
+ await writeFile(path.join(claudeHome, 'history.jsonl'), '', 'utf8');
2117
+
2118
+ // Session JSONL with an ai-title that should win over the DB default.
2119
+ await writeSessionJsonl(workspacePath, 'test-session-1.jsonl', [
2120
+ JSON.stringify({ type: 'ai-title', aiTitle: 'Real AI title from JSONL', sessionId: 'test-session-1' }),
2121
+ JSON.stringify({ type: 'last-prompt', lastPrompt: 'first prompt', sessionId: 'test-session-1' }),
2122
+ ]);
2123
+
2124
+ await withIsolatedDatabase(async () => {
2125
+ // Seed with the default fallback name — should be ignored.
2126
+ sessionsDb.createSession(
2127
+ 'test-session-1',
2128
+ 'claude',
2129
+ workspacePath,
2130
+ 'Untitled Claude Session',
2131
+ );
2132
+
2133
+ const synchronizer = new ClaudeSessionSynchronizer();
2134
+ const result = await synchronizer.synchronizeFile(
2135
+ path.join(workspacePath, 'test-session-1.jsonl'),
2136
+ );
2137
+
2138
+ assert.ok(result);
2139
+ const session = sessionsDb.getSessionById(result!);
2140
+ assert.equal(session?.custom_name, 'Real AI title from JSONL');
2141
+ });
2142
+ } finally {
2143
+ restoreHomeDir();
2144
+ await rm(tmp, { recursive: true, force: true });
2145
+ }
2146
+ });
2147
+
2148
+ // ---------------------------------------------------------------------------
2149
+ // Edge cases
2150
+ // ---------------------------------------------------------------------------
2151
+
2152
+ test('synchronizeFile skips subagent transcripts', { concurrency: false }, async () => {
2153
+ const tmp = await mkdtemp(path.join(os.tmpdir(), 'claude-sync-subagent-'));
2154
+ const workspacePath = path.join(tmp, 'workspace');
2155
+ await mkdir(workspacePath, { recursive: true });
2156
+ const restoreHomeDir = patchHomeDir(tmp);
2157
+
2158
+ try {
2159
+ const claudeHome = path.join(tmp, '.claude');
2160
+ await mkdir(claudeHome, { recursive: true });
2161
+ await writeFile(path.join(claudeHome, 'history.jsonl'), '', 'utf8');
2162
+
2163
+ // Create a file whose path contains "subagents".
2164
+ const subagentsDir = path.join(workspacePath, 'test-session-1', 'subagents');
2165
+ await mkdir(subagentsDir, { recursive: true });
2166
+ await writeSessionJsonl(subagentsDir, 'agent-1.jsonl', [
2167
+ JSON.stringify({ type: 'ai-title', aiTitle: 'Subagent title', sessionId: 'test-session-1' }),
2168
+ ]);
2169
+
2170
+ await withIsolatedDatabase(async () => {
2171
+ const synchronizer = new ClaudeSessionSynchronizer();
2172
+ const result = await synchronizer.synchronizeFile(
2173
+ path.join(subagentsDir, 'agent-1.jsonl'),
2174
+ );
2175
+
2176
+ // Subagent transcripts should be silently skipped (return null).
2177
+ assert.equal(result, null);
2178
+ });
2179
+ } finally {
2180
+ restoreHomeDir();
2181
+ await rm(tmp, { recursive: true, force: true });
2182
+ }
2183
+ });
57
2184
 
58
- assert.equal(messages.length, 1);
59
- assert.equal(messages[0].kind, 'tool_result');
60
- assert.equal(messages[0].toolId, 'toolu_1');
2185
+ test('synchronizeFile skips non-jsonl files', { concurrency: false }, async () => {
2186
+ await withIsolatedDatabase(async () => {
2187
+ const synchronizer = new ClaudeSessionSynchronizer();
2188
+ const result = await synchronizer.synchronizeFile('/tmp/not-a-jsonl.txt');
2189
+ assert.equal(result, null);
2190
+ });
61
2191
  });