@qduc/term2 0.6.1 → 0.7.1

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 (659) hide show
  1. package/dist/agent.d.ts +9 -1
  2. package/dist/agent.d.ts.map +1 -1
  3. package/dist/agent.js +158 -69
  4. package/dist/agent.js.map +1 -1
  5. package/dist/agent.test.js +165 -15
  6. package/dist/agent.test.js.map +1 -1
  7. package/dist/app.d.ts +14 -3
  8. package/dist/app.d.ts.map +1 -1
  9. package/dist/app.js +192 -28
  10. package/dist/app.js.map +1 -1
  11. package/dist/app.large-uncached-warning.test.d.ts +2 -0
  12. package/dist/app.large-uncached-warning.test.d.ts.map +1 -0
  13. package/dist/app.large-uncached-warning.test.js +15 -0
  14. package/dist/app.large-uncached-warning.test.js.map +1 -0
  15. package/dist/app.startup-banner.test.js +3 -19
  16. package/dist/app.startup-banner.test.js.map +1 -1
  17. package/dist/cli.integration.test.d.ts +2 -0
  18. package/dist/cli.integration.test.d.ts.map +1 -0
  19. package/dist/cli.integration.test.js +102 -0
  20. package/dist/cli.integration.test.js.map +1 -0
  21. package/dist/cli.js +158 -71
  22. package/dist/cli.js.map +1 -1
  23. package/dist/commands/auto-approve-command.d.ts +10 -0
  24. package/dist/commands/auto-approve-command.d.ts.map +1 -0
  25. package/dist/commands/auto-approve-command.js +40 -0
  26. package/dist/commands/auto-approve-command.js.map +1 -0
  27. package/dist/commands/clear-command.d.ts +3 -0
  28. package/dist/commands/clear-command.d.ts.map +1 -0
  29. package/dist/commands/clear-command.js +12 -0
  30. package/dist/commands/clear-command.js.map +1 -0
  31. package/dist/commands/copy-command.d.ts +10 -0
  32. package/dist/commands/copy-command.d.ts.map +1 -0
  33. package/dist/commands/copy-command.js +25 -0
  34. package/dist/commands/copy-command.js.map +1 -0
  35. package/dist/commands/effort-command.d.ts +11 -0
  36. package/dist/commands/effort-command.d.ts.map +1 -0
  37. package/dist/commands/effort-command.js +34 -0
  38. package/dist/commands/effort-command.js.map +1 -0
  39. package/dist/commands/guarded-settings-command.d.ts +14 -0
  40. package/dist/commands/guarded-settings-command.d.ts.map +1 -0
  41. package/dist/commands/guarded-settings-command.js +23 -0
  42. package/dist/commands/guarded-settings-command.js.map +1 -0
  43. package/dist/commands/handoff-command.d.ts +10 -0
  44. package/dist/commands/handoff-command.d.ts.map +1 -0
  45. package/dist/commands/handoff-command.js +17 -0
  46. package/dist/commands/handoff-command.js.map +1 -0
  47. package/dist/commands/mode-commands.d.ts +32 -0
  48. package/dist/commands/mode-commands.d.ts.map +1 -0
  49. package/dist/commands/mode-commands.js +64 -0
  50. package/dist/commands/mode-commands.js.map +1 -0
  51. package/dist/commands/model-command.d.ts +11 -0
  52. package/dist/commands/model-command.d.ts.map +1 -0
  53. package/dist/commands/model-command.js +35 -0
  54. package/dist/commands/model-command.js.map +1 -0
  55. package/dist/commands/quit-command.d.ts +3 -0
  56. package/dist/commands/quit-command.d.ts.map +1 -0
  57. package/dist/commands/quit-command.js +10 -0
  58. package/dist/commands/quit-command.js.map +1 -0
  59. package/dist/commands/retry-command.d.ts +19 -0
  60. package/dist/commands/retry-command.d.ts.map +1 -0
  61. package/dist/commands/retry-command.js +30 -0
  62. package/dist/commands/retry-command.js.map +1 -0
  63. package/dist/commands/undo-command.d.ts +15 -0
  64. package/dist/commands/undo-command.d.ts.map +1 -0
  65. package/dist/commands/undo-command.js +28 -0
  66. package/dist/commands/undo-command.js.map +1 -0
  67. package/dist/commands/usage-command.d.ts +3 -0
  68. package/dist/commands/usage-command.d.ts.map +1 -0
  69. package/dist/commands/usage-command.js +11 -0
  70. package/dist/commands/usage-command.js.map +1 -0
  71. package/dist/components/Banner.d.ts.map +1 -1
  72. package/dist/components/Banner.js +35 -17
  73. package/dist/components/Banner.js.map +1 -1
  74. package/dist/components/BottomArea.d.ts +10 -1
  75. package/dist/components/BottomArea.d.ts.map +1 -1
  76. package/dist/components/BottomArea.js +8 -6
  77. package/dist/components/BottomArea.js.map +1 -1
  78. package/dist/components/BottomArea.test.js +14 -1
  79. package/dist/components/BottomArea.test.js.map +1 -1
  80. package/dist/components/CommandMessage.d.ts.map +1 -1
  81. package/dist/components/CommandMessage.js +31 -1
  82. package/dist/components/CommandMessage.js.map +1 -1
  83. package/dist/components/CommandMessage.test.js +150 -31
  84. package/dist/components/CommandMessage.test.js.map +1 -1
  85. package/dist/components/Common/MenuContainer.d.ts +20 -0
  86. package/dist/components/Common/MenuContainer.d.ts.map +1 -0
  87. package/dist/components/Common/MenuContainer.js +52 -0
  88. package/dist/components/Common/MenuContainer.js.map +1 -0
  89. package/dist/components/Common/ScrollableTabBar.d.ts +26 -0
  90. package/dist/components/Common/ScrollableTabBar.d.ts.map +1 -0
  91. package/dist/components/Common/ScrollableTabBar.js +26 -0
  92. package/dist/components/Common/ScrollableTabBar.js.map +1 -0
  93. package/dist/components/Common/compute-visible-tabs.d.ts +16 -0
  94. package/dist/components/Common/compute-visible-tabs.d.ts.map +1 -0
  95. package/dist/components/Common/compute-visible-tabs.js +53 -0
  96. package/dist/components/Common/compute-visible-tabs.js.map +1 -0
  97. package/dist/components/Common/compute-visible-tabs.test.d.ts +2 -0
  98. package/dist/components/Common/compute-visible-tabs.test.d.ts.map +1 -0
  99. package/dist/components/Common/compute-visible-tabs.test.js +97 -0
  100. package/dist/components/Common/compute-visible-tabs.test.js.map +1 -0
  101. package/dist/components/HandoffConfirmationPrompt.d.ts +9 -0
  102. package/dist/components/HandoffConfirmationPrompt.d.ts.map +1 -0
  103. package/dist/components/HandoffConfirmationPrompt.js +38 -0
  104. package/dist/components/HandoffConfirmationPrompt.js.map +1 -0
  105. package/dist/components/HandoffConfirmationPrompt.test.d.ts +2 -0
  106. package/dist/components/HandoffConfirmationPrompt.test.d.ts.map +1 -0
  107. package/dist/components/HandoffConfirmationPrompt.test.js +12 -0
  108. package/dist/components/HandoffConfirmationPrompt.test.js.map +1 -0
  109. package/dist/components/Input/PopupManager.d.ts +5 -1
  110. package/dist/components/Input/PopupManager.d.ts.map +1 -1
  111. package/dist/components/Input/PopupManager.js +2 -2
  112. package/dist/components/Input/PopupManager.js.map +1 -1
  113. package/dist/components/Input/popup-key-navigation.js +2 -2
  114. package/dist/components/Input/popup-key-navigation.js.map +1 -1
  115. package/dist/components/Input/popup-key-navigation.test.js +5 -3
  116. package/dist/components/Input/popup-key-navigation.test.js.map +1 -1
  117. package/dist/components/Input/popup-props.d.ts.map +1 -1
  118. package/dist/components/Input/popup-props.js +4 -0
  119. package/dist/components/Input/popup-props.js.map +1 -1
  120. package/dist/components/Input/popup-props.test.d.ts +2 -0
  121. package/dist/components/Input/popup-props.test.d.ts.map +1 -0
  122. package/dist/components/Input/popup-props.test.js +70 -0
  123. package/dist/components/Input/popup-props.test.js.map +1 -0
  124. package/dist/components/InputBox.d.ts +1 -1
  125. package/dist/components/InputBox.d.ts.map +1 -1
  126. package/dist/components/InputBox.js +67 -14
  127. package/dist/components/InputBox.js.map +1 -1
  128. package/dist/components/InputBox.test.js +192 -12
  129. package/dist/components/InputBox.test.js.map +1 -1
  130. package/dist/components/MessageList.d.ts +1 -0
  131. package/dist/components/MessageList.d.ts.map +1 -1
  132. package/dist/components/MessageList.js.map +1 -1
  133. package/dist/components/ModelSelectionMenu.d.ts.map +1 -1
  134. package/dist/components/ModelSelectionMenu.js +29 -125
  135. package/dist/components/ModelSelectionMenu.js.map +1 -1
  136. package/dist/components/ModelSelectionMenu.test.js +9 -5
  137. package/dist/components/ModelSelectionMenu.test.js.map +1 -1
  138. package/dist/components/PathSelectionMenu.d.ts.map +1 -1
  139. package/dist/components/PathSelectionMenu.js +15 -55
  140. package/dist/components/PathSelectionMenu.js.map +1 -1
  141. package/dist/components/SettingsSelectionMenu.d.ts +4 -1
  142. package/dist/components/SettingsSelectionMenu.d.ts.map +1 -1
  143. package/dist/components/SettingsSelectionMenu.js +48 -104
  144. package/dist/components/SettingsSelectionMenu.js.map +1 -1
  145. package/dist/components/SettingsSelectionMenu.scroll.test.js +140 -0
  146. package/dist/components/SettingsSelectionMenu.scroll.test.js.map +1 -1
  147. package/dist/components/SettingsSelectionMenu.test.js +38 -5
  148. package/dist/components/SettingsSelectionMenu.test.js.map +1 -1
  149. package/dist/components/SettingsValueSelectionMenu.d.ts.map +1 -1
  150. package/dist/components/SettingsValueSelectionMenu.js +24 -51
  151. package/dist/components/SettingsValueSelectionMenu.js.map +1 -1
  152. package/dist/components/SlashCommandMenu.d.ts +1 -0
  153. package/dist/components/SlashCommandMenu.d.ts.map +1 -1
  154. package/dist/components/SlashCommandMenu.js +11 -14
  155. package/dist/components/SlashCommandMenu.js.map +1 -1
  156. package/dist/components/StatusBar.d.ts +6 -0
  157. package/dist/components/StatusBar.d.ts.map +1 -1
  158. package/dist/components/StatusBar.js +51 -4
  159. package/dist/components/StatusBar.js.map +1 -1
  160. package/dist/components/StatusBar.test.js +47 -0
  161. package/dist/components/StatusBar.test.js.map +1 -1
  162. package/dist/components/UndoSelectionMenu.d.ts.map +1 -1
  163. package/dist/components/UndoSelectionMenu.js +6 -33
  164. package/dist/components/UndoSelectionMenu.js.map +1 -1
  165. package/dist/context/InputContext.stability.test.js +9 -7
  166. package/dist/context/InputContext.stability.test.js.map +1 -1
  167. package/dist/contracts/conversation.d.ts +3 -0
  168. package/dist/contracts/conversation.d.ts.map +1 -1
  169. package/dist/env-setup.js +1 -0
  170. package/dist/env-setup.js.map +1 -1
  171. package/dist/hooks/use-app-commands.d.ts +20 -21
  172. package/dist/hooks/use-app-commands.d.ts.map +1 -1
  173. package/dist/hooks/use-app-commands.js +65 -330
  174. package/dist/hooks/use-app-commands.js.map +1 -1
  175. package/dist/hooks/use-app-commands.test.js +146 -3
  176. package/dist/hooks/use-app-commands.test.js.map +1 -1
  177. package/dist/hooks/use-conversation.clear.test.js +61 -8
  178. package/dist/hooks/use-conversation.clear.test.js.map +1 -1
  179. package/dist/hooks/use-conversation.d.ts +25 -2
  180. package/dist/hooks/use-conversation.d.ts.map +1 -1
  181. package/dist/hooks/use-conversation.js +91 -30
  182. package/dist/hooks/use-conversation.js.map +1 -1
  183. package/dist/hooks/use-escape-key.d.ts +8 -1
  184. package/dist/hooks/use-escape-key.d.ts.map +1 -1
  185. package/dist/hooks/use-escape-key.js +15 -4
  186. package/dist/hooks/use-escape-key.js.map +1 -1
  187. package/dist/hooks/use-escape-key.test.js +135 -28
  188. package/dist/hooks/use-escape-key.test.js.map +1 -1
  189. package/dist/hooks/use-input-history.test.js +14 -8
  190. package/dist/hooks/use-input-history.test.js.map +1 -1
  191. package/dist/hooks/use-mode-handlers.d.ts +16 -2
  192. package/dist/hooks/use-mode-handlers.d.ts.map +1 -1
  193. package/dist/hooks/use-mode-handlers.js +37 -2
  194. package/dist/hooks/use-mode-handlers.js.map +1 -1
  195. package/dist/hooks/use-model-selection.d.ts +6 -1
  196. package/dist/hooks/use-model-selection.d.ts.map +1 -1
  197. package/dist/hooks/use-model-selection.js +50 -8
  198. package/dist/hooks/use-model-selection.js.map +1 -1
  199. package/dist/hooks/use-model-selection.test.js +306 -50
  200. package/dist/hooks/use-model-selection.test.js.map +1 -1
  201. package/dist/hooks/use-path-completion.d.ts +4 -0
  202. package/dist/hooks/use-path-completion.d.ts.map +1 -1
  203. package/dist/hooks/use-path-completion.js +23 -17
  204. package/dist/hooks/use-path-completion.js.map +1 -1
  205. package/dist/hooks/use-runtime-settings.d.ts.map +1 -1
  206. package/dist/hooks/use-runtime-settings.js +9 -4
  207. package/dist/hooks/use-runtime-settings.js.map +1 -1
  208. package/dist/hooks/use-runtime-settings.test.d.ts +2 -0
  209. package/dist/hooks/use-runtime-settings.test.d.ts.map +1 -0
  210. package/dist/hooks/use-runtime-settings.test.js +44 -0
  211. package/dist/hooks/use-runtime-settings.test.js.map +1 -0
  212. package/dist/hooks/use-selection.d.ts +6 -2
  213. package/dist/hooks/use-selection.d.ts.map +1 -1
  214. package/dist/hooks/use-selection.js +53 -16
  215. package/dist/hooks/use-selection.js.map +1 -1
  216. package/dist/hooks/use-selection.test.d.ts +2 -0
  217. package/dist/hooks/use-selection.test.d.ts.map +1 -0
  218. package/dist/hooks/use-selection.test.js +132 -0
  219. package/dist/hooks/use-selection.test.js.map +1 -0
  220. package/dist/hooks/use-settings-completion.d.ts +19 -0
  221. package/dist/hooks/use-settings-completion.d.ts.map +1 -1
  222. package/dist/hooks/use-settings-completion.js +106 -18
  223. package/dist/hooks/use-settings-completion.js.map +1 -1
  224. package/dist/hooks/use-settings-completion.scroll.test.js +68 -29
  225. package/dist/hooks/use-settings-completion.scroll.test.js.map +1 -1
  226. package/dist/hooks/use-settings-completion.test.js +36 -1
  227. package/dist/hooks/use-settings-completion.test.js.map +1 -1
  228. package/dist/hooks/use-settings-value-completion.d.ts +7 -1
  229. package/dist/hooks/use-settings-value-completion.d.ts.map +1 -1
  230. package/dist/hooks/use-settings-value-completion.js +18 -7
  231. package/dist/hooks/use-settings-value-completion.js.map +1 -1
  232. package/dist/hooks/use-slash-commands.d.ts +5 -0
  233. package/dist/hooks/use-slash-commands.d.ts.map +1 -1
  234. package/dist/hooks/use-slash-commands.js +32 -2
  235. package/dist/hooks/use-slash-commands.js.map +1 -1
  236. package/dist/hooks/use-slash-commands.test.js +35 -5
  237. package/dist/hooks/use-slash-commands.test.js.map +1 -1
  238. package/dist/hooks/use-trigger-detection.test.js +9 -6
  239. package/dist/hooks/use-trigger-detection.test.js.map +1 -1
  240. package/dist/hooks/use-undo-selection.d.ts +4 -0
  241. package/dist/hooks/use-undo-selection.d.ts.map +1 -1
  242. package/dist/hooks/use-undo-selection.js +20 -1
  243. package/dist/hooks/use-undo-selection.js.map +1 -1
  244. package/dist/lib/openai-agent-client.d.ts +10 -3
  245. package/dist/lib/openai-agent-client.d.ts.map +1 -1
  246. package/dist/lib/openai-agent-client.flex-tier.test.js +8 -3
  247. package/dist/lib/openai-agent-client.flex-tier.test.js.map +1 -1
  248. package/dist/lib/openai-agent-client.js +198 -38
  249. package/dist/lib/openai-agent-client.js.map +1 -1
  250. package/dist/lib/openai-agent-client.public-methods.test.js +193 -0
  251. package/dist/lib/openai-agent-client.public-methods.test.js.map +1 -1
  252. package/dist/lib/openai-agent-client.test.js +71 -1
  253. package/dist/lib/openai-agent-client.test.js.map +1 -1
  254. package/dist/lib/tool-invoke.d.ts +3 -2
  255. package/dist/lib/tool-invoke.d.ts.map +1 -1
  256. package/dist/lib/tool-invoke.js +213 -10
  257. package/dist/lib/tool-invoke.js.map +1 -1
  258. package/dist/lib/tool-invoke.test.js +103 -0
  259. package/dist/lib/tool-invoke.test.js.map +1 -1
  260. package/dist/lib/tool-selection-policy.d.ts.map +1 -1
  261. package/dist/lib/tool-selection-policy.js +43 -2
  262. package/dist/lib/tool-selection-policy.js.map +1 -1
  263. package/dist/lib/tool-selection-policy.test.js +39 -0
  264. package/dist/lib/tool-selection-policy.test.js.map +1 -1
  265. package/dist/prompts/codex.md +105 -97
  266. package/dist/prompts/fragments/gpt-5.3-codex.md +7 -0
  267. package/dist/prompts/fragments/gpt-5.4-small.md +6 -0
  268. package/dist/prompts/fragments/gpt-5.4.md +7 -0
  269. package/dist/prompts/fragments/gpt-5.5.md +7 -0
  270. package/dist/prompts/gpt-5-modern.md +90 -54
  271. package/dist/prompts/plan-mode-info.md +14 -3
  272. package/dist/prompts/prompt-constructor.d.ts +19 -0
  273. package/dist/prompts/prompt-constructor.d.ts.map +1 -0
  274. package/dist/prompts/prompt-constructor.js +53 -0
  275. package/dist/prompts/prompt-constructor.js.map +1 -0
  276. package/dist/prompts/prompt-constructor.test.d.ts +2 -0
  277. package/dist/prompts/prompt-constructor.test.d.ts.map +1 -0
  278. package/dist/prompts/prompt-constructor.test.js +99 -0
  279. package/dist/prompts/prompt-constructor.test.js.map +1 -0
  280. package/dist/prompts/prompt-constructor.test.ts +112 -0
  281. package/dist/prompts/prompt-constructor.ts +91 -0
  282. package/dist/prompts/prompt-profiles.d.ts +17 -0
  283. package/dist/prompts/prompt-profiles.d.ts.map +1 -0
  284. package/dist/prompts/prompt-profiles.js +61 -0
  285. package/dist/prompts/prompt-profiles.js.map +1 -0
  286. package/dist/prompts/prompt-profiles.ts +77 -0
  287. package/dist/prompts/prompt-selector.d.ts.map +1 -1
  288. package/dist/prompts/prompt-selector.js +3 -21
  289. package/dist/prompts/prompt-selector.js.map +1 -1
  290. package/dist/prompts/prompt-selector.ts +3 -25
  291. package/dist/prompts/reasoning-efficiency.d.ts.map +1 -1
  292. package/dist/prompts/reasoning-efficiency.js +2 -0
  293. package/dist/prompts/reasoning-efficiency.js.map +1 -1
  294. package/dist/prompts/reasoning-efficiency.test.d.ts +2 -0
  295. package/dist/prompts/reasoning-efficiency.test.d.ts.map +1 -0
  296. package/dist/prompts/reasoning-efficiency.test.js +11 -0
  297. package/dist/prompts/reasoning-efficiency.test.js.map +1 -0
  298. package/dist/prompts/reasoning-efficiency.test.ts +12 -0
  299. package/dist/prompts/reasoning-efficiency.ts +2 -0
  300. package/dist/prompts/search-via-shell.d.ts.map +1 -1
  301. package/dist/prompts/search-via-shell.js +0 -1
  302. package/dist/prompts/search-via-shell.js.map +1 -1
  303. package/dist/prompts/search-via-shell.test.js +19 -16
  304. package/dist/prompts/search-via-shell.test.js.map +1 -1
  305. package/dist/prompts/search-via-shell.test.ts +20 -16
  306. package/dist/prompts/search-via-shell.ts +0 -1
  307. package/dist/prompts/shell-auto-approval.d.ts +2 -2
  308. package/dist/prompts/shell-auto-approval.d.ts.map +1 -1
  309. package/dist/prompts/shell-auto-approval.js +6 -6
  310. package/dist/prompts/shell-auto-approval.js.map +1 -1
  311. package/dist/prompts/shell-auto-approval.ts +6 -6
  312. package/dist/prompts/simple-prev.md +55 -0
  313. package/dist/prompts/simple.md +31 -53
  314. package/dist/prompts/subagent-delegation.js +2 -2
  315. package/dist/prompts/subagent-delegation.js.map +1 -1
  316. package/dist/prompts/subagent-delegation.test.js +12 -10
  317. package/dist/prompts/subagent-delegation.test.js.map +1 -1
  318. package/dist/prompts/subagent-delegation.test.ts +13 -10
  319. package/dist/prompts/subagent-delegation.ts +2 -2
  320. package/dist/prompts/worktree-hygiene.md +9 -0
  321. package/dist/providers/ai-sdk-logging-fetch.d.ts.map +1 -1
  322. package/dist/providers/ai-sdk-logging-fetch.js +78 -3
  323. package/dist/providers/ai-sdk-logging-fetch.js.map +1 -1
  324. package/dist/providers/ai-sdk-logging-fetch.test.js +143 -0
  325. package/dist/providers/ai-sdk-logging-fetch.test.js.map +1 -1
  326. package/dist/providers/codex-responses-model.d.ts +21 -0
  327. package/dist/providers/codex-responses-model.d.ts.map +1 -0
  328. package/dist/providers/codex-responses-model.js +180 -0
  329. package/dist/providers/codex-responses-model.js.map +1 -0
  330. package/dist/providers/codex-responses-model.test.d.ts +2 -0
  331. package/dist/providers/codex-responses-model.test.d.ts.map +1 -0
  332. package/dist/providers/codex-responses-model.test.js +354 -0
  333. package/dist/providers/codex-responses-model.test.js.map +1 -0
  334. package/dist/providers/codex.provider.d.ts +27 -0
  335. package/dist/providers/codex.provider.d.ts.map +1 -0
  336. package/dist/providers/codex.provider.js +482 -0
  337. package/dist/providers/codex.provider.js.map +1 -0
  338. package/dist/providers/codex.provider.test.d.ts +2 -0
  339. package/dist/providers/codex.provider.test.d.ts.map +1 -0
  340. package/dist/providers/codex.provider.test.js +607 -0
  341. package/dist/providers/codex.provider.test.js.map +1 -0
  342. package/dist/providers/custom-provider-adapter.test.js +59 -3
  343. package/dist/providers/custom-provider-adapter.test.js.map +1 -1
  344. package/dist/providers/fallback-responses-model.d.ts +20 -0
  345. package/dist/providers/fallback-responses-model.d.ts.map +1 -0
  346. package/dist/providers/fallback-responses-model.js +346 -0
  347. package/dist/providers/fallback-responses-model.js.map +1 -0
  348. package/dist/providers/fallback-responses-model.test.d.ts +2 -0
  349. package/dist/providers/fallback-responses-model.test.d.ts.map +1 -0
  350. package/dist/providers/fallback-responses-model.test.js +568 -0
  351. package/dist/providers/fallback-responses-model.test.js.map +1 -0
  352. package/dist/providers/fetch/compose.d.ts +7 -0
  353. package/dist/providers/fetch/compose.d.ts.map +1 -0
  354. package/dist/providers/fetch/compose.js +20 -0
  355. package/dist/providers/fetch/compose.js.map +1 -0
  356. package/dist/providers/fetch/composer.d.ts +13 -0
  357. package/dist/providers/fetch/composer.d.ts.map +1 -0
  358. package/dist/providers/fetch/composer.js +15 -0
  359. package/dist/providers/fetch/composer.js.map +1 -0
  360. package/dist/providers/fetch/composer.test.d.ts +2 -0
  361. package/dist/providers/fetch/composer.test.d.ts.map +1 -0
  362. package/dist/providers/fetch/composer.test.js +448 -0
  363. package/dist/providers/fetch/composer.test.js.map +1 -0
  364. package/dist/providers/fetch/logging-middleware.d.ts +16 -0
  365. package/dist/providers/fetch/logging-middleware.d.ts.map +1 -0
  366. package/dist/providers/fetch/logging-middleware.js +214 -0
  367. package/dist/providers/fetch/logging-middleware.js.map +1 -0
  368. package/dist/providers/index.d.ts +1 -0
  369. package/dist/providers/index.d.ts.map +1 -1
  370. package/dist/providers/index.js +1 -0
  371. package/dist/providers/index.js.map +1 -1
  372. package/dist/providers/llama-cpp.provider.d.ts +3 -0
  373. package/dist/providers/llama-cpp.provider.d.ts.map +1 -0
  374. package/dist/providers/llama-cpp.provider.js +34 -0
  375. package/dist/providers/llama-cpp.provider.js.map +1 -0
  376. package/dist/providers/openai-compatible-middleware.d.ts +7 -0
  377. package/dist/providers/openai-compatible-middleware.d.ts.map +1 -0
  378. package/dist/providers/openai-compatible-middleware.js +133 -0
  379. package/dist/providers/openai-compatible-middleware.js.map +1 -0
  380. package/dist/providers/openai-compatible-models.d.ts +6 -0
  381. package/dist/providers/openai-compatible-models.d.ts.map +1 -0
  382. package/dist/providers/openai-compatible-models.js +17 -0
  383. package/dist/providers/openai-compatible-models.js.map +1 -0
  384. package/dist/providers/openai-compatible-response-normalizer.d.ts +7 -0
  385. package/dist/providers/openai-compatible-response-normalizer.d.ts.map +1 -0
  386. package/dist/providers/openai-compatible-response-normalizer.js +58 -0
  387. package/dist/providers/openai-compatible-response-normalizer.js.map +1 -0
  388. package/dist/providers/openai-compatible.provider.d.ts +6 -3
  389. package/dist/providers/openai-compatible.provider.d.ts.map +1 -1
  390. package/dist/providers/openai-compatible.provider.js +71 -327
  391. package/dist/providers/openai-compatible.provider.js.map +1 -1
  392. package/dist/providers/openai-compatible.provider.test.js +106 -1
  393. package/dist/providers/openai-compatible.provider.test.js.map +1 -1
  394. package/dist/providers/openai.provider.js +44 -10
  395. package/dist/providers/openai.provider.js.map +1 -1
  396. package/dist/providers/opencode.provider.d.ts +19 -0
  397. package/dist/providers/opencode.provider.d.ts.map +1 -0
  398. package/dist/providers/opencode.provider.js +45 -0
  399. package/dist/providers/opencode.provider.js.map +1 -0
  400. package/dist/providers/openrouter.provider.d.ts +2 -3
  401. package/dist/providers/openrouter.provider.d.ts.map +1 -1
  402. package/dist/providers/openrouter.provider.js +23 -24
  403. package/dist/providers/openrouter.provider.js.map +1 -1
  404. package/dist/providers/openrouter.provider.request-preprocessing.test.js +12 -9
  405. package/dist/providers/openrouter.provider.request-preprocessing.test.js.map +1 -1
  406. package/dist/providers/protocol-client.d.ts +17 -0
  407. package/dist/providers/protocol-client.d.ts.map +1 -0
  408. package/dist/providers/protocol-client.js +57 -0
  409. package/dist/providers/protocol-client.js.map +1 -0
  410. package/dist/providers/protocol-client.test.d.ts +2 -0
  411. package/dist/providers/protocol-client.test.d.ts.map +1 -0
  412. package/dist/providers/protocol-client.test.js +55 -0
  413. package/dist/providers/protocol-client.test.js.map +1 -0
  414. package/dist/providers/registry.d.ts +1 -0
  415. package/dist/providers/registry.d.ts.map +1 -1
  416. package/dist/providers/registry.js.map +1 -1
  417. package/dist/providers/registry.test.js +6 -22
  418. package/dist/providers/registry.test.js.map +1 -1
  419. package/dist/providers/timed-responses-ws-model.d.ts +20 -0
  420. package/dist/providers/timed-responses-ws-model.d.ts.map +1 -0
  421. package/dist/providers/timed-responses-ws-model.js +381 -0
  422. package/dist/providers/timed-responses-ws-model.js.map +1 -0
  423. package/dist/providers/timed-responses-ws-model.test.d.ts +2 -0
  424. package/dist/providers/timed-responses-ws-model.test.d.ts.map +1 -0
  425. package/dist/providers/timed-responses-ws-model.test.js +441 -0
  426. package/dist/providers/timed-responses-ws-model.test.js.map +1 -0
  427. package/dist/providers/timed-ws-connection.d.ts +26 -0
  428. package/dist/providers/timed-ws-connection.d.ts.map +1 -0
  429. package/dist/providers/timed-ws-connection.js +156 -0
  430. package/dist/providers/timed-ws-connection.js.map +1 -0
  431. package/dist/providers/timed-ws-connection.test.d.ts +2 -0
  432. package/dist/providers/timed-ws-connection.test.d.ts.map +1 -0
  433. package/dist/providers/timed-ws-connection.test.js +223 -0
  434. package/dist/providers/timed-ws-connection.test.js.map +1 -0
  435. package/dist/providers/timed-ws-timeouts.d.ts +6 -0
  436. package/dist/providers/timed-ws-timeouts.d.ts.map +1 -0
  437. package/dist/providers/timed-ws-timeouts.js +6 -0
  438. package/dist/providers/timed-ws-timeouts.js.map +1 -0
  439. package/dist/scripts/eval-auto-approval/dataset.d.ts +2 -2
  440. package/dist/services/approval-flow-coordinator.d.ts.map +1 -1
  441. package/dist/services/approval-flow-coordinator.js +17 -1
  442. package/dist/services/approval-flow-coordinator.js.map +1 -1
  443. package/dist/services/approval-flow-coordinator.test.js +22 -0
  444. package/dist/services/approval-flow-coordinator.test.js.map +1 -1
  445. package/dist/services/command-message-streaming.d.ts.map +1 -1
  446. package/dist/services/command-message-streaming.js +8 -6
  447. package/dist/services/command-message-streaming.js.map +1 -1
  448. package/dist/services/command-message-streaming.test.js +4 -2
  449. package/dist/services/command-message-streaming.test.js.map +1 -1
  450. package/dist/services/conversation-clear-save.test.js +37 -66
  451. package/dist/services/conversation-clear-save.test.js.map +1 -1
  452. package/dist/services/conversation-events.d.ts +48 -1
  453. package/dist/services/conversation-events.d.ts.map +1 -1
  454. package/dist/services/conversation-history-repair.d.ts.map +1 -1
  455. package/dist/services/conversation-history-repair.js +81 -26
  456. package/dist/services/conversation-history-repair.js.map +1 -1
  457. package/dist/services/conversation-integration.test.js +112 -1
  458. package/dist/services/conversation-integration.test.js.map +1 -1
  459. package/dist/services/conversation-log-events.d.ts +113 -0
  460. package/dist/services/conversation-log-events.d.ts.map +1 -0
  461. package/dist/services/conversation-log-events.js +12 -0
  462. package/dist/services/conversation-log-events.js.map +1 -0
  463. package/dist/services/conversation-log-writer.d.ts +40 -0
  464. package/dist/services/conversation-log-writer.d.ts.map +1 -0
  465. package/dist/services/conversation-log-writer.js +264 -0
  466. package/dist/services/conversation-log-writer.js.map +1 -0
  467. package/dist/services/conversation-persistence-types.d.ts +46 -0
  468. package/dist/services/conversation-persistence-types.d.ts.map +1 -0
  469. package/dist/services/conversation-persistence-types.js +2 -0
  470. package/dist/services/conversation-persistence-types.js.map +1 -0
  471. package/dist/services/conversation-persistence.d.ts +44 -39
  472. package/dist/services/conversation-persistence.d.ts.map +1 -1
  473. package/dist/services/conversation-persistence.js +273 -83
  474. package/dist/services/conversation-persistence.js.map +1 -1
  475. package/dist/services/conversation-persistence.test.js +708 -624
  476. package/dist/services/conversation-persistence.test.js.map +1 -1
  477. package/dist/services/conversation-replay.d.ts +26 -0
  478. package/dist/services/conversation-replay.d.ts.map +1 -0
  479. package/dist/services/conversation-replay.js +460 -0
  480. package/dist/services/conversation-replay.js.map +1 -0
  481. package/dist/services/conversation-replay.test.d.ts +2 -0
  482. package/dist/services/conversation-replay.test.d.ts.map +1 -0
  483. package/dist/services/conversation-replay.test.js +492 -0
  484. package/dist/services/conversation-replay.test.js.map +1 -0
  485. package/dist/services/conversation-result-builder.d.ts +2 -0
  486. package/dist/services/conversation-result-builder.d.ts.map +1 -1
  487. package/dist/services/conversation-result-builder.js +12 -2
  488. package/dist/services/conversation-result-builder.js.map +1 -1
  489. package/dist/services/conversation-result-builder.test.js +179 -0
  490. package/dist/services/conversation-result-builder.test.js.map +1 -1
  491. package/dist/services/conversation-retry-policy.d.ts +17 -0
  492. package/dist/services/conversation-retry-policy.d.ts.map +1 -1
  493. package/dist/services/conversation-retry-policy.js +38 -5
  494. package/dist/services/conversation-retry-policy.js.map +1 -1
  495. package/dist/services/conversation-retry-policy.test.js +60 -1
  496. package/dist/services/conversation-retry-policy.test.js.map +1 -1
  497. package/dist/services/conversation-service.d.ts +12 -3
  498. package/dist/services/conversation-service.d.ts.map +1 -1
  499. package/dist/services/conversation-service.js +18 -3
  500. package/dist/services/conversation-service.js.map +1 -1
  501. package/dist/services/conversation-session.d.ts +23 -7
  502. package/dist/services/conversation-session.d.ts.map +1 -1
  503. package/dist/services/conversation-session.js +620 -67
  504. package/dist/services/conversation-session.js.map +1 -1
  505. package/dist/services/conversation-store.d.ts +16 -13
  506. package/dist/services/conversation-store.d.ts.map +1 -1
  507. package/dist/services/conversation-store.js +62 -191
  508. package/dist/services/conversation-store.js.map +1 -1
  509. package/dist/services/conversation-store.merge-modes.removed.d.ts +2 -0
  510. package/dist/services/conversation-store.merge-modes.removed.d.ts.map +1 -0
  511. package/dist/services/conversation-store.merge-modes.removed.js +2 -0
  512. package/dist/services/conversation-store.merge-modes.removed.js.map +1 -0
  513. package/dist/services/conversation-store.merge-modes.test.d.ts +2 -0
  514. package/dist/services/conversation-store.merge-modes.test.d.ts.map +1 -0
  515. package/dist/services/conversation-store.merge-modes.test.js +2 -0
  516. package/dist/services/conversation-store.merge-modes.test.js.map +1 -0
  517. package/dist/services/conversation-store.test.js +157 -294
  518. package/dist/services/conversation-store.test.js.map +1 -1
  519. package/dist/services/conversation-turn-items.d.ts +5 -0
  520. package/dist/services/conversation-turn-items.d.ts.map +1 -0
  521. package/dist/services/conversation-turn-items.js +351 -0
  522. package/dist/services/conversation-turn-items.js.map +1 -0
  523. package/dist/services/conversation-turn-items.test.d.ts +2 -0
  524. package/dist/services/conversation-turn-items.test.d.ts.map +1 -0
  525. package/dist/services/conversation-turn-items.test.js +68 -0
  526. package/dist/services/conversation-turn-items.test.js.map +1 -0
  527. package/dist/services/input-surge-guard.d.ts +4 -0
  528. package/dist/services/input-surge-guard.d.ts.map +1 -1
  529. package/dist/services/input-surge-guard.js +2 -2
  530. package/dist/services/input-surge-guard.js.map +1 -1
  531. package/dist/services/input-surge-guard.test.js +15 -1
  532. package/dist/services/input-surge-guard.test.js.map +1 -1
  533. package/dist/services/large-uncached-input-guard.d.ts +35 -0
  534. package/dist/services/large-uncached-input-guard.d.ts.map +1 -0
  535. package/dist/services/large-uncached-input-guard.js +127 -0
  536. package/dist/services/large-uncached-input-guard.js.map +1 -0
  537. package/dist/services/large-uncached-input-guard.test.d.ts +2 -0
  538. package/dist/services/large-uncached-input-guard.test.d.ts.map +1 -0
  539. package/dist/services/large-uncached-input-guard.test.js +160 -0
  540. package/dist/services/large-uncached-input-guard.test.js.map +1 -0
  541. package/dist/services/model-service.d.ts +2 -0
  542. package/dist/services/model-service.d.ts.map +1 -1
  543. package/dist/services/model-service.js +7 -0
  544. package/dist/services/model-service.js.map +1 -1
  545. package/dist/services/provider-traffic.d.ts.map +1 -1
  546. package/dist/services/provider-traffic.js +82 -24
  547. package/dist/services/provider-traffic.js.map +1 -1
  548. package/dist/services/provider-traffic.test.js +98 -6
  549. package/dist/services/provider-traffic.test.js.map +1 -1
  550. package/dist/services/repro-error.test.d.ts +2 -0
  551. package/dist/services/repro-error.test.d.ts.map +1 -0
  552. package/dist/services/repro-error.test.js +38 -0
  553. package/dist/services/repro-error.test.js.map +1 -0
  554. package/dist/services/rtk-service.d.ts.map +1 -1
  555. package/dist/services/rtk-service.js +1 -2
  556. package/dist/services/rtk-service.js.map +1 -1
  557. package/dist/services/settings-schema.d.ts +36 -28
  558. package/dist/services/settings-schema.d.ts.map +1 -1
  559. package/dist/services/settings-schema.js +7 -2
  560. package/dist/services/settings-schema.js.map +1 -1
  561. package/dist/services/settings-sources.d.ts.map +1 -1
  562. package/dist/services/settings-sources.js +1 -0
  563. package/dist/services/settings-sources.js.map +1 -1
  564. package/dist/services/stream-event-processor.d.ts +4 -0
  565. package/dist/services/stream-event-processor.d.ts.map +1 -1
  566. package/dist/services/stream-event-processor.js +78 -39
  567. package/dist/services/stream-event-processor.js.map +1 -1
  568. package/dist/services/stream-event-processor.test.js +71 -0
  569. package/dist/services/stream-event-processor.test.js.map +1 -1
  570. package/dist/services/subagents/subagent-manager.d.ts.map +1 -1
  571. package/dist/services/subagents/subagent-manager.js +120 -46
  572. package/dist/services/subagents/subagent-manager.js.map +1 -1
  573. package/dist/services/subagents/subagent-manager.test.js +361 -11
  574. package/dist/services/subagents/subagent-manager.test.js.map +1 -1
  575. package/dist/services/subagents/subagent-session.d.ts +1 -1
  576. package/dist/services/subagents/subagent-session.d.ts.map +1 -1
  577. package/dist/services/subagents/subagent-session.js +2 -2
  578. package/dist/services/subagents/subagent-session.js.map +1 -1
  579. package/dist/services/subagents/subagent-session.test.js +3 -3
  580. package/dist/services/subagents/subagent-session.test.js.map +1 -1
  581. package/dist/services/subagents/types.d.ts +2 -1
  582. package/dist/services/subagents/types.d.ts.map +1 -1
  583. package/dist/services/terminal-result-collector.d.ts.map +1 -1
  584. package/dist/services/terminal-result-collector.js +49 -1
  585. package/dist/services/terminal-result-collector.js.map +1 -1
  586. package/dist/services/terminal-result-collector.test.js +44 -0
  587. package/dist/services/terminal-result-collector.test.js.map +1 -1
  588. package/dist/services/tool-call-arguments.d.ts +20 -0
  589. package/dist/services/tool-call-arguments.d.ts.map +1 -0
  590. package/dist/services/tool-call-arguments.js +49 -0
  591. package/dist/services/tool-call-arguments.js.map +1 -0
  592. package/dist/services/tool-execution-ledger.d.ts +41 -0
  593. package/dist/services/tool-execution-ledger.d.ts.map +1 -0
  594. package/dist/services/tool-execution-ledger.js +269 -0
  595. package/dist/services/tool-execution-ledger.js.map +1 -0
  596. package/dist/services/tool-execution-ledger.test.d.ts +2 -0
  597. package/dist/services/tool-execution-ledger.test.d.ts.map +1 -0
  598. package/dist/services/tool-execution-ledger.test.js +271 -0
  599. package/dist/services/tool-execution-ledger.test.js.map +1 -0
  600. package/dist/tools/apply-patch.d.ts +8 -0
  601. package/dist/tools/apply-patch.d.ts.map +1 -1
  602. package/dist/tools/apply-patch.js +187 -14
  603. package/dist/tools/apply-patch.js.map +1 -1
  604. package/dist/tools/apply-patch.test.js +81 -0
  605. package/dist/tools/apply-patch.test.js.map +1 -1
  606. package/dist/tools/edit-healing.d.ts.map +1 -1
  607. package/dist/tools/edit-healing.js +9 -3
  608. package/dist/tools/edit-healing.js.map +1 -1
  609. package/dist/tools/run-subagent.d.ts +0 -1
  610. package/dist/tools/run-subagent.d.ts.map +1 -1
  611. package/dist/tools/run-subagent.js +1 -8
  612. package/dist/tools/run-subagent.js.map +1 -1
  613. package/dist/tools/run-subagent.test.js +0 -18
  614. package/dist/tools/run-subagent.test.js.map +1 -1
  615. package/dist/tools/search-replace.d.ts.map +1 -1
  616. package/dist/tools/search-replace.js +92 -140
  617. package/dist/tools/search-replace.js.map +1 -1
  618. package/dist/utils/clipboard.d.ts +7 -0
  619. package/dist/utils/clipboard.d.ts.map +1 -1
  620. package/dist/utils/clipboard.js +83 -4
  621. package/dist/utils/clipboard.js.map +1 -1
  622. package/dist/utils/clipboard.test.js +152 -2
  623. package/dist/utils/clipboard.test.js.map +1 -1
  624. package/dist/utils/conversation-event-handler.d.ts.map +1 -1
  625. package/dist/utils/conversation-event-handler.js +36 -1
  626. package/dist/utils/conversation-event-handler.js.map +1 -1
  627. package/dist/utils/conversation-event-handler.test.js +116 -0
  628. package/dist/utils/conversation-event-handler.test.js.map +1 -1
  629. package/dist/utils/error-helpers.d.ts +1 -0
  630. package/dist/utils/error-helpers.d.ts.map +1 -1
  631. package/dist/utils/error-helpers.js +61 -0
  632. package/dist/utils/error-helpers.js.map +1 -1
  633. package/dist/utils/error-helpers.test.js +13 -1
  634. package/dist/utils/error-helpers.test.js.map +1 -1
  635. package/dist/utils/extract-command-messages.d.ts.map +1 -1
  636. package/dist/utils/extract-command-messages.js +8 -4
  637. package/dist/utils/extract-command-messages.js.map +1 -1
  638. package/dist/utils/ink-render-options.d.ts +1 -1
  639. package/dist/utils/ink-render-options.d.ts.map +1 -1
  640. package/dist/utils/ink-render-options.js +1 -1
  641. package/dist/utils/ink-render-options.js.map +1 -1
  642. package/dist/utils/message-utils.d.ts +7 -0
  643. package/dist/utils/message-utils.d.ts.map +1 -0
  644. package/dist/utils/message-utils.js +31 -0
  645. package/dist/utils/message-utils.js.map +1 -0
  646. package/dist/utils/resume-list.d.ts +15 -0
  647. package/dist/utils/resume-list.d.ts.map +1 -0
  648. package/dist/utils/resume-list.js +68 -0
  649. package/dist/utils/resume-list.js.map +1 -0
  650. package/dist/utils/resume-list.test.d.ts +2 -0
  651. package/dist/utils/resume-list.test.d.ts.map +1 -0
  652. package/dist/utils/resume-list.test.js +89 -0
  653. package/dist/utils/resume-list.test.js.map +1 -0
  654. package/dist/utils/streaming-session-factory.d.ts +2 -0
  655. package/dist/utils/streaming-session-factory.d.ts.map +1 -1
  656. package/dist/utils/streaming-session-factory.js +4 -0
  657. package/dist/utils/streaming-session-factory.js.map +1 -1
  658. package/package.json +2 -2
  659. package/readme.md +22 -7
