@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,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-B11 TC-01/03/05 + CLI-B12 TC-01/02/04: session-switch channel ownership
|
|
3
|
+
* at the App boundary.
|
|
4
|
+
*
|
|
5
|
+
* The 2026-05-31 context-loss bug lived between render.tsx, App.tsx and
|
|
6
|
+
* TuiInteractionChannel — InteractiveSession-level tests stayed green through it.
|
|
7
|
+
* These tests render the REAL App with a mocked createChannel factory and drive
|
|
8
|
+
* switches through the real SessionPicker, pinning the factory-call contract.
|
|
9
|
+
* Since CLI-B12 the factory is the SOLE channel source: App creates the initial
|
|
10
|
+
* channel in its useState initializer and replaces it on every switch.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { render } from 'ink-testing-library';
|
|
18
|
+
import React from 'react';
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
20
|
+
|
|
21
|
+
import App from '../App.js';
|
|
22
|
+
import { TuiStateManager } from '../tui-state-manager.js';
|
|
23
|
+
|
|
24
|
+
import type { ITuiCliAdapter } from '../tui-cli-adapter.js';
|
|
25
|
+
import type { ITuiAppChannelPort } from '../tui-app-channel-port.js';
|
|
26
|
+
import type {
|
|
27
|
+
IInteractiveSessionRecord,
|
|
28
|
+
IInteractiveSessionStore,
|
|
29
|
+
} from '@robota-sdk/agent-interface-session';
|
|
30
|
+
|
|
31
|
+
const TICK_MS = 30;
|
|
32
|
+
const FRAME_DEADLINE_MS = 3000;
|
|
33
|
+
|
|
34
|
+
function tick(ms = TICK_MS): Promise<void> {
|
|
35
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function waitForFrame(
|
|
39
|
+
lastFrame: () => string | undefined,
|
|
40
|
+
predicate: (frame: string) => boolean,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const deadline = Date.now() + FRAME_DEADLINE_MS;
|
|
43
|
+
while (Date.now() < deadline) {
|
|
44
|
+
const frame = lastFrame();
|
|
45
|
+
if (frame !== undefined && predicate(frame)) return;
|
|
46
|
+
await tick(10);
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`waitForFrame timeout\n--- frame ---\n${lastFrame() ?? '<none>'}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function waitForSessionPicker(lastFrame: () => string | undefined): Promise<void> {
|
|
52
|
+
await waitForFrame(lastFrame, (frame) => frame.includes('Select a session to resume'));
|
|
53
|
+
await tick();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface IFakeChannel {
|
|
57
|
+
sessionName: string | undefined;
|
|
58
|
+
stateManager: TuiStateManager;
|
|
59
|
+
onChange: (() => void) | null;
|
|
60
|
+
isShuttingDown: boolean;
|
|
61
|
+
permissionRequest: null;
|
|
62
|
+
pendingUserAction: null;
|
|
63
|
+
start: ReturnType<typeof vi.fn>;
|
|
64
|
+
stop: ReturnType<typeof vi.fn>;
|
|
65
|
+
handleInput: ReturnType<typeof vi.fn>;
|
|
66
|
+
abort: ReturnType<typeof vi.fn>;
|
|
67
|
+
cancelQueue: ReturnType<typeof vi.fn>;
|
|
68
|
+
shutdown: ReturnType<typeof vi.fn>;
|
|
69
|
+
selectExecutionWorkspaceEntry: ReturnType<typeof vi.fn>;
|
|
70
|
+
readExecutionWorkspaceDetail: ReturnType<typeof vi.fn>;
|
|
71
|
+
getSession: () => unknown;
|
|
72
|
+
getRegistry: () => unknown;
|
|
73
|
+
/** Test handle: emit a session event to this channel's subscribers (CMD-004 `ui_intent` path). */
|
|
74
|
+
emitSessionEvent: (event: string, payload: unknown) => void;
|
|
75
|
+
/** Test handle: which resumeSessionId this channel was created for. */
|
|
76
|
+
createdFor: string | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createFakeChannel(createdFor: string | undefined): IFakeChannel & ITuiAppChannelPort {
|
|
80
|
+
// CMD-004 Stage C: App subscribes to `ui_intent`/`session_renamed` on the session.
|
|
81
|
+
const sessionListeners = new Map<string, Set<(payload: unknown) => void>>();
|
|
82
|
+
const fakeSession = {
|
|
83
|
+
// ARCH-012: required. `useTuiChannel` reads the co-drive queue length live from the session; the
|
|
84
|
+
// `?.() ?? …` that tolerated its absence was a branch nothing could take once the member became
|
|
85
|
+
// required, and a wrong count if it ever did.
|
|
86
|
+
getPendingCount: (): number => 0,
|
|
87
|
+
getName: (): string | undefined => undefined,
|
|
88
|
+
getSession: (): never => {
|
|
89
|
+
throw new Error('session not initialized (test fake)');
|
|
90
|
+
},
|
|
91
|
+
getFullHistory: (): never[] => [],
|
|
92
|
+
setName: vi.fn(),
|
|
93
|
+
shutdown: vi.fn(async () => {}),
|
|
94
|
+
sendAgentJob: vi.fn(async () => {}),
|
|
95
|
+
on: (event: string, handler: (payload: unknown) => void): void => {
|
|
96
|
+
const set = sessionListeners.get(event) ?? new Set<(payload: unknown) => void>();
|
|
97
|
+
set.add(handler);
|
|
98
|
+
sessionListeners.set(event, set);
|
|
99
|
+
},
|
|
100
|
+
off: (event: string, handler: (payload: unknown) => void): void => {
|
|
101
|
+
sessionListeners.get(event)?.delete(handler);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const fakeRegistry = {
|
|
105
|
+
getCommands: (): never[] => [],
|
|
106
|
+
getSubcommands: (): never[] => [],
|
|
107
|
+
};
|
|
108
|
+
const manager = new TuiStateManager();
|
|
109
|
+
let onChange: (() => void) | null = null;
|
|
110
|
+
const fake: IFakeChannel & ITuiAppChannelPort = {
|
|
111
|
+
terminalHandoffController: undefined,
|
|
112
|
+
sessionName: undefined,
|
|
113
|
+
stateManager: manager,
|
|
114
|
+
onChange: null,
|
|
115
|
+
isShuttingDown: false,
|
|
116
|
+
permissionRequest: null,
|
|
117
|
+
pendingUserAction: null,
|
|
118
|
+
start: vi.fn(async () => {}),
|
|
119
|
+
stop: vi.fn(async () => {}),
|
|
120
|
+
handleInput: vi.fn(async () => {}),
|
|
121
|
+
abort: vi.fn(),
|
|
122
|
+
cancelQueue: vi.fn(),
|
|
123
|
+
stopWaitingSelfPacedLoop: vi.fn(async () => {}),
|
|
124
|
+
shutdown: vi.fn(async () => {}),
|
|
125
|
+
selectExecutionWorkspaceEntry: vi.fn(),
|
|
126
|
+
readExecutionWorkspaceDetail: vi.fn(async (entryId: string) => ({ entryId, records: [] })),
|
|
127
|
+
getSession: () => fakeSession,
|
|
128
|
+
getRegistry: () => fakeRegistry,
|
|
129
|
+
subscribe: (handler) => {
|
|
130
|
+
onChange = handler;
|
|
131
|
+
fake.onChange = handler;
|
|
132
|
+
return () => {
|
|
133
|
+
if (onChange === handler) onChange = null;
|
|
134
|
+
if (fake.onChange === handler) fake.onChange = null;
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
getSnapshot: () => ({
|
|
138
|
+
history: manager.history,
|
|
139
|
+
streamingText: manager.streamingText,
|
|
140
|
+
activeTools: manager.activeTools,
|
|
141
|
+
isThinking: manager.isThinking,
|
|
142
|
+
isAborting: manager.isAborting,
|
|
143
|
+
lastErrorMessage: manager.lastErrorMessage,
|
|
144
|
+
isStalled: manager.isStalled,
|
|
145
|
+
sessionEventNotices: manager.sessionEventNotices,
|
|
146
|
+
isShuttingDown: false,
|
|
147
|
+
pendingPrompt: manager.pendingPrompt,
|
|
148
|
+
pendingCount: 0,
|
|
149
|
+
executionWorkspaceSnapshot: manager.executionWorkspaceSnapshot,
|
|
150
|
+
permissionRequest: null,
|
|
151
|
+
pendingUserAction: null,
|
|
152
|
+
contextState: manager.contextState,
|
|
153
|
+
}),
|
|
154
|
+
getSessionUiEventPort: () => fakeSession,
|
|
155
|
+
getCommandQueryPort: () => fakeRegistry,
|
|
156
|
+
getRuntimeStatusSnapshot: (permissionMode) => ({ permissionMode, sessionId: '' }),
|
|
157
|
+
addEntry: (entry) => manager.addEntry(entry),
|
|
158
|
+
sendAgentJob: vi.fn(async () => {}),
|
|
159
|
+
resolveUserAction: vi.fn(),
|
|
160
|
+
emitSessionEvent: (event, payload) => {
|
|
161
|
+
for (const handler of [...(sessionListeners.get(event) ?? [])]) handler(payload);
|
|
162
|
+
},
|
|
163
|
+
createdFor,
|
|
164
|
+
};
|
|
165
|
+
return fake;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function createFakeStore(records: IInteractiveSessionRecord[]): IInteractiveSessionStore {
|
|
169
|
+
return {
|
|
170
|
+
save: () => undefined,
|
|
171
|
+
load: (id) => {
|
|
172
|
+
const record = records.find((r) => r.id === id);
|
|
173
|
+
return record === undefined ? { status: 'missing' } : { status: 'valid', record };
|
|
174
|
+
},
|
|
175
|
+
list: () => records.map((record) => ({ id: record.id, outcome: { status: 'valid', record } })),
|
|
176
|
+
delete: () => undefined,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sessionRecord(
|
|
181
|
+
id: string,
|
|
182
|
+
cwd: string,
|
|
183
|
+
updatedAt = '2026-06-13T00:00:00.000Z',
|
|
184
|
+
): IInteractiveSessionRecord {
|
|
185
|
+
return {
|
|
186
|
+
id,
|
|
187
|
+
cwd,
|
|
188
|
+
createdAt: '2026-06-13T00:00:00.000Z',
|
|
189
|
+
updatedAt,
|
|
190
|
+
messages: [
|
|
191
|
+
{ role: 'user', content: `hello from ${id}` },
|
|
192
|
+
{ role: 'assistant', content: `reply in ${id}` },
|
|
193
|
+
] as IInteractiveSessionRecord['messages'],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The picker lists sessions newest-first; bumping updatedAt puts a record on top. */
|
|
198
|
+
function touch(records: IInteractiveSessionRecord[], id: string, updatedAt: string): void {
|
|
199
|
+
const record = records.find((r) => r.id === id);
|
|
200
|
+
if (!record) throw new Error(`no record ${id}`);
|
|
201
|
+
record.updatedAt = updatedAt;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function createCliAdapter(settingsPath: string): ITuiCliAdapter {
|
|
205
|
+
return {
|
|
206
|
+
getUserSettingsPath: () => settingsPath,
|
|
207
|
+
readSettings: () => ({}),
|
|
208
|
+
reloadPluginCommandSource: vi.fn(),
|
|
209
|
+
applyActiveModelChange: vi.fn().mockReturnValue({ applied: true }),
|
|
210
|
+
getGitBranch: vi.fn().mockReturnValue(undefined),
|
|
211
|
+
getProviderDisplayName: vi.fn((type: string) => type),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
describe('App session-switch channel ownership (CLI-B11)', () => {
|
|
216
|
+
let cwd: string;
|
|
217
|
+
let created: IFakeChannel[];
|
|
218
|
+
let createChannel: ReturnType<typeof vi.fn>;
|
|
219
|
+
let initialStartError: Error | undefined;
|
|
220
|
+
let initialStartIntent: boolean;
|
|
221
|
+
|
|
222
|
+
beforeEach(() => {
|
|
223
|
+
cwd = mkdtempSync(join(tmpdir(), 'robota-b11-'));
|
|
224
|
+
created = [];
|
|
225
|
+
initialStartError = undefined;
|
|
226
|
+
initialStartIntent = false;
|
|
227
|
+
createChannel = vi.fn((resumeSessionId?: string) => {
|
|
228
|
+
const fake = createFakeChannel(resumeSessionId);
|
|
229
|
+
if (created.length === 0 && initialStartError !== undefined) {
|
|
230
|
+
fake.start.mockRejectedValueOnce(initialStartError);
|
|
231
|
+
} else if (created.length === 0 && initialStartIntent) {
|
|
232
|
+
fake.start.mockImplementationOnce(async () => {
|
|
233
|
+
fake.emitSessionEvent('ui_intent', {
|
|
234
|
+
intent: { type: 'show-session-picker' },
|
|
235
|
+
requesterDriverId: 'owner',
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
created.push(fake);
|
|
240
|
+
return fake;
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
afterEach(() => {
|
|
245
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
function renderApp(options?: { sessionIds?: string[]; showSessionPickerOnStart?: boolean }) {
|
|
249
|
+
const ids = options?.sessionIds ?? ['session-aaaaaaaa', 'session-bbbbbbbb'];
|
|
250
|
+
const records = ids.map((id) => sessionRecord(id, cwd));
|
|
251
|
+
const store = createFakeStore(records);
|
|
252
|
+
const instance = render(
|
|
253
|
+
<App
|
|
254
|
+
cwd={cwd}
|
|
255
|
+
createChannel={createChannel}
|
|
256
|
+
sessionStore={store}
|
|
257
|
+
showSessionPickerOnStart={options?.showSessionPickerOnStart ?? true}
|
|
258
|
+
cliAdapter={createCliAdapter(join(cwd, 'settings.json'))}
|
|
259
|
+
/>,
|
|
260
|
+
);
|
|
261
|
+
return { ...instance, records };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
it('TC-01 (B11) / TC-01 (B12): the factory is the sole channel source — once at mount, once per switch with the selected sessionId', async () => {
|
|
265
|
+
const { stdin, lastFrame } = renderApp();
|
|
266
|
+
await waitForSessionPicker(lastFrame);
|
|
267
|
+
expect(lastFrame()).toContain('Select a session to resume');
|
|
268
|
+
|
|
269
|
+
// CLI-B12 TC-01: initial channel from the useState initializer, exactly once.
|
|
270
|
+
expect(createChannel).toHaveBeenCalledTimes(1);
|
|
271
|
+
expect(createChannel).toHaveBeenNthCalledWith(1, undefined);
|
|
272
|
+
|
|
273
|
+
stdin.write('\r'); // select first item (newest first — equal timestamps keep list order)
|
|
274
|
+
await tick();
|
|
275
|
+
|
|
276
|
+
// CLI-B11 TC-A: the switch asks the factory for exactly one channel with the id.
|
|
277
|
+
expect(createChannel).toHaveBeenCalledTimes(2);
|
|
278
|
+
expect(createChannel).toHaveBeenNthCalledWith(2, 'session-aaaaaaaa');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('TC-03 (B11) / TC-02 (B12): the previous channel is stopped before the new one becomes active', async () => {
|
|
282
|
+
const { stdin, lastFrame } = renderApp();
|
|
283
|
+
await waitForSessionPicker(lastFrame);
|
|
284
|
+
const initialChannel = created[0]!;
|
|
285
|
+
expect(initialChannel.start).toHaveBeenCalled();
|
|
286
|
+
|
|
287
|
+
stdin.write('\r');
|
|
288
|
+
await tick();
|
|
289
|
+
|
|
290
|
+
// Old channel released by the switch handler; final App teardown belongs to renderApp's
|
|
291
|
+
// awaitable composition boundary.
|
|
292
|
+
expect(initialChannel.stop).toHaveBeenCalled();
|
|
293
|
+
expect(created).toHaveLength(2);
|
|
294
|
+
const newChannel = created[1]!;
|
|
295
|
+
expect(newChannel.start).toHaveBeenCalled();
|
|
296
|
+
expect(newChannel.stop).not.toHaveBeenCalled();
|
|
297
|
+
|
|
298
|
+
// CLI-B12 TC-02 ordering: old stop() was invoked BEFORE the factory built
|
|
299
|
+
// the replacement channel (stop-before-active contract).
|
|
300
|
+
const stopOrder = initialChannel.stop.mock.invocationCallOrder[0]!;
|
|
301
|
+
const replacementOrder = createChannel.mock.invocationCallOrder[1]!;
|
|
302
|
+
expect(stopOrder).toBeLessThan(replacementOrder);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('TC-02 (REFACTOR-025): waits for the prior stop to finish before constructing the replacement', async () => {
|
|
306
|
+
const { stdin, lastFrame } = renderApp();
|
|
307
|
+
await waitForSessionPicker(lastFrame);
|
|
308
|
+
const initialChannel = created[0]!;
|
|
309
|
+
let releaseStop: (() => void) | undefined;
|
|
310
|
+
initialChannel.stop.mockImplementationOnce(
|
|
311
|
+
() => new Promise<void>((resolve) => (releaseStop = resolve)),
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
stdin.write('\r');
|
|
315
|
+
await tick();
|
|
316
|
+
expect(initialChannel.stop).toHaveBeenCalledTimes(1);
|
|
317
|
+
expect(createChannel).toHaveBeenCalledTimes(1);
|
|
318
|
+
expect(lastFrame()).not.toContain('Select a session to resume');
|
|
319
|
+
|
|
320
|
+
releaseStop?.();
|
|
321
|
+
await waitForFrame(
|
|
322
|
+
() => '',
|
|
323
|
+
() => createChannel.mock.calls.length === 2,
|
|
324
|
+
);
|
|
325
|
+
expect(createChannel).toHaveBeenNthCalledWith(2, 'session-aaaaaaaa');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('does not construct a replacement after App unmounts during an in-flight switch', async () => {
|
|
329
|
+
const { stdin, lastFrame, unmount } = renderApp();
|
|
330
|
+
await waitForSessionPicker(lastFrame);
|
|
331
|
+
let releaseStop: (() => void) | undefined;
|
|
332
|
+
created[0]!.stop.mockImplementationOnce(
|
|
333
|
+
() => new Promise<void>((resolve) => (releaseStop = resolve)),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
stdin.write('\r');
|
|
337
|
+
await tick();
|
|
338
|
+
unmount();
|
|
339
|
+
releaseStop?.();
|
|
340
|
+
await tick();
|
|
341
|
+
|
|
342
|
+
expect(createChannel).toHaveBeenCalledTimes(1);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('TC-02 (REFACTOR-025): keeps the old channel selected and renders a stop failure', async () => {
|
|
346
|
+
const { stdin, lastFrame } = renderApp();
|
|
347
|
+
await waitForSessionPicker(lastFrame);
|
|
348
|
+
created[0]!.stop.mockRejectedValueOnce(new Error('transport stop failed'));
|
|
349
|
+
|
|
350
|
+
stdin.write('\r');
|
|
351
|
+
await waitForFrame(lastFrame, (frame) =>
|
|
352
|
+
frame.includes('Session switch failed: transport stop failed'),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
expect(createChannel).toHaveBeenCalledTimes(1);
|
|
356
|
+
expect(lastFrame()).toContain('Press Enter to retry.');
|
|
357
|
+
|
|
358
|
+
stdin.write('\r');
|
|
359
|
+
await waitForFrame(lastFrame, () => createChannel.mock.calls.length === 2);
|
|
360
|
+
// One failed switch and one successful retry.
|
|
361
|
+
expect(created[0]!.stop).toHaveBeenCalledTimes(2);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('TC-04 (B12): App renders from the factory alone — no channel prop exists', async () => {
|
|
365
|
+
// The old no-factory fallback (B11 TC-D) is deleted with CLI-B12: createChannel
|
|
366
|
+
// is required and `channel` is no longer a prop (enforced at the type level —
|
|
367
|
+
// passing one is a compile error). This pins the runtime half: a render with
|
|
368
|
+
// only the factory boots, starts the initial channel, and keeps rendering.
|
|
369
|
+
const { lastFrame } = renderApp();
|
|
370
|
+
await tick();
|
|
371
|
+
|
|
372
|
+
expect(lastFrame()).toBeTruthy();
|
|
373
|
+
expect(createChannel).toHaveBeenCalledTimes(1);
|
|
374
|
+
expect(created[0]!.start).toHaveBeenCalled();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('REFACTOR-025: renders a start failure, blocks normal input, and retries on Enter', async () => {
|
|
378
|
+
initialStartError = new Error('transport start failed');
|
|
379
|
+
const { stdin, lastFrame } = renderApp();
|
|
380
|
+
await waitForFrame(lastFrame, (frame) =>
|
|
381
|
+
frame.includes('TUI start failed: transport start failed'),
|
|
382
|
+
);
|
|
383
|
+
expect(lastFrame()).toContain('Press Enter to retry.');
|
|
384
|
+
|
|
385
|
+
stdin.write('\r');
|
|
386
|
+
await waitForFrame(lastFrame, () => created[0]!.start.mock.calls.length === 2);
|
|
387
|
+
await tick();
|
|
388
|
+
expect(createChannel).toHaveBeenCalledTimes(1);
|
|
389
|
+
expect(lastFrame()).toContain('Select a session to resume');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('REFACTOR-025: ignores workspace shortcuts while recovery owns input', async () => {
|
|
393
|
+
initialStartError = new Error('transport start failed');
|
|
394
|
+
const { stdin, lastFrame } = renderApp({ showSessionPickerOnStart: false });
|
|
395
|
+
await waitForFrame(lastFrame, (frame) =>
|
|
396
|
+
frame.includes('TUI start failed: transport start failed'),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
stdin.write('\x02'); // Ctrl+B must not mutate hidden overlay state.
|
|
400
|
+
stdin.write('\r');
|
|
401
|
+
await waitForFrame(lastFrame, () => created[0]!.start.mock.calls.length === 2);
|
|
402
|
+
await tick();
|
|
403
|
+
|
|
404
|
+
expect(lastFrame()).not.toContain('Execution workspace');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('REFACTOR-025: subscribes to UI events before channel startup', async () => {
|
|
408
|
+
initialStartIntent = true;
|
|
409
|
+
const { lastFrame } = renderApp({ showSessionPickerOnStart: false });
|
|
410
|
+
|
|
411
|
+
await tick(100);
|
|
412
|
+
expect(lastFrame()).toContain('Select a session to resume');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('TC-05: consecutive switches A→B→C create one channel per switch and stop each prior channel', async () => {
|
|
416
|
+
// Selection always takes the top (newest) entry; arrow-key navigation itself
|
|
417
|
+
// is covered by ListPicker.test.tsx. updatedAt ordering decides the target.
|
|
418
|
+
const ids = ['aaaaaaaa-1111', 'bbbbbbbb-2222', 'cccccccc-3333'];
|
|
419
|
+
const { stdin, lastFrame, records } = renderApp({ sessionIds: ids });
|
|
420
|
+
touch(records, 'aaaaaaaa-1111', '2026-06-13T01:00:00.000Z'); // A on top
|
|
421
|
+
await waitForSessionPicker(lastFrame);
|
|
422
|
+
|
|
423
|
+
// Mount creates the initial channel (factory call 1, undefined).
|
|
424
|
+
expect(createChannel).toHaveBeenNthCalledWith(1, undefined);
|
|
425
|
+
const channelInitial = created[0]!;
|
|
426
|
+
|
|
427
|
+
// Switch 1: pick A (top) from the startup picker.
|
|
428
|
+
stdin.write('\r');
|
|
429
|
+
await waitForFrame(lastFrame, () => createChannel.mock.calls.length === 2);
|
|
430
|
+
expect(createChannel).toHaveBeenNthCalledWith(2, 'aaaaaaaa-1111');
|
|
431
|
+
expect(channelInitial.stop).toHaveBeenCalled();
|
|
432
|
+
const channelA = created[1]!;
|
|
433
|
+
await tick(); // allow the replacement view's session-event subscription to attach
|
|
434
|
+
|
|
435
|
+
// Switch 2: reopen the picker via the requester-routed ui_intent (real /resume path since
|
|
436
|
+
// CMD-004 Stage C: the session emits `ui_intent` and the owner surface renders it).
|
|
437
|
+
touch(records, 'bbbbbbbb-2222', '2026-06-13T02:00:00.000Z'); // B on top
|
|
438
|
+
channelA.emitSessionEvent('ui_intent', {
|
|
439
|
+
intent: { type: 'show-session-picker' },
|
|
440
|
+
requesterDriverId: 'owner',
|
|
441
|
+
});
|
|
442
|
+
await waitForFrame(lastFrame, (f) => f.includes('> bbbbbbbb'));
|
|
443
|
+
await tick(); // settle: let the reopened picker's useInput subscription attach
|
|
444
|
+
stdin.write('\r');
|
|
445
|
+
await waitForFrame(lastFrame, () => createChannel.mock.calls.length === 3);
|
|
446
|
+
expect(createChannel).toHaveBeenNthCalledWith(3, 'bbbbbbbb-2222');
|
|
447
|
+
expect(channelA.stop).toHaveBeenCalled();
|
|
448
|
+
const channelB = created[2]!;
|
|
449
|
+
expect(channelB.start).toHaveBeenCalled();
|
|
450
|
+
await tick(); // allow the replacement view's session-event subscription to attach
|
|
451
|
+
|
|
452
|
+
// Switch 3: same drill from B to C.
|
|
453
|
+
touch(records, 'cccccccc-3333', '2026-06-13T03:00:00.000Z'); // C on top
|
|
454
|
+
channelB.emitSessionEvent('ui_intent', {
|
|
455
|
+
intent: { type: 'show-session-picker' },
|
|
456
|
+
requesterDriverId: 'owner',
|
|
457
|
+
});
|
|
458
|
+
await waitForFrame(lastFrame, (f) => f.includes('> cccccccc'));
|
|
459
|
+
await tick(); // settle: let the reopened picker's useInput subscription attach
|
|
460
|
+
stdin.write('\r');
|
|
461
|
+
await waitForFrame(lastFrame, () => createChannel.mock.calls.length === 4);
|
|
462
|
+
expect(createChannel).toHaveBeenNthCalledWith(4, 'cccccccc-3333');
|
|
463
|
+
expect(channelB.stop).toHaveBeenCalled();
|
|
464
|
+
|
|
465
|
+
const channelC = created[3]!;
|
|
466
|
+
expect(channelC.start).toHaveBeenCalled();
|
|
467
|
+
expect(channelC.stop).not.toHaveBeenCalled();
|
|
468
|
+
expect(createChannel).toHaveBeenCalledTimes(4);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-075 (RUNTIME-33): interrupt-signal policy. The first Ctrl+C / signal runs the graceful
|
|
3
|
+
* shutdown; a second one received while a shutdown is already in flight force-quits with 130 so a
|
|
4
|
+
* user can always escape a wedged shutdown.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { handleInterrupt, FORCE_QUIT_EXIT_CODE } from '../shutdown-signal.js';
|
|
9
|
+
|
|
10
|
+
describe('handleInterrupt (CLI-075 RUNTIME-33)', () => {
|
|
11
|
+
it('runs the graceful path on the first interrupt (not shutting down yet)', () => {
|
|
12
|
+
const graceful = vi.fn();
|
|
13
|
+
const forceExit = vi.fn();
|
|
14
|
+
|
|
15
|
+
handleInterrupt({ isShuttingDown: false, graceful, forceExit });
|
|
16
|
+
|
|
17
|
+
expect(graceful).toHaveBeenCalledOnce();
|
|
18
|
+
expect(forceExit).not.toHaveBeenCalled();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('force-exits with 130 on a second interrupt during an in-flight shutdown', () => {
|
|
22
|
+
const graceful = vi.fn();
|
|
23
|
+
const forceExit = vi.fn();
|
|
24
|
+
|
|
25
|
+
handleInterrupt({ isShuttingDown: true, graceful, forceExit });
|
|
26
|
+
|
|
27
|
+
expect(forceExit).toHaveBeenCalledWith(130);
|
|
28
|
+
expect(graceful).not.toHaveBeenCalled();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('exposes 128 + SIGINT(2) as the force-quit exit code', () => {
|
|
32
|
+
expect(FORCE_QUIT_EXIT_CODE).toBe(130);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatStatusActivity } from '../status-activity.js';
|
|
3
|
+
|
|
4
|
+
describe('formatStatusActivity', () => {
|
|
5
|
+
it('prioritizes running tools over thinking, background work, and queued prompts', () => {
|
|
6
|
+
const activity = formatStatusActivity({
|
|
7
|
+
isThinking: true,
|
|
8
|
+
activeToolCount: 2,
|
|
9
|
+
activeBackgroundTaskCount: 3,
|
|
10
|
+
hasPendingPrompt: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
expect(activity.kind).toBe('tools');
|
|
14
|
+
expect(activity.label).toBe('Tools (2)');
|
|
15
|
+
expect(activity.tone).toBe('accent');
|
|
16
|
+
expect(activity.segments).toEqual(['queued']);
|
|
17
|
+
expect(activity.text).toBe('Tools (2) · queued');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('shows thinking as the primary model waiting state', () => {
|
|
21
|
+
const activity = formatStatusActivity({
|
|
22
|
+
isThinking: true,
|
|
23
|
+
activeToolCount: 0,
|
|
24
|
+
activeBackgroundTaskCount: 0,
|
|
25
|
+
hasPendingPrompt: false,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(activity.kind).toBe('thinking');
|
|
29
|
+
expect(activity.label).toBe('Thinking');
|
|
30
|
+
expect(activity.segments).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('shows background activity when foreground work is idle', () => {
|
|
34
|
+
const activity = formatStatusActivity({
|
|
35
|
+
isThinking: false,
|
|
36
|
+
activeToolCount: 0,
|
|
37
|
+
activeBackgroundTaskCount: 1,
|
|
38
|
+
hasPendingPrompt: false,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(activity.kind).toBe('background');
|
|
42
|
+
expect(activity.label).toBe('Background (1)');
|
|
43
|
+
expect(activity.tone).toBe('accent');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('shows queued prompt before idle when no work is active', () => {
|
|
47
|
+
const activity = formatStatusActivity({
|
|
48
|
+
isThinking: false,
|
|
49
|
+
activeToolCount: 0,
|
|
50
|
+
activeBackgroundTaskCount: 0,
|
|
51
|
+
hasPendingPrompt: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(activity.kind).toBe('queued');
|
|
55
|
+
expect(activity.label).toBe('Queued');
|
|
56
|
+
expect(activity.tone).toBe('warning');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('keeps idle compact and dim', () => {
|
|
60
|
+
const activity = formatStatusActivity({
|
|
61
|
+
isThinking: false,
|
|
62
|
+
activeToolCount: 0,
|
|
63
|
+
activeBackgroundTaskCount: 0,
|
|
64
|
+
hasPendingPrompt: false,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(activity.kind).toBe('idle');
|
|
68
|
+
expect(activity.text).toBe('Idle');
|
|
69
|
+
expect(activity.tone).toBe('muted');
|
|
70
|
+
});
|
|
71
|
+
});
|