@robota-sdk/agent-ui-terminal 3.0.0-beta.100
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/CHANGELOG.md +1204 -0
- package/LICENSE +661 -0
- package/README.md +50 -0
- package/dist/node/index.d.ts +1224 -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 +88 -0
- package/src/App.tsx +157 -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 +236 -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 +92 -0
- package/src/SessionStatusBar.tsx +80 -0
- package/src/SlashAutocomplete.tsx +154 -0
- package/src/StatusBar.tsx +308 -0
- package/src/StreamingIndicator.tsx +145 -0
- package/src/SupervisedSessionView.tsx +872 -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 +530 -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 +52 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +1112 -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 +294 -0
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +650 -0
- package/src/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/__tests__/__fixtures__/korean-text.json +17 -0
- package/src/__tests__/abort-after-permission.test.tsx +160 -0
- package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/__tests__/attached-app-session-switch.test.tsx +317 -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__/checkpoint-store-per-channel.test.ts +81 -0
- package/src/__tests__/cjk-defer-submit.test.tsx +68 -0
- package/src/__tests__/cjk-fallback-render.test.tsx +89 -0
- package/src/__tests__/cjk-text-input-flow.test.ts +187 -0
- package/src/__tests__/cjk-text-input.test.ts +196 -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 +47 -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 +114 -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__/grant-history-reaches-the-session.test.ts +31 -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 +474 -0
- package/src/__tests__/numbered-selection-chunk.test.tsx +41 -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 +329 -0
- package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
- package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
- package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
- package/src/__tests__/permission-prompt-requester.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 +350 -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 +320 -0
- package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +211 -0
- package/src/__tests__/pty/ime-cursor.ptytest.ts +187 -0
- package/src/__tests__/pty/isolated-home.ts +97 -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 +155 -0
- package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +135 -0
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +140 -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/session-attach.ptytest.ts +182 -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/trust-prompt.ptytest.ts +134 -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 +264 -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__/sandbox-reaches-the-session.test.ts +37 -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-picker.test.tsx +93 -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 +290 -0
- package/src/__tests__/status-glyph.test.ts +30 -0
- package/src/__tests__/streaming-indicator.test.tsx +178 -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 +70 -0
- package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
- package/src/__tests__/tui-interaction-queues.test.ts +149 -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 +482 -0
- package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
- package/src/__tests__/wave-text.test.tsx +133 -0
- package/src/__tests__/wire-tui-channel.test.ts +1457 -0
- package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
- package/src/app-banner.tsx +42 -0
- package/src/app-static-items.ts +35 -0
- package/src/app-view-model.ts +140 -0
- package/src/attached-session-connection.ts +14 -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 +93 -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 +88 -0
- package/src/flows/__tests__/real-cursor-flow.test.ts +139 -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-detach-key.test.tsx +72 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +87 -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 +271 -0
- package/src/hooks/useAppInputBindings.ts +163 -0
- package/src/hooks/useAppInteractionState.ts +84 -0
- package/src/hooks/useAppLifecycleState.ts +81 -0
- package/src/hooks/useAppOverlays.ts +90 -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 +93 -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 +141 -0
- package/src/humanize-tool-name.ts +51 -0
- package/src/index.ts +72 -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 +118 -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 +746 -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/short-session-id.ts +9 -0
- package/src/shutdown-signal.ts +34 -0
- package/src/slash-command-input.ts +11 -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 +111 -0
- package/src/tui-channel-lifecycle-coordinator.ts +149 -0
- package/src/tui-channel-options.ts +184 -0
- package/src/tui-cli-adapter-context.tsx +13 -0
- package/src/tui-cli-adapter.ts +24 -0
- package/src/tui-interaction-queues.ts +171 -0
- package/src/tui-session-binding.ts +52 -0
- package/src/tui-session-event-projector.ts +149 -0
- package/src/tui-session-events.ts +89 -0
- package/src/tui-session-notice-store.ts +43 -0
- package/src/tui-session-options.ts +134 -0
- package/src/tui-state-manager.ts +347 -0
- package/src/types.ts +21 -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
- package/src/waiting-loop-stop-notice.ts +23 -0
- package/src/wire-history-sync.ts +159 -0
- package/src/wire-tui-channel.ts +963 -0
- package/src/wire-tui-client-commands.ts +142 -0
- package/src/wire-tui-projection.ts +136 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type {
|
|
3
|
+
IExecutionWorkspaceEntry,
|
|
4
|
+
IExecutionWorkspaceSnapshot,
|
|
5
|
+
} from '@robota-sdk/agent-interface-execution';
|
|
6
|
+
import {
|
|
7
|
+
countActiveBackgroundWorkspaceEntries,
|
|
8
|
+
formatExecutionDetailRecord,
|
|
9
|
+
formatExecutionWorkspaceEntryRow,
|
|
10
|
+
getDefaultBackgroundWorkspaceEntries,
|
|
11
|
+
} from '../execution-workspace-view-model.js';
|
|
12
|
+
|
|
13
|
+
function makeEntry(overrides: Partial<IExecutionWorkspaceEntry>): IExecutionWorkspaceEntry {
|
|
14
|
+
return {
|
|
15
|
+
id: 'task:agent_1',
|
|
16
|
+
sourceId: 'agent_1',
|
|
17
|
+
kind: 'background_task',
|
|
18
|
+
origin: { kind: 'slash_command', sessionId: 'session_1', commandName: 'agent' },
|
|
19
|
+
taskKind: 'agent',
|
|
20
|
+
status: 'running',
|
|
21
|
+
state: 'working',
|
|
22
|
+
title: 'Explore',
|
|
23
|
+
subtitle: 'general-purpose',
|
|
24
|
+
preview: 'Map the CLI state manager',
|
|
25
|
+
unread: false,
|
|
26
|
+
attention: 'none',
|
|
27
|
+
visibility: 'default',
|
|
28
|
+
updatedAt: '2026-05-09T00:00:00.000Z',
|
|
29
|
+
controls: ['select', 'cancel'],
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeSnapshot(entries: readonly IExecutionWorkspaceEntry[]): IExecutionWorkspaceSnapshot {
|
|
35
|
+
return {
|
|
36
|
+
sessionId: 'session_1',
|
|
37
|
+
selectedEntryId: 'main:session_1',
|
|
38
|
+
updatedAt: '2026-05-09T00:00:00.000Z',
|
|
39
|
+
entries,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('execution workspace view model', () => {
|
|
44
|
+
it('renders selected and inactive entries with radio markers', () => {
|
|
45
|
+
const row = formatExecutionWorkspaceEntryRow(makeEntry({ id: 'task:agent_1' }), {
|
|
46
|
+
selectedEntryId: 'task:agent_1',
|
|
47
|
+
});
|
|
48
|
+
const inactive = formatExecutionWorkspaceEntryRow(makeEntry({ id: 'task:process_1' }), {
|
|
49
|
+
selectedEntryId: 'task:agent_1',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(row.radio).toBe('●');
|
|
53
|
+
expect(row.title).toBe('Explore agent');
|
|
54
|
+
expect(row.subtitle).toBe('agent · general-purpose');
|
|
55
|
+
expect(row.statusLabel).toBe('running');
|
|
56
|
+
expect(inactive.radio).toBe('○');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('filters default-visible background task entries for the compact panel', () => {
|
|
60
|
+
const snapshot = makeSnapshot([
|
|
61
|
+
makeEntry({ id: 'main:session_1', kind: 'main_thread', title: 'Main thread' }),
|
|
62
|
+
makeEntry({ id: 'task:agent_1', visibility: 'default' }),
|
|
63
|
+
makeEntry({ id: 'task:agent_2', visibility: 'collapsed' }),
|
|
64
|
+
makeEntry({ id: 'group:group_1', kind: 'background_group', visibility: 'default' }),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
expect(getDefaultBackgroundWorkspaceEntries(snapshot).map((entry) => entry.id)).toEqual([
|
|
68
|
+
'task:agent_1',
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('counts active default-visible background tasks without treating collapsed tasks as active', () => {
|
|
73
|
+
const snapshot = makeSnapshot([
|
|
74
|
+
makeEntry({ id: 'task:queued', status: 'queued' }),
|
|
75
|
+
makeEntry({ id: 'task:running', status: 'running' }),
|
|
76
|
+
makeEntry({ id: 'task:permission', status: 'waiting_permission' }),
|
|
77
|
+
makeEntry({ id: 'task:done', status: 'completed' }),
|
|
78
|
+
makeEntry({ id: 'task:hidden', status: 'running', visibility: 'collapsed' }),
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
expect(countActiveBackgroundWorkspaceEntries(snapshot)).toBe(3);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('bounds detail record text without parsing runner-specific output', () => {
|
|
85
|
+
const text = formatExecutionDetailRecord({
|
|
86
|
+
id: 'record_1',
|
|
87
|
+
kind: 'process_output',
|
|
88
|
+
text: ` ${'a'.repeat(180)} `,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(text).toHaveLength(163);
|
|
92
|
+
expect(text.endsWith('...')).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FLOW-2006 TC-05 — the provenance notice and the consumed-once prefill.
|
|
3
|
+
*
|
|
4
|
+
* The notice is what keeps a prompt the user never typed from reading like one they did, and the
|
|
5
|
+
* consumed-once rule is why a handoff remount cannot re-seed a prompt they already cleared.
|
|
6
|
+
*/
|
|
7
|
+
import { render } from 'ink-testing-library';
|
|
8
|
+
import React from 'react';
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import InputArea from '../InputArea.js';
|
|
12
|
+
import ExternalPromptNotice, {
|
|
13
|
+
externalPromptNotice,
|
|
14
|
+
EXTERNAL_PROMPT_NOTICE,
|
|
15
|
+
EXTERNAL_PROMPT_REVIEW_THRESHOLD,
|
|
16
|
+
} from '../external-prompt-notice.js';
|
|
17
|
+
|
|
18
|
+
const delay = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 20));
|
|
19
|
+
|
|
20
|
+
describe('externalPromptNotice', () => {
|
|
21
|
+
it('labels a prompt, escalates a long one, and says nothing about an empty composer', () => {
|
|
22
|
+
expect(externalPromptNotice('hi')).toBe(EXTERNAL_PROMPT_NOTICE);
|
|
23
|
+
expect(externalPromptNotice('')).toBeUndefined();
|
|
24
|
+
const long = 'x'.repeat(EXTERNAL_PROMPT_REVIEW_THRESHOLD + 1);
|
|
25
|
+
const escalated = externalPromptNotice(long) ?? '';
|
|
26
|
+
expect(escalated).toContain(EXTERNAL_PROMPT_NOTICE);
|
|
27
|
+
expect(escalated).toContain(String(long.length));
|
|
28
|
+
expect(escalated).toContain('review');
|
|
29
|
+
// Exactly at the threshold is still the plain label.
|
|
30
|
+
expect(externalPromptNotice('x'.repeat(EXTERNAL_PROMPT_REVIEW_THRESHOLD))).toBe(
|
|
31
|
+
EXTERNAL_PROMPT_NOTICE,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('ExternalPromptNotice', () => {
|
|
37
|
+
it('renders the line while the prompt is there and nothing once it is gone', () => {
|
|
38
|
+
const withPrompt = render(<ExternalPromptNotice value="from a link" />);
|
|
39
|
+
expect(withPrompt.lastFrame()).toContain(EXTERNAL_PROMPT_NOTICE);
|
|
40
|
+
const cleared = render(<ExternalPromptNotice value="" />);
|
|
41
|
+
expect(cleared.lastFrame() ?? '').not.toContain(EXTERNAL_PROMPT_NOTICE);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The consumed-once contract and the provenance lifetime, driven through the REAL composer.
|
|
47
|
+
*
|
|
48
|
+
* A hand-copied stand-in would keep passing if `InputArea`'s own seeding changed — which is the one
|
|
49
|
+
* thing these assertions exist to catch — so every case below mounts the shipped component.
|
|
50
|
+
*/
|
|
51
|
+
describe('the prefill is consumed once, and the label belongs to that text', () => {
|
|
52
|
+
it('seeds the composer, tells the controller, and drops the label once the text changes', async () => {
|
|
53
|
+
const consume = vi.fn();
|
|
54
|
+
const { lastFrame, stdin } = render(
|
|
55
|
+
<InputArea
|
|
56
|
+
onSubmit={vi.fn()}
|
|
57
|
+
isDisabled={false}
|
|
58
|
+
initialValue="from a link"
|
|
59
|
+
consumeInitialValue={consume}
|
|
60
|
+
externalPromptOrigin
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
await delay();
|
|
64
|
+
expect(lastFrame()).toContain('from a link');
|
|
65
|
+
expect(lastFrame()).toContain(EXTERNAL_PROMPT_NOTICE);
|
|
66
|
+
expect(consume).toHaveBeenCalledTimes(1);
|
|
67
|
+
|
|
68
|
+
// The user edits: what is in the composer is no longer what the link supplied, so the
|
|
69
|
+
// provenance line goes with it. A label that outlived the text would mark the user's own words.
|
|
70
|
+
stdin.write('!');
|
|
71
|
+
await delay();
|
|
72
|
+
expect(lastFrame()).toContain('from a link!');
|
|
73
|
+
expect(lastFrame() ?? '').not.toContain(EXTERNAL_PROMPT_NOTICE);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does not re-seed on the handoff remount, after the controller dropped the value', async () => {
|
|
77
|
+
const consume = vi.fn();
|
|
78
|
+
const remounted = render(
|
|
79
|
+
<InputArea
|
|
80
|
+
onSubmit={vi.fn()}
|
|
81
|
+
isDisabled={false}
|
|
82
|
+
initialValue={undefined}
|
|
83
|
+
consumeInitialValue={consume}
|
|
84
|
+
externalPromptOrigin
|
|
85
|
+
/>,
|
|
86
|
+
);
|
|
87
|
+
await delay();
|
|
88
|
+
expect(remounted.lastFrame() ?? '').not.toContain(EXTERNAL_PROMPT_NOTICE);
|
|
89
|
+
expect(consume).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('never labels a prompt the user typed themselves', async () => {
|
|
93
|
+
const { lastFrame, stdin } = render(<InputArea onSubmit={vi.fn()} isDisabled={false} />);
|
|
94
|
+
await delay();
|
|
95
|
+
stdin.write('my own prompt');
|
|
96
|
+
await delay();
|
|
97
|
+
expect(lastFrame()).toContain('my own prompt');
|
|
98
|
+
expect(lastFrame() ?? '').not.toContain(EXTERNAL_PROMPT_NOTICE);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TEST-007 / TERM-003 + TERM-004 fixture: run the real `/shell` and `/editor` command executors
|
|
3
|
+
* through the real `TerminalHandoffController` on a real PTY.
|
|
4
|
+
*
|
|
5
|
+
* A real Ink app (input hooks below a suspend gate, like the App) provides the live handoff; a minimal
|
|
6
|
+
* host context wires the executors' three touch-points (`canHandoffTerminal` / `runWithTerminal` /
|
|
7
|
+
* `getCwd`) to the controller. The session command-pipeline integration is already covered by the
|
|
8
|
+
* framework functional tests with a fake handoff — this fixture exercises the one thing only a real
|
|
9
|
+
* terminal can: the controller releasing raw mode so the child (subshell / `$EDITOR`) owns the TTY,
|
|
10
|
+
* and `runWithTerminal` returning so the TUI resumes.
|
|
11
|
+
*
|
|
12
|
+
* argv: [mode ('shell'|'editor'), outputPath]. The `/shell` command text is fixed here rather than
|
|
13
|
+
* taken from argv: argv only selects the mode, so no process input becomes shell text.
|
|
14
|
+
*
|
|
15
|
+
* Markers: `@@READY canHandoff=…@@`, `@@CMD_DONE@@`, `@@INPUT_AFTER_HANDOFF …@@`. A JSON result
|
|
16
|
+
* `{ success, exitCode?, message }` is written to outputPath.
|
|
17
|
+
*/
|
|
18
|
+
import { writeFileSync } from 'node:fs';
|
|
19
|
+
|
|
20
|
+
import { executeEditorCommand, executeShellCommand } from '@robota-sdk/agent-command';
|
|
21
|
+
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
22
|
+
import { Box, Text, render, useInput } from 'ink';
|
|
23
|
+
import React from 'react';
|
|
24
|
+
|
|
25
|
+
import { useTerminalHandoffSuspension } from '../../hooks/useTerminalHandoffSuspension.js';
|
|
26
|
+
import { TerminalHandoffController } from '../../terminal-handoff-controller.js';
|
|
27
|
+
|
|
28
|
+
import type {
|
|
29
|
+
ICommandHostTerminalHandoff,
|
|
30
|
+
ICommandHostWorkspace,
|
|
31
|
+
} from '@robota-sdk/agent-framework';
|
|
32
|
+
|
|
33
|
+
const MODE = process.argv[2];
|
|
34
|
+
const OUTPUT_PATH = process.argv[3];
|
|
35
|
+
/** Reads one line from the handed-off terminal and echoes it back, proving the child owned the TTY. */
|
|
36
|
+
const SHELL_COMMAND = 'IFS= read -r line; printf "SHELL_GOT:[%s]\\n" "$line"';
|
|
37
|
+
|
|
38
|
+
const controller = new TerminalHandoffController();
|
|
39
|
+
let resolveInputAfterHandoff: ((input: string) => void) | undefined;
|
|
40
|
+
|
|
41
|
+
/** Minimal host context — the shell/editor executors only read these three members. */
|
|
42
|
+
const context = {
|
|
43
|
+
canHandoffTerminal: (): boolean => controller.canHandoffTerminal,
|
|
44
|
+
runWithTerminal: <T,>(fn: () => Promise<T>): Promise<T> => controller.runWithTerminal(fn),
|
|
45
|
+
getCwd: (): string => process.cwd(),
|
|
46
|
+
} satisfies ICommandHostTerminalHandoff & Pick<ICommandHostWorkspace, 'getCwd'>;
|
|
47
|
+
|
|
48
|
+
function InputCapture(): React.ReactElement {
|
|
49
|
+
useInput((input) => resolveInputAfterHandoff?.(input));
|
|
50
|
+
return <Text>READY</Text>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function CommandApp(): React.ReactElement {
|
|
54
|
+
const suspended = useTerminalHandoffSuspension(controller);
|
|
55
|
+
if (suspended) return <Box />;
|
|
56
|
+
return (
|
|
57
|
+
<Box>
|
|
58
|
+
<InputCapture />
|
|
59
|
+
</Box>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function marker(line: string): void {
|
|
64
|
+
process.stdout.write(`\r\n@@${line}@@\r\n`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeResult(result: Record<string, string | number | boolean | undefined>): void {
|
|
68
|
+
if (OUTPUT_PATH) writeFileSync(OUTPUT_PATH, JSON.stringify(result), 'utf8');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function main(): Promise<void> {
|
|
72
|
+
const instance = render(<CommandApp />);
|
|
73
|
+
controller.setInkInstance(instance);
|
|
74
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
75
|
+
marker(`READY canHandoff=${controller.canHandoffTerminal}`);
|
|
76
|
+
|
|
77
|
+
// ARCH-029: the fixture's `satisfies Pick<>` above is the honest part — it declares exactly the
|
|
78
|
+
// three members these executors read. The widening cast existed only because the executors demand
|
|
79
|
+
// the whole 46-member facade. S3's role ports remove the need to widen at all; until then the
|
|
80
|
+
// double supplies the rest so no cast is required.
|
|
81
|
+
const ctx = createTestCommandHost({ overrides: context });
|
|
82
|
+
const result =
|
|
83
|
+
MODE === 'editor'
|
|
84
|
+
? await executeEditorCommand(ctx, '')
|
|
85
|
+
: await executeShellCommand(ctx, SHELL_COMMAND);
|
|
86
|
+
|
|
87
|
+
marker('CMD_DONE');
|
|
88
|
+
const inputAfterHandoff = await new Promise<string>((resolve) => {
|
|
89
|
+
resolveInputAfterHandoff = resolve;
|
|
90
|
+
});
|
|
91
|
+
marker(`INPUT_AFTER_HANDOFF ${inputAfterHandoff}`);
|
|
92
|
+
instance.unmount();
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
94
|
+
|
|
95
|
+
writeResult({
|
|
96
|
+
success: result.success,
|
|
97
|
+
message: result.message,
|
|
98
|
+
exitCode: (result.data as { exitCode?: number } | undefined)?.exitCode,
|
|
99
|
+
});
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
process.on('uncaughtException', (error) => {
|
|
105
|
+
writeResult({ success: false, message: String(error) });
|
|
106
|
+
process.stdout.write(`\r\n@@ERROR ${String(error)}@@\r\n`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
void main().catch((error) => {
|
|
111
|
+
writeResult({ success: false, message: String(error) });
|
|
112
|
+
process.stdout.write(`\r\n@@ERROR ${String(error)}@@\r\n`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# TEST-007 fake $EDITOR for the /editor PTY scenario.
|
|
3
|
+
# Invoked as: sh fake-editor.sh <file>. Reads ONE line from the inherited terminal (proving the
|
|
4
|
+
# editor runs on the real TTY under the handoff) and writes it as the file's contents, then exits 0.
|
|
5
|
+
file="$1"
|
|
6
|
+
IFS= read -r line
|
|
7
|
+
printf '%s\n' "$line" > "$file"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TEST-007 / TERM-002 fixture: real-terminal handoff under a PTY.
|
|
3
|
+
*
|
|
4
|
+
* Renders a real Ink app that takes raw mode (useInput) BELOW a suspend gate — exactly like the real
|
|
5
|
+
* App — and uses the real `TerminalHandoffController` + `useTerminalHandoffSuspension`. It performs an
|
|
6
|
+
* actual handoff: `runWithTerminal` spawns a child that reads ONE line from the inherited terminal and
|
|
7
|
+
* echoes it. If Ink released raw mode on suspend, the child receives the driver's keystrokes; after
|
|
8
|
+
* the child exits, `runWithTerminal` must RETURN and the App must resume.
|
|
9
|
+
*
|
|
10
|
+
* Driven by `terminal-handoff-pty-e2e.test.ts`. Markers the harness keys off: `@@READY@@`,
|
|
11
|
+
* `@@HANDOFF_STARTED@@`, `CHILD_GOT:[...]`, `@@RESUMED exit=N@@`. A JSON result is written to argv[0].
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { writeFileSync } from 'node:fs';
|
|
15
|
+
|
|
16
|
+
import { Box, Text, render, useInput } from 'ink';
|
|
17
|
+
import React from 'react';
|
|
18
|
+
|
|
19
|
+
import { useTerminalHandoffSuspension } from '../../hooks/useTerminalHandoffSuspension.js';
|
|
20
|
+
import { TerminalHandoffController } from '../../terminal-handoff-controller.js';
|
|
21
|
+
|
|
22
|
+
const OUTPUT_PATH = process.argv[2];
|
|
23
|
+
const controller = new TerminalHandoffController();
|
|
24
|
+
|
|
25
|
+
/** Input-bearing subtree: useInput lives BELOW the suspend gate, so it unmounts (Ink releases raw
|
|
26
|
+
* mode) while suspended — mirrors the real App. */
|
|
27
|
+
function InputCapture(): React.ReactElement {
|
|
28
|
+
useInput(() => {});
|
|
29
|
+
return <Text>READY</Text>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function HandoffApp(): React.ReactElement {
|
|
33
|
+
const suspended = useTerminalHandoffSuspension(controller);
|
|
34
|
+
if (suspended) return <Box />;
|
|
35
|
+
return (
|
|
36
|
+
<Box>
|
|
37
|
+
<InputCapture />
|
|
38
|
+
</Box>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function marker(line: string): void {
|
|
43
|
+
process.stdout.write(`\r\n@@${line}@@\r\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function writeResult(result: Record<string, string | number | boolean>): void {
|
|
47
|
+
if (OUTPUT_PATH) writeFileSync(OUTPUT_PATH, JSON.stringify(result), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function main(): Promise<void> {
|
|
51
|
+
const instance = render(<HandoffApp />);
|
|
52
|
+
controller.setInkInstance(instance);
|
|
53
|
+
|
|
54
|
+
// Wait for the App to mount + register its suspend hooks.
|
|
55
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
56
|
+
const canHandoff = controller.canHandoffTerminal;
|
|
57
|
+
marker(`READY canHandoff=${canHandoff}`);
|
|
58
|
+
|
|
59
|
+
const exitCode = await controller.runWithTerminal(async () => {
|
|
60
|
+
marker('HANDOFF_STARTED');
|
|
61
|
+
return await new Promise<number>((resolve) => {
|
|
62
|
+
const child = spawn('sh', ['-c', 'IFS= read -r line; printf "CHILD_GOT:[%s]\\n" "$line"'], {
|
|
63
|
+
stdio: 'inherit',
|
|
64
|
+
});
|
|
65
|
+
child.on('exit', (code) => resolve(code ?? 0));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// If runWithTerminal returned, the App resumed (finally → resume ran). Confirm with a marker.
|
|
70
|
+
marker(`AFTER_HANDOFF exit=${exitCode}`);
|
|
71
|
+
instance.unmount();
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
73
|
+
marker(`RESUMED exit=${exitCode}`);
|
|
74
|
+
|
|
75
|
+
writeResult({ canHandoff, exitCode, returned: true });
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
process.on('uncaughtException', (error) => {
|
|
81
|
+
writeResult({ error: String(error), returned: false });
|
|
82
|
+
process.stdout.write(`\r\n@@ERROR ${String(error)}@@\r\n`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
void main().catch((error) => {
|
|
87
|
+
writeResult({ error: String(error), returned: false });
|
|
88
|
+
process.stdout.write(`\r\n@@ERROR ${String(error)}@@\r\n`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-1994 TC-08 / TC-09 — the `attach` control, and what it refuses.
|
|
3
|
+
*
|
|
4
|
+
* Attach is the only genuinely new user surface this item adds, and the whole of its meaning is that
|
|
5
|
+
* it is a VIEW switch: the terminal starts looking at the fork's own session record, and neither
|
|
6
|
+
* record is written or merged. So what these cases pin is the pair — which entries offer the
|
|
7
|
+
* control, and that selecting it switches this terminal onto the FORK's record — reporting the
|
|
8
|
+
* FORK's id — plus the three refusals, each of which must reach the operator with its reason rather
|
|
9
|
+
* than leaving the terminal pointed at nothing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { render } from 'ink-testing-library';
|
|
13
|
+
import React from 'react';
|
|
14
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
15
|
+
|
|
16
|
+
import ExecutionWorkspaceSwitcher, {
|
|
17
|
+
EXECUTION_WORKSPACE_ATTACH_HINT,
|
|
18
|
+
} from '../ExecutionWorkspaceSwitcher.js';
|
|
19
|
+
import { attachToForkedSession } from '../flows/fork-attach-flow.js';
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
IExecutionWorkspaceEntry,
|
|
23
|
+
IExecutionWorkspaceSnapshot,
|
|
24
|
+
TExecutionControl,
|
|
25
|
+
} from '@robota-sdk/agent-interface-execution';
|
|
26
|
+
|
|
27
|
+
const FORK_SESSION_ID = 'session_cli-1994-fork';
|
|
28
|
+
|
|
29
|
+
function makeForkEntry(
|
|
30
|
+
overrides: Partial<IExecutionWorkspaceEntry> = {},
|
|
31
|
+
): IExecutionWorkspaceEntry {
|
|
32
|
+
return {
|
|
33
|
+
id: 'task:agent_1',
|
|
34
|
+
sourceId: 'agent_1',
|
|
35
|
+
kind: 'background_task',
|
|
36
|
+
taskKind: 'agent',
|
|
37
|
+
origin: { kind: 'slash_command', sessionId: 'session_parent' },
|
|
38
|
+
status: 'running',
|
|
39
|
+
state: 'working',
|
|
40
|
+
title: 'experiment',
|
|
41
|
+
subtitle: 'general-purpose',
|
|
42
|
+
unread: false,
|
|
43
|
+
attention: 'none',
|
|
44
|
+
visibility: 'default',
|
|
45
|
+
updatedAt: '2026-09-07T00:00:00.000Z',
|
|
46
|
+
controls: ['select', 'cancel', 'attach'],
|
|
47
|
+
resumeSessionId: FORK_SESSION_ID,
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makePlainEntry(): IExecutionWorkspaceEntry {
|
|
53
|
+
const { resumeSessionId: _dropped, ...entry } = makeForkEntry({
|
|
54
|
+
id: 'task:agent_2',
|
|
55
|
+
sourceId: 'agent_2',
|
|
56
|
+
title: 'plain job',
|
|
57
|
+
controls: ['select', 'cancel'],
|
|
58
|
+
});
|
|
59
|
+
return entry;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeSnapshot(entries: readonly IExecutionWorkspaceEntry[]): IExecutionWorkspaceSnapshot {
|
|
63
|
+
return {
|
|
64
|
+
sessionId: 'session_parent',
|
|
65
|
+
selectedEntryId: entries[0]?.id ?? '',
|
|
66
|
+
updatedAt: '2026-09-07T00:00:00.000Z',
|
|
67
|
+
entries: [...entries],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The store answer: which records still exist. */
|
|
72
|
+
function storeWith(ids: readonly string[]): (sessionId: string) => boolean {
|
|
73
|
+
return (sessionId) => ids.includes(sessionId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('attaching to a fork switches the view (CLI-1994 TC-08)', () => {
|
|
77
|
+
it('a forked entry switches to the FORK, not the parent', () => {
|
|
78
|
+
const switchSession = vi.fn();
|
|
79
|
+
const notify = vi.fn();
|
|
80
|
+
|
|
81
|
+
const outcome = attachToForkedSession(makeForkEntry(), {
|
|
82
|
+
hasSessionRecord: storeWith([FORK_SESSION_ID]),
|
|
83
|
+
switchSession,
|
|
84
|
+
notify,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(outcome).toEqual({ sessionId: FORK_SESSION_ID });
|
|
88
|
+
expect(switchSession).toHaveBeenCalledWith(FORK_SESSION_ID);
|
|
89
|
+
// A view switch, not a merge: the only thing that happened is the switch.
|
|
90
|
+
expect(notify).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('an entry with no resumeSessionId is not attachable and emits no intent', () => {
|
|
94
|
+
const switchSession = vi.fn();
|
|
95
|
+
const notify = vi.fn();
|
|
96
|
+
|
|
97
|
+
const intent = attachToForkedSession(makePlainEntry(), {
|
|
98
|
+
hasSessionRecord: storeWith([FORK_SESSION_ID]),
|
|
99
|
+
switchSession,
|
|
100
|
+
notify,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(intent).toBeUndefined();
|
|
104
|
+
expect(switchSession).not.toHaveBeenCalled();
|
|
105
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining('not a forked session'));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('the switcher offers the attach hint on a forked entry and calls onAttach for the `a` key', () => {
|
|
109
|
+
const onAttach = vi.fn();
|
|
110
|
+
const entry = makeForkEntry();
|
|
111
|
+
const { lastFrame, stdin } = render(
|
|
112
|
+
<ExecutionWorkspaceSwitcher
|
|
113
|
+
snapshot={makeSnapshot([entry])}
|
|
114
|
+
selectedEntryId={entry.id}
|
|
115
|
+
onSelect={vi.fn()}
|
|
116
|
+
onClose={vi.fn()}
|
|
117
|
+
onAttach={onAttach}
|
|
118
|
+
/>,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(lastFrame()).toContain(EXECUTION_WORKSPACE_ATTACH_HINT.label);
|
|
122
|
+
|
|
123
|
+
stdin.write('a');
|
|
124
|
+
|
|
125
|
+
expect(onAttach).toHaveBeenCalledWith(entry);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('an entry without the attach control neither offers the hint nor answers the key', () => {
|
|
129
|
+
const onAttach = vi.fn();
|
|
130
|
+
const entry = makePlainEntry();
|
|
131
|
+
const { lastFrame, stdin } = render(
|
|
132
|
+
<ExecutionWorkspaceSwitcher
|
|
133
|
+
snapshot={makeSnapshot([entry])}
|
|
134
|
+
selectedEntryId={entry.id}
|
|
135
|
+
onSelect={vi.fn()}
|
|
136
|
+
onClose={vi.fn()}
|
|
137
|
+
onAttach={onAttach}
|
|
138
|
+
/>,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(lastFrame()).not.toContain(EXECUTION_WORKSPACE_ATTACH_HINT.label);
|
|
142
|
+
|
|
143
|
+
stdin.write('a');
|
|
144
|
+
|
|
145
|
+
expect(onAttach).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('`attach` is a member of TExecutionControl — every control list still accepts it', () => {
|
|
149
|
+
// The runtime half of the union change; the compile half is the package `typecheck` this suite
|
|
150
|
+
// is paired with in the spec's TC-08, which reaches every exhaustive switch over the union.
|
|
151
|
+
const controls: readonly TExecutionControl[] = [
|
|
152
|
+
'select',
|
|
153
|
+
'cancel',
|
|
154
|
+
'close',
|
|
155
|
+
'send',
|
|
156
|
+
'read_log',
|
|
157
|
+
'wait',
|
|
158
|
+
'attach',
|
|
159
|
+
];
|
|
160
|
+
expect(controls).toContain('attach');
|
|
161
|
+
expect(makeForkEntry().controls).toContain('attach');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('attach refuses rather than stranding the terminal (CLI-1994 TC-09)', () => {
|
|
166
|
+
it.each(['completed', 'failed', 'cancelled'] as const)(
|
|
167
|
+
'a %s fork is refused with its status, and no intent is emitted',
|
|
168
|
+
(status) => {
|
|
169
|
+
const switchSession = vi.fn();
|
|
170
|
+
const notify = vi.fn();
|
|
171
|
+
|
|
172
|
+
const intent = attachToForkedSession(makeForkEntry({ status }), {
|
|
173
|
+
// The record is still there; the TASK being terminal is what refuses.
|
|
174
|
+
hasSessionRecord: storeWith([FORK_SESSION_ID]),
|
|
175
|
+
switchSession,
|
|
176
|
+
notify,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(intent).toBeUndefined();
|
|
180
|
+
expect(switchSession).not.toHaveBeenCalled();
|
|
181
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining(status));
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
it('uses a host-supplied resume command for a terminal fork while a neutral host gets the session id', () => {
|
|
186
|
+
const entry = makeForkEntry({ status: 'completed' });
|
|
187
|
+
const neutral = vi.fn();
|
|
188
|
+
attachToForkedSession(entry, {
|
|
189
|
+
hasSessionRecord: storeWith([FORK_SESSION_ID]),
|
|
190
|
+
switchSession: vi.fn(),
|
|
191
|
+
notify: neutral,
|
|
192
|
+
});
|
|
193
|
+
expect(neutral.mock.calls[0]?.[0]).toContain(FORK_SESSION_ID);
|
|
194
|
+
expect(neutral.mock.calls[0]?.[0]).not.toContain('agent');
|
|
195
|
+
|
|
196
|
+
const product = vi.fn();
|
|
197
|
+
attachToForkedSession(entry, {
|
|
198
|
+
hasSessionRecord: storeWith([FORK_SESSION_ID]),
|
|
199
|
+
switchSession: vi.fn(),
|
|
200
|
+
notify: product,
|
|
201
|
+
formatResumeCommand: (id) => `agent --resume ${id}`,
|
|
202
|
+
});
|
|
203
|
+
expect(product.mock.calls[0]?.[0]).toContain(`agent --resume ${FORK_SESSION_ID}`);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('a missing session record is refused, naming the record and the task status', () => {
|
|
207
|
+
const switchSession = vi.fn();
|
|
208
|
+
const notify = vi.fn();
|
|
209
|
+
|
|
210
|
+
const intent = attachToForkedSession(makeForkEntry({ status: 'running' }), {
|
|
211
|
+
hasSessionRecord: storeWith([]),
|
|
212
|
+
switchSession,
|
|
213
|
+
notify,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
expect(intent).toBeUndefined();
|
|
217
|
+
expect(switchSession).not.toHaveBeenCalled();
|
|
218
|
+
const reason = notify.mock.calls[0]?.[0] as string;
|
|
219
|
+
expect(reason).toContain(FORK_SESSION_ID);
|
|
220
|
+
expect(reason).toContain('running');
|
|
221
|
+
expect(reason).toContain('no longer in the session store');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('a live fork whose record exists is the ONLY case that switches', () => {
|
|
225
|
+
const switchSession = vi.fn();
|
|
226
|
+
|
|
227
|
+
attachToForkedSession(makeForkEntry({ status: 'waiting_permission' }), {
|
|
228
|
+
hasSessionRecord: storeWith([FORK_SESSION_ID]),
|
|
229
|
+
switchSession,
|
|
230
|
+
notify: vi.fn(),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(switchSession).toHaveBeenCalledTimes(1);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3189 — the run's external-event grant history reaches every session the TUI builds. It is created
|
|
3
|
+
* once per run so a session switch replays no spent token and resets no rate; dropped on the way
|
|
4
|
+
* (the render projection copies field by field, and a spread skips the excess-property check), each
|
|
5
|
+
* session would open its grants with an empty history.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createExternalEventGrantHistory } from '@robota-sdk/agent-framework';
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import { toChannelOptions } from '../render.js';
|
|
12
|
+
import { buildTuiSessionOptions } from '../tui-session-options.js';
|
|
13
|
+
|
|
14
|
+
import type { IRenderOptions } from '../render.js';
|
|
15
|
+
|
|
16
|
+
describe('#3189: the grant history survives the TUI projection', () => {
|
|
17
|
+
it('carries the same history instance into every session it builds', () => {
|
|
18
|
+
const history = createExternalEventGrantHistory();
|
|
19
|
+
const options = {
|
|
20
|
+
cwd: '/work',
|
|
21
|
+
provider: {} as never,
|
|
22
|
+
externalEventGrantHistory: history,
|
|
23
|
+
} as unknown as IRenderOptions;
|
|
24
|
+
|
|
25
|
+
const first = buildTuiSessionOptions(toChannelOptions(options, undefined));
|
|
26
|
+
const switchedTo = buildTuiSessionOptions(toChannelOptions(options, 'session-b'));
|
|
27
|
+
|
|
28
|
+
expect(first.externalEventGrantHistory).toBe(history);
|
|
29
|
+
expect(switchedTo.externalEventGrantHistory).toBe(history);
|
|
30
|
+
});
|
|
31
|
+
});
|