@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.
- package/LICENSE +661 -0
- package/README.md +27 -0
- package/dist/node/index.cjs +32 -0
- package/dist/node/index.d.cts +1066 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1066 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +33 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +93 -0
- package/src/App.tsx +146 -0
- package/src/AppPresentation.tsx +190 -0
- package/src/AppView.tsx +26 -0
- package/src/BackgroundTaskPanel.tsx +73 -0
- package/src/CjkTextInput.tsx +336 -0
- package/src/ConfirmPrompt.tsx +140 -0
- package/src/ContextWarningBanner.tsx +45 -0
- package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
- package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
- package/src/HistorySearchOverlay.tsx +150 -0
- package/src/InputArea.tsx +338 -0
- package/src/ListPicker.tsx +158 -0
- package/src/MenuSelect.tsx +159 -0
- package/src/MessageList.tsx +308 -0
- package/src/MultiSelectList.tsx +294 -0
- package/src/PendingActionPrompt.tsx +121 -0
- package/src/PermissionPrompt.tsx +186 -0
- package/src/PluginTUI.tsx +258 -0
- package/src/RoleLabel.tsx +72 -0
- package/src/SafeText.tsx +66 -0
- package/src/SessionEventNotices.tsx +27 -0
- package/src/SessionPicker.tsx +72 -0
- package/src/SessionStatusBar.tsx +76 -0
- package/src/SlashAutocomplete.tsx +154 -0
- package/src/StatusBar.tsx +284 -0
- package/src/StreamingIndicator.tsx +145 -0
- package/src/SupervisedSessionView.tsx +455 -0
- package/src/TextPrompt.tsx +112 -0
- package/src/ThemePicker.tsx +366 -0
- package/src/ToolCommandOutput.tsx +48 -0
- package/src/ToolDiffBlock.tsx +44 -0
- package/src/TransportTUI.tsx +166 -0
- package/src/TuiInteractionChannel.ts +567 -0
- package/src/UpdateNotice.tsx +18 -0
- package/src/UsageSummaryEntry.tsx +49 -0
- package/src/WaveText.tsx +70 -0
- package/src/__tests__/ListPicker.test.tsx +188 -0
- package/src/__tests__/MenuSelect.test.tsx +127 -0
- package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
- package/src/__tests__/PluginTUI.test.tsx +167 -0
- package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
- package/src/__tests__/SupervisedSessionView-render.test.tsx +24 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +629 -0
- package/src/__tests__/TextPrompt.test.tsx +112 -0
- package/src/__tests__/TransportTUI.test.tsx +124 -0
- package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +284 -0
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +642 -0
- package/src/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/__tests__/abort-after-permission.test.tsx +160 -0
- package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/__tests__/attention-bell-and-marks.test.ts +144 -0
- package/src/__tests__/autocomplete-editing-keys.test.tsx +127 -0
- package/src/__tests__/background-task-panel-countdown.test.tsx +75 -0
- package/src/__tests__/background-task-panel.test.tsx +98 -0
- package/src/__tests__/background-task-row-format.test.ts +152 -0
- package/src/__tests__/channel-factory-integration.test.ts +150 -0
- package/src/__tests__/cjk-defer-submit.test.tsx +67 -0
- package/src/__tests__/cjk-fallback-render.test.tsx +78 -0
- package/src/__tests__/cjk-text-input-flow.test.ts +186 -0
- package/src/__tests__/cjk-text-input.test.ts +191 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
- package/src/__tests__/command-output-summary.test.ts +95 -0
- package/src/__tests__/command-result-handler.test.ts +171 -0
- package/src/__tests__/compact-event-bridge.test.ts +20 -0
- package/src/__tests__/confirm-permission-flow.test.ts +149 -0
- package/src/__tests__/confirm-prompt.test.tsx +103 -0
- package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +45 -0
- package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
- package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
- package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
- package/src/__tests__/external-prompt-notice.test.tsx +100 -0
- package/src/__tests__/fixtures/command-handoff-driver.tsx +111 -0
- package/src/__tests__/fixtures/fake-editor.sh +7 -0
- package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
- package/src/__tests__/fork-attach.test.tsx +235 -0
- package/src/__tests__/helpers/terminal-profiles.ts +152 -0
- package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
- package/src/__tests__/history-clear-broadcast.test.ts +66 -0
- package/src/__tests__/history-search-overlay.test.tsx +329 -0
- package/src/__tests__/humanize-tool-name.test.ts +30 -0
- package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
- package/src/__tests__/input-area-flow.test.ts +199 -0
- package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
- package/src/__tests__/interaction-channel-charter.test.ts +13 -0
- package/src/__tests__/key-hint-consistency.test.tsx +249 -0
- package/src/__tests__/key-hint-footer.test.tsx +69 -0
- package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
- package/src/__tests__/message-list-rendering.test.tsx +473 -0
- package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
- package/src/__tests__/output-style-reaches-session.test.ts +35 -0
- package/src/__tests__/palette-consistency.test.ts +330 -0
- package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
- package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
- package/src/__tests__/product-display-name.test.tsx +79 -0
- package/src/__tests__/prompt-queue.test.tsx +260 -0
- package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
- package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
- package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
- package/src/__tests__/pty/behavior-2437-git.ptytest.ts +342 -0
- package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
- package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
- package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
- package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
- package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
- package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +291 -0
- package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +165 -0
- package/src/__tests__/pty/ime-cursor.ptytest.ts +186 -0
- package/src/__tests__/pty/isolated-home.ts +64 -0
- package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
- package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
- package/src/__tests__/pty/pty-driver.ts +153 -0
- package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +134 -0
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +138 -0
- package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
- package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
- package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
- package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
- package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
- package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
- package/src/__tests__/pty/spawn-pty.test.ts +120 -0
- package/src/__tests__/pty/spawn-pty.ts +229 -0
- package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
- package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
- package/src/__tests__/pty-driver-dispose.test.ts +43 -0
- package/src/__tests__/real-cursor-positioning.test.tsx +263 -0
- package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
- package/src/__tests__/render-channel-options.test.ts +201 -0
- package/src/__tests__/render-lifecycle.test.ts +36 -0
- package/src/__tests__/render-markdown.test.ts +164 -0
- package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
- package/src/__tests__/safe-text-boundary.test.tsx +75 -0
- package/src/__tests__/screen-reader-input.test.tsx +106 -0
- package/src/__tests__/screen-reader-labels.test.ts +91 -0
- package/src/__tests__/screen-reader-menus.test.tsx +200 -0
- package/src/__tests__/screen-reader-pacing.test.ts +230 -0
- package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
- package/src/__tests__/screen-reader-render-options.test.ts +262 -0
- package/src/__tests__/screen-reader-stdout.test.ts +322 -0
- package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
- package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
- package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
- package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
- package/src/__tests__/selection-flow.test.ts +61 -0
- package/src/__tests__/session-event-notices.test.tsx +31 -0
- package/src/__tests__/session-init-poller.test.ts +102 -0
- package/src/__tests__/session-switch-channel.test.tsx +470 -0
- package/src/__tests__/shutdown-signal.test.ts +34 -0
- package/src/__tests__/status-activity.test.ts +71 -0
- package/src/__tests__/status-bar.test.tsx +282 -0
- package/src/__tests__/status-glyph.test.ts +30 -0
- package/src/__tests__/streaming-indicator.test.tsx +177 -0
- package/src/__tests__/terminal-capabilities.test.ts +153 -0
- package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
- package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
- package/src/__tests__/text-prompt-flow.test.ts +77 -0
- package/src/__tests__/theme-picker.test.tsx +345 -0
- package/src/__tests__/transport-boundary.test.ts +11 -0
- package/src/__tests__/tui-app-channel-port.test.ts +31 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +62 -0
- package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
- package/src/__tests__/tui-interaction-queues.test.ts +139 -0
- package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
- package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
- package/src/__tests__/tui-state-manager.test.ts +451 -0
- package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
- package/src/__tests__/wave-text.test.tsx +133 -0
- package/src/app-banner.tsx +42 -0
- package/src/app-static-items.ts +35 -0
- package/src/app-view-model.ts +136 -0
- package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
- package/src/attention/__tests__/attention-tracker.test.ts +103 -0
- package/src/attention/__tests__/countdown.test.ts +15 -0
- package/src/attention/__tests__/focus-input-filter.test.ts +92 -0
- package/src/attention/__tests__/interval-recap.test.ts +49 -0
- package/src/attention/attention-coordinator.ts +71 -0
- package/src/attention/attention-tracker.ts +124 -0
- package/src/attention/countdown.ts +24 -0
- package/src/attention/focus-input-filter.ts +131 -0
- package/src/attention/interval-recap.ts +148 -0
- package/src/attention/time-units.ts +6 -0
- package/src/attention-bell.ts +79 -0
- package/src/attributed-user-echo.ts +30 -0
- package/src/background-task-row-format.ts +99 -0
- package/src/command-interaction.ts +8 -0
- package/src/command-output-summary.ts +129 -0
- package/src/create-default-tui-cli-adapter.ts +45 -0
- package/src/execution-workspace-switcher-selection.ts +118 -0
- package/src/execution-workspace-view-model.ts +139 -0
- package/src/external-prompt-notice.tsx +38 -0
- package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
- package/src/flows/__tests__/defer-submit.test.ts +87 -0
- package/src/flows/__tests__/real-cursor-flow.test.ts +124 -0
- package/src/flows/background-focus-flow.ts +44 -0
- package/src/flows/cjk-cursor-motion.ts +75 -0
- package/src/flows/cjk-text-deletion.ts +53 -0
- package/src/flows/cjk-text-input-flow.ts +248 -0
- package/src/flows/cjk-text-input-types.ts +24 -0
- package/src/flows/confirm-prompt-flow.ts +90 -0
- package/src/flows/defer-submit.ts +67 -0
- package/src/flows/fork-attach-flow.ts +62 -0
- package/src/flows/input-area-flow.ts +188 -0
- package/src/flows/permission-prompt-flow.ts +108 -0
- package/src/flows/real-cursor-flow.ts +83 -0
- package/src/flows/selection-flow.ts +205 -0
- package/src/flows/session-init-poller.ts +76 -0
- package/src/flows/text-prompt-flow.ts +93 -0
- package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
- package/src/history-search/history-search-flow.ts +94 -0
- package/src/history-search/useHistorySearch.ts +336 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +74 -0
- package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
- package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
- package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
- package/src/hooks/command-result-handler.ts +44 -0
- package/src/hooks/side-effects-types.ts +45 -0
- package/src/hooks/useAppController.ts +267 -0
- package/src/hooks/useAppInputBindings.ts +145 -0
- package/src/hooks/useAppInteractionState.ts +81 -0
- package/src/hooks/useAppLifecycleState.ts +81 -0
- package/src/hooks/useAppOverlays.ts +74 -0
- package/src/hooks/useAppScreenState.ts +81 -0
- package/src/hooks/useAppSubmissionState.ts +45 -0
- package/src/hooks/useAppThemeState.ts +157 -0
- package/src/hooks/useAppWorkspaceState.ts +149 -0
- package/src/hooks/useAppearanceSettings.ts +30 -0
- package/src/hooks/useAutocomplete.ts +101 -0
- package/src/hooks/useBackgroundPanel.ts +58 -0
- package/src/hooks/useCountdownTick.ts +24 -0
- package/src/hooks/useExecutionDetailPage.ts +69 -0
- package/src/hooks/useForkAttach.ts +60 -0
- package/src/hooks/useInputAreaHistorySearch.ts +72 -0
- package/src/hooks/useInputAreaKeys.ts +114 -0
- package/src/hooks/useNumberedSelection.ts +76 -0
- package/src/hooks/usePluginCallbacks.ts +38 -0
- package/src/hooks/usePluginScreenData.ts +85 -0
- package/src/hooks/useRealCursorPosition.ts +125 -0
- package/src/hooks/useRenderMarkdown.ts +38 -0
- package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
- package/src/hooks/useSideEffects.ts +144 -0
- package/src/hooks/useStatusLineSettings.ts +50 -0
- package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
- package/src/hooks/useTuiChannel.ts +129 -0
- package/src/humanize-tool-name.ts +51 -0
- package/src/index.ts +56 -0
- package/src/input-area-rules.tsx +71 -0
- package/src/json-value.ts +18 -0
- package/src/key-hint-footer.tsx +54 -0
- package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
- package/src/keybindings/__tests__/node-keybindings-source.test.ts +111 -0
- package/src/keybindings/keybinding-catalogue.ts +171 -0
- package/src/keybindings/keybinding-registry.ts +303 -0
- package/src/keybindings/keybinding-syntax.ts +155 -0
- package/src/keybindings/keybindings-context.tsx +124 -0
- package/src/keybindings/node-keybindings-source.ts +189 -0
- package/src/numbered-list.tsx +113 -0
- package/src/plugin-tui-handlers.ts +163 -0
- package/src/product-display-name-context.tsx +30 -0
- package/src/render-markdown.ts +209 -0
- package/src/render.tsx +544 -0
- package/src/sanitize-terminal-text.ts +171 -0
- package/src/screen-reader-announcement.ts +52 -0
- package/src/screen-reader-context.tsx +32 -0
- package/src/screen-reader-labels.ts +64 -0
- package/src/screen-reader-pacing-context.tsx +47 -0
- package/src/screen-reader-pacing.ts +178 -0
- package/src/screen-reader-stdout.ts +328 -0
- package/src/shutdown-signal.ts +34 -0
- package/src/status-activity.ts +70 -0
- package/src/status-glyph.ts +78 -0
- package/src/terminal-capabilities-context.tsx +27 -0
- package/src/terminal-capabilities.ts +77 -0
- package/src/terminal-focus-reporting.ts +50 -0
- package/src/terminal-handoff-controller.ts +98 -0
- package/src/terminal-marks.ts +63 -0
- package/src/theme/__tests__/built-in-themes.test.ts +117 -0
- package/src/theme/__tests__/color-vision.test.ts +82 -0
- package/src/theme/__tests__/theme-context.test.tsx +146 -0
- package/src/theme/__tests__/theme-document.test.ts +222 -0
- package/src/theme/__tests__/theme-registry.test.ts +138 -0
- package/src/theme/__tests__/theme-styles.test.ts +233 -0
- package/src/theme/built-in-themes.ts +342 -0
- package/src/theme/color-vision.ts +171 -0
- package/src/theme/index.ts +45 -0
- package/src/theme/theme-context.tsx +81 -0
- package/src/theme/theme-contracts.ts +139 -0
- package/src/theme/theme-document.ts +278 -0
- package/src/theme/theme-registry.ts +127 -0
- package/src/theme/theme-styles.ts +177 -0
- package/src/tool-summary-status.ts +59 -0
- package/src/tui-app-channel-port.ts +83 -0
- package/src/tui-channel-lifecycle-coordinator.ts +149 -0
- package/src/tui-channel-options.ts +172 -0
- package/src/tui-cli-adapter-context.tsx +13 -0
- package/src/tui-cli-adapter.ts +24 -0
- package/src/tui-interaction-queues.ts +163 -0
- package/src/tui-session-binding.ts +52 -0
- package/src/tui-session-event-projector.ts +140 -0
- package/src/tui-session-events.ts +82 -0
- package/src/tui-session-notice-store.ts +43 -0
- package/src/tui-session-options.ts +118 -0
- package/src/tui-state-manager.ts +327 -0
- package/src/types.ts +19 -0
- package/src/use-terminal-title.ts +29 -0
- package/src/utils/__tests__/edit-diff.test.ts +426 -0
- package/src/utils/__tests__/paste-detection.test.ts +116 -0
- package/src/utils/__tests__/paste-labels.test.ts +46 -0
- package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
- package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
- package/src/utils/edit-diff.ts +151 -0
- package/src/utils/input-top-border.ts +19 -0
- package/src/utils/paste-labels.ts +9 -0
- package/src/utils/tool-call-extractor.ts +92 -0
- package/src/utils/tool-diff-summary.ts +75 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for TuiInteractionChannel lifecycle:
|
|
3
|
+
* session event wiring, handleInput roundtrip, onChange propagation.
|
|
4
|
+
*
|
|
5
|
+
* No Ink rendering, no PTY — pure TypeScript.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
8
|
+
|
|
9
|
+
vi.mock('@robota-sdk/agent-framework', async () => {
|
|
10
|
+
const actual = await vi.importActual<typeof import('@robota-sdk/agent-framework')>(
|
|
11
|
+
'@robota-sdk/agent-framework',
|
|
12
|
+
);
|
|
13
|
+
// RUNTIME-001: construction now flows through `buildRuntimeSession` (which wraps InteractiveSession inside
|
|
14
|
+
// agent-framework), so mocking `InteractiveSession` alone no longer intercepts it — mock both with one factory.
|
|
15
|
+
const makeMockSession = (): unknown => {
|
|
16
|
+
{
|
|
17
|
+
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
|
18
|
+
return {
|
|
19
|
+
getFullHistory: vi.fn().mockReturnValue([]),
|
|
20
|
+
setName: vi.fn(),
|
|
21
|
+
getName: vi.fn().mockReturnValue(undefined),
|
|
22
|
+
getPermissionMode: vi.fn().mockReturnValue('default'),
|
|
23
|
+
isInitialized: false,
|
|
24
|
+
on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
|
|
25
|
+
if (!handlers.has(event)) handlers.set(event, []);
|
|
26
|
+
handlers.get(event)!.push(handler);
|
|
27
|
+
}),
|
|
28
|
+
off: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
|
|
29
|
+
const arr = handlers.get(event);
|
|
30
|
+
if (!arr) return;
|
|
31
|
+
const i = arr.indexOf(handler);
|
|
32
|
+
if (i !== -1) arr.splice(i, 1);
|
|
33
|
+
}),
|
|
34
|
+
emit: (event: string, ...args: unknown[]) => {
|
|
35
|
+
(handlers.get(event) ?? []).forEach((h) => h(...args));
|
|
36
|
+
},
|
|
37
|
+
_listenerCount: (): number =>
|
|
38
|
+
[...handlers.values()].reduce((total, arr) => total + arr.length, 0),
|
|
39
|
+
submit: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
executeCommand: vi.fn().mockResolvedValue(null),
|
|
41
|
+
getPendingPrompt: vi.fn().mockReturnValue(null),
|
|
42
|
+
abort: vi.fn(),
|
|
43
|
+
cancelQueue: vi.fn(),
|
|
44
|
+
resolvePermission: vi.fn(),
|
|
45
|
+
resolveAsk: vi.fn(),
|
|
46
|
+
getContextState: vi.fn().mockReturnValue({
|
|
47
|
+
usedPercentage: 0,
|
|
48
|
+
usedTokens: 0,
|
|
49
|
+
maxTokens: 100_000,
|
|
50
|
+
}),
|
|
51
|
+
getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
|
|
52
|
+
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
53
|
+
sendAgentJob: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
readExecutionWorkspaceDetail: vi.fn().mockResolvedValue({}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
...actual,
|
|
60
|
+
InteractiveSession: vi.fn().mockImplementation(makeMockSession),
|
|
61
|
+
buildRuntimeSession: vi.fn().mockImplementation(makeMockSession),
|
|
62
|
+
CommandRegistry: vi.fn().mockImplementation(() => ({
|
|
63
|
+
addModule: vi.fn(),
|
|
64
|
+
})),
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
import { TuiInteractionChannel } from '../TuiInteractionChannel.js';
|
|
69
|
+
import { TUI_SESSION_EVENT_CLASSIFICATION } from '../tui-session-events.js';
|
|
70
|
+
|
|
71
|
+
import type { IAIProvider } from '@robota-sdk/agent-core';
|
|
72
|
+
import type { ITransportRegistryView } from '@robota-sdk/agent-interface-transport';
|
|
73
|
+
import type {
|
|
74
|
+
IExecutionResult,
|
|
75
|
+
IInteractiveSession,
|
|
76
|
+
TInteractiveEventName,
|
|
77
|
+
} from '@robota-sdk/agent-interface-session';
|
|
78
|
+
|
|
79
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
type MockSession = {
|
|
82
|
+
getFullHistory: ReturnType<typeof vi.fn>;
|
|
83
|
+
submit: ReturnType<typeof vi.fn>;
|
|
84
|
+
executeCommand: ReturnType<typeof vi.fn>;
|
|
85
|
+
on: ReturnType<typeof vi.fn>;
|
|
86
|
+
off: ReturnType<typeof vi.fn>;
|
|
87
|
+
shutdown: ReturnType<typeof vi.fn>;
|
|
88
|
+
resolvePermission: ReturnType<typeof vi.fn>;
|
|
89
|
+
resolveAsk: ReturnType<typeof vi.fn>;
|
|
90
|
+
emit: (event: string, ...args: unknown[]) => void;
|
|
91
|
+
_listenerCount: () => number;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function getMockSession(channel: TuiInteractionChannel): MockSession {
|
|
95
|
+
return (channel as unknown as { interactiveSession: MockSession }).interactiveSession;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function emitSessionEvent(channel: TuiInteractionChannel, event: string, ...args: unknown[]): void {
|
|
99
|
+
getMockSession(channel).emit(event, ...args);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Drive the channel's private permission handler (what the session's permissionHandler invokes). */
|
|
103
|
+
function requestPermission(
|
|
104
|
+
channel: TuiInteractionChannel,
|
|
105
|
+
toolName: string,
|
|
106
|
+
toolArgs: unknown,
|
|
107
|
+
): Promise<unknown> {
|
|
108
|
+
return (
|
|
109
|
+
channel as unknown as {
|
|
110
|
+
handlePermissionRequest: (n: string, a: unknown) => Promise<unknown>;
|
|
111
|
+
}
|
|
112
|
+
).handlePermissionRequest(toolName, toolArgs);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function makeMockTransportRegistry(): {
|
|
116
|
+
registry: ITransportRegistryView;
|
|
117
|
+
startAll: ReturnType<typeof vi.fn>;
|
|
118
|
+
stopAll: ReturnType<typeof vi.fn>;
|
|
119
|
+
} {
|
|
120
|
+
const startAll = vi.fn().mockResolvedValue(undefined);
|
|
121
|
+
const stopAll = vi.fn().mockResolvedValue({ errors: [] });
|
|
122
|
+
return {
|
|
123
|
+
registry: { startAll, stopAll } as unknown as ITransportRegistryView,
|
|
124
|
+
startAll,
|
|
125
|
+
stopAll,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function makeChannel(opts?: {
|
|
130
|
+
transportRegistry?: ITransportRegistryView;
|
|
131
|
+
bindTransports?: (session: IInteractiveSession) => void | Promise<void>;
|
|
132
|
+
onSessionEventDeliveryError?: (error: Error, event: TInteractiveEventName) => void;
|
|
133
|
+
}): TuiInteractionChannel {
|
|
134
|
+
return new TuiInteractionChannel({
|
|
135
|
+
cwd: '/tmp/test',
|
|
136
|
+
provider: {} as IAIProvider,
|
|
137
|
+
...opts,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const MOCK_RESULT = {
|
|
142
|
+
contextState: { usedPercentage: 10, usedTokens: 1_000, maxTokens: 100_000 },
|
|
143
|
+
response: 'Hello!',
|
|
144
|
+
} as unknown as IExecutionResult;
|
|
145
|
+
|
|
146
|
+
const MOCK_TOOL = {
|
|
147
|
+
toolName: 'bash',
|
|
148
|
+
isRunning: true,
|
|
149
|
+
input: '{}',
|
|
150
|
+
startTime: Date.now(),
|
|
151
|
+
} as unknown as Parameters<
|
|
152
|
+
InstanceType<typeof TuiInteractionChannel>['stateManager']['onToolStart']
|
|
153
|
+
>[0];
|
|
154
|
+
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
vi.useFakeTimers();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
afterEach(() => {
|
|
160
|
+
vi.useRealTimers();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── Group A: channel.start() / channel.stop() lifecycle ───────────────────────
|
|
164
|
+
|
|
165
|
+
describe('Group A — channel.start() / channel.stop() lifecycle', () => {
|
|
166
|
+
it('A1: text_delta after start() updates stateManager.streamingText', async () => {
|
|
167
|
+
const channel = makeChannel();
|
|
168
|
+
await channel.start();
|
|
169
|
+
|
|
170
|
+
emitSessionEvent(channel, 'text_delta', 'Hello!');
|
|
171
|
+
|
|
172
|
+
expect(channel.stateManager.streamingText).toBe('Hello!');
|
|
173
|
+
await channel.stop();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('A2: complete after start() clears streaming state and updates contextState', async () => {
|
|
177
|
+
const channel = makeChannel();
|
|
178
|
+
await channel.start();
|
|
179
|
+
|
|
180
|
+
emitSessionEvent(channel, 'text_delta', 'streaming...');
|
|
181
|
+
emitSessionEvent(channel, 'complete', MOCK_RESULT);
|
|
182
|
+
|
|
183
|
+
expect(channel.stateManager.streamingText).toBe('');
|
|
184
|
+
expect(channel.stateManager.contextState.percentage).toBe(10);
|
|
185
|
+
expect(channel.stateManager.contextState.usedTokens).toBe(1_000);
|
|
186
|
+
await channel.stop();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('A3: tool_start after start() adds entry to stateManager.activeTools', async () => {
|
|
190
|
+
const channel = makeChannel();
|
|
191
|
+
await channel.start();
|
|
192
|
+
|
|
193
|
+
emitSessionEvent(channel, 'tool_start', MOCK_TOOL);
|
|
194
|
+
|
|
195
|
+
expect(channel.stateManager.activeTools).toHaveLength(1);
|
|
196
|
+
expect(channel.stateManager.activeTools[0]).toMatchObject({ toolName: 'bash' });
|
|
197
|
+
await channel.stop();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('A4: error after start() clears stateManager.streamingText', async () => {
|
|
201
|
+
const channel = makeChannel();
|
|
202
|
+
await channel.start();
|
|
203
|
+
|
|
204
|
+
emitSessionEvent(channel, 'text_delta', 'partial...');
|
|
205
|
+
emitSessionEvent(channel, 'error');
|
|
206
|
+
|
|
207
|
+
expect(channel.stateManager.streamingText).toBe('');
|
|
208
|
+
await channel.stop();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('A5: calling start() twice does not duplicate subscriptions', async () => {
|
|
212
|
+
const channel = makeChannel();
|
|
213
|
+
await channel.start();
|
|
214
|
+
await channel.start(); // second call is a no-op (sessionStarted guard)
|
|
215
|
+
|
|
216
|
+
emitSessionEvent(channel, 'text_delta', 'hi');
|
|
217
|
+
|
|
218
|
+
expect(channel.stateManager.streamingText).toBe('hi'); // not 'hihi'
|
|
219
|
+
await channel.stop();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('A6: stop() calls transportRegistry.stopAll exactly once', async () => {
|
|
223
|
+
const { registry, stopAll } = makeMockTransportRegistry();
|
|
224
|
+
const channel = makeChannel({ transportRegistry: registry });
|
|
225
|
+
await channel.start();
|
|
226
|
+
await channel.stop();
|
|
227
|
+
|
|
228
|
+
expect(stopAll).toHaveBeenCalledOnce();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('binds each replacement channel to its own session before transport startup', async () => {
|
|
232
|
+
const { registry, startAll } = makeMockTransportRegistry();
|
|
233
|
+
const bindTransports = vi.fn();
|
|
234
|
+
const first = makeChannel({ transportRegistry: registry, bindTransports });
|
|
235
|
+
await first.start();
|
|
236
|
+
await first.stop();
|
|
237
|
+
const second = makeChannel({ transportRegistry: registry, bindTransports });
|
|
238
|
+
await second.start();
|
|
239
|
+
|
|
240
|
+
expect(bindTransports).toHaveBeenNthCalledWith(1, getMockSession(first));
|
|
241
|
+
expect(bindTransports).toHaveBeenNthCalledWith(2, getMockSession(second));
|
|
242
|
+
expect(getMockSession(first)).not.toBe(getMockSession(second));
|
|
243
|
+
expect(startAll).toHaveBeenCalledTimes(2);
|
|
244
|
+
expect(startAll).toHaveBeenNthCalledWith(1);
|
|
245
|
+
expect(startAll).toHaveBeenNthCalledWith(2);
|
|
246
|
+
await second.stop();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('waits for asynchronous source binding before starting transports', async () => {
|
|
250
|
+
const { registry, startAll } = makeMockTransportRegistry();
|
|
251
|
+
let release: (() => void) | undefined;
|
|
252
|
+
const pending = new Promise<void>((resolve) => { release = resolve; });
|
|
253
|
+
const channel = makeChannel({ transportRegistry: registry, bindTransports: () => pending });
|
|
254
|
+
const starting = channel.start();
|
|
255
|
+
await Promise.resolve();
|
|
256
|
+
expect(startAll).not.toHaveBeenCalled();
|
|
257
|
+
release?.();
|
|
258
|
+
await starting;
|
|
259
|
+
expect(startAll).toHaveBeenCalledOnce();
|
|
260
|
+
await channel.stop();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('A7: rolls back a failed transport start before retrying the real channel', async () => {
|
|
264
|
+
const { registry, startAll, stopAll } = makeMockTransportRegistry();
|
|
265
|
+
startAll.mockRejectedValueOnce(new Error('transport start failed'));
|
|
266
|
+
const channel = makeChannel({ transportRegistry: registry });
|
|
267
|
+
const session = getMockSession(channel);
|
|
268
|
+
|
|
269
|
+
await expect(channel.start()).rejects.toThrow('transport start failed');
|
|
270
|
+
expect(session._listenerCount()).toBe(0);
|
|
271
|
+
expect(stopAll).toHaveBeenCalledTimes(1);
|
|
272
|
+
|
|
273
|
+
await channel.start();
|
|
274
|
+
expect(startAll).toHaveBeenCalledTimes(2);
|
|
275
|
+
expect(session._listenerCount()).toBeGreaterThan(0);
|
|
276
|
+
|
|
277
|
+
await channel.stop();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('A8: treats returned transport stop errors as retryable teardown failures', async () => {
|
|
281
|
+
const { registry, stopAll } = makeMockTransportRegistry();
|
|
282
|
+
stopAll.mockResolvedValueOnce({ errors: [new Error('transport stop failed')] });
|
|
283
|
+
const channel = makeChannel({ transportRegistry: registry });
|
|
284
|
+
await channel.start();
|
|
285
|
+
|
|
286
|
+
await expect(channel.stop()).rejects.toThrow('TUI transport teardown failed.');
|
|
287
|
+
await channel.stop();
|
|
288
|
+
|
|
289
|
+
expect(stopAll).toHaveBeenCalledTimes(2);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('A9: unwires a partially connected event projector before start retry', async () => {
|
|
293
|
+
const channel = makeChannel();
|
|
294
|
+
const session = getMockSession(channel);
|
|
295
|
+
const on = session.on.getMockImplementation();
|
|
296
|
+
if (!on) throw new Error('session.on test implementation is missing');
|
|
297
|
+
session.on.mockImplementationOnce(on).mockImplementationOnce(() => {
|
|
298
|
+
throw new Error('listener registration failed');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await expect(channel.start()).rejects.toThrow('listener registration failed');
|
|
302
|
+
expect(session._listenerCount()).toBe(0);
|
|
303
|
+
|
|
304
|
+
await channel.start();
|
|
305
|
+
expect(session._listenerCount()).toBeGreaterThan(0);
|
|
306
|
+
await channel.stop();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('A10: reports both startup and returned rollback errors', async () => {
|
|
310
|
+
const { registry, startAll, stopAll } = makeMockTransportRegistry();
|
|
311
|
+
startAll.mockRejectedValueOnce(new Error('transport start failed'));
|
|
312
|
+
stopAll.mockResolvedValueOnce({ errors: [new Error('transport rollback failed')] });
|
|
313
|
+
const channel = makeChannel({ transportRegistry: registry });
|
|
314
|
+
|
|
315
|
+
const failure = await channel.start().catch((error: unknown) => error);
|
|
316
|
+
|
|
317
|
+
expect(failure).toBeInstanceOf(AggregateError);
|
|
318
|
+
expect((failure as AggregateError).errors).toHaveLength(2);
|
|
319
|
+
expect(String((failure as AggregateError).errors[1])).toContain('transport rollback failed');
|
|
320
|
+
await expect(channel.start()).rejects.toThrow(
|
|
321
|
+
'Cannot start a channel after teardown has begun.',
|
|
322
|
+
);
|
|
323
|
+
expect(startAll).toHaveBeenCalledTimes(1);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ── Group B: handleInput() AI-response roundtrip ──────────────────────────────
|
|
328
|
+
|
|
329
|
+
describe('Group B — handleInput() roundtrip', () => {
|
|
330
|
+
it('B1: handleInput("hello") calls session.submit with "hello"', async () => {
|
|
331
|
+
const channel = makeChannel();
|
|
332
|
+
await channel.start();
|
|
333
|
+
|
|
334
|
+
await channel.handleInput('hello');
|
|
335
|
+
|
|
336
|
+
const mockSession = getMockSession(channel);
|
|
337
|
+
expect(mockSession.submit).toHaveBeenCalledWith('hello');
|
|
338
|
+
await channel.stop();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('B2: text_delta + complete syncs history to stateManager', async () => {
|
|
342
|
+
const channel = makeChannel();
|
|
343
|
+
const mockSession = getMockSession(channel);
|
|
344
|
+
const historyEntry = {
|
|
345
|
+
role: 'assistant',
|
|
346
|
+
content: [{ type: 'text', text: 'Hi!' }],
|
|
347
|
+
timestamp: Date.now(),
|
|
348
|
+
};
|
|
349
|
+
mockSession.getFullHistory.mockReturnValue([historyEntry]);
|
|
350
|
+
await channel.start();
|
|
351
|
+
|
|
352
|
+
await channel.handleInput('hello');
|
|
353
|
+
emitSessionEvent(channel, 'text_delta', 'Hi!');
|
|
354
|
+
expect(channel.stateManager.streamingText).toBe('Hi!');
|
|
355
|
+
|
|
356
|
+
emitSessionEvent(channel, 'complete', MOCK_RESULT);
|
|
357
|
+
expect(channel.stateManager.streamingText).toBe('');
|
|
358
|
+
expect(channel.stateManager.history).toHaveLength(1);
|
|
359
|
+
|
|
360
|
+
await channel.stop();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('B3: handleInput("/help") calls executeCommand, not session.submit', async () => {
|
|
364
|
+
const channel = makeChannel();
|
|
365
|
+
await channel.start();
|
|
366
|
+
|
|
367
|
+
await channel.handleInput('/help');
|
|
368
|
+
|
|
369
|
+
const mockSession = getMockSession(channel);
|
|
370
|
+
expect(mockSession.submit).not.toHaveBeenCalled();
|
|
371
|
+
expect(mockSession.executeCommand).toHaveBeenCalledWith('help', '');
|
|
372
|
+
await channel.stop();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('B4: handleInput("hello") triggers channel.onChange at least once', async () => {
|
|
376
|
+
const channel = makeChannel();
|
|
377
|
+
const onChange = vi.fn();
|
|
378
|
+
channel.onChange = onChange;
|
|
379
|
+
await channel.start();
|
|
380
|
+
onChange.mockClear();
|
|
381
|
+
|
|
382
|
+
await channel.handleInput('hello');
|
|
383
|
+
emitSessionEvent(channel, 'text_delta', 'hey');
|
|
384
|
+
|
|
385
|
+
expect(onChange).toHaveBeenCalled();
|
|
386
|
+
await channel.stop();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ── Group C: onChange propagation invariant ───────────────────────────────────
|
|
391
|
+
|
|
392
|
+
describe('Group C — onChange propagation invariant', () => {
|
|
393
|
+
it('C1: session event after start() causes channel.onChange to fire', async () => {
|
|
394
|
+
const channel = makeChannel();
|
|
395
|
+
const onChange = vi.fn();
|
|
396
|
+
channel.onChange = onChange;
|
|
397
|
+
await channel.start();
|
|
398
|
+
onChange.mockClear();
|
|
399
|
+
|
|
400
|
+
// tool_start calls notify() directly (not debounced), so onChange fires immediately
|
|
401
|
+
emitSessionEvent(channel, 'tool_start', MOCK_TOOL);
|
|
402
|
+
|
|
403
|
+
expect(onChange).toHaveBeenCalled();
|
|
404
|
+
await channel.stop();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('C2: channel.onChange does not fire for events before start()', () => {
|
|
408
|
+
const channel = makeChannel();
|
|
409
|
+
const onChange = vi.fn();
|
|
410
|
+
channel.onChange = onChange;
|
|
411
|
+
// Do NOT call channel.start() — handlers not registered yet
|
|
412
|
+
emitSessionEvent(channel, 'text_delta', 'hello'); // no-op: no handlers
|
|
413
|
+
|
|
414
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('C3: channel.onChange does not fire for events after stop()', async () => {
|
|
418
|
+
const channel = makeChannel();
|
|
419
|
+
const onChange = vi.fn();
|
|
420
|
+
channel.onChange = onChange;
|
|
421
|
+
await channel.start();
|
|
422
|
+
await channel.stop(); // sets this.onChange = null
|
|
423
|
+
onChange.mockClear();
|
|
424
|
+
|
|
425
|
+
emitSessionEvent(channel, 'text_delta', 'hello');
|
|
426
|
+
|
|
427
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// ── Group D: teardown hygiene (CLI-075) ───────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
describe('Group D — teardown hygiene (CLI-075)', () => {
|
|
434
|
+
it('D1: stop() unwires every session listener', async () => {
|
|
435
|
+
const channel = makeChannel();
|
|
436
|
+
await channel.start();
|
|
437
|
+
const session = getMockSession(channel);
|
|
438
|
+
expect(session._listenerCount()).toBeGreaterThan(0);
|
|
439
|
+
|
|
440
|
+
await channel.stop();
|
|
441
|
+
|
|
442
|
+
expect(session._listenerCount()).toBe(0);
|
|
443
|
+
// A post-stop emit reaches no handler — state must not mutate.
|
|
444
|
+
emitSessionEvent(channel, 'text_delta', 'ghost');
|
|
445
|
+
expect(channel.stateManager.streamingText).toBe('');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('D2: stop() shuts the underlying session down on discard/switch', async () => {
|
|
449
|
+
const channel = makeChannel();
|
|
450
|
+
await channel.start();
|
|
451
|
+
const session = getMockSession(channel);
|
|
452
|
+
|
|
453
|
+
await channel.stop();
|
|
454
|
+
|
|
455
|
+
expect(session.shutdown).toHaveBeenCalledTimes(1);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('D3: stop() after a graceful shutdown() does not shut the session down twice', async () => {
|
|
459
|
+
const channel = makeChannel();
|
|
460
|
+
await channel.start();
|
|
461
|
+
const session = getMockSession(channel);
|
|
462
|
+
|
|
463
|
+
await channel.shutdown();
|
|
464
|
+
await channel.stop();
|
|
465
|
+
|
|
466
|
+
expect(session.shutdown).toHaveBeenCalledTimes(1);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('D4: stop() is idempotent — a second stop() does not repeat teardown', async () => {
|
|
470
|
+
const { registry, stopAll } = makeMockTransportRegistry();
|
|
471
|
+
const channel = makeChannel({ transportRegistry: registry });
|
|
472
|
+
await channel.start();
|
|
473
|
+
|
|
474
|
+
await channel.stop();
|
|
475
|
+
await channel.stop();
|
|
476
|
+
|
|
477
|
+
expect(stopAll).toHaveBeenCalledOnce();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('D5: shutdown() drains a pending permission as deny (false)', async () => {
|
|
481
|
+
const channel = makeChannel();
|
|
482
|
+
await channel.start();
|
|
483
|
+
const pending = requestPermission(channel, 'bash', {});
|
|
484
|
+
expect(channel.permissionRequest).not.toBeNull();
|
|
485
|
+
|
|
486
|
+
await channel.shutdown();
|
|
487
|
+
|
|
488
|
+
await expect(pending).resolves.toBe(false);
|
|
489
|
+
expect(channel.permissionRequest).toBeNull();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('D6: abort() drains a pending permission as deny (false)', async () => {
|
|
493
|
+
const channel = makeChannel();
|
|
494
|
+
await channel.start();
|
|
495
|
+
const pending = requestPermission(channel, 'bash', {});
|
|
496
|
+
|
|
497
|
+
channel.abort();
|
|
498
|
+
|
|
499
|
+
await expect(pending).resolves.toBe(false);
|
|
500
|
+
expect(channel.permissionRequest).toBeNull();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it('D7: shutdown() resolves even if the session shutdown hangs (timeout-bounded)', async () => {
|
|
504
|
+
const channel = makeChannel();
|
|
505
|
+
await channel.start();
|
|
506
|
+
const session = getMockSession(channel);
|
|
507
|
+
session.shutdown.mockReturnValue(new Promise<void>(() => undefined)); // never resolves
|
|
508
|
+
|
|
509
|
+
let resolved = false;
|
|
510
|
+
const done = channel.shutdown({ timeoutMs: 5_000 }).then(() => {
|
|
511
|
+
resolved = true;
|
|
512
|
+
});
|
|
513
|
+
await Promise.resolve();
|
|
514
|
+
expect(resolved).toBe(false);
|
|
515
|
+
|
|
516
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
517
|
+
await done;
|
|
518
|
+
expect(resolved).toBe(true);
|
|
519
|
+
});
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
describe('Group E — canonical prompt request settlement (ARCH-017)', () => {
|
|
523
|
+
it('E1: permission_request renders locally and settles through resolvePermission', async () => {
|
|
524
|
+
const channel = makeChannel();
|
|
525
|
+
await channel.start();
|
|
526
|
+
const session = getMockSession(channel);
|
|
527
|
+
|
|
528
|
+
emitSessionEvent(channel, 'permission_request', {
|
|
529
|
+
id: 'permission-1',
|
|
530
|
+
toolName: 'Bash',
|
|
531
|
+
toolArgs: { command: 'pwd' },
|
|
532
|
+
});
|
|
533
|
+
expect(channel.permissionRequest).toMatchObject({ toolName: 'Bash' });
|
|
534
|
+
channel.permissionRequest!.resolve(true);
|
|
535
|
+
|
|
536
|
+
await vi.waitFor(() => {
|
|
537
|
+
expect(session.resolvePermission).toHaveBeenCalledWith('permission-1', true);
|
|
538
|
+
});
|
|
539
|
+
await channel.stop();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('E2: ask_request callback rejection settles cancelled through resolveAsk', async () => {
|
|
543
|
+
const channel = makeChannel();
|
|
544
|
+
await channel.start();
|
|
545
|
+
const session = getMockSession(channel);
|
|
546
|
+
vi.spyOn(channel, 'askUser').mockRejectedValueOnce(new Error('render failed'));
|
|
547
|
+
|
|
548
|
+
emitSessionEvent(channel, 'ask_request', {
|
|
549
|
+
id: 'ask-1',
|
|
550
|
+
request: { id: 'request-1', title: 'Choose' },
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
await vi.waitFor(() => {
|
|
554
|
+
expect(session.resolveAsk).toHaveBeenCalledWith('ask-1', { type: 'cancelled' });
|
|
555
|
+
});
|
|
556
|
+
await channel.stop();
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
describe('Group F — total session-event surface delivery (ARCH-020/ARCH-028)', () => {
|
|
561
|
+
it('F1: actual channel subscriptions exactly match the exhaustive channel classification', async () => {
|
|
562
|
+
const channel = makeChannel();
|
|
563
|
+
await channel.start();
|
|
564
|
+
const session = getMockSession(channel);
|
|
565
|
+
const expected = Object.entries(TUI_SESSION_EVENT_CLASSIFICATION)
|
|
566
|
+
.filter(([, classification]) => classification === 'channel')
|
|
567
|
+
.map(([event]) => event)
|
|
568
|
+
.sort();
|
|
569
|
+
const onCalls = session.on.mock.calls as Array<[string, (...args: unknown[]) => void]>;
|
|
570
|
+
expect(onCalls.map(([event]) => event).sort()).toEqual(expected);
|
|
571
|
+
|
|
572
|
+
await channel.stop();
|
|
573
|
+
const offCalls = session.off.mock.calls as Array<[string, (...args: unknown[]) => void]>;
|
|
574
|
+
expect(offCalls.map(([event]) => event).sort()).toEqual(expected);
|
|
575
|
+
for (const [event, handler] of onCalls) expect(offCalls).toContainEqual([event, handler]);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('F2: plan, context refresh, and branch events append deterministic render notices', async () => {
|
|
579
|
+
const channel = makeChannel();
|
|
580
|
+
await channel.start();
|
|
581
|
+
|
|
582
|
+
emitSessionEvent(channel, 'plan_event', { type: 'plan_created', plan: { id: 'plan-1' } });
|
|
583
|
+
emitSessionEvent(channel, 'context_file_refreshed', { filePath: '/repo/AGENTS.md' });
|
|
584
|
+
emitSessionEvent(channel, 'branch_event', {
|
|
585
|
+
kind: 'branch_switched',
|
|
586
|
+
checkpointId: 'turn-0002',
|
|
587
|
+
branchId: 'branch-2',
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
expect(channel.stateManager.sessionEventNotices.map((notice) => notice.message)).toEqual([
|
|
591
|
+
'Plan plan created',
|
|
592
|
+
'Context refreshed: /repo/AGENTS.md',
|
|
593
|
+
'Branch branch switched: branch-2 @ turn-0002',
|
|
594
|
+
]);
|
|
595
|
+
await channel.stop();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('F3: a render handler failure is isolated and reported to the TUI owner callback', async () => {
|
|
599
|
+
const onDeliveryError = vi.fn(() => {
|
|
600
|
+
throw new Error('diagnostic callback failed');
|
|
601
|
+
});
|
|
602
|
+
const channel = makeChannel({ onSessionEventDeliveryError: onDeliveryError });
|
|
603
|
+
await channel.start();
|
|
604
|
+
vi.spyOn(channel.stateManager, 'addSessionEventNotice').mockImplementation(() => {
|
|
605
|
+
throw new Error('render projection failed');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
expect(() =>
|
|
609
|
+
emitSessionEvent(channel, 'plan_event', {
|
|
610
|
+
type: 'plan_created',
|
|
611
|
+
plan: { id: 'plan-1' },
|
|
612
|
+
}),
|
|
613
|
+
).not.toThrow();
|
|
614
|
+
expect(onDeliveryError).toHaveBeenCalledWith(
|
|
615
|
+
expect.objectContaining({ message: 'render projection failed' }),
|
|
616
|
+
'plan_event',
|
|
617
|
+
);
|
|
618
|
+
await channel.stop();
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('F4: a render handler failure becomes a visible notice when no observer is injected', async () => {
|
|
622
|
+
const channel = makeChannel();
|
|
623
|
+
await channel.start();
|
|
624
|
+
vi.spyOn(channel.stateManager, 'addSessionEventNotice').mockImplementation(() => {
|
|
625
|
+
throw new Error('render projection failed');
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
expect(() =>
|
|
629
|
+
emitSessionEvent(channel, 'plan_event', {
|
|
630
|
+
type: 'plan_created',
|
|
631
|
+
plan: { id: 'plan-1' },
|
|
632
|
+
}),
|
|
633
|
+
).not.toThrow();
|
|
634
|
+
expect(channel.stateManager.sessionEventNotices).toContainEqual(
|
|
635
|
+
expect.objectContaining({
|
|
636
|
+
event: 'delivery-error',
|
|
637
|
+
message: 'Session event delivery failed (plan_event): render projection failed',
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
await channel.stop();
|
|
641
|
+
});
|
|
642
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import UpdateNotice from '../UpdateNotice.js';
|
|
5
|
+
|
|
6
|
+
describe('UpdateNotice', () => {
|
|
7
|
+
it('renders an update notice outside session history', () => {
|
|
8
|
+
const { lastFrame } = render(
|
|
9
|
+
<UpdateNotice message="Robota update available. Run npm install -g '@robota-sdk/agent-cli@latest'." />,
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
expect(lastFrame()).toContain('Robota update available');
|
|
13
|
+
expect(lastFrame()).toContain('npm install');
|
|
14
|
+
});
|
|
15
|
+
});
|