@robota-sdk/agent-ui-terminal 3.0.0-beta.81

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 (328) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +27 -0
  3. package/dist/node/index.cjs +32 -0
  4. package/dist/node/index.d.cts +1066 -0
  5. package/dist/node/index.d.cts.map +1 -0
  6. package/dist/node/index.d.ts +1066 -0
  7. package/dist/node/index.d.ts.map +1 -0
  8. package/dist/node/index.js +33 -0
  9. package/dist/node/index.js.map +1 -0
  10. package/package.json +93 -0
  11. package/src/App.tsx +146 -0
  12. package/src/AppPresentation.tsx +190 -0
  13. package/src/AppView.tsx +26 -0
  14. package/src/BackgroundTaskPanel.tsx +73 -0
  15. package/src/CjkTextInput.tsx +336 -0
  16. package/src/ConfirmPrompt.tsx +140 -0
  17. package/src/ContextWarningBanner.tsx +45 -0
  18. package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
  19. package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
  20. package/src/HistorySearchOverlay.tsx +150 -0
  21. package/src/InputArea.tsx +338 -0
  22. package/src/ListPicker.tsx +158 -0
  23. package/src/MenuSelect.tsx +159 -0
  24. package/src/MessageList.tsx +308 -0
  25. package/src/MultiSelectList.tsx +294 -0
  26. package/src/PendingActionPrompt.tsx +121 -0
  27. package/src/PermissionPrompt.tsx +186 -0
  28. package/src/PluginTUI.tsx +258 -0
  29. package/src/RoleLabel.tsx +72 -0
  30. package/src/SafeText.tsx +66 -0
  31. package/src/SessionEventNotices.tsx +27 -0
  32. package/src/SessionPicker.tsx +72 -0
  33. package/src/SessionStatusBar.tsx +76 -0
  34. package/src/SlashAutocomplete.tsx +154 -0
  35. package/src/StatusBar.tsx +284 -0
  36. package/src/StreamingIndicator.tsx +145 -0
  37. package/src/SupervisedSessionView.tsx +455 -0
  38. package/src/TextPrompt.tsx +112 -0
  39. package/src/ThemePicker.tsx +366 -0
  40. package/src/ToolCommandOutput.tsx +48 -0
  41. package/src/ToolDiffBlock.tsx +44 -0
  42. package/src/TransportTUI.tsx +166 -0
  43. package/src/TuiInteractionChannel.ts +567 -0
  44. package/src/UpdateNotice.tsx +18 -0
  45. package/src/UsageSummaryEntry.tsx +49 -0
  46. package/src/WaveText.tsx +70 -0
  47. package/src/__tests__/ListPicker.test.tsx +188 -0
  48. package/src/__tests__/MenuSelect.test.tsx +127 -0
  49. package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
  50. package/src/__tests__/PluginTUI.test.tsx +167 -0
  51. package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
  52. package/src/__tests__/SupervisedSessionView-render.test.tsx +24 -0
  53. package/src/__tests__/SupervisedSessionView.test.tsx +629 -0
  54. package/src/__tests__/TextPrompt.test.tsx +112 -0
  55. package/src/__tests__/TransportTUI.test.tsx +124 -0
  56. package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
  57. package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +284 -0
  58. package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +642 -0
  59. package/src/__tests__/UpdateNotice.test.tsx +15 -0
  60. package/src/__tests__/abort-after-permission.test.tsx +160 -0
  61. package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
  62. package/src/__tests__/attention-bell-and-marks.test.ts +144 -0
  63. package/src/__tests__/autocomplete-editing-keys.test.tsx +127 -0
  64. package/src/__tests__/background-task-panel-countdown.test.tsx +75 -0
  65. package/src/__tests__/background-task-panel.test.tsx +98 -0
  66. package/src/__tests__/background-task-row-format.test.ts +152 -0
  67. package/src/__tests__/channel-factory-integration.test.ts +150 -0
  68. package/src/__tests__/cjk-defer-submit.test.tsx +67 -0
  69. package/src/__tests__/cjk-fallback-render.test.tsx +78 -0
  70. package/src/__tests__/cjk-text-input-flow.test.ts +186 -0
  71. package/src/__tests__/cjk-text-input.test.ts +191 -0
  72. package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
  73. package/src/__tests__/command-output-summary.test.ts +95 -0
  74. package/src/__tests__/command-result-handler.test.ts +171 -0
  75. package/src/__tests__/compact-event-bridge.test.ts +20 -0
  76. package/src/__tests__/confirm-permission-flow.test.ts +149 -0
  77. package/src/__tests__/confirm-prompt.test.tsx +103 -0
  78. package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
  79. package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +45 -0
  80. package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
  81. package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
  82. package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
  83. package/src/__tests__/external-prompt-notice.test.tsx +100 -0
  84. package/src/__tests__/fixtures/command-handoff-driver.tsx +111 -0
  85. package/src/__tests__/fixtures/fake-editor.sh +7 -0
  86. package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
  87. package/src/__tests__/fork-attach.test.tsx +235 -0
  88. package/src/__tests__/helpers/terminal-profiles.ts +152 -0
  89. package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
  90. package/src/__tests__/history-clear-broadcast.test.ts +66 -0
  91. package/src/__tests__/history-search-overlay.test.tsx +329 -0
  92. package/src/__tests__/humanize-tool-name.test.ts +30 -0
  93. package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
  94. package/src/__tests__/input-area-flow.test.ts +199 -0
  95. package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
  96. package/src/__tests__/interaction-channel-charter.test.ts +13 -0
  97. package/src/__tests__/key-hint-consistency.test.tsx +249 -0
  98. package/src/__tests__/key-hint-footer.test.tsx +69 -0
  99. package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
  100. package/src/__tests__/message-list-rendering.test.tsx +473 -0
  101. package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
  102. package/src/__tests__/output-style-reaches-session.test.ts +35 -0
  103. package/src/__tests__/palette-consistency.test.ts +330 -0
  104. package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
  105. package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
  106. package/src/__tests__/product-display-name.test.tsx +79 -0
  107. package/src/__tests__/prompt-queue.test.tsx +260 -0
  108. package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
  109. package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
  110. package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
  111. package/src/__tests__/pty/behavior-2437-git.ptytest.ts +342 -0
  112. package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
  113. package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
  114. package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
  115. package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
  116. package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
  117. package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
  118. package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
  119. package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +291 -0
  120. package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +165 -0
  121. package/src/__tests__/pty/ime-cursor.ptytest.ts +186 -0
  122. package/src/__tests__/pty/isolated-home.ts +64 -0
  123. package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
  124. package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
  125. package/src/__tests__/pty/pty-driver.ts +153 -0
  126. package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
  127. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +134 -0
  128. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +138 -0
  129. package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
  130. package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
  131. package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
  132. package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
  133. package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
  134. package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
  135. package/src/__tests__/pty/spawn-pty.test.ts +120 -0
  136. package/src/__tests__/pty/spawn-pty.ts +229 -0
  137. package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
  138. package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
  139. package/src/__tests__/pty-driver-dispose.test.ts +43 -0
  140. package/src/__tests__/real-cursor-positioning.test.tsx +263 -0
  141. package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
  142. package/src/__tests__/render-channel-options.test.ts +201 -0
  143. package/src/__tests__/render-lifecycle.test.ts +36 -0
  144. package/src/__tests__/render-markdown.test.ts +164 -0
  145. package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
  146. package/src/__tests__/safe-text-boundary.test.tsx +75 -0
  147. package/src/__tests__/screen-reader-input.test.tsx +106 -0
  148. package/src/__tests__/screen-reader-labels.test.ts +91 -0
  149. package/src/__tests__/screen-reader-menus.test.tsx +200 -0
  150. package/src/__tests__/screen-reader-pacing.test.ts +230 -0
  151. package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
  152. package/src/__tests__/screen-reader-render-options.test.ts +262 -0
  153. package/src/__tests__/screen-reader-stdout.test.ts +322 -0
  154. package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
  155. package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
  156. package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
  157. package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
  158. package/src/__tests__/selection-flow.test.ts +61 -0
  159. package/src/__tests__/session-event-notices.test.tsx +31 -0
  160. package/src/__tests__/session-init-poller.test.ts +102 -0
  161. package/src/__tests__/session-switch-channel.test.tsx +470 -0
  162. package/src/__tests__/shutdown-signal.test.ts +34 -0
  163. package/src/__tests__/status-activity.test.ts +71 -0
  164. package/src/__tests__/status-bar.test.tsx +282 -0
  165. package/src/__tests__/status-glyph.test.ts +30 -0
  166. package/src/__tests__/streaming-indicator.test.tsx +177 -0
  167. package/src/__tests__/terminal-capabilities.test.ts +153 -0
  168. package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
  169. package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
  170. package/src/__tests__/text-prompt-flow.test.ts +77 -0
  171. package/src/__tests__/theme-picker.test.tsx +345 -0
  172. package/src/__tests__/transport-boundary.test.ts +11 -0
  173. package/src/__tests__/tui-app-channel-port.test.ts +31 -0
  174. package/src/__tests__/tui-channel-init-failure.test.ts +62 -0
  175. package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
  176. package/src/__tests__/tui-interaction-queues.test.ts +139 -0
  177. package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
  178. package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
  179. package/src/__tests__/tui-state-manager.test.ts +451 -0
  180. package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
  181. package/src/__tests__/wave-text.test.tsx +133 -0
  182. package/src/app-banner.tsx +42 -0
  183. package/src/app-static-items.ts +35 -0
  184. package/src/app-view-model.ts +136 -0
  185. package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
  186. package/src/attention/__tests__/attention-tracker.test.ts +103 -0
  187. package/src/attention/__tests__/countdown.test.ts +15 -0
  188. package/src/attention/__tests__/focus-input-filter.test.ts +92 -0
  189. package/src/attention/__tests__/interval-recap.test.ts +49 -0
  190. package/src/attention/attention-coordinator.ts +71 -0
  191. package/src/attention/attention-tracker.ts +124 -0
  192. package/src/attention/countdown.ts +24 -0
  193. package/src/attention/focus-input-filter.ts +131 -0
  194. package/src/attention/interval-recap.ts +148 -0
  195. package/src/attention/time-units.ts +6 -0
  196. package/src/attention-bell.ts +79 -0
  197. package/src/attributed-user-echo.ts +30 -0
  198. package/src/background-task-row-format.ts +99 -0
  199. package/src/command-interaction.ts +8 -0
  200. package/src/command-output-summary.ts +129 -0
  201. package/src/create-default-tui-cli-adapter.ts +45 -0
  202. package/src/execution-workspace-switcher-selection.ts +118 -0
  203. package/src/execution-workspace-view-model.ts +139 -0
  204. package/src/external-prompt-notice.tsx +38 -0
  205. package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
  206. package/src/flows/__tests__/defer-submit.test.ts +87 -0
  207. package/src/flows/__tests__/real-cursor-flow.test.ts +124 -0
  208. package/src/flows/background-focus-flow.ts +44 -0
  209. package/src/flows/cjk-cursor-motion.ts +75 -0
  210. package/src/flows/cjk-text-deletion.ts +53 -0
  211. package/src/flows/cjk-text-input-flow.ts +248 -0
  212. package/src/flows/cjk-text-input-types.ts +24 -0
  213. package/src/flows/confirm-prompt-flow.ts +90 -0
  214. package/src/flows/defer-submit.ts +67 -0
  215. package/src/flows/fork-attach-flow.ts +62 -0
  216. package/src/flows/input-area-flow.ts +188 -0
  217. package/src/flows/permission-prompt-flow.ts +108 -0
  218. package/src/flows/real-cursor-flow.ts +83 -0
  219. package/src/flows/selection-flow.ts +205 -0
  220. package/src/flows/session-init-poller.ts +76 -0
  221. package/src/flows/text-prompt-flow.ts +93 -0
  222. package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
  223. package/src/history-search/history-search-flow.ts +94 -0
  224. package/src/history-search/useHistorySearch.ts +336 -0
  225. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +74 -0
  226. package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
  227. package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
  228. package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
  229. package/src/hooks/command-result-handler.ts +44 -0
  230. package/src/hooks/side-effects-types.ts +45 -0
  231. package/src/hooks/useAppController.ts +267 -0
  232. package/src/hooks/useAppInputBindings.ts +145 -0
  233. package/src/hooks/useAppInteractionState.ts +81 -0
  234. package/src/hooks/useAppLifecycleState.ts +81 -0
  235. package/src/hooks/useAppOverlays.ts +74 -0
  236. package/src/hooks/useAppScreenState.ts +81 -0
  237. package/src/hooks/useAppSubmissionState.ts +45 -0
  238. package/src/hooks/useAppThemeState.ts +157 -0
  239. package/src/hooks/useAppWorkspaceState.ts +149 -0
  240. package/src/hooks/useAppearanceSettings.ts +30 -0
  241. package/src/hooks/useAutocomplete.ts +101 -0
  242. package/src/hooks/useBackgroundPanel.ts +58 -0
  243. package/src/hooks/useCountdownTick.ts +24 -0
  244. package/src/hooks/useExecutionDetailPage.ts +69 -0
  245. package/src/hooks/useForkAttach.ts +60 -0
  246. package/src/hooks/useInputAreaHistorySearch.ts +72 -0
  247. package/src/hooks/useInputAreaKeys.ts +114 -0
  248. package/src/hooks/useNumberedSelection.ts +76 -0
  249. package/src/hooks/usePluginCallbacks.ts +38 -0
  250. package/src/hooks/usePluginScreenData.ts +85 -0
  251. package/src/hooks/useRealCursorPosition.ts +125 -0
  252. package/src/hooks/useRenderMarkdown.ts +38 -0
  253. package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
  254. package/src/hooks/useSideEffects.ts +144 -0
  255. package/src/hooks/useStatusLineSettings.ts +50 -0
  256. package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
  257. package/src/hooks/useTuiChannel.ts +129 -0
  258. package/src/humanize-tool-name.ts +51 -0
  259. package/src/index.ts +56 -0
  260. package/src/input-area-rules.tsx +71 -0
  261. package/src/json-value.ts +18 -0
  262. package/src/key-hint-footer.tsx +54 -0
  263. package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
  264. package/src/keybindings/__tests__/node-keybindings-source.test.ts +111 -0
  265. package/src/keybindings/keybinding-catalogue.ts +171 -0
  266. package/src/keybindings/keybinding-registry.ts +303 -0
  267. package/src/keybindings/keybinding-syntax.ts +155 -0
  268. package/src/keybindings/keybindings-context.tsx +124 -0
  269. package/src/keybindings/node-keybindings-source.ts +189 -0
  270. package/src/numbered-list.tsx +113 -0
  271. package/src/plugin-tui-handlers.ts +163 -0
  272. package/src/product-display-name-context.tsx +30 -0
  273. package/src/render-markdown.ts +209 -0
  274. package/src/render.tsx +544 -0
  275. package/src/sanitize-terminal-text.ts +171 -0
  276. package/src/screen-reader-announcement.ts +52 -0
  277. package/src/screen-reader-context.tsx +32 -0
  278. package/src/screen-reader-labels.ts +64 -0
  279. package/src/screen-reader-pacing-context.tsx +47 -0
  280. package/src/screen-reader-pacing.ts +178 -0
  281. package/src/screen-reader-stdout.ts +328 -0
  282. package/src/shutdown-signal.ts +34 -0
  283. package/src/status-activity.ts +70 -0
  284. package/src/status-glyph.ts +78 -0
  285. package/src/terminal-capabilities-context.tsx +27 -0
  286. package/src/terminal-capabilities.ts +77 -0
  287. package/src/terminal-focus-reporting.ts +50 -0
  288. package/src/terminal-handoff-controller.ts +98 -0
  289. package/src/terminal-marks.ts +63 -0
  290. package/src/theme/__tests__/built-in-themes.test.ts +117 -0
  291. package/src/theme/__tests__/color-vision.test.ts +82 -0
  292. package/src/theme/__tests__/theme-context.test.tsx +146 -0
  293. package/src/theme/__tests__/theme-document.test.ts +222 -0
  294. package/src/theme/__tests__/theme-registry.test.ts +138 -0
  295. package/src/theme/__tests__/theme-styles.test.ts +233 -0
  296. package/src/theme/built-in-themes.ts +342 -0
  297. package/src/theme/color-vision.ts +171 -0
  298. package/src/theme/index.ts +45 -0
  299. package/src/theme/theme-context.tsx +81 -0
  300. package/src/theme/theme-contracts.ts +139 -0
  301. package/src/theme/theme-document.ts +278 -0
  302. package/src/theme/theme-registry.ts +127 -0
  303. package/src/theme/theme-styles.ts +177 -0
  304. package/src/tool-summary-status.ts +59 -0
  305. package/src/tui-app-channel-port.ts +83 -0
  306. package/src/tui-channel-lifecycle-coordinator.ts +149 -0
  307. package/src/tui-channel-options.ts +172 -0
  308. package/src/tui-cli-adapter-context.tsx +13 -0
  309. package/src/tui-cli-adapter.ts +24 -0
  310. package/src/tui-interaction-queues.ts +163 -0
  311. package/src/tui-session-binding.ts +52 -0
  312. package/src/tui-session-event-projector.ts +140 -0
  313. package/src/tui-session-events.ts +82 -0
  314. package/src/tui-session-notice-store.ts +43 -0
  315. package/src/tui-session-options.ts +118 -0
  316. package/src/tui-state-manager.ts +327 -0
  317. package/src/types.ts +19 -0
  318. package/src/use-terminal-title.ts +29 -0
  319. package/src/utils/__tests__/edit-diff.test.ts +426 -0
  320. package/src/utils/__tests__/paste-detection.test.ts +116 -0
  321. package/src/utils/__tests__/paste-labels.test.ts +46 -0
  322. package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
  323. package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
  324. package/src/utils/edit-diff.ts +151 -0
  325. package/src/utils/input-top-border.ts +19 -0
  326. package/src/utils/paste-labels.ts +9 -0
  327. package/src/utils/tool-call-extractor.ts +92 -0
  328. package/src/utils/tool-diff-summary.ts +75 -0