@@ -3,18 +3,43 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import * as persistenceModule from './conversation-persistence.js';
6
+ import { createConversationLogWriter, LockConflictError } from './conversation-log-writer.js';
7
+ import { ConversationSession } from './conversation-session.js';
8
+ import { createMockSettingsService } from './settings-service.mock.js';
6
9
  let testDir = '';
7
- // Clean up all conversation files before and after each test
8
10
  function cleanupAll() {
9
11
  if (testDir && fs.existsSync(testDir)) {
10
12
  fs.rmSync(testDir, { recursive: true, force: true });
11
13
  }
12
14
  }
13
- function waitForDistinctTimestamp() {
14
- const start = Date.now();
15
- while (Date.now() - start < 10) {
16
- // busy wait for 10ms
17
- }
15
+ const stubLogger = {
16
+ error: () => { },
17
+ warn: () => { },
18
+ info: () => { },
19
+ debug: () => { },
20
+ trace: () => { },
21
+ getCorrelationId: () => undefined,
22
+ };
23
+ function emptySnapshot() {
24
+ return { history: [], previousResponseId: null, toolLedger: [] };
25
+ }
26
+ function assistantTurn(text, previousResponseId = 'r1') {
27
+ return {
28
+ type: 'assistant_turn',
29
+ turn: { items: [{ type: 'assistant_text', text }] },
30
+ state: { previousResponseId },
31
+ };
32
+ }
33
+ class MockStream {
34
+ completed = Promise.resolve(undefined);
35
+ lastResponseId = 'resp-v3';
36
+ interruptions = [];
37
+ state = {};
38
+ newItems = [];
39
+ history = [];
40
+ output = [];
41
+ finalOutput = 'Done.';
42
+ async *[Symbol.asyncIterator]() { }
18
43
  }
19
44
  test.beforeEach(() => {
20
45
  testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'term2-conversations-test-'));
@@ -30,684 +55,743 @@ test.serial('generateId: returns a valid UUID', (t) => {
30
55
  t.regex(id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
31
56
  });
32
57
  test.serial('generateId: returns unique IDs', (t) => {
33
- const id1 = persistenceModule.generateId();
34
- const id2 = persistenceModule.generateId();
35
- t.not(id1, id2);
58
+ t.not(persistenceModule.generateId(), persistenceModule.generateId());
36
59
  });
37
60
  test.serial('getResumeCommand: returns correct format', (t) => {
38
61
  const id = 'test-uuid-123';
39
- // Local (no SSH)
40
62
  t.is(persistenceModule.getResumeCommand(id), 'term2 --resume test-uuid-123');
41
- // SSH with host
42
63
  t.is(persistenceModule.getResumeCommand(id, 'user@host'), 'term2 --ssh user@host --resume test-uuid-123');
43
- // SSH with host and remote directory
44
- t.is(persistenceModule.getResumeCommand(id, 'user@host', '/path/to/project'), 'term2 --ssh user@host --remote-dir /path/to/project --resume test-uuid-123');
45
- // SSH with host, remote directory, and custom port
46
- t.is(persistenceModule.getResumeCommand(id, 'user@host', '/path/to/project', 2222), 'term2 --ssh user@host --remote-dir /path/to/project --ssh-port 2222 --resume test-uuid-123');
47
- // SSH with host and default port 22 (should be omitted)
48
- t.is(persistenceModule.getResumeCommand(id, 'user@host', '/path/to/project', 22), 'term2 --ssh user@host --remote-dir /path/to/project --resume test-uuid-123');
49
- });
50
- test.serial('saveConversation: creates file and last.json pointer', (t) => {
64
+ t.is(persistenceModule.getResumeCommand(id, 'user@host', '/path'), 'term2 --ssh user@host --remote-dir /path --resume test-uuid-123');
65
+ t.is(persistenceModule.getResumeCommand(id, 'user@host', '/path', 2222), 'term2 --ssh user@host --remote-dir /path --ssh-port 2222 --resume test-uuid-123');
66
+ });
67
+ test.serial('writer + loadConversation: round-trips a basic conversation', (t) => {
51
68
  const id = persistenceModule.generateId();
52
- const conversation = {
69
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
70
+ writer.init({
53
71
  id,
54
- createdAt: new Date().toISOString(),
55
- updatedAt: new Date().toISOString(),
72
+ createdAt: '2026-05-26T00:00:00.000Z',
73
+ projectPath: '/workspace/x',
56
74
  model: 'gpt-4o',
57
- previousResponseId: null,
58
- history: [{ role: 'user', content: 'hello' }],
59
- messages: [
60
- { id: 'msg-1', sender: 'user', text: 'hello' },
61
- { id: 'msg-2', sender: 'bot', text: 'hi there', status: 'finalized' },
62
- ],
63
- };
64
- const filePath = persistenceModule.saveConversation(conversation);
65
- t.true(fs.existsSync(filePath));
66
- // Verify last.json was created
67
- const lastPath = path.join(path.dirname(filePath), 'last.json');
68
- t.true(fs.existsSync(lastPath));
69
- const lastData = JSON.parse(fs.readFileSync(lastPath, 'utf-8'));
70
- t.is(lastData.id, id);
71
- // Cleanup
72
- fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
73
- });
74
- test.serial('getConversationsDirForTest: uses isolated test override', (t) => {
75
- t.is(persistenceModule.getConversationsDirForTest(), testDir);
76
- });
77
- test.serial('saveConversation: normalizes streaming bot messages to finalized', (t) => {
75
+ provider: 'openai',
76
+ });
77
+ writer.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'hello' } });
78
+ writer.append({
79
+ type: 'assistant_turn',
80
+ turn: { items: [{ type: 'assistant_text', text: 'hi there' }] },
81
+ state: { previousResponseId: 'resp-1', model: 'gpt-4o', provider: 'openai' },
82
+ });
83
+ void writer.close();
84
+ const restored = persistenceModule.loadConversation(id);
85
+ t.truthy(restored);
86
+ t.is(restored.id, id);
87
+ t.is(restored.previousResponseId, 'resp-1');
88
+ t.is(restored.history.length, 2);
89
+ t.is(restored.messages.length, 2);
90
+ t.is(restored.messages[0].sender, 'user');
91
+ t.is(restored.messages[1].sender, 'bot');
92
+ });
93
+ test.serial('loadConversation: returns null for missing id', (t) => {
94
+ t.is(persistenceModule.loadConversation('nope'), null);
95
+ });
96
+ test.serial('replay: mid-turn crash with tool_started inserts recovery notice', (t) => {
78
97
  const id = persistenceModule.generateId();
79
- const conversation = {
80
- id,
81
- createdAt: new Date().toISOString(),
82
- updatedAt: new Date().toISOString(),
83
- previousResponseId: null,
84
- history: [],
85
- messages: [
86
- { id: 'msg-1', sender: 'user', text: 'hello' },
87
- { id: 'msg-2', sender: 'bot', text: 'thinking...', status: 'streaming' },
88
- ],
89
- };
90
- const filePath = persistenceModule.saveConversation(conversation);
91
- const saved = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
92
- t.is(saved.messages[1].status, 'finalized');
93
- // Cleanup
94
- fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
95
- });
96
- test.serial('saveConversation: normalizes pending command messages to completed', (t) => {
98
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
99
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
100
+ // First turn completes cleanly
101
+ writer.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'do it' } });
102
+ writer.append(assistantTurn('done'));
103
+ // Second turn: user submits, tool starts, then crash
104
+ writer.append({ type: 'user_message', message: { id: 'u2', sender: 'user', text: 'more' } });
105
+ writer.append({ type: 'tool_started', toolCallId: 'call-1', toolName: 'shell', arguments: {} });
106
+ void writer.close();
107
+ const restored = persistenceModule.loadConversation(id);
108
+ t.truthy(restored);
109
+ t.true(restored.replayWarnings.some((w) => w.includes('interrupted')));
110
+ // The interrupted system message is on UI messages
111
+ t.true(restored.messages.some((m) => m.sender === 'system' && String(m.text).includes('interrupted')));
112
+ });
113
+ test.serial('replay: user_message only with no assistant_turn flags interruption', (t) => {
97
114
  const id = persistenceModule.generateId();
98
- const conversation = {
99
- id,
100
- createdAt: new Date().toISOString(),
101
- updatedAt: new Date().toISOString(),
102
- previousResponseId: null,
103
- history: [],
104
- messages: [{ id: 'msg-1', sender: 'command', status: 'pending', command: 'ls', toolName: 'shell' }],
105
- };
106
- const filePath = persistenceModule.saveConversation(conversation);
107
- const saved = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
108
- t.is(saved.messages[0].status, 'completed');
109
- t.is(saved.messages[0].success, false);
110
- // Cleanup
111
- fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
112
- });
113
- test.serial('saveConversation: writes repaired history for duplicated tool replay artifacts', (t) => {
115
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
116
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
117
+ writer.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'hi' } });
118
+ void writer.close();
119
+ const restored = persistenceModule.loadConversation(id);
120
+ t.truthy(restored);
121
+ t.true(restored.replayWarnings.length > 0);
122
+ t.true(restored.messages.some((m) => m.sender === 'system' && String(m.text).includes('interrupted')));
123
+ });
124
+ test.serial('replay: settings_changed updates restored model', (t) => {
114
125
  const id = persistenceModule.generateId();
115
- const conversation = {
116
- id,
117
- createdAt: new Date().toISOString(),
118
- updatedAt: new Date().toISOString(),
119
- previousResponseId: 'resp-123',
120
- history: [
121
- { role: 'user', type: 'message', content: 'Inspect' },
122
- { type: 'function_call', id: 'fc_1', callId: 'call-read', name: 'read_file', arguments: '{}' },
123
- { type: 'function_call_result', id: 'fcr_1', callId: 'call-read', output: 'contents' },
124
- { type: 'function_call', id: 'fc_2', callId: 'call-read', name: 'read_file', arguments: '{}' },
125
- { type: 'function_call_result', id: 'fcr_2', callId: 'call-read', output: 'contents' },
126
- ],
127
- messages: [{ id: 'msg-1', sender: 'user', text: 'Inspect' }],
128
- };
129
- const filePath = persistenceModule.saveConversation(conversation);
130
- const saved = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
131
- t.is(saved.previousResponseId, 'resp-123');
132
- t.is(saved.history.length, 3);
133
- t.deepEqual(saved.history.filter((item) => item.callId === 'call-read').map((item) => item.id), ['fc_1', 'fcr_1']);
134
- });
135
- test.serial('loadConversation: returns null for non-existent id', (t) => {
136
- const result = persistenceModule.loadConversation('non-existent-id');
137
- t.is(result, null);
126
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
127
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z', model: 'gpt-4o' });
128
+ writer.append({ type: 'settings_changed', key: 'agent.model', value: 'gpt-5' });
129
+ writer.append({
130
+ type: 'assistant_turn',
131
+ turn: { items: [{ type: 'assistant_text', text: 'ok' }] },
132
+ state: { previousResponseId: null, model: 'gpt-5' },
133
+ });
134
+ void writer.close();
135
+ const restored = persistenceModule.loadConversation(id);
136
+ t.is(restored.model, 'gpt-5');
138
137
  });
139
- test.serial('loadConversation: returns saved conversation', (t) => {
138
+ test.serial('replay: undo with snapshot replaces state', (t) => {
140
139
  const id = persistenceModule.generateId();
141
- const conversation = {
142
- id,
143
- createdAt: new Date().toISOString(),
144
- updatedAt: new Date().toISOString(),
145
- model: 'gpt-4o',
146
- previousResponseId: 'resp-123',
147
- history: [{ role: 'user', content: 'hello' }],
148
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
149
- };
150
- persistenceModule.saveConversation(conversation);
151
- const loaded = persistenceModule.loadConversation(id);
152
- t.truthy(loaded);
153
- t.is(loaded.id, id);
154
- t.is(loaded.model, 'gpt-4o');
155
- t.is(loaded.previousResponseId, 'resp-123');
156
- t.is(loaded.messages.length, 1);
157
- // Cleanup
158
- const filePath = path.join(persistenceModule.getConversationsDirForTest(), `${id}.json`);
159
- fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
160
- });
161
- test.serial('loadConversation: returns repaired history with transient repair metadata', (t) => {
140
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
141
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
142
+ writer.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'first' } });
143
+ writer.append(assistantTurn('A'));
144
+ writer.append({ type: 'user_message', message: { id: 'u2', sender: 'user', text: 'second' } });
145
+ writer.append(assistantTurn('B', 'r2'));
146
+ writer.append({ type: 'undo', removedUserTurns: 1, snapshot: emptySnapshot() });
147
+ void writer.close();
148
+ const restored = persistenceModule.loadConversation(id);
149
+ t.is(restored.history.length, 0);
150
+ t.is(restored.previousResponseId, null);
151
+ });
152
+ test.serial('replay: corrupt line is skipped', (t) => {
162
153
  const id = persistenceModule.generateId();
163
- const dir = persistenceModule.getConversationsDirForTest();
164
- fs.mkdirSync(dir, { recursive: true });
165
- fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify({
166
- id,
167
- createdAt: new Date().toISOString(),
168
- updatedAt: new Date().toISOString(),
169
- previousResponseId: 'resp-123',
170
- history: [
171
- { role: 'user', type: 'message', content: 'Inspect' },
172
- { type: 'function_call', id: 'fc_1', callId: 'call-read', name: 'read_file', arguments: '{}' },
173
- { type: 'function_call_result', id: 'fcr_1', callId: 'call-read', output: 'contents' },
174
- { type: 'function_call', id: 'fc_2', callId: 'call-read', name: 'read_file', arguments: '{}' },
175
- { type: 'function_call_result', id: 'fcr_2', callId: 'call-read', output: 'contents' },
176
- ],
177
- messages: [{ id: 'msg-1', sender: 'user', text: 'Inspect' }],
178
- }, null, 2), 'utf-8');
179
- const loaded = persistenceModule.loadConversation(id);
180
- t.truthy(loaded);
181
- t.is(loaded.previousResponseId, 'resp-123');
182
- t.is(loaded.history.length, 3);
183
- t.true(loaded.historyRepair?.repaired);
184
- t.is(loaded.historyRepair?.removedItems, 2);
185
- });
186
- test.serial('loadConversation: preserves saved app mode settings', (t) => {
187
- const id = persistenceModule.generateId();
188
- const conversation = {
189
- id,
190
- createdAt: new Date().toISOString(),
191
- updatedAt: new Date().toISOString(),
192
- appMode: {
193
- liteMode: false,
194
- mentorMode: true,
195
- planMode: true,
196
- orchestratorMode: false,
154
+ fs.mkdirSync(testDir, { recursive: true });
155
+ const filePath = path.join(testDir, `${id}.jsonl`);
156
+ const goodInit = JSON.stringify({
157
+ v: 1,
158
+ seq: 1,
159
+ ts: '2026-05-26T00:00:00.000Z',
160
+ event: { type: 'session_init', id, createdAt: '2026-05-26T00:00:00.000Z' },
161
+ });
162
+ const goodTurn = JSON.stringify({
163
+ v: 3,
164
+ seq: 3,
165
+ ts: '2026-05-26T00:00:01.000Z',
166
+ event: {
167
+ type: 'assistant_turn',
168
+ turn: { items: [{ type: 'assistant_text', text: 'ok' }] },
169
+ state: { previousResponseId: 'r1' },
197
170
  },
198
- previousResponseId: null,
199
- history: [],
200
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
201
- };
202
- persistenceModule.saveConversation(conversation);
203
- const loaded = persistenceModule.loadConversation(id);
204
- t.deepEqual(loaded?.appMode, {
205
- liteMode: false,
206
- mentorMode: true,
207
- planMode: true,
208
- orchestratorMode: false,
209
171
  });
172
+ fs.writeFileSync(filePath, `${goodInit}\n{not json\n${goodTurn}\n`, 'utf-8');
173
+ const restored = persistenceModule.loadConversation(id);
174
+ t.is(restored.previousResponseId, 'r1');
210
175
  });
211
- test.serial('loadConversation: returns null when saved project path differs from expected path', (t) => {
176
+ test.serial('lock: collision throws LockConflictError', (t) => {
212
177
  const id = persistenceModule.generateId();
213
- const conversation = {
214
- id,
215
- createdAt: new Date().toISOString(),
216
- updatedAt: new Date().toISOString(),
217
- projectPath: '/workspace/alpha',
218
- previousResponseId: null,
219
- history: [],
220
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
221
- };
222
- persistenceModule.saveConversation(conversation);
223
- const loaded = persistenceModule.loadConversation(id, '/workspace/beta');
224
- t.is(loaded, null);
178
+ const w1 = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
179
+ w1.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
180
+ const w2 = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
181
+ t.throws(() => w2.init({ id, createdAt: '2026-05-26T00:00:00.000Z' }), { instanceOf: LockConflictError });
182
+ void w1.close();
225
183
  });
226
- test.serial('loadConversationForProject: reports project mismatch for existing conversation in another path', (t) => {
184
+ test.serial('lock: released on writer close, second writer succeeds', async (t) => {
227
185
  const id = persistenceModule.generateId();
228
- const conversation = {
229
- id,
230
- createdAt: new Date().toISOString(),
231
- updatedAt: new Date().toISOString(),
232
- projectPath: '/workspace/alpha',
233
- previousResponseId: null,
234
- history: [],
235
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
236
- };
237
- persistenceModule.saveConversation(conversation);
238
- const result = persistenceModule.loadConversationForProject(id, '/workspace/beta');
239
- t.is(result.status, 'project_mismatch');
240
- if (result.status !== 'project_mismatch') {
241
- t.fail('expected project_mismatch');
242
- return;
186
+ const w1 = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
187
+ w1.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
188
+ await w1.close();
189
+ const w2 = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
190
+ t.notThrows(() => w2.init({ id, createdAt: '2026-05-26T00:00:00.000Z' }));
191
+ await w2.close();
192
+ });
193
+ test.serial('forkConversation: copies the source jsonl to a new id', (t) => {
194
+ const srcId = persistenceModule.generateId();
195
+ const dstId = persistenceModule.generateId();
196
+ const writer = createConversationLogWriter({ sessionId: srcId, dir: testDir, logger: stubLogger });
197
+ writer.init({ id: srcId, createdAt: '2026-05-26T00:00:00.000Z' });
198
+ writer.append(assistantTurn('A'));
199
+ void writer.close();
200
+ t.true(persistenceModule.forkConversation(srcId, dstId));
201
+ const restored = persistenceModule.loadConversation(dstId);
202
+ t.is(restored.previousResponseId, 'r1');
203
+ });
204
+ test.serial('listConversations: lists sessions sorted by mtime desc', (t) => {
205
+ const id1 = persistenceModule.generateId();
206
+ const w1 = createConversationLogWriter({ sessionId: id1, dir: testDir, logger: stubLogger });
207
+ w1.init({ id: id1, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/p1' });
208
+ void w1.close();
209
+ // Sleep to ensure mtime differs
210
+ const target = Date.now() + 20;
211
+ while (Date.now() < target) {
212
+ /* spin */
243
213
  }
244
- t.is(result.conversation.id, id);
245
- });
246
- test.serial('loadConversationForProject: reports not_found for missing conversation', (t) => {
247
- const result = persistenceModule.loadConversationForProject('missing-id', '/workspace/beta');
248
- t.is(result.status, 'not_found');
214
+ const id2 = persistenceModule.generateId();
215
+ const w2 = createConversationLogWriter({ sessionId: id2, dir: testDir, logger: stubLogger });
216
+ w2.init({ id: id2, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/p2' });
217
+ void w2.close();
218
+ const list = persistenceModule.listConversations();
219
+ t.is(list.length, 2);
220
+ t.is(list[0].id, id2);
221
+ t.is(list[0].projectPath, '/p2');
249
222
  });
250
- test.serial('loadConversation: returns saved conversation when expected project path matches', (t) => {
223
+ test.serial('loadConversation: returns null when expected project path differs', (t) => {
251
224
  const id = persistenceModule.generateId();
252
- const conversation = {
253
- id,
254
- createdAt: new Date().toISOString(),
255
- updatedAt: new Date().toISOString(),
256
- projectPath: '/workspace/alpha',
257
- previousResponseId: null,
258
- history: [],
259
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
260
- };
261
- persistenceModule.saveConversation(conversation);
262
- const loaded = persistenceModule.loadConversation(id, '/workspace/alpha');
263
- t.truthy(loaded);
264
- t.is(loaded.id, id);
225
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
226
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/workspace/alpha' });
227
+ void writer.close();
228
+ t.is(persistenceModule.loadConversation(id, '/workspace/beta'), null);
265
229
  });
266
- test.serial('loadConversation: unscoped loads keep backward-compatible behavior', (t) => {
230
+ test.serial('loadConversationForProject: reports project mismatch', (t) => {
267
231
  const id = persistenceModule.generateId();
268
- const conversation = {
269
- id,
270
- createdAt: new Date().toISOString(),
271
- updatedAt: new Date().toISOString(),
272
- projectPath: '/workspace/alpha',
273
- previousResponseId: null,
274
- history: [],
275
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
276
- };
277
- persistenceModule.saveConversation(conversation);
278
- const loaded = persistenceModule.loadConversation(id);
279
- t.truthy(loaded);
280
- t.is(loaded.id, id);
232
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
233
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/workspace/alpha' });
234
+ void writer.close();
235
+ const result = persistenceModule.loadConversationForProject(id, '/workspace/beta');
236
+ t.is(result.status, 'project_mismatch');
281
237
  });
282
- test.serial('loadLastConversation: returns null when no last.json exists', (t) => {
283
- const result = persistenceModule.loadLastConversation();
284
- t.is(result, null);
238
+ test.serial('loadConversationForProject: not_found for missing', (t) => {
239
+ t.is(persistenceModule.loadConversationForProject('missing', '/x').status, 'not_found');
285
240
  });
286
- test.serial('loadLastConversation: returns the last saved conversation', (t) => {
241
+ test.serial('loadLastConversation: returns the last written conversation', (t) => {
287
242
  const id1 = persistenceModule.generateId();
243
+ const w1 = createConversationLogWriter({ sessionId: id1, dir: testDir, logger: stubLogger });
244
+ w1.init({ id: id1, createdAt: '2026-05-26T00:00:00.000Z' });
245
+ w1.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'hi' } });
246
+ void w1.close();
247
+ const target = Date.now() + 20;
248
+ while (Date.now() < target) {
249
+ /* spin */
250
+ }
288
251
  const id2 = persistenceModule.generateId();
289
- const conv1 = {
290
- id: id1,
291
- createdAt: new Date().toISOString(),
292
- updatedAt: new Date().toISOString(),
293
- previousResponseId: null,
294
- history: [],
295
- messages: [{ id: 'msg-1', sender: 'user', text: 'first' }],
296
- };
297
- const conv2 = {
298
- id: id2,
299
- createdAt: new Date().toISOString(),
300
- updatedAt: new Date().toISOString(),
301
- previousResponseId: null,
302
- history: [],
303
- messages: [{ id: 'msg-2', sender: 'user', text: 'second' }],
304
- };
305
- persistenceModule.saveConversation(conv1);
306
- persistenceModule.saveConversation(conv2);
252
+ const w2 = createConversationLogWriter({ sessionId: id2, dir: testDir, logger: stubLogger });
253
+ w2.init({ id: id2, createdAt: '2026-05-26T00:00:00.000Z' });
254
+ w2.append({ type: 'user_message', message: { id: 'u2', sender: 'user', text: 'hi2' } });
255
+ void w2.close();
307
256
  const last = persistenceModule.loadLastConversation();
308
257
  t.truthy(last);
309
258
  t.is(last.id, id2);
310
- // Cleanup
311
- fs.rmSync(persistenceModule.getConversationsDirForTest(), { recursive: true, force: true });
312
259
  });
313
- test.serial('loadLastConversation: surfaces repaired conversation metadata', (t) => {
260
+ test.serial('loadLastConversation: does not save last.json for empty conversation', (t) => {
314
261
  const id = persistenceModule.generateId();
315
- const dir = persistenceModule.getConversationsDirForTest();
316
- fs.mkdirSync(dir, { recursive: true });
317
- fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify({
318
- id,
319
- createdAt: new Date().toISOString(),
320
- updatedAt: new Date().toISOString(),
321
- previousResponseId: null,
322
- history: [
323
- { role: 'user', type: 'message', content: 'Inspect' },
324
- { type: 'function_call', id: 'fc_1', callId: 'call-read', name: 'read_file', arguments: '{}' },
325
- { type: 'function_call_result', id: 'fcr_1', callId: 'call-read', output: 'contents' },
326
- { type: 'function_call', id: 'fc_2', callId: 'call-read', name: 'read_file', arguments: '{}' },
327
- { type: 'function_call_result', id: 'fcr_2', callId: 'call-read', output: 'contents' },
328
- ],
329
- messages: [{ id: 'msg-1', sender: 'user', text: 'Inspect' }],
330
- }, null, 2), 'utf-8');
331
- fs.writeFileSync(path.join(dir, 'last.json'), JSON.stringify({ id }), 'utf-8');
262
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
263
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
264
+ void writer.close();
332
265
  const last = persistenceModule.loadLastConversation();
333
- t.truthy(last);
334
- t.true(last.historyRepair?.repaired);
335
- t.is(last.history.length, 3);
336
- });
337
- test.serial('loadLastConversation: returns most recent conversation for expected project path', (t) => {
338
- const projectAOlder = persistenceModule.generateId();
339
- const projectANewer = persistenceModule.generateId();
340
- const projectBNewer = persistenceModule.generateId();
341
- persistenceModule.saveConversation({
342
- id: projectAOlder,
343
- createdAt: '2024-01-01T00:00:00.000Z',
344
- updatedAt: '2024-01-01T00:00:00.000Z',
345
- projectPath: '/workspace/alpha',
346
- previousResponseId: null,
347
- history: [],
348
- messages: [{ id: 'msg-1', sender: 'user', text: 'alpha older' }],
349
- });
350
- waitForDistinctTimestamp();
351
- persistenceModule.saveConversation({
352
- id: projectANewer,
353
- createdAt: '2024-01-02T00:00:00.000Z',
354
- updatedAt: '2024-01-02T00:00:00.000Z',
355
- projectPath: '/workspace/alpha',
356
- previousResponseId: null,
357
- history: [],
358
- messages: [{ id: 'msg-2', sender: 'user', text: 'alpha newer' }],
359
- });
360
- waitForDistinctTimestamp();
361
- persistenceModule.saveConversation({
362
- id: projectBNewer,
363
- createdAt: '2024-01-03T00:00:00.000Z',
364
- updatedAt: '2024-01-03T00:00:00.000Z',
365
- projectPath: '/workspace/beta',
366
- previousResponseId: null,
367
- history: [],
368
- messages: [{ id: 'msg-3', sender: 'user', text: 'beta newer' }],
369
- });
370
- const last = persistenceModule.loadLastConversation('/workspace/alpha');
371
- t.truthy(last);
372
- t.is(last.id, projectANewer);
373
- });
374
- test.serial('loadLastConversation: returns null when no conversation matches expected project path', (t) => {
375
- persistenceModule.saveConversation({
376
- id: persistenceModule.generateId(),
377
- createdAt: new Date().toISOString(),
378
- updatedAt: new Date().toISOString(),
379
- projectPath: '/workspace/alpha',
380
- previousResponseId: null,
381
- history: [],
382
- messages: [{ id: 'msg-1', sender: 'user', text: 'alpha' }],
383
- });
384
- const last = persistenceModule.loadLastConversation('/workspace/beta');
385
266
  t.is(last, null);
267
+ t.false(fs.existsSync(path.join(testDir, 'last.json')));
386
268
  });
387
- test.serial('deleteConversation: removes the conversation file', (t) => {
388
- const id = persistenceModule.generateId();
389
- const conversation = {
390
- id,
391
- createdAt: new Date().toISOString(),
392
- updatedAt: new Date().toISOString(),
393
- previousResponseId: null,
394
- history: [],
395
- messages: [],
396
- };
397
- persistenceModule.saveConversation(conversation);
398
- const result = persistenceModule.deleteConversation(id);
399
- t.true(result);
400
- t.is(persistenceModule.loadConversation(id), null);
401
- // Cleanup
402
- fs.rmSync(persistenceModule.getConversationsDirForTest(), { recursive: true, force: true });
403
- });
404
- test.serial('deleteConversation: returns false for non-existent id', (t) => {
405
- const result = persistenceModule.deleteConversation('non-existent');
406
- t.false(result);
407
- });
408
- test.serial('listConversations: returns empty array when no conversations exist', (t) => {
409
- // Ensure dir exists but is empty
410
- const dir = persistenceModule.getConversationsDirForTest();
411
- if (fs.existsSync(dir)) {
412
- fs.rmSync(dir, { recursive: true, force: true });
413
- }
414
- const list = persistenceModule.listConversations();
415
- t.deepEqual(list, []);
269
+ test.serial('hasConversationContent: returns false for missing conversation', (t) => {
270
+ t.false(persistenceModule.hasConversationContent('non-existent-id'));
416
271
  });
417
- test.serial('loadConversation: old save without orchestratorMode — resolves to false, no error', (t) => {
272
+ test.serial('hasConversationContent: returns false for empty conversation', (t) => {
418
273
  const id = persistenceModule.generateId();
419
- // Write a raw JSON file that looks like an old save (missing orchestratorMode)
420
- const dir = persistenceModule.getConversationsDirForTest();
421
- if (!fs.existsSync(dir)) {
422
- fs.mkdirSync(dir, { recursive: true });
423
- }
424
- const rawData = {
425
- id,
426
- createdAt: new Date().toISOString(),
427
- updatedAt: new Date().toISOString(),
428
- appMode: {
429
- mentorMode: false,
430
- liteMode: false,
431
- planMode: false,
432
- // orchestratorMode is intentionally absent (old format)
433
- },
434
- previousResponseId: null,
435
- history: [],
436
- messages: [],
437
- };
438
- fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(rawData, null, 2), 'utf-8');
439
- // loadConversation should not throw — it returns null on error
440
- const loaded = persistenceModule.loadConversation(id);
441
- t.truthy(loaded);
442
- // orchestratorMode should be absent or false-y, not throw
443
- const orchestratorMode = loaded?.appMode?.orchestratorMode;
444
- t.falsy(orchestratorMode, 'orchestratorMode missing from old save should not be truthy');
445
- });
446
- test.serial('loadConversation: save with orchestratorMode: true is correctly restored', (t) => {
447
- const id = persistenceModule.generateId();
448
- const conversation = {
449
- id,
450
- createdAt: new Date().toISOString(),
451
- updatedAt: new Date().toISOString(),
452
- appMode: {
453
- mentorMode: false,
454
- liteMode: false,
455
- planMode: false,
456
- orchestratorMode: true,
457
- },
458
- previousResponseId: null,
459
- history: [],
460
- messages: [],
461
- };
462
- persistenceModule.saveConversation(conversation);
463
- const loaded = persistenceModule.loadConversation(id);
464
- t.truthy(loaded);
465
- t.is(loaded?.appMode?.orchestratorMode, true);
466
- });
467
- test.serial('listConversations: returns conversations sorted by updatedAt descending', (t) => {
468
- const id1 = persistenceModule.generateId();
469
- const id2 = persistenceModule.generateId();
470
- const conv1 = {
471
- id: id1,
472
- createdAt: '2024-01-01T00:00:00.000Z',
473
- updatedAt: '2024-01-01T00:00:00.000Z',
474
- previousResponseId: null,
475
- history: [],
476
- messages: [],
477
- };
478
- const conv2 = {
479
- id: id2,
480
- createdAt: '2024-01-02T00:00:00.000Z',
481
- updatedAt: '2024-01-02T00:00:00.000Z',
482
- previousResponseId: null,
483
- history: [],
484
- messages: [],
485
- };
486
- // Save conv1 first
487
- persistenceModule.saveConversation(conv1);
488
- // Wait a moment so updatedAt differs
489
- waitForDistinctTimestamp();
490
- // Save conv2 second (should have later updatedAt)
491
- persistenceModule.saveConversation(conv2);
492
- const list = persistenceModule.listConversations();
493
- t.is(list.length, 2);
494
- t.is(list[0].id, id2); // Most recently saved first
495
- t.is(list[1].id, id1);
274
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
275
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
276
+ void writer.close();
277
+ t.false(persistenceModule.hasConversationContent(id));
496
278
  });
497
- test.serial('conversationMatchesProject: loadConversation with matching sshHost returns conversation', (t) => {
279
+ test.serial('hasConversationContent: returns true for user_message', (t) => {
498
280
  const id = persistenceModule.generateId();
499
- const conversation = {
500
- id,
501
- createdAt: new Date().toISOString(),
502
- updatedAt: new Date().toISOString(),
503
- sshHost: 'myhost.example.com',
504
- previousResponseId: null,
505
- history: [],
506
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
507
- };
508
- persistenceModule.saveConversation(conversation);
509
- const loaded = persistenceModule.loadConversation(id, undefined, 'myhost.example.com');
510
- t.truthy(loaded);
511
- t.is(loaded.id, id);
512
- t.is(loaded.sshHost, 'myhost.example.com');
281
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
282
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
283
+ writer.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'hi' } });
284
+ void writer.close();
285
+ t.true(persistenceModule.hasConversationContent(id));
513
286
  });
514
- test.serial('conversationMatchesProject: loadConversation with mismatched sshHost returns null', (t) => {
287
+ test.serial('hasConversationContent: returns true for assistant_turn', (t) => {
515
288
  const id = persistenceModule.generateId();
516
- const conversation = {
517
- id,
518
- createdAt: new Date().toISOString(),
519
- updatedAt: new Date().toISOString(),
520
- sshHost: 'myhost.example.com',
521
- previousResponseId: null,
522
- history: [],
523
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
524
- };
525
- persistenceModule.saveConversation(conversation);
526
- const loaded = persistenceModule.loadConversation(id, undefined, 'otherhost.example.com');
527
- t.is(loaded, null);
289
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
290
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
291
+ writer.append(assistantTurn('hello'));
292
+ void writer.close();
293
+ t.true(persistenceModule.hasConversationContent(id));
528
294
  });
529
- test.serial('conversationMatchesProject: loading remote conversation from local session (no expectedSshHost) returns null', (t) => {
295
+ test.serial('hasConversationContent: ignores unsupported assistant_final events', (t) => {
530
296
  const id = persistenceModule.generateId();
531
- const conversation = {
532
- id,
533
- createdAt: new Date().toISOString(),
534
- updatedAt: new Date().toISOString(),
535
- sshHost: 'remote.example.com',
536
- projectPath: '/workspace/project',
537
- previousResponseId: null,
538
- history: [],
539
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
540
- };
541
- persistenceModule.saveConversation(conversation);
542
- // Local session (expectedProjectPath given, but no expectedSshHost)
543
- // should reject a remote conversation that has sshHost
544
- const loaded = persistenceModule.loadConversation(id, '/workspace/project', undefined);
545
- t.is(loaded, null);
546
- });
547
- test.serial('conversationMatchesProject: loading local conversation from remote session (no conversation.sshHost) returns null', (t) => {
297
+ const filePath = path.join(testDir, `${id}.jsonl`);
298
+ fs.writeFileSync(filePath, JSON.stringify({
299
+ v: 1,
300
+ seq: 1,
301
+ ts: '2026-05-26T00:00:00.000Z',
302
+ event: { type: 'assistant_final', message: { id: 'a1', sender: 'bot', text: 'legacy' } },
303
+ }) + '\n', 'utf-8');
304
+ t.false(persistenceModule.hasConversationContent(id));
305
+ });
306
+ test.serial('hasConversationContent: skips corrupt lines and finds content', (t) => {
548
307
  const id = persistenceModule.generateId();
549
- const conversation = {
550
- id,
551
- createdAt: new Date().toISOString(),
552
- updatedAt: new Date().toISOString(),
553
- // no sshHost local conversation
554
- projectPath: '/workspace/project',
555
- previousResponseId: null,
556
- history: [],
557
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
558
- };
559
- persistenceModule.saveConversation(conversation);
560
- // Remote session (expectedSshHost given) should reject a local conversation
561
- const loaded = persistenceModule.loadConversation(id, '/workspace/project', 'remote.example.com');
562
- t.is(loaded, null);
563
- });
564
- test.serial('loadConversationForProject: returns project_mismatch for sshHost mismatch', (t) => {
308
+ const filePath = path.join(testDir, `${id}.jsonl`);
309
+ fs.writeFileSync(filePath, '{"v":1,"seq":1,"ts":"2026-05-26T00:00:00.000Z","event":{"type":"session_init","id":"' +
310
+ id +
311
+ '","createdAt":"2026-05-26T00:00:00.000Z"}}\n' +
312
+ 'this is not json\n' +
313
+ '{"v":1,"seq":2,"ts":"2026-05-26T00:00:01.000Z","event":{"type":"user_message","message":{"id":"u1","sender":"user","text":"hi"}}}\n', 'utf-8');
314
+ t.true(persistenceModule.hasConversationContent(id));
315
+ });
316
+ test.serial('deleteConversation: removes the jsonl and clears last.json', (t) => {
565
317
  const id = persistenceModule.generateId();
566
- const conversation = {
567
- id,
568
- createdAt: new Date().toISOString(),
569
- updatedAt: new Date().toISOString(),
570
- sshHost: 'myhost.example.com',
571
- projectPath: '/workspace/alpha',
572
- previousResponseId: null,
573
- history: [],
574
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
575
- };
576
- persistenceModule.saveConversation(conversation);
577
- const result = persistenceModule.loadConversationForProject(id, '/workspace/alpha', 'otherhost.example.com');
578
- t.is(result.status, 'project_mismatch');
579
- if (result.status === 'project_mismatch') {
580
- t.is(result.conversation.id, id);
581
- t.is(result.conversation.sshHost, 'myhost.example.com');
582
- }
583
- });
584
- test.serial('loadConversationForProject: returns project_mismatch for project path mismatch with sshHost', (t) => {
318
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
319
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
320
+ writer.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'hi' } });
321
+ void writer.close();
322
+ t.true(persistenceModule.deleteConversation(id));
323
+ t.false(fs.existsSync(path.join(testDir, `${id}.jsonl`)));
324
+ t.false(fs.existsSync(path.join(testDir, 'last.json')));
325
+ });
326
+ test.serial('subagent_completed and corresponding records omit nestedRunResult', (t) => {
585
327
  const id = persistenceModule.generateId();
586
- const conversation = {
587
- id,
588
- createdAt: new Date().toISOString(),
589
- updatedAt: new Date().toISOString(),
590
- sshHost: 'myhost.example.com',
591
- projectPath: '/workspace/alpha',
592
- previousResponseId: null,
593
- history: [],
594
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
595
- };
596
- persistenceModule.saveConversation(conversation);
597
- const result = persistenceModule.loadConversationForProject(id, '/workspace/beta', 'myhost.example.com');
598
- t.is(result.status, 'project_mismatch');
599
- if (result.status === 'project_mismatch') {
600
- t.is(result.conversation.id, id);
601
- }
328
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
329
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
330
+ // 1. Log subagent_completed event
331
+ writer.append({
332
+ type: 'subagent_completed',
333
+ result: {
334
+ agentId: 'sub-agent-1',
335
+ role: 'worker',
336
+ status: 'completed',
337
+ finalText: 'Task resolved successfully',
338
+ filesChanged: ['src/app.ts'],
339
+ toolsUsed: [{ toolName: 'create_file', count: 1 }],
340
+ usage: { prompt_tokens: 120, completion_tokens: 80, total_tokens: 200 },
341
+ nestedRunResult: {
342
+ state: {
343
+ history: [{ role: 'user', content: 'test' }],
344
+ generatedItems: ['item1'],
345
+ },
346
+ },
347
+ },
348
+ });
349
+ // 2. Log tool_result event with JSON string containing nestedRunResult
350
+ writer.append({
351
+ type: 'tool_result',
352
+ callId: 'call-subagent-1',
353
+ toolName: 'run_subagent',
354
+ status: 'completed',
355
+ output: JSON.stringify({
356
+ status: 'completed',
357
+ finalText: 'Result text',
358
+ nestedRunResult: { state: { internalStuff: 'hidden' } },
359
+ }),
360
+ });
361
+ // 3. Log assistant_turn event with nestedRunResult in turn items
362
+ writer.append({
363
+ type: 'assistant_turn',
364
+ turn: {
365
+ items: [
366
+ {
367
+ type: 'tool_result',
368
+ callId: 'call-subagent-1',
369
+ toolName: 'run_subagent',
370
+ status: 'completed',
371
+ output: JSON.stringify({
372
+ status: 'completed',
373
+ finalText: 'Turn result text',
374
+ nestedRunResult: { state: { internalStuff: 'hidden' } },
375
+ }),
376
+ },
377
+ { type: 'assistant_text', text: 'all done' },
378
+ ],
379
+ },
380
+ state: { previousResponseId: 'resp-1' },
381
+ });
382
+ void writer.close();
383
+ // Load raw file contents to check what was written to disk
384
+ const filePath = path.join(testDir, `${id}.jsonl`);
385
+ const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n');
386
+ // Parse lines to check events
387
+ const envelopes = lines.map((line) => JSON.parse(line));
388
+ // Find subagent_completed
389
+ const completedEnv = envelopes.find((env) => env.event?.type === 'subagent_completed');
390
+ t.truthy(completedEnv);
391
+ t.is(completedEnv.event.result.status, 'completed');
392
+ t.is(completedEnv.event.result.finalText, 'Task resolved successfully');
393
+ t.deepEqual(completedEnv.event.result.filesChanged, ['src/app.ts']);
394
+ t.is(completedEnv.event.result.nestedRunResult, undefined);
395
+ // Find tool_result
396
+ const toolResultEnv = envelopes.find((env) => env.event?.type === 'tool_result');
397
+ t.truthy(toolResultEnv);
398
+ t.is(toolResultEnv.event.toolName, 'run_subagent');
399
+ const parsedOutput = JSON.parse(toolResultEnv.event.output);
400
+ t.is(parsedOutput.status, 'completed');
401
+ t.is(parsedOutput.finalText, 'Result text');
402
+ t.is(parsedOutput.nestedRunResult, undefined);
403
+ // Find assistant_turn and check turn items
404
+ const turnEnv = envelopes.find((env) => env.event?.type === 'assistant_turn');
405
+ t.truthy(turnEnv);
406
+ const turnOutput = JSON.parse(turnEnv.event.turn.items[0].output);
407
+ t.is(turnOutput.status, 'completed');
408
+ t.is(turnOutput.finalText, 'Turn result text');
409
+ t.is(turnOutput.nestedRunResult, undefined);
410
+ });
411
+ test.serial('saveLastConversation: stores per-project last conversation', (t) => {
412
+ const id1 = persistenceModule.generateId();
413
+ const w1 = createConversationLogWriter({ sessionId: id1, dir: testDir, logger: stubLogger });
414
+ w1.init({ id: id1, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/project-a' });
415
+ w1.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'hi a' } });
416
+ void w1.close();
417
+ const id2 = persistenceModule.generateId();
418
+ const w2 = createConversationLogWriter({ sessionId: id2, dir: testDir, logger: stubLogger });
419
+ w2.init({ id: id2, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/project-b' });
420
+ w2.append({ type: 'user_message', message: { id: 'u2', sender: 'user', text: 'hi b' } });
421
+ void w2.close();
422
+ const lastA = persistenceModule.loadLastConversation('/project-a');
423
+ t.truthy(lastA);
424
+ t.is(lastA.id, id1);
425
+ const lastB = persistenceModule.loadLastConversation('/project-b');
426
+ t.truthy(lastB);
427
+ t.is(lastB.id, id2);
428
+ });
429
+ test.serial('saveLastConversation: stores per-ssh-host last conversation', (t) => {
430
+ const id1 = persistenceModule.generateId();
431
+ const w1 = createConversationLogWriter({ sessionId: id1, dir: testDir, logger: stubLogger });
432
+ w1.init({ id: id1, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/remote', sshHost: 'host-a' });
433
+ w1.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'hi a' } });
434
+ void w1.close();
435
+ const id2 = persistenceModule.generateId();
436
+ const w2 = createConversationLogWriter({ sessionId: id2, dir: testDir, logger: stubLogger });
437
+ w2.init({ id: id2, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/remote', sshHost: 'host-b' });
438
+ w2.append({ type: 'user_message', message: { id: 'u2', sender: 'user', text: 'hi b' } });
439
+ void w2.close();
440
+ const lastA = persistenceModule.loadLastConversation('/remote', 'host-a');
441
+ t.truthy(lastA);
442
+ t.is(lastA.id, id1);
443
+ const lastB = persistenceModule.loadLastConversation('/remote', 'host-b');
444
+ t.truthy(lastB);
445
+ t.is(lastB.id, id2);
446
+ });
447
+ test.serial('loadLastConversation: falls back to scanning when no last.json entry matches', (t) => {
448
+ const id = persistenceModule.generateId();
449
+ const w = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
450
+ w.init({ id, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/fallback' });
451
+ w.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'hi' } });
452
+ void w.close();
453
+ // Directly overwrite last.json so the entry has no projectPath
454
+ fs.writeFileSync(path.join(testDir, 'last.json'), JSON.stringify({ entries: [{ id, updatedAt: new Date().toISOString() }] }), 'utf-8');
455
+ const last = persistenceModule.loadLastConversation('/fallback');
456
+ t.truthy(last);
457
+ t.is(last.id, id);
602
458
  });
603
- test.serial('loadConversationForProject: returns loaded when both sshHost and projectPath match', (t) => {
459
+ test.serial('loadLastConversation: migrates old last.json format', (t) => {
604
460
  const id = persistenceModule.generateId();
605
- const conversation = {
606
- id,
607
- createdAt: new Date().toISOString(),
608
- updatedAt: new Date().toISOString(),
609
- sshHost: 'myhost.example.com',
610
- projectPath: '/workspace/alpha',
611
- previousResponseId: null,
612
- history: [],
613
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
614
- };
615
- persistenceModule.saveConversation(conversation);
616
- const result = persistenceModule.loadConversationForProject(id, '/workspace/alpha', 'myhost.example.com');
617
- t.is(result.status, 'loaded');
618
- if (result.status === 'loaded') {
619
- t.is(result.conversation.id, id);
620
- t.is(result.conversation.sshHost, 'myhost.example.com');
621
- }
461
+ const w = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
462
+ w.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
463
+ w.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'hi' } });
464
+ void w.close();
465
+ // Simulate old format
466
+ fs.writeFileSync(path.join(testDir, 'last.json'), JSON.stringify({ id, updatedAt: '2026-05-26T00:00:00.000Z' }), 'utf-8');
467
+ const last = persistenceModule.loadLastConversation();
468
+ t.truthy(last);
469
+ t.is(last.id, id);
622
470
  });
623
- test.serial('loadConversationForProject: SSH host matching is case-insensitive', (t) => {
471
+ test.serial('deleteConversation: removes only matching entry from last.json', (t) => {
472
+ const id1 = persistenceModule.generateId();
473
+ const w1 = createConversationLogWriter({ sessionId: id1, dir: testDir, logger: stubLogger });
474
+ w1.init({ id: id1, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/project-a' });
475
+ w1.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'hi a' } });
476
+ void w1.close();
477
+ const id2 = persistenceModule.generateId();
478
+ const w2 = createConversationLogWriter({ sessionId: id2, dir: testDir, logger: stubLogger });
479
+ w2.init({ id: id2, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/project-b' });
480
+ w2.append({ type: 'user_message', message: { id: 'u2', sender: 'user', text: 'hi b' } });
481
+ void w2.close();
482
+ t.true(persistenceModule.deleteConversation(id1));
483
+ t.false(fs.existsSync(path.join(testDir, `${id1}.jsonl`)));
484
+ t.true(fs.existsSync(path.join(testDir, 'last.json')));
485
+ const lastB = persistenceModule.loadLastConversation('/project-b');
486
+ t.truthy(lastB);
487
+ t.is(lastB.id, id2);
488
+ });
489
+ test.serial('saveLastConversation: updates entry when projectPath changes for same id', (t) => {
490
+ const id = persistenceModule.generateId();
491
+ const w = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
492
+ w.init({ id, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/old-path' });
493
+ w.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'hi' } });
494
+ void w.close();
495
+ // Re-open same session with different project path (simulating rotate or manual update)
496
+ const w2 = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
497
+ w2.init({ id, createdAt: '2026-05-26T00:00:00.000Z', projectPath: '/new-path' });
498
+ w2.append({ type: 'user_message', message: { id: 'u2', sender: 'user', text: 'hi again' } });
499
+ void w2.close();
500
+ t.is(persistenceModule.loadLastConversation('/old-path'), null);
501
+ const lastNew = persistenceModule.loadLastConversation('/new-path');
502
+ t.truthy(lastNew);
503
+ t.is(lastNew.id, id);
504
+ });
505
+ // Suppress unused-event-import lint
506
+ const _ev = null;
507
+ void _ev;
508
+ test.serial('writer + loadConversation: round-trips a v2 conversation with assistant_turn', (t) => {
624
509
  const id = persistenceModule.generateId();
625
- const conversation = {
510
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
511
+ writer.init({
626
512
  id,
627
- createdAt: new Date().toISOString(),
628
- updatedAt: new Date().toISOString(),
629
- sshHost: 'MyHost.EXAMPLE.COM',
630
- projectPath: '/workspace/alpha',
631
- previousResponseId: null,
632
- history: [],
633
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
634
- };
635
- persistenceModule.saveConversation(conversation);
636
- const result = persistenceModule.loadConversationForProject(id, '/workspace/alpha', 'myhost.example.com');
637
- t.is(result.status, 'loaded');
638
- if (result.status === 'loaded') {
639
- t.is(result.conversation.id, id);
640
- }
641
- });
642
- test.serial('loadLastConversation: filters by both expectedProjectPath and expectedSshHost', (t) => {
643
- const matchingId = persistenceModule.generateId();
644
- const wrongSshId = persistenceModule.generateId();
645
- const wrongProjectId = persistenceModule.generateId();
646
- persistenceModule.saveConversation({
647
- id: matchingId,
648
- createdAt: '2024-01-03T00:00:00.000Z',
649
- updatedAt: '2024-01-03T00:00:00.000Z',
650
- projectPath: '/workspace/alpha',
651
- sshHost: 'myhost.example.com',
652
- previousResponseId: null,
653
- history: [],
654
- messages: [{ id: 'msg-1', sender: 'user', text: 'matching' }],
513
+ createdAt: '2026-05-26T00:00:00.000Z',
514
+ projectPath: '/workspace/y',
515
+ model: 'gpt-4o',
516
+ provider: 'openai',
655
517
  });
656
- persistenceModule.saveConversation({
657
- id: wrongSshId,
658
- createdAt: '2024-01-02T00:00:00.000Z',
659
- updatedAt: '2024-01-02T00:00:00.000Z',
660
- projectPath: '/workspace/alpha',
661
- sshHost: 'other.example.com',
662
- previousResponseId: null,
663
- history: [],
664
- messages: [{ id: 'msg-2', sender: 'user', text: 'wrong ssh' }],
518
+ writer.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'run tool' } });
519
+ writer.append({ type: 'tool_started', toolCallId: 'call-v2', toolName: 'shell', arguments: 'ls' });
520
+ writer.append({
521
+ type: 'command_message',
522
+ message: {
523
+ id: 'cmd-v2',
524
+ sender: 'command',
525
+ status: 'completed',
526
+ command: 'ls',
527
+ output: 'file.txt',
528
+ success: true,
529
+ callId: 'call-v2',
530
+ toolName: 'shell',
531
+ },
665
532
  });
666
- persistenceModule.saveConversation({
667
- id: wrongProjectId,
668
- createdAt: '2024-01-01T00:00:00.000Z',
669
- updatedAt: '2024-01-01T00:00:00.000Z',
670
- projectPath: '/workspace/beta',
671
- sshHost: 'myhost.example.com',
672
- previousResponseId: null,
673
- history: [],
674
- messages: [{ id: 'msg-3', sender: 'user', text: 'wrong project' }],
533
+ writer.append({
534
+ type: 'assistant_turn',
535
+ turn: {
536
+ items: [
537
+ { type: 'reasoning', text: 'thinking about ls' },
538
+ { type: 'tool_call', callId: 'call-v2', toolName: 'shell', arguments: 'ls' },
539
+ { type: 'tool_result', callId: 'call-v2', toolName: 'shell', status: 'completed', output: 'file.txt' },
540
+ { type: 'assistant_text', text: 'here is the file' },
541
+ ],
542
+ },
543
+ snapshot: {
544
+ history: [
545
+ { role: 'user', type: 'message', content: 'run tool' },
546
+ { role: 'assistant', type: 'message', content: 'here is the file' },
547
+ ],
548
+ previousResponseId: 'resp-v2',
549
+ toolLedger: [
550
+ {
551
+ turnId: 'turn-1',
552
+ callId: 'call-v2',
553
+ toolName: 'shell',
554
+ status: 'completed',
555
+ startedAt: '2026-05-26T00:00:00.000Z',
556
+ completedAt: '2026-05-26T00:00:01.000Z',
557
+ arguments: 'ls',
558
+ output: 'file.txt',
559
+ },
560
+ ],
561
+ model: 'gpt-4o',
562
+ provider: 'openai',
563
+ },
675
564
  });
676
- const last = persistenceModule.loadLastConversation('/workspace/alpha', 'myhost.example.com');
677
- t.truthy(last);
678
- t.is(last.id, matchingId);
679
- });
680
- test.serial('loadLastConversation: returns null when no conversation matches both project and ssh', (t) => {
681
- persistenceModule.saveConversation({
682
- id: persistenceModule.generateId(),
683
- createdAt: new Date().toISOString(),
684
- updatedAt: new Date().toISOString(),
685
- projectPath: '/workspace/alpha',
686
- sshHost: 'myhost.example.com',
687
- previousResponseId: null,
688
- history: [],
689
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
565
+ void writer.close();
566
+ const restored = persistenceModule.loadConversation(id);
567
+ t.truthy(restored);
568
+ t.is(restored.id, id);
569
+ t.is(restored.previousResponseId, 'resp-v2');
570
+ // Reasoning is reconstructed as a standalone history item (matching the live SDK
571
+ // output), NOT folded into the following tool_call's providerData. Folding caused
572
+ // the reasoning_content to be serialized onto both the assistant message and the
573
+ // tool call (duplicate reasoning_content) by the chat-completions converter.
574
+ t.is(restored.history.length, 5);
575
+ t.deepEqual(restored.history[0], { role: 'user', type: 'message', content: 'run tool' });
576
+ t.deepEqual(restored.history[1], {
577
+ type: 'reasoning',
578
+ content: [{ type: 'reasoning_text', text: 'thinking about ls' }],
579
+ rawContent: [{ type: 'reasoning_text', text: 'thinking about ls' }],
690
580
  });
691
- const last = persistenceModule.loadLastConversation('/workspace/alpha', 'otherhost.example.com');
692
- t.is(last, null);
581
+ t.deepEqual(restored.history[2], {
582
+ type: 'function_call',
583
+ callId: 'call-v2',
584
+ name: 'shell',
585
+ arguments: 'ls',
586
+ });
587
+ t.deepEqual(restored.history[3], {
588
+ type: 'function_call_result',
589
+ callId: 'call-v2',
590
+ name: 'shell',
591
+ output: 'file.txt',
592
+ });
593
+ t.deepEqual(restored.history[4], {
594
+ role: 'assistant',
595
+ type: 'message',
596
+ status: 'completed',
597
+ content: [{ type: 'output_text', text: 'here is the file' }],
598
+ });
599
+ t.is(restored.toolLedger.length, 1);
600
+ t.is(restored.toolLedger[0].callId, 'call-v2');
601
+ // exact messages ordering: user, reasoning, command, bot
602
+ t.is(restored.messages.length, 4);
603
+ t.is(restored.messages[0].sender, 'user');
604
+ t.is(restored.messages[1].sender, 'reasoning');
605
+ t.is(restored.messages[1].text, 'thinking about ls');
606
+ t.is(restored.messages[2].sender, 'command');
607
+ t.is(restored.messages[2].status, 'completed');
608
+ t.is(restored.messages[3].sender, 'bot');
609
+ t.is(restored.messages[3].text, 'here is the file');
610
+ });
611
+ test.serial('session logging writes compact v3 assistant_turn state without cumulative snapshot', async (t) => {
612
+ const id = persistenceModule.generateId();
613
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
614
+ writer.init({
615
+ id,
616
+ createdAt: '2026-05-26T00:00:00.000Z',
617
+ projectPath: '/workspace/v3',
618
+ model: 'gpt-5',
619
+ provider: 'openai',
620
+ });
621
+ const stream = new MockStream();
622
+ stream.output = [
623
+ {
624
+ role: 'assistant',
625
+ type: 'message',
626
+ status: 'completed',
627
+ content: [{ type: 'output_text', text: 'Done.' }],
628
+ },
629
+ ];
630
+ const session = new ConversationSession(id, {
631
+ agentClient: {
632
+ startStream: async () => stream,
633
+ getProvider: () => 'openai',
634
+ },
635
+ deps: {
636
+ logger: stubLogger,
637
+ settingsService: createMockSettingsService({
638
+ 'agent.model': 'gpt-5',
639
+ 'agent.provider': 'openai',
640
+ }),
641
+ },
642
+ sessionStartedAt: '2026-05-26T00:00:00.000Z',
643
+ });
644
+ session.setLogSink((event) => writer.append(event));
645
+ const result = await session.sendMessage('hello');
646
+ t.is(result.type, 'response');
647
+ await writer.close();
648
+ const filePath = path.join(testDir, `${id}.jsonl`);
649
+ const envelopes = fs
650
+ .readFileSync(filePath, 'utf-8')
651
+ .trim()
652
+ .split('\n')
653
+ .map((line) => JSON.parse(line));
654
+ const turnEnvelope = envelopes.find((env) => env.event?.type === 'assistant_turn');
655
+ t.truthy(turnEnvelope);
656
+ t.is(turnEnvelope.v, 3);
657
+ t.deepEqual(turnEnvelope.event.state, {
658
+ previousResponseId: 'resp-v3',
659
+ model: 'gpt-5',
660
+ provider: 'openai',
661
+ });
662
+ t.is(turnEnvelope.event.snapshot, undefined);
663
+ t.is(turnEnvelope.event.turn.items.length, 1);
664
+ t.is(turnEnvelope.event.turn.items[0].text, 'Done.');
693
665
  });
694
- test.serial('loadConversation: unscoped load (no filters) accepts conversation with sshHost', (t) => {
666
+ test.serial('replay: interrupted v2 logs without assistant_turn still recover from coarse events', (t) => {
695
667
  const id = persistenceModule.generateId();
696
- const conversation = {
668
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
669
+ writer.init({ id, createdAt: '2026-05-26T00:00:00.000Z' });
670
+ writer.append({ type: 'user_message', message: { id: 'u1', sender: 'user', text: 'interrupted' } });
671
+ writer.append({ type: 'tool_started', toolCallId: 'call-coarse', toolName: 'shell', arguments: 'ls' });
672
+ writer.append({
673
+ type: 'command_message',
674
+ message: {
675
+ id: 'cmd-coarse',
676
+ sender: 'command',
677
+ status: 'completed',
678
+ command: 'ls',
679
+ output: 'some files',
680
+ success: true,
681
+ callId: 'call-coarse',
682
+ toolName: 'shell',
683
+ },
684
+ });
685
+ // No assistant_turn written!
686
+ void writer.close();
687
+ const restored = persistenceModule.loadConversation(id);
688
+ t.truthy(restored);
689
+ t.true(restored.replayWarnings.some((w) => w.includes('interrupted')));
690
+ t.is(restored.messages.length, 3); // user, command, system interrupted warning
691
+ t.is(restored.messages[0].sender, 'user');
692
+ t.is(restored.messages[1].sender, 'command');
693
+ t.is(restored.messages[1].status, 'completed');
694
+ t.is(restored.messages[2].sender, 'system');
695
+ });
696
+ test.serial('session logging with auto-approved tool continuation writes only one assistant_turn containing all details', async (t) => {
697
+ const id = persistenceModule.generateId();
698
+ const writer = createConversationLogWriter({ sessionId: id, dir: testDir, logger: stubLogger });
699
+ writer.init({
697
700
  id,
698
- createdAt: new Date().toISOString(),
699
- updatedAt: new Date().toISOString(),
700
- sshHost: 'myhost.example.com',
701
- projectPath: '/workspace/alpha',
702
- previousResponseId: null,
703
- history: [],
704
- messages: [{ id: 'msg-1', sender: 'user', text: 'hello' }],
701
+ createdAt: '2026-05-26T00:00:00.000Z',
702
+ projectPath: '/workspace/autoapprove',
703
+ model: 'gpt-5',
704
+ provider: 'openai',
705
+ });
706
+ const stream1 = new MockStream();
707
+ stream1.state = {
708
+ approve: () => { },
709
+ };
710
+ stream1.interruptions = [
711
+ {
712
+ name: 'shell',
713
+ arguments: { command: 'echo hello' },
714
+ callId: 'call-1',
715
+ agent: { name: 'Agent' },
716
+ },
717
+ ];
718
+ let continueCalled = false;
719
+ const mockAgentClient = {
720
+ getProvider: () => 'openai',
721
+ startStream: async () => stream1,
722
+ continueRunStream: async () => {
723
+ continueCalled = true;
724
+ const stream2 = new MockStream();
725
+ stream2.newItems = [
726
+ {
727
+ type: 'function_call',
728
+ callId: 'call-1',
729
+ name: 'shell',
730
+ arguments: { command: 'echo hello' },
731
+ },
732
+ {
733
+ type: 'function_call_result',
734
+ callId: 'call-1',
735
+ name: 'shell',
736
+ output: 'hello\n',
737
+ },
738
+ {
739
+ role: 'assistant',
740
+ type: 'message',
741
+ content: [{ type: 'output_text', text: 'Done continuation.' }],
742
+ },
743
+ ];
744
+ stream2.output = [
745
+ {
746
+ role: 'assistant',
747
+ type: 'message',
748
+ status: 'completed',
749
+ content: [{ type: 'output_text', text: 'Done continuation.' }],
750
+ },
751
+ ];
752
+ return stream2;
753
+ },
754
+ };
755
+ const session = new ConversationSession(id, {
756
+ agentClient: mockAgentClient,
757
+ deps: {
758
+ logger: stubLogger,
759
+ settingsService: createMockSettingsService({
760
+ 'agent.model': 'gpt-5',
761
+ 'agent.provider': 'openai',
762
+ }),
763
+ },
764
+ sessionStartedAt: '2026-05-26T00:00:00.000Z',
765
+ });
766
+ // Inject a mock shellAutoApproval resolver that auto-approves
767
+ session.shellAutoApproval = {
768
+ resolveAdvisoryForInterruption: async () => ({ model: 'gpt-5', reasoning: 'allow', decision: 'approve' }),
769
+ shouldAutoApprove: () => true,
770
+ clearCache: () => { },
705
771
  };
706
- persistenceModule.saveConversation(conversation);
707
- // No filters at all — should pass through
708
- const loaded = persistenceModule.loadConversation(id);
709
- t.truthy(loaded);
710
- t.is(loaded.id, id);
711
- t.is(loaded.sshHost, 'myhost.example.com');
772
+ session.setLogSink((event) => writer.append(event));
773
+ const result = await session.sendMessage('hello');
774
+ t.is(result.type, 'response');
775
+ t.true(continueCalled);
776
+ await writer.close();
777
+ // Read raw file contents to check what was written to disk
778
+ const filePath = path.join(testDir, `${id}.jsonl`);
779
+ const envelopes = fs
780
+ .readFileSync(filePath, 'utf-8')
781
+ .trim()
782
+ .split('\n')
783
+ .map((line) => JSON.parse(line));
784
+ const assistantTurnEnvelopes = envelopes.filter((env) => env.event?.type === 'assistant_turn');
785
+ // Verify that EXACTLY one assistant_turn event was logged!
786
+ t.is(assistantTurnEnvelopes.length, 1);
787
+ const turnEvent = assistantTurnEnvelopes[0].event;
788
+ t.truthy(turnEvent);
789
+ t.is(turnEvent.turn.items.length, 3); // tool_call, tool_result, assistant_text
790
+ t.is(turnEvent.turn.items[0].type, 'tool_call');
791
+ t.is(turnEvent.turn.items[0].callId, 'call-1');
792
+ t.is(turnEvent.turn.items[1].type, 'tool_result');
793
+ t.is(turnEvent.turn.items[1].callId, 'call-1');
794
+ t.is(turnEvent.turn.items[2].type, 'assistant_text');
795
+ t.is(turnEvent.turn.items[2].text, 'Done continuation.');
712
796
  });
713
797
  //# sourceMappingURL=conversation-persistence.test.js.map