@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,112 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import TextPrompt from '../TextPrompt.js';
|
|
5
|
+
import { formatKeyHints } from '../key-hint-footer.js';
|
|
6
|
+
|
|
7
|
+
describe('TextPrompt', () => {
|
|
8
|
+
it('renders title', () => {
|
|
9
|
+
const { lastFrame } = render(
|
|
10
|
+
<TextPrompt title="Enter URL" onSubmit={() => {}} onCancel={() => {}} />,
|
|
11
|
+
);
|
|
12
|
+
expect(lastFrame()!).toContain('Enter URL');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('renders placeholder when provided', () => {
|
|
16
|
+
const { lastFrame } = render(
|
|
17
|
+
<TextPrompt title="Enter" placeholder="owner/repo" onSubmit={() => {}} onCancel={() => {}} />,
|
|
18
|
+
);
|
|
19
|
+
expect(lastFrame()!).toContain('owner/repo');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('calls onCancel on Escape', async () => {
|
|
23
|
+
let cancelled = false;
|
|
24
|
+
const { stdin } = render(
|
|
25
|
+
<TextPrompt
|
|
26
|
+
title="Enter"
|
|
27
|
+
onSubmit={() => {}}
|
|
28
|
+
onCancel={() => {
|
|
29
|
+
cancelled = true;
|
|
30
|
+
}}
|
|
31
|
+
/>,
|
|
32
|
+
);
|
|
33
|
+
stdin.write('\x1B');
|
|
34
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
35
|
+
expect(cancelled).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('calls onSubmit with value on Enter', () => {
|
|
39
|
+
let submitted = '';
|
|
40
|
+
const { stdin } = render(
|
|
41
|
+
<TextPrompt
|
|
42
|
+
title="Enter"
|
|
43
|
+
onSubmit={(v) => {
|
|
44
|
+
submitted = v;
|
|
45
|
+
}}
|
|
46
|
+
onCancel={() => {}}
|
|
47
|
+
/>,
|
|
48
|
+
);
|
|
49
|
+
stdin.write('hello');
|
|
50
|
+
stdin.write('\r');
|
|
51
|
+
expect(submitted).toBe('hello');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('can submit an empty value when allowed', () => {
|
|
55
|
+
let submitted = 'not-called';
|
|
56
|
+
const { stdin } = render(
|
|
57
|
+
<TextPrompt
|
|
58
|
+
title="Enter"
|
|
59
|
+
allowEmpty
|
|
60
|
+
onSubmit={(v) => {
|
|
61
|
+
submitted = v;
|
|
62
|
+
}}
|
|
63
|
+
onCancel={() => {}}
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
stdin.write('\r');
|
|
67
|
+
expect(submitted).toBe('');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('masks typed values when requested', async () => {
|
|
71
|
+
const { stdin, lastFrame } = render(
|
|
72
|
+
<TextPrompt title="Secret" masked onSubmit={() => {}} onCancel={() => {}} />,
|
|
73
|
+
);
|
|
74
|
+
stdin.write('abc');
|
|
75
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
76
|
+
expect(lastFrame()!).toContain('***');
|
|
77
|
+
expect(lastFrame()!).not.toContain('abc');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('shows validation error and blocks submit', async () => {
|
|
81
|
+
let submitted = false;
|
|
82
|
+
const validate = (v: string) => (v.length < 3 ? 'Too short' : undefined);
|
|
83
|
+
const { stdin, lastFrame } = render(
|
|
84
|
+
<TextPrompt
|
|
85
|
+
title="Enter"
|
|
86
|
+
onSubmit={() => {
|
|
87
|
+
submitted = true;
|
|
88
|
+
}}
|
|
89
|
+
onCancel={() => {}}
|
|
90
|
+
validate={validate}
|
|
91
|
+
/>,
|
|
92
|
+
);
|
|
93
|
+
stdin.write('ab');
|
|
94
|
+
stdin.write('\r');
|
|
95
|
+
expect(submitted).toBe(false);
|
|
96
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
97
|
+
expect(lastFrame()!).toContain('Too short');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// SCREEN-005: footer renders through the key-hint SSOT grammar.
|
|
101
|
+
it('renders the footer in the shared key-hint grammar', () => {
|
|
102
|
+
const { lastFrame } = render(
|
|
103
|
+
<TextPrompt title="Enter value" onSubmit={() => {}} onCancel={() => {}} />,
|
|
104
|
+
);
|
|
105
|
+
expect(lastFrame()!).toContain(
|
|
106
|
+
formatKeyHints([
|
|
107
|
+
{ keys: 'Enter', label: 'Submit' },
|
|
108
|
+
{ keys: 'Esc', label: 'Cancel' },
|
|
109
|
+
]),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TRANS-009 — the toggle surface tells the truth about what it did.
|
|
3
|
+
*
|
|
4
|
+
* Two defects, one place. `registry.setEnabled` writes a settings file and returns; nothing on that
|
|
5
|
+
* path starts a transport, and `startAll` reads `getEnabled()` at session start. So the row used to
|
|
6
|
+
* report a transport as running because a file had changed. And the failure branch was
|
|
7
|
+
* `.catch(() => setSaving(false))` — the error taken and discarded, so an unwritable settings file
|
|
8
|
+
* looked exactly like a successful save.
|
|
9
|
+
*
|
|
10
|
+
* The assertions are on what the COMPONENT RENDERS, not on whether the registry was called. A test
|
|
11
|
+
* that asserts `setEnabled` was invoked passes against both the old code and the new one, because
|
|
12
|
+
* the call was never the defect.
|
|
13
|
+
*/
|
|
14
|
+
import { render } from 'ink-testing-library';
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import TransportTUI from '../TransportTUI.js';
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
ITransportEntry,
|
|
22
|
+
ITransportSettingsRegistryView,
|
|
23
|
+
} from '@robota-sdk/agent-interface-transport';
|
|
24
|
+
|
|
25
|
+
function entry(name: string, enabled: boolean): ITransportEntry {
|
|
26
|
+
return {
|
|
27
|
+
transport: { name },
|
|
28
|
+
config: { enabled, options: {} },
|
|
29
|
+
} as unknown as ITransportEntry;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function registryWith(
|
|
33
|
+
setEnabled: ITransportSettingsRegistryView['setEnabled'],
|
|
34
|
+
entries = [entry('ws', false)],
|
|
35
|
+
): ITransportSettingsRegistryView {
|
|
36
|
+
return {
|
|
37
|
+
getAll: () => entries,
|
|
38
|
+
setEnabled,
|
|
39
|
+
setOptions: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
} as unknown as ITransportSettingsRegistryView;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const flush = async (): Promise<void> => {
|
|
44
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
45
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe('TRANS-009: the surface says when a toggle applies', () => {
|
|
49
|
+
it('states that a toggle applies at the next start', () => {
|
|
50
|
+
const { lastFrame } = render(
|
|
51
|
+
<TransportTUI
|
|
52
|
+
registry={registryWith(vi.fn().mockResolvedValue(undefined))}
|
|
53
|
+
onClose={vi.fn()}
|
|
54
|
+
/>,
|
|
55
|
+
);
|
|
56
|
+
expect(lastFrame()).toContain('applies the next time Assistant starts');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not describe a saved setting as a running transport', () => {
|
|
60
|
+
// `[enabled]` read as "this is up". The badge names the saved setting instead.
|
|
61
|
+
const { lastFrame } = render(
|
|
62
|
+
<TransportTUI
|
|
63
|
+
registry={registryWith(vi.fn().mockResolvedValue(undefined), [entry('ws', true)])}
|
|
64
|
+
onClose={vi.fn()}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
const frame = lastFrame() ?? '';
|
|
68
|
+
expect(frame).not.toContain('[enabled]');
|
|
69
|
+
expect(frame).toContain('[on]');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('names the saved setting for a disabled transport too', () => {
|
|
73
|
+
// The other half of the badge's ternary. Asserting only the enabled branch leaves the disabled
|
|
74
|
+
// one uncovered by the change that rewrote both — the coverage gap HARNESS-122 was made of: a
|
|
75
|
+
// mutation proves a case measures the guard, not that the cases reach across it.
|
|
76
|
+
const { lastFrame } = render(
|
|
77
|
+
<TransportTUI
|
|
78
|
+
registry={registryWith(vi.fn().mockResolvedValue(undefined), [entry('ws', false)])}
|
|
79
|
+
onClose={vi.fn()}
|
|
80
|
+
/>,
|
|
81
|
+
);
|
|
82
|
+
const frame = lastFrame() ?? '';
|
|
83
|
+
expect(frame).not.toContain('[disabled]');
|
|
84
|
+
expect(frame).toContain('[off]');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('TRANS-009: a failed save is reported, not swallowed', () => {
|
|
89
|
+
it('renders the reason when the write fails', async () => {
|
|
90
|
+
const registry = registryWith(vi.fn().mockRejectedValue(new Error('EACCES: settings.json')));
|
|
91
|
+
const { stdin, lastFrame } = render(<TransportTUI registry={registry} onClose={vi.fn()} />);
|
|
92
|
+
|
|
93
|
+
stdin.write(' ');
|
|
94
|
+
await flush();
|
|
95
|
+
|
|
96
|
+
const frame = lastFrame() ?? '';
|
|
97
|
+
expect(frame).toContain('Not saved');
|
|
98
|
+
expect(frame).toContain('EACCES: settings.json');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('reports a non-Error rejection rather than rendering nothing', async () => {
|
|
102
|
+
// The branch that would otherwise print `undefined` — a rejection that is not an Error still has
|
|
103
|
+
// to produce a sentence, because the user's question is whether their change took effect.
|
|
104
|
+
const registry = registryWith(vi.fn().mockRejectedValue('nope'));
|
|
105
|
+
const { stdin, lastFrame } = render(<TransportTUI registry={registry} onClose={vi.fn()} />);
|
|
106
|
+
|
|
107
|
+
stdin.write(' ');
|
|
108
|
+
await flush();
|
|
109
|
+
|
|
110
|
+
expect(lastFrame() ?? '').toContain('could not save the setting');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// THE POSITIVE CONTROL. Without it the two cases above pass against a component that renders
|
|
114
|
+
// "Not saved" unconditionally — which would be a worse defect than the one being fixed.
|
|
115
|
+
it('shows no failure when the save succeeds', async () => {
|
|
116
|
+
const registry = registryWith(vi.fn().mockResolvedValue(undefined));
|
|
117
|
+
const { stdin, lastFrame } = render(<TransportTUI registry={registry} onClose={vi.fn()} />);
|
|
118
|
+
|
|
119
|
+
stdin.write(' ');
|
|
120
|
+
await flush();
|
|
121
|
+
|
|
122
|
+
expect(lastFrame() ?? '').not.toContain('Not saved');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for TuiInteractionChannel.askUser() — the CMD-004 unified ask path (the sole
|
|
3
|
+
* "ask the user" seam). Queue resolution + abort cancellation in isolation; no Ink render.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
|
|
7
|
+
vi.mock('@robota-sdk/agent-framework', async () => {
|
|
8
|
+
const actual = await vi.importActual<typeof import('@robota-sdk/agent-framework')>(
|
|
9
|
+
'@robota-sdk/agent-framework',
|
|
10
|
+
);
|
|
11
|
+
// RUNTIME-001: construction flows through buildRuntimeSession (wraps InteractiveSession); mock both.
|
|
12
|
+
const makeMockSession = (): unknown => ({
|
|
13
|
+
getFullHistory: vi.fn().mockReturnValue([]),
|
|
14
|
+
setName: vi.fn(),
|
|
15
|
+
getSessionId: vi.fn().mockReturnValue('test-id'),
|
|
16
|
+
isInitialized: false,
|
|
17
|
+
on: vi.fn(),
|
|
18
|
+
off: vi.fn(),
|
|
19
|
+
cancelQueue: vi.fn(),
|
|
20
|
+
abort: vi.fn(),
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
...actual,
|
|
24
|
+
InteractiveSession: vi.fn().mockImplementation(makeMockSession),
|
|
25
|
+
buildRuntimeSession: vi.fn().mockImplementation(makeMockSession),
|
|
26
|
+
CommandRegistry: vi.fn().mockImplementation(() => ({
|
|
27
|
+
addModule: vi.fn(),
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
import { TuiInteractionChannel } from '../TuiInteractionChannel.js';
|
|
33
|
+
|
|
34
|
+
import type { IAIProvider, IActionRequest } from '@robota-sdk/agent-core';
|
|
35
|
+
|
|
36
|
+
function makeChannel(): TuiInteractionChannel {
|
|
37
|
+
return new TuiInteractionChannel({ cwd: '/tmp', provider: {} as IAIProvider });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SELECT: IActionRequest = {
|
|
41
|
+
id: 'mode',
|
|
42
|
+
title: 'Select mode',
|
|
43
|
+
options: [
|
|
44
|
+
{ value: 'plan', label: 'Plan' },
|
|
45
|
+
{ value: 'default', label: 'Default' },
|
|
46
|
+
],
|
|
47
|
+
maxSelect: 1,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const CONFIRM: IActionRequest = {
|
|
51
|
+
id: 'exit',
|
|
52
|
+
title: 'Exit?',
|
|
53
|
+
options: [
|
|
54
|
+
{ value: 'yes', label: 'Yes' },
|
|
55
|
+
{ value: 'no', label: 'No' },
|
|
56
|
+
],
|
|
57
|
+
maxSelect: 1,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe('TuiInteractionChannel.askUser', () => {
|
|
61
|
+
let channel: TuiInteractionChannel;
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
channel = makeChannel();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('sets pendingUserAction when askUser is called', () => {
|
|
68
|
+
void channel.askUser(SELECT);
|
|
69
|
+
expect(channel.pendingUserAction).toMatchObject({ id: 'mode' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('resolves the answer when resolveUserAction is called, then clears pendingUserAction', async () => {
|
|
73
|
+
const promise = channel.askUser(SELECT);
|
|
74
|
+
channel.resolveUserAction(channel.pendingUserAction!, {
|
|
75
|
+
type: 'answer',
|
|
76
|
+
values: ['default'],
|
|
77
|
+
});
|
|
78
|
+
const response = await promise;
|
|
79
|
+
expect(response).toEqual({ type: 'answer', values: ['default'] });
|
|
80
|
+
expect(channel.pendingUserAction).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('resolves cancelled', async () => {
|
|
84
|
+
const promise = channel.askUser(SELECT);
|
|
85
|
+
channel.resolveUserAction(channel.pendingUserAction!, { type: 'cancelled' });
|
|
86
|
+
expect(await promise).toEqual({ type: 'cancelled' });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('queues multiple asks and processes them sequentially', async () => {
|
|
90
|
+
const p1 = channel.askUser(SELECT);
|
|
91
|
+
const p2 = channel.askUser(CONFIRM);
|
|
92
|
+
expect(channel.pendingUserAction).toMatchObject({ id: 'mode' });
|
|
93
|
+
|
|
94
|
+
channel.resolveUserAction(channel.pendingUserAction!, { type: 'answer', values: ['plan'] });
|
|
95
|
+
await p1;
|
|
96
|
+
expect(channel.pendingUserAction).toMatchObject({ id: 'exit' });
|
|
97
|
+
|
|
98
|
+
channel.resolveUserAction(channel.pendingUserAction!, { type: 'answer', values: ['no'] });
|
|
99
|
+
expect(await p2).toEqual({ type: 'answer', values: ['no'] });
|
|
100
|
+
expect(channel.pendingUserAction).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('cancelQueue() resolves every in-flight/queued ask as cancelled', async () => {
|
|
104
|
+
const p1 = channel.askUser(SELECT);
|
|
105
|
+
const p2 = channel.askUser(CONFIRM);
|
|
106
|
+
channel.cancelQueue();
|
|
107
|
+
expect(await p1).toEqual({ type: 'cancelled' });
|
|
108
|
+
expect(await p2).toEqual({ type: 'cancelled' });
|
|
109
|
+
expect(channel.pendingUserAction).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('abort() resolves an in-flight ask as cancelled', async () => {
|
|
113
|
+
const promise = channel.askUser(SELECT);
|
|
114
|
+
channel.abort();
|
|
115
|
+
expect(await promise).toEqual({ type: 'cancelled' });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('calls onChange when pendingUserAction changes', () => {
|
|
119
|
+
const onChange = vi.fn();
|
|
120
|
+
channel.onChange = onChange;
|
|
121
|
+
void channel.askUser(SELECT);
|
|
122
|
+
expect(onChange).toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-contract tests for TuiInteractionChannel.
|
|
3
|
+
*
|
|
4
|
+
* Verifies user-visible state: which entries appear in stateManager.history,
|
|
5
|
+
* with which roles, and in which order.
|
|
6
|
+
*
|
|
7
|
+
* These are deliberately separate from lifecycle.test.ts:
|
|
8
|
+
* lifecycle → event routing / onChange propagation (mechanism layer)
|
|
9
|
+
* this file → what the user sees on screen (display contract layer)
|
|
10
|
+
*
|
|
11
|
+
* Design principles:
|
|
12
|
+
* - user_message assertions do NOT require getFullHistory injection
|
|
13
|
+
* - every history assertion checks entry.type (= role) explicitly
|
|
14
|
+
* - timing: user message visible BEFORE complete fires
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
17
|
+
|
|
18
|
+
vi.mock('@robota-sdk/agent-framework', async () => {
|
|
19
|
+
const actual = await vi.importActual<typeof import('@robota-sdk/agent-framework')>(
|
|
20
|
+
'@robota-sdk/agent-framework',
|
|
21
|
+
);
|
|
22
|
+
// RUNTIME-001: construction flows through buildRuntimeSession (wraps InteractiveSession); mock both.
|
|
23
|
+
const makeMockSession = (): unknown => {
|
|
24
|
+
{
|
|
25
|
+
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
|
26
|
+
return {
|
|
27
|
+
getFullHistory: vi.fn().mockReturnValue([]),
|
|
28
|
+
setName: vi.fn(),
|
|
29
|
+
getName: vi.fn().mockReturnValue(undefined),
|
|
30
|
+
getPermissionMode: vi.fn().mockReturnValue('default'),
|
|
31
|
+
isInitialized: false,
|
|
32
|
+
on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
|
|
33
|
+
if (!handlers.has(event)) handlers.set(event, []);
|
|
34
|
+
handlers.get(event)!.push(handler);
|
|
35
|
+
}),
|
|
36
|
+
off: vi.fn(),
|
|
37
|
+
emit: (event: string, ...args: unknown[]) => {
|
|
38
|
+
(handlers.get(event) ?? []).forEach((h) => h(...args));
|
|
39
|
+
},
|
|
40
|
+
submit: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
executeCommand: vi.fn().mockResolvedValue(null),
|
|
42
|
+
getPendingPrompt: vi.fn().mockReturnValue(null),
|
|
43
|
+
abort: vi.fn(),
|
|
44
|
+
cancelQueue: vi.fn(),
|
|
45
|
+
listSelfPacedLoops: vi.fn().mockReturnValue([]),
|
|
46
|
+
stopSelfPacedLoop: vi.fn().mockResolvedValue(undefined),
|
|
47
|
+
getContextState: vi.fn().mockReturnValue({
|
|
48
|
+
usedPercentage: 0,
|
|
49
|
+
usedTokens: 0,
|
|
50
|
+
maxTokens: 100_000,
|
|
51
|
+
}),
|
|
52
|
+
getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
|
|
53
|
+
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
sendAgentJob: vi.fn().mockResolvedValue(undefined),
|
|
55
|
+
readExecutionWorkspaceDetail: vi.fn().mockResolvedValue({}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
...actual,
|
|
61
|
+
InteractiveSession: vi.fn().mockImplementation(makeMockSession),
|
|
62
|
+
buildRuntimeSession: vi.fn().mockImplementation(makeMockSession),
|
|
63
|
+
CommandRegistry: vi.fn().mockImplementation(() => ({
|
|
64
|
+
addModule: vi.fn(),
|
|
65
|
+
})),
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
import {
|
|
70
|
+
createAssistantMessage,
|
|
71
|
+
createSystemMessage,
|
|
72
|
+
createUserMessage,
|
|
73
|
+
messageToHistoryEntry,
|
|
74
|
+
} from '@robota-sdk/agent-core';
|
|
75
|
+
import { TuiInteractionChannel } from '../TuiInteractionChannel.js';
|
|
76
|
+
|
|
77
|
+
import type { IAIProvider, IHistoryEntry } from '@robota-sdk/agent-core';
|
|
78
|
+
import type { IExecutionResult } from '@robota-sdk/agent-interface-session';
|
|
79
|
+
|
|
80
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
type MockSession = {
|
|
83
|
+
getFullHistory: ReturnType<typeof vi.fn>;
|
|
84
|
+
listSelfPacedLoops: ReturnType<typeof vi.fn>;
|
|
85
|
+
stopSelfPacedLoop: ReturnType<typeof vi.fn>;
|
|
86
|
+
on: ReturnType<typeof vi.fn>;
|
|
87
|
+
emit: (event: string, ...args: unknown[]) => void;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function getMockSession(channel: TuiInteractionChannel): MockSession {
|
|
91
|
+
return (channel as unknown as { interactiveSession: MockSession }).interactiveSession;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function emitSessionEvent(channel: TuiInteractionChannel, event: string, ...args: unknown[]): void {
|
|
95
|
+
getMockSession(channel).emit(event, ...args);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function makeChannel(provider: IAIProvider = {} as IAIProvider): TuiInteractionChannel {
|
|
99
|
+
return new TuiInteractionChannel({
|
|
100
|
+
cwd: '/tmp/test',
|
|
101
|
+
provider,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function makeResult(overrides?: Partial<IExecutionResult>): IExecutionResult {
|
|
106
|
+
return {
|
|
107
|
+
contextState: { usedPercentage: 5, usedTokens: 500, maxTokens: 100_000 },
|
|
108
|
+
response: 'done',
|
|
109
|
+
...overrides,
|
|
110
|
+
} as unknown as IExecutionResult;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const MOCK_TOOL_RUNNING = {
|
|
114
|
+
toolName: 'bash',
|
|
115
|
+
isRunning: true,
|
|
116
|
+
input: '{}',
|
|
117
|
+
startTime: Date.now(),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const MOCK_TOOL_DONE = {
|
|
121
|
+
toolName: 'bash',
|
|
122
|
+
isRunning: false,
|
|
123
|
+
input: '{}',
|
|
124
|
+
startTime: Date.now(),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function entryRole(entry: IHistoryEntry): string {
|
|
128
|
+
return entry.type;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
vi.useFakeTimers();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
vi.useRealTimers();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── Group D: display contract (what the user sees) ────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe('Group D — display contract: history entries and active tools', () => {
|
|
142
|
+
it('keeps automatic naming text-only when an external event starts the session', async () => {
|
|
143
|
+
const chat = vi.fn().mockResolvedValue({ role: 'assistant', content: 'Event summary' });
|
|
144
|
+
const channel = makeChannel({ chat } as unknown as IAIProvider);
|
|
145
|
+
await channel.start();
|
|
146
|
+
|
|
147
|
+
emitSessionEvent(channel, 'turn_source', 'external');
|
|
148
|
+
emitSessionEvent(channel, 'user_message', 'external event');
|
|
149
|
+
|
|
150
|
+
expect(chat).toHaveBeenCalledTimes(1);
|
|
151
|
+
expect(chat.mock.calls[0]?.[1]?.toolChoice).toBe('none');
|
|
152
|
+
await channel.stop();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('Esc stops the only waiting self-paced loop without aborting the session', async () => {
|
|
156
|
+
const channel = makeChannel();
|
|
157
|
+
const session = getMockSession(channel);
|
|
158
|
+
session.listSelfPacedLoops.mockReturnValue([{ loopId: 'loop_one', phase: 'waiting' }]);
|
|
159
|
+
await channel.stopWaitingSelfPacedLoop();
|
|
160
|
+
expect(session.stopSelfPacedLoop).toHaveBeenCalledWith('loop_one', 'Loop stopped by Esc');
|
|
161
|
+
expect(channel.stateManager.history.some((entry) =>
|
|
162
|
+
JSON.stringify(entry).includes('loop_one'))).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('Esc does not guess which loop to stop when several are waiting', async () => {
|
|
166
|
+
const channel = makeChannel();
|
|
167
|
+
const session = getMockSession(channel);
|
|
168
|
+
session.listSelfPacedLoops.mockReturnValue([
|
|
169
|
+
{ loopId: 'loop_one', phase: 'waiting' },
|
|
170
|
+
{ loopId: 'loop_two', phase: 'waiting' },
|
|
171
|
+
]);
|
|
172
|
+
await channel.stopWaitingSelfPacedLoop();
|
|
173
|
+
expect(session.stopSelfPacedLoop).not.toHaveBeenCalled();
|
|
174
|
+
expect(channel.stateManager.history.some((entry) =>
|
|
175
|
+
JSON.stringify(entry).includes('/loop stop'))).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('D1 (CLI-B05): user_message event immediately adds role=user entry before complete', async () => {
|
|
179
|
+
const channel = makeChannel();
|
|
180
|
+
await channel.start();
|
|
181
|
+
|
|
182
|
+
// user message fires BEFORE complete — must be in history immediately
|
|
183
|
+
emitSessionEvent(channel, 'user_message', 'hello');
|
|
184
|
+
|
|
185
|
+
expect(channel.stateManager.history).toHaveLength(1);
|
|
186
|
+
expect(entryRole(channel.stateManager.history[0])).toBe('user');
|
|
187
|
+
expect((channel.stateManager.history[0].data as { content: string }).content).toBe('hello');
|
|
188
|
+
|
|
189
|
+
await channel.stop();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('D2: complete syncs assistant entry into history', async () => {
|
|
193
|
+
const channel = makeChannel();
|
|
194
|
+
const mockSession = getMockSession(channel);
|
|
195
|
+
const assistantEntry = messageToHistoryEntry(createAssistantMessage('Hi!'));
|
|
196
|
+
mockSession.getFullHistory.mockReturnValue([assistantEntry]);
|
|
197
|
+
await channel.start();
|
|
198
|
+
|
|
199
|
+
emitSessionEvent(channel, 'complete', makeResult());
|
|
200
|
+
|
|
201
|
+
const roles = channel.stateManager.history.map(entryRole);
|
|
202
|
+
expect(roles).toContain('assistant');
|
|
203
|
+
await channel.stop();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('D3 (CLI-B06): error event syncs error entry into history — no silent failure', async () => {
|
|
207
|
+
const channel = makeChannel();
|
|
208
|
+
const mockSession = getMockSession(channel);
|
|
209
|
+
const userEntry = messageToHistoryEntry(createUserMessage('hello'));
|
|
210
|
+
const errorEntry = messageToHistoryEntry(createSystemMessage('Error: network failure'));
|
|
211
|
+
mockSession.getFullHistory.mockReturnValue([userEntry, errorEntry]);
|
|
212
|
+
await channel.start();
|
|
213
|
+
|
|
214
|
+
emitSessionEvent(channel, 'user_message', 'hello');
|
|
215
|
+
emitSessionEvent(channel, 'error');
|
|
216
|
+
|
|
217
|
+
// error entry from getFullHistory must be visible
|
|
218
|
+
const roles = channel.stateManager.history.map(entryRole);
|
|
219
|
+
expect(roles).toContain('system');
|
|
220
|
+
await channel.stop();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('D4 (CLI-B07): tool_end marks tool isRunning=false; complete clears all (no stale spinner after complete)', async () => {
|
|
224
|
+
const channel = makeChannel();
|
|
225
|
+
await channel.start();
|
|
226
|
+
|
|
227
|
+
emitSessionEvent(channel, 'tool_start', MOCK_TOOL_RUNNING);
|
|
228
|
+
expect(channel.stateManager.activeTools).toHaveLength(1);
|
|
229
|
+
|
|
230
|
+
// After tool_end: tool stays in activeTools with isRunning:false (shows "ran" status during streaming)
|
|
231
|
+
emitSessionEvent(channel, 'tool_end', MOCK_TOOL_DONE);
|
|
232
|
+
expect(channel.stateManager.activeTools).toHaveLength(1);
|
|
233
|
+
expect(channel.stateManager.activeTools[0]!.isRunning).toBe(false);
|
|
234
|
+
|
|
235
|
+
// After complete: all tools cleared — StreamingIndicator must be gone
|
|
236
|
+
emitSessionEvent(channel, 'complete', makeResult());
|
|
237
|
+
expect(channel.stateManager.activeTools).toHaveLength(0);
|
|
238
|
+
|
|
239
|
+
await channel.stop();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('D5 (CLI-B08): thinking(false) clears activeTools even without complete', async () => {
|
|
243
|
+
const channel = makeChannel();
|
|
244
|
+
await channel.start();
|
|
245
|
+
|
|
246
|
+
emitSessionEvent(channel, 'thinking', true);
|
|
247
|
+
emitSessionEvent(channel, 'tool_start', MOCK_TOOL_RUNNING);
|
|
248
|
+
expect(channel.stateManager.activeTools).toHaveLength(1);
|
|
249
|
+
|
|
250
|
+
// abort path: thinking(false) without complete
|
|
251
|
+
emitSessionEvent(channel, 'thinking', false);
|
|
252
|
+
expect(channel.stateManager.activeTools).toHaveLength(0);
|
|
253
|
+
|
|
254
|
+
await channel.stop();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('D6: full turn — user entry appears first, assistant entry follows after complete', async () => {
|
|
258
|
+
const channel = makeChannel();
|
|
259
|
+
const mockSession = getMockSession(channel);
|
|
260
|
+
const userEntry = messageToHistoryEntry(createUserMessage('hello'));
|
|
261
|
+
const assistantEntry = messageToHistoryEntry(createAssistantMessage('world'));
|
|
262
|
+
// session history after complete includes both
|
|
263
|
+
mockSession.getFullHistory.mockReturnValue([userEntry, assistantEntry]);
|
|
264
|
+
await channel.start();
|
|
265
|
+
|
|
266
|
+
// 1. user message fires — visible immediately
|
|
267
|
+
emitSessionEvent(channel, 'user_message', 'hello');
|
|
268
|
+
expect(entryRole(channel.stateManager.history[0])).toBe('user');
|
|
269
|
+
|
|
270
|
+
// 2. streaming
|
|
271
|
+
emitSessionEvent(channel, 'text_delta', 'world');
|
|
272
|
+
expect(channel.stateManager.streamingText).toBe('world');
|
|
273
|
+
|
|
274
|
+
// 3. complete — syncHistory replaces with authoritative session history
|
|
275
|
+
emitSessionEvent(channel, 'complete', makeResult());
|
|
276
|
+
expect(channel.stateManager.streamingText).toBe('');
|
|
277
|
+
|
|
278
|
+
const roles = channel.stateManager.history.map(entryRole);
|
|
279
|
+
expect(roles[0]).toBe('user');
|
|
280
|
+
expect(roles[1]).toBe('assistant');
|
|
281
|
+
|
|
282
|
+
await channel.stop();
|
|
283
|
+
});
|
|
284
|
+
});
|