@@ -0,0 +1,31 @@
1
+ import { describe, expectTypeOf, it } from 'vitest';
2
+
3
+ import type {
4
+ ITuiAppChannelPort,
5
+ ITuiCommandQueryPort,
6
+ ITuiSessionUiEventPort,
7
+ } from '../tui-app-channel-port.js';
8
+
9
+ describe('TUI React capability boundary', () => {
10
+ it('excludes concrete channel and state-manager escape hatches', () => {
11
+ expectTypeOf<ITuiAppChannelPort>().not.toHaveProperty('getSession');
12
+ expectTypeOf<ITuiAppChannelPort>().not.toHaveProperty('getRegistry');
13
+ expectTypeOf<ITuiAppChannelPort>().not.toHaveProperty('stateManager');
14
+ });
15
+
16
+ it('limits autocomplete to command queries', () => {
17
+ expectTypeOf<ITuiCommandQueryPort>().toHaveProperty('getCommands');
18
+ expectTypeOf<ITuiCommandQueryPort>().toHaveProperty('getSubcommands');
19
+ expectTypeOf<ITuiCommandQueryPort>().not.toHaveProperty('addModule');
20
+ expectTypeOf<ITuiCommandQueryPort>().not.toHaveProperty('execute');
21
+ });
22
+
23
+ it('limits React event effects to the two UI event names', () => {
24
+ expectTypeOf<Parameters<ITuiSessionUiEventPort['on']>[0]>().toEqualTypeOf<
25
+ 'ui_intent' | 'session_renamed'
26
+ >();
27
+ expectTypeOf<Parameters<ITuiSessionUiEventPort['off']>[0]>().toEqualTypeOf<
28
+ 'ui_intent' | 'session_renamed'
29
+ >();
30
+ });
31
+ });
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { IAIProvider } from '@robota-sdk/agent-core';
3
+
4
+ vi.mock('@robota-sdk/agent-framework', async (importOriginal) => {
5
+ const mod = await importOriginal<typeof import('@robota-sdk/agent-framework')>();
6
+
7
+ class FakeInteractiveSession {
8
+ on(): void {}
9
+ off(): void {}
10
+ getFullHistory(): unknown[] {
11
+ return [];
12
+ }
13
+ getContextState(): never {
14
+ throw new Error('ENOENT: session store unreadable');
15
+ }
16
+ getName(): string | undefined {
17
+ return undefined;
18
+ }
19
+ getSession(): { getSessionId: () => string } {
20
+ return { getSessionId: () => 'test-session' };
21
+ }
22
+ async shutdown(): Promise<void> {}
23
+ }
24
+
25
+ // RUNTIME-001: construction flows through buildRuntimeSession (wraps InteractiveSession in agent-framework).
26
+ return {
27
+ ...mod,
28
+ InteractiveSession: FakeInteractiveSession,
29
+ buildRuntimeSession: () => new FakeInteractiveSession(),
30
+ };
31
+ });
32
+
33
+ import { TuiInteractionChannel } from '../TuiInteractionChannel.js';
34
+
35
+ describe('TuiInteractionChannel init failure surfacing', () => {
36
+ beforeEach(() => {
37
+ vi.useFakeTimers();
38
+ });
39
+
40
+ afterEach(() => {
41
+ vi.useRealTimers();
42
+ });
43
+
44
+ it('TC-05: a real init error records a session-init-error entry and sets error state', async () => {
45
+ const channel = new TuiInteractionChannel({
46
+ cwd: '/tmp/project',
47
+ provider: {} as IAIProvider,
48
+ });
49
+ await channel.start();
50
+
51
+ vi.advanceTimersByTime(400);
52
+
53
+ const entries = channel.stateManager.history;
54
+ const initError = entries.find((e) => e.type === 'session-init-error');
55
+ expect(initError).toBeDefined();
56
+ const message = (initError?.data as { message?: string } | undefined)?.message ?? '';
57
+ expect(message).toContain('Session initialization failed');
58
+ expect(message).toContain('ENOENT');
59
+
60
+ await channel.stop();
61
+ });
62
+ });
@@ -0,0 +1,258 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { TuiChannelLifecycleCoordinator } from '../tui-channel-lifecycle-coordinator.js';
4
+
5
+ function operations() {
6
+ return {
7
+ start: vi.fn(async () => {}),
8
+ stop: vi.fn(async () => {}),
9
+ beginShutdown: vi.fn(),
10
+ shutdownSession: vi.fn(async () => {}),
11
+ };
12
+ }
13
+
14
+ describe('TuiChannelLifecycleCoordinator', () => {
15
+ it('starts and stops idempotently, including one discard shutdown', async () => {
16
+ const ops = operations();
17
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
18
+ await lifecycle.start();
19
+ expect(lifecycle.isActiveForPeerStatus).toBe(true);
20
+ await lifecycle.start();
21
+ await lifecycle.stop();
22
+ expect(lifecycle.isActiveForPeerStatus).toBe(false);
23
+ await lifecycle.stop();
24
+
25
+ expect(ops.start).toHaveBeenCalledTimes(1);
26
+ expect(ops.stop).toHaveBeenCalledTimes(1);
27
+ expect(ops.shutdownSession).toHaveBeenCalledTimes(1);
28
+ expect(ops.shutdownSession).toHaveBeenCalledWith({
29
+ reason: 'other',
30
+ message: 'channel stopped',
31
+ });
32
+ });
33
+
34
+ it('does not repeat a graceful shutdown when stop follows it', async () => {
35
+ const ops = operations();
36
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
37
+ await lifecycle.shutdown({ reason: 'prompt_input_exit' });
38
+ await lifecycle.shutdown({ reason: 'other' });
39
+ await lifecycle.stop();
40
+
41
+ expect(lifecycle.isShuttingDown).toBe(true);
42
+ expect(ops.beginShutdown).toHaveBeenCalledTimes(1);
43
+ expect(ops.shutdownSession).toHaveBeenCalledTimes(1);
44
+ });
45
+
46
+ it('allows a failed start to be retried', async () => {
47
+ const ops = operations();
48
+ ops.start.mockRejectedValueOnce(new Error('transport start failed'));
49
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
50
+
51
+ await expect(lifecycle.start()).rejects.toThrow('transport start failed');
52
+ expect(lifecycle.isActiveForPeerStatus).toBe(false);
53
+ await lifecycle.start();
54
+ await lifecycle.start();
55
+
56
+ expect(ops.start).toHaveBeenCalledTimes(2);
57
+ });
58
+
59
+ it('invalidates peer activity before a failing stop settles', async () => {
60
+ const ops = operations();
61
+ ops.stop.mockRejectedValueOnce(new Error('teardown failed'));
62
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
63
+ await lifecycle.start();
64
+ expect(lifecycle.isActiveForPeerStatus).toBe(true);
65
+ const stopping = lifecycle.stop();
66
+ expect(lifecycle.isActiveForPeerStatus).toBe(false);
67
+ await expect(stopping).rejects.toThrow('teardown failed');
68
+ expect(lifecycle.isActiveForPeerStatus).toBe(false);
69
+ });
70
+
71
+ it('shares one in-flight start across concurrent callers', async () => {
72
+ const ops = operations();
73
+ let release: (() => void) | undefined;
74
+ ops.start.mockImplementationOnce(() => new Promise<void>((resolve) => (release = resolve)));
75
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
76
+
77
+ const first = lifecycle.start();
78
+ const second = lifecycle.start();
79
+ expect(ops.start).toHaveBeenCalledTimes(1);
80
+ release?.();
81
+ await Promise.all([first, second]);
82
+ });
83
+
84
+ it('waits for an in-flight start before tearing the channel down', async () => {
85
+ const ops = operations();
86
+ let release: (() => void) | undefined;
87
+ ops.start.mockImplementationOnce(() => new Promise<void>((resolve) => (release = resolve)));
88
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
89
+
90
+ const start = lifecycle.start();
91
+ const stop = lifecycle.stop();
92
+ await Promise.resolve();
93
+ expect(ops.stop).not.toHaveBeenCalled();
94
+
95
+ release?.();
96
+ await Promise.all([start, stop]);
97
+ expect(ops.start.mock.invocationCallOrder[0]).toBeLessThan(
98
+ ops.stop.mock.invocationCallOrder[0]!,
99
+ );
100
+ });
101
+
102
+ it('surfaces an in-flight start failure after teardown completes', async () => {
103
+ const ops = operations();
104
+ let rejectStart: ((error: Error) => void) | undefined;
105
+ ops.start.mockImplementationOnce(
106
+ () => new Promise<void>((_resolve, reject) => (rejectStart = reject)),
107
+ );
108
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
109
+
110
+ const start = lifecycle.start();
111
+ const stop = lifecycle.stop();
112
+ rejectStart?.(new Error('transport start failed'));
113
+
114
+ await expect(start).rejects.toThrow('transport start failed');
115
+ await expect(stop).rejects.toThrow('transport start failed');
116
+ expect(ops.stop).toHaveBeenCalledTimes(1);
117
+ await lifecycle.stop();
118
+ expect(ops.stop).toHaveBeenCalledTimes(1);
119
+ });
120
+
121
+ it('preserves concurrent start and teardown errors and keeps teardown retryable', async () => {
122
+ const ops = operations();
123
+ let rejectStart: ((error: Error) => void) | undefined;
124
+ ops.start.mockImplementationOnce(
125
+ () => new Promise<void>((_resolve, reject) => (rejectStart = reject)),
126
+ );
127
+ ops.stop.mockRejectedValueOnce(new Error('transport stop failed'));
128
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
129
+
130
+ const start = lifecycle.start();
131
+ const stop = lifecycle.stop();
132
+ rejectStart?.(new Error('transport start failed'));
133
+ await expect(start).rejects.toThrow('transport start failed');
134
+ const failure = await stop.catch((error: Error) => error);
135
+
136
+ expect(failure).toBeInstanceOf(AggregateError);
137
+ expect((failure as AggregateError).errors).toEqual([
138
+ expect.objectContaining({ message: 'transport start failed' }),
139
+ expect.objectContaining({ message: 'transport stop failed' }),
140
+ ]);
141
+
142
+ await lifecycle.stop();
143
+ expect(ops.stop).toHaveBeenCalledTimes(2);
144
+ });
145
+
146
+ it('shares session shutdown while failed transport cleanup remains retryable', async () => {
147
+ const ops = operations();
148
+ ops.stop.mockRejectedValueOnce(new Error('transport stop failed'));
149
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
150
+
151
+ await expect(lifecycle.stop()).rejects.toThrow('transport stop failed');
152
+ expect(ops.shutdownSession).toHaveBeenCalledTimes(1);
153
+
154
+ await lifecycle.stop();
155
+ expect(ops.stop).toHaveBeenCalledTimes(2);
156
+ expect(ops.shutdownSession).toHaveBeenCalledTimes(1);
157
+ });
158
+
159
+ it('refuses to restart after a failed teardown', async () => {
160
+ const ops = operations();
161
+ ops.stop.mockRejectedValueOnce(new Error('transport stop failed'));
162
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
163
+
164
+ await lifecycle.start();
165
+ await expect(lifecycle.stop()).rejects.toThrow('transport stop failed');
166
+ await expect(lifecycle.start()).rejects.toThrow(
167
+ 'Cannot start a channel after teardown has begun.',
168
+ );
169
+
170
+ expect(ops.start).toHaveBeenCalledTimes(1);
171
+ });
172
+
173
+ it('refuses to start after graceful shutdown has begun', async () => {
174
+ const ops = operations();
175
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
176
+
177
+ await lifecycle.shutdown();
178
+ await expect(lifecycle.start()).rejects.toThrow(
179
+ 'Cannot start a channel after teardown has begun.',
180
+ );
181
+
182
+ expect(ops.start).not.toHaveBeenCalled();
183
+ });
184
+
185
+ it('serializes graceful shutdown after start and shares its completion', async () => {
186
+ const ops = operations();
187
+ let releaseStart: (() => void) | undefined;
188
+ let releaseShutdown: (() => void) | undefined;
189
+ ops.start.mockImplementationOnce(
190
+ () => new Promise<void>((resolve) => (releaseStart = resolve)),
191
+ );
192
+ ops.shutdownSession.mockImplementationOnce(
193
+ () => new Promise<void>((resolve) => (releaseShutdown = resolve)),
194
+ );
195
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
196
+
197
+ const start = lifecycle.start();
198
+ const firstShutdown = lifecycle.shutdown();
199
+ const secondShutdown = lifecycle.shutdown();
200
+ await Promise.resolve();
201
+ expect(ops.shutdownSession).not.toHaveBeenCalled();
202
+
203
+ releaseStart?.();
204
+ await start;
205
+ await Promise.resolve();
206
+ expect(ops.shutdownSession).toHaveBeenCalledTimes(1);
207
+
208
+ let secondSettled = false;
209
+ void secondShutdown.then(() => (secondSettled = true));
210
+ await Promise.resolve();
211
+ expect(secondSettled).toBe(false);
212
+
213
+ releaseShutdown?.();
214
+ await Promise.all([firstShutdown, secondShutdown]);
215
+ });
216
+
217
+ it('does not finish stop before an in-flight graceful shutdown', async () => {
218
+ const ops = operations();
219
+ let releaseShutdown: (() => void) | undefined;
220
+ ops.shutdownSession.mockImplementationOnce(
221
+ () => new Promise<void>((resolve) => (releaseShutdown = resolve)),
222
+ );
223
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
224
+ await lifecycle.start();
225
+
226
+ const shutdown = lifecycle.shutdown();
227
+ let stopSettled = false;
228
+ const stop = lifecycle.stop().then(() => (stopSettled = true));
229
+ await Promise.resolve();
230
+ await Promise.resolve();
231
+ expect(stopSettled).toBe(false);
232
+
233
+ releaseShutdown?.();
234
+ await Promise.all([shutdown, stop]);
235
+ });
236
+
237
+ it('does not finish graceful shutdown before an in-flight stop', async () => {
238
+ const ops = operations();
239
+ let releaseStop: (() => void) | undefined;
240
+ ops.stop.mockImplementationOnce(() => new Promise<void>((resolve) => (releaseStop = resolve)));
241
+ const lifecycle = new TuiChannelLifecycleCoordinator(ops, 100);
242
+ await lifecycle.start();
243
+
244
+ const stop = lifecycle.stop();
245
+ await Promise.resolve();
246
+ const shutdown = lifecycle.shutdown();
247
+ let shutdownSettled = false;
248
+ void shutdown.then(() => (shutdownSettled = true));
249
+ await Promise.resolve();
250
+ await Promise.resolve();
251
+ expect(shutdownSettled).toBe(false);
252
+ expect(ops.shutdownSession).not.toHaveBeenCalled();
253
+
254
+ releaseStop?.();
255
+ await Promise.all([stop, shutdown]);
256
+ expect(ops.shutdownSession).toHaveBeenCalledTimes(1);
257
+ });
258
+ });
@@ -0,0 +1,139 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { TuiPermissionQueue, TuiUserActionQueue } from '../tui-interaction-queues.js';
4
+
5
+ import type { IActionRequest } from '@robota-sdk/agent-core';
6
+
7
+ const FIRST: IActionRequest = {
8
+ id: 'first',
9
+ title: 'First?',
10
+ options: [{ value: 'yes', label: 'Yes' }],
11
+ maxSelect: 1,
12
+ };
13
+ const SECOND: IActionRequest = {
14
+ id: 'second',
15
+ title: 'Second?',
16
+ options: [{ value: 'no', label: 'No' }],
17
+ maxSelect: 1,
18
+ };
19
+
20
+ afterEach(() => vi.useRealTimers());
21
+
22
+ describe('TuiUserActionQueue', () => {
23
+ it('resolves FIFO and promotes the next action', async () => {
24
+ const queue = new TuiUserActionQueue(vi.fn());
25
+ const first = queue.enqueue(FIRST, 'prompt-1');
26
+ const second = queue.enqueue(SECOND, 'prompt-2');
27
+
28
+ const firstPrompt = queue.current;
29
+ expect(firstPrompt).toEqual(FIRST);
30
+ queue.resolveCurrent(firstPrompt!, { type: 'answer', values: ['yes'] });
31
+ expect(await first).toEqual({ type: 'answer', values: ['yes'] });
32
+ const secondPrompt = queue.current;
33
+ expect(secondPrompt).toEqual(SECOND);
34
+ queue.resolveCurrent(secondPrompt!, { type: 'cancelled' });
35
+ expect(await second).toEqual({ type: 'cancelled' });
36
+ expect(queue.current).toBeNull();
37
+ });
38
+
39
+ it('cancels every active and queued action during a drain', async () => {
40
+ const queue = new TuiUserActionQueue(vi.fn());
41
+ const first = queue.enqueue(FIRST);
42
+ const second = queue.enqueue(SECOND);
43
+ queue.cancelAll();
44
+ await expect(first).resolves.toEqual({ type: 'cancelled' });
45
+ await expect(second).resolves.toEqual({ type: 'cancelled' });
46
+ expect(queue.current).toBeNull();
47
+ });
48
+
49
+ it('ignores a stale answer after remote dismissal promotes the next action', async () => {
50
+ const queue = new TuiUserActionQueue(vi.fn());
51
+ const first = queue.enqueue(FIRST, 'prompt-1');
52
+ const second = queue.enqueue(SECOND, 'prompt-2');
53
+ const stalePrompt = queue.current;
54
+
55
+ expect(queue.dismissById('prompt-1')).toBe(true);
56
+ await expect(first).resolves.toEqual({ type: 'cancelled' });
57
+ const activePrompt = queue.current;
58
+ expect(activePrompt).toEqual(SECOND);
59
+
60
+ queue.resolveCurrent(stalePrompt!, { type: 'answer', values: ['yes'] });
61
+ expect(queue.current).toBe(activePrompt);
62
+ queue.resolveCurrent(activePrompt!, { type: 'answer', values: ['no'] });
63
+ await expect(second).resolves.toEqual({ type: 'answer', values: ['no'] });
64
+ });
65
+
66
+ it('binds stale-answer protection to an entry when the request object is reused', async () => {
67
+ const queue = new TuiUserActionQueue(vi.fn());
68
+ const first = queue.enqueue(FIRST, 'prompt-1');
69
+ const second = queue.enqueue(FIRST, 'prompt-2');
70
+ const stalePrompt = queue.current;
71
+
72
+ expect(queue.dismissById('prompt-1')).toBe(true);
73
+ await expect(first).resolves.toEqual({ type: 'cancelled' });
74
+ const activePrompt = queue.current;
75
+ expect(activePrompt).not.toBeNull();
76
+
77
+ queue.resolveCurrent(stalePrompt!, { type: 'answer', values: ['stale'] });
78
+ expect(queue.current).toBe(activePrompt);
79
+ queue.resolveCurrent(activePrompt!, { type: 'answer', values: ['current'] });
80
+ await expect(second).resolves.toEqual({ type: 'answer', values: ['current'] });
81
+ });
82
+ });
83
+
84
+ describe('TuiPermissionQueue', () => {
85
+ it('resolves FIFO and promotes the next permission on the scheduled turn', async () => {
86
+ vi.useFakeTimers();
87
+ const queue = new TuiPermissionQueue(vi.fn());
88
+ const first = queue.enqueue('Read', { path: 'a' }, 'permission-1');
89
+ const second = queue.enqueue('Write', { path: 'b' }, 'permission-2');
90
+
91
+ expect(queue.current?.toolName).toBe('Read');
92
+ queue.current?.resolve(true);
93
+ await expect(first).resolves.toBe(true);
94
+ await vi.runAllTimersAsync();
95
+ expect(queue.current?.toolName).toBe('Write');
96
+ queue.current?.resolve('allow-session');
97
+ await expect(second).resolves.toBe('allow-session');
98
+ });
99
+
100
+ it('denies every active and queued permission during a drain', async () => {
101
+ const queue = new TuiPermissionQueue(vi.fn());
102
+ const first = queue.enqueue('Read', { path: 'a' });
103
+ const second = queue.enqueue('Write', { path: 'b' });
104
+ queue.cancelAll();
105
+ await expect(first).resolves.toBe(false);
106
+ await expect(second).resolves.toBe(false);
107
+ expect(queue.current).toBeNull();
108
+ });
109
+
110
+ it('preserves the active permission when a queued request is dismissed', async () => {
111
+ const queue = new TuiPermissionQueue(vi.fn());
112
+ const first = queue.enqueue('Read', { path: 'a' }, 'permission-1');
113
+ const second = queue.enqueue('Write', { path: 'b' }, 'permission-2');
114
+ const active = queue.current;
115
+
116
+ expect(queue.dismissById('permission-2')).toBe(true);
117
+ expect(queue.current).toBe(active);
118
+ await expect(second).resolves.toBe(false);
119
+
120
+ active?.resolve(true);
121
+ await expect(first).resolves.toBe(true);
122
+ });
123
+
124
+ it('ignores a stale permission callback after remote dismissal promotes the next request', async () => {
125
+ const queue = new TuiPermissionQueue(vi.fn());
126
+ const first = queue.enqueue('Read', { path: 'a' }, 'permission-1');
127
+ const second = queue.enqueue('Write', { path: 'b' }, 'permission-2');
128
+ const stale = queue.current;
129
+
130
+ expect(queue.dismissById('permission-1')).toBe(true);
131
+ await expect(first).resolves.toBe(false);
132
+ expect(queue.current?.toolName).toBe('Write');
133
+
134
+ stale?.resolve(true);
135
+ expect(queue.current?.toolName).toBe('Write');
136
+ queue.current?.resolve(true);
137
+ await expect(second).resolves.toBe(true);
138
+ });
139
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { toChannelOptions } from '../render.js';
4
+ import { buildTuiSessionOptions } from '../tui-session-options.js';
5
+
6
+ import type { IRenderOptions } from '../render.js';
7
+
8
+ function renderOptions(extra: Partial<IRenderOptions> = {}): IRenderOptions {
9
+ return { cwd: '/work', provider: {} as never, ...extra } as IRenderOptions;
10
+ }
11
+
12
+ describe('TUI preset capabilities reach the session', () => {
13
+ it('preserves generation, prompt-seed, and response-format fields', () => {
14
+ const responseFormat = { type: 'json_object' as const };
15
+ const render = renderOptions({
16
+ temperature: 0.37,
17
+ maxOutputTokens: 481,
18
+ presetSystemPrompt: 'Preset seed',
19
+ appendSystemPrompt: 'CLI addition',
20
+ responseFormat,
21
+ });
22
+
23
+ const session = buildTuiSessionOptions(toChannelOptions(render));
24
+
25
+ expect(session).toMatchObject({
26
+ temperature: 0.37,
27
+ maxOutputTokens: 481,
28
+ presetSystemPrompt: 'Preset seed',
29
+ appendSystemPrompt: 'CLI addition',
30
+ responseFormat,
31
+ });
32
+ });
33
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * ERR-001 G2/G3 — TUI error-state reducer: last-error message lifecycle and the
3
+ * dead-air stall hint timer.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+
8
+ import { TuiStateManager } from '../tui-state-manager';
9
+
10
+ describe('TuiStateManager — error state (ERR-001 G2)', () => {
11
+ it('records the error message on onError and clears it when the next turn starts', () => {
12
+ const manager = new TuiStateManager();
13
+
14
+ manager.onError(new Error('connection reset'));
15
+ expect(manager.lastErrorMessage).toBe('connection reset');
16
+ expect(manager.isThinking).toBe(false);
17
+
18
+ manager.onThinking(true);
19
+ expect(manager.lastErrorMessage).toBeNull();
20
+ });
21
+
22
+ it('falls back to a generic message when no Error object arrives', () => {
23
+ const manager = new TuiStateManager();
24
+ manager.onError();
25
+ expect(manager.lastErrorMessage).toBe('Unknown error');
26
+ });
27
+ });
28
+
29
+ describe('TuiStateManager — dead-air stall hint (ERR-001 G3)', () => {
30
+ beforeEach(() => {
31
+ vi.useFakeTimers();
32
+ });
33
+ afterEach(() => {
34
+ vi.useRealTimers();
35
+ });
36
+
37
+ it('flags isStalled after 15s of thinking with no provider activity', () => {
38
+ const manager = new TuiStateManager();
39
+ manager.onThinking(true);
40
+ expect(manager.isStalled).toBe(false);
41
+
42
+ vi.advanceTimersByTime(15_000);
43
+ expect(manager.isStalled).toBe(true);
44
+ });
45
+
46
+ it('provider activity (text delta) resets the timer — no stall flag', () => {
47
+ const manager = new TuiStateManager();
48
+ manager.onThinking(true);
49
+
50
+ vi.advanceTimersByTime(10_000);
51
+ manager.onTextDelta('still alive');
52
+ vi.advanceTimersByTime(10_000);
53
+ expect(manager.isStalled).toBe(false);
54
+
55
+ vi.advanceTimersByTime(5_000);
56
+ expect(manager.isStalled).toBe(true);
57
+ });
58
+
59
+ it('clears the stall flag and timer when the turn ends (thinking false / error)', () => {
60
+ const manager = new TuiStateManager();
61
+ manager.onThinking(true);
62
+ vi.advanceTimersByTime(15_000);
63
+ expect(manager.isStalled).toBe(true);
64
+
65
+ manager.onThinking(false);
66
+ expect(manager.isStalled).toBe(false);
67
+
68
+ manager.onThinking(true);
69
+ manager.onError(new Error('boom'));
70
+ vi.advanceTimersByTime(20_000);
71
+ expect(manager.isStalled).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe('TuiStateManager — stall hint suppressed during tool execution (RUNTIME-39)', () => {
76
+ beforeEach(() => {
77
+ vi.useFakeTimers();
78
+ });
79
+ afterEach(() => {
80
+ vi.useRealTimers();
81
+ });
82
+
83
+ const runningTool = { toolName: 'bash', firstArg: 'sleep 60', isRunning: true };
84
+ const endedTool = {
85
+ toolName: 'bash',
86
+ firstArg: 'sleep 60',
87
+ isRunning: false,
88
+ result: 'success' as const,
89
+ };
90
+
91
+ it('a running tool suppresses the dead-air hint — no stall while a tool executes', () => {
92
+ const manager = new TuiStateManager();
93
+ manager.onThinking(true); // arms the stall timer
94
+ vi.advanceTimersByTime(10_000);
95
+ manager.onToolStart(runningTool); // a tool is now legitimately busy → suppress
96
+ vi.advanceTimersByTime(60_000); // long tool, no provider deltas
97
+ expect(manager.isStalled).toBe(false);
98
+ });
99
+
100
+ it('re-arms the hint when the last running tool ends and the turn is still thinking', () => {
101
+ const manager = new TuiStateManager();
102
+ manager.onThinking(true);
103
+ manager.onToolStart(runningTool);
104
+ vi.advanceTimersByTime(60_000);
105
+ expect(manager.isStalled).toBe(false);
106
+
107
+ manager.onToolEnd(endedTool); // no more running tools → re-arm dead-air watch
108
+ vi.advanceTimersByTime(15_000);
109
+ expect(manager.isStalled).toBe(true);
110
+ });
111
+
112
+ it('does not re-arm after tool end once the turn is no longer thinking', () => {
113
+ const manager = new TuiStateManager();
114
+ manager.onThinking(true);
115
+ manager.onToolStart(runningTool);
116
+ manager.onThinking(false); // turn ended
117
+ manager.onToolEnd(endedTool);
118
+ vi.advanceTimersByTime(20_000);
119
+ expect(manager.isStalled).toBe(false);
120
+ });
121
+
122
+ it('keeps the hint suppressed while ANY tool is still running (concurrent tools)', () => {
123
+ const manager = new TuiStateManager();
124
+ manager.onThinking(true);
125
+ manager.onToolStart({ toolName: 'bash', firstArg: 'a', isRunning: true });
126
+ manager.onToolStart({ toolName: 'bash', firstArg: 'b', isRunning: true });
127
+ // End one of the two — one tool is still running, so the hint stays suppressed.
128
+ manager.onToolEnd({ toolName: 'bash', firstArg: 'b', isRunning: false, result: 'success' });
129
+ vi.advanceTimersByTime(60_000);
130
+ expect(manager.isStalled).toBe(false);
131
+ });
132
+ });
133
+
134
+ describe('TuiStateManager — dispose (RUNTIME-52)', () => {
135
+ beforeEach(() => {
136
+ vi.useFakeTimers();
137
+ });
138
+ afterEach(() => {
139
+ vi.useRealTimers();
140
+ });
141
+
142
+ it('clears the stall timer and nulls onChange so nothing fires after dispose', () => {
143
+ const manager = new TuiStateManager();
144
+ const onChange = vi.fn();
145
+ manager.onChange = onChange;
146
+ manager.onThinking(true); // arms the stall timer
147
+ onChange.mockClear();
148
+
149
+ manager.dispose();
150
+ vi.advanceTimersByTime(30_000);
151
+
152
+ expect(manager.isStalled).toBe(false);
153
+ expect(onChange).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it('flushes the pending streaming-debounce timer on dispose', () => {
157
+ const manager = new TuiStateManager();
158
+ const onChange = vi.fn();
159
+ manager.onChange = onChange;
160
+ manager.isThinking = true;
161
+ manager.onTextDelta('partial'); // schedules the debounced notify
162
+ onChange.mockClear();
163
+
164
+ manager.dispose();
165
+ vi.advanceTimersByTime(1_000);
166
+
167
+ expect(onChange).not.toHaveBeenCalled();
168
+ });
169
+ });