@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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-062 — PTY regression for real-terminal-cursor positioning (the OS-IME evidence).
|
|
3
|
+
*
|
|
4
|
+
* Runs the BUILT CLI binary in a real pseudo-terminal and interprets the raw ANSI stream the
|
|
5
|
+
* way a terminal emulator (and the OS IME) does. Two geometries from the implementation contract
|
|
6
|
+
* (.design/investigations/2026-07-25-cli-062-ime-cursor-design.md):
|
|
7
|
+
*
|
|
8
|
+
* - 24-row pty: while typing CJK, every `ESC[?25h` emitted after boot lands ON the input row at
|
|
9
|
+
* the composition column — never the top region (the historical hardcoded-y:0 crash geometry).
|
|
10
|
+
* - 5-row pty: the live frame fills the viewport (ink's fullscreen path, bottom-anchor
|
|
11
|
+
* off-by-one) — invariant I2 must refuse to position: ZERO cursor-show sequences after boot.
|
|
12
|
+
*
|
|
13
|
+
* Assertions only cover the post-boot composition window: boot may legitimately show the cursor
|
|
14
|
+
* (spinners etc.), and teardown restores visibility — both are outside the invariant.
|
|
15
|
+
*
|
|
16
|
+
* The second describe block drives four boundary representatives through the real binary. The full
|
|
17
|
+
* terminal/override matrix remains exhaustive in terminal-capabilities.test.ts; process startup is
|
|
18
|
+
* reserved for supported-default, Terminal.app-default-off, force-on, and kill-switch boundaries.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
26
|
+
import korean from '../__fixtures__/korean-text.json';
|
|
27
|
+
|
|
28
|
+
import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
|
|
29
|
+
import {
|
|
30
|
+
REPRESENTATIVE_IME_PTY_CELLS,
|
|
31
|
+
TERMINAL_PROFILES,
|
|
32
|
+
cellLabel,
|
|
33
|
+
expectedImeCursorEnabled,
|
|
34
|
+
profileEnv,
|
|
35
|
+
} from '../helpers/terminal-profiles.js';
|
|
36
|
+
import { interpretVtStream } from '../helpers/vt-cursor-interpreter.js';
|
|
37
|
+
|
|
38
|
+
import type { IPtySession } from './pty-driver.js';
|
|
39
|
+
import type { ICursorShowEvent } from '../helpers/vt-cursor-interpreter.js';
|
|
40
|
+
|
|
41
|
+
const COLS = 80;
|
|
42
|
+
const PROMPT_PLACEHOLDER = /Type a message or \/help/;
|
|
43
|
+
const COMPOSITION = korean.greetingFirstTwoSyllables;
|
|
44
|
+
/** The composition occupies 4 display columns; the prompt adds `> `. */
|
|
45
|
+
const COMPOSITION_WIDTH = 4;
|
|
46
|
+
/** Let ink's throttled (≤30fps) frame writes flush after the last keystroke. */
|
|
47
|
+
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 400));
|
|
48
|
+
|
|
49
|
+
interface IProbeResult {
|
|
50
|
+
/** Cursor shows emitted after boot — the composition window the OS IME would act on. */
|
|
51
|
+
compositionShows: ICursorShowEvent[];
|
|
52
|
+
/** Screen row holding `> ` followed by the composition, or -1 when the composition never rendered. */
|
|
53
|
+
inputRow: number;
|
|
54
|
+
/** Column of the `> ` prompt on the input row, or -1. */
|
|
55
|
+
promptCol: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Boot the built binary in a pty of the given geometry, type CJK, and report the cursor shows the
|
|
60
|
+
* terminal would have acted on. Shared by the original two cases and by every matrix cell.
|
|
61
|
+
*/
|
|
62
|
+
async function probe(
|
|
63
|
+
projectDir: string,
|
|
64
|
+
rows: number,
|
|
65
|
+
env: Record<string, string>,
|
|
66
|
+
register: (session: IPtySession) => void,
|
|
67
|
+
): Promise<IProbeResult> {
|
|
68
|
+
const session = spawnTui({
|
|
69
|
+
projectDir,
|
|
70
|
+
homeDir: join(projectDir, 'home'),
|
|
71
|
+
cols: COLS,
|
|
72
|
+
rows,
|
|
73
|
+
env,
|
|
74
|
+
});
|
|
75
|
+
register(session);
|
|
76
|
+
await session.waitFor(PROMPT_PLACEHOLDER);
|
|
77
|
+
await flush();
|
|
78
|
+
|
|
79
|
+
const bootMark = session.raw().length;
|
|
80
|
+
await session.sendKeys(COMPOSITION);
|
|
81
|
+
await session.waitFor(new RegExp(COMPOSITION));
|
|
82
|
+
await flush();
|
|
83
|
+
|
|
84
|
+
const vt = interpretVtStream(session.raw(), rows, COLS);
|
|
85
|
+
const inputRow = vt.screen.findIndex((line) => line.includes(`> ${COMPOSITION}`));
|
|
86
|
+
return {
|
|
87
|
+
compositionShows: vt.showEvents.filter((event) => event.offset >= bootMark),
|
|
88
|
+
inputRow,
|
|
89
|
+
promptCol: inputRow < 0 ? -1 : vt.screen[inputRow]!.indexOf('> '),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Every post-boot show sits on the input row at the composition column (the positive contract). */
|
|
94
|
+
function expectPositionedOnInputRow(result: IProbeResult): void {
|
|
95
|
+
expect(result.compositionShows.length).toBeGreaterThan(0);
|
|
96
|
+
expect(result.inputRow).toBeGreaterThanOrEqual(0);
|
|
97
|
+
for (const event of result.compositionShows) {
|
|
98
|
+
// Never the top region (the Terminal.app SIGSEGV geometry), always the input row.
|
|
99
|
+
expect(event.row).toBe(result.inputRow);
|
|
100
|
+
}
|
|
101
|
+
expect(result.compositionShows.at(-1)!.col).toBe(result.promptCol + 2 + COMPOSITION_WIDTH);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('CLI-062 — IME hardware-cursor positioning through a real PTY', () => {
|
|
105
|
+
let projectDir: string;
|
|
106
|
+
let session: IPtySession | undefined;
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'agent-pty-ime-')));
|
|
110
|
+
writeTuiProviderSettings(projectDir);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
afterEach(async () => {
|
|
114
|
+
await session?.disposeAsync();
|
|
115
|
+
session = undefined;
|
|
116
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('24-row pty: every post-boot cursor show lands on the input row at the composition column', async () => {
|
|
120
|
+
const result = await probe(projectDir, 24, {}, (s) => (session = s));
|
|
121
|
+
expectPositionedOnInputRow(result);
|
|
122
|
+
}, 60_000);
|
|
123
|
+
|
|
124
|
+
it('5-row pty (frame ≥ viewport, I2): zero cursor-show sequences during composition', async () => {
|
|
125
|
+
const result = await probe(projectDir, 5, {}, (s) => (session = s));
|
|
126
|
+
expect(result.compositionShows).toEqual([]);
|
|
127
|
+
}, 60_000);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Representative terminal boundaries, re-runnable through the real binary.
|
|
132
|
+
*
|
|
133
|
+
* PROVES, per cell: the built binary's observable cursor contract under exactly the environment
|
|
134
|
+
* that terminal exports — positioned on the input row when the capability gate says yes, and no
|
|
135
|
+
* cursor shown at all when it says no (Terminal.app's default-off I5 branch, and the
|
|
136
|
+
* `PRODUCT_IME_CURSOR=0` kill switch).
|
|
137
|
+
*
|
|
138
|
+
* DOES NOT PROVE, for a `documented` row: anything about that emulator's own rendering, its
|
|
139
|
+
* pre-edit display, or its OS IME. Terminal.app's historical Korean-IME SIGSEGV in particular is
|
|
140
|
+
* an Apple-side defect that can only be re-checked on macOS; it stays off by default (I5) until
|
|
141
|
+
* someone runs the macOS cells. tmux is additionally driven as a REAL emulator in
|
|
142
|
+
* ime-cursor-tmux.ptytest.ts, where the emulator reports its own cursor position back.
|
|
143
|
+
*/
|
|
144
|
+
describe('CLI-062 terminal matrix — observable cursor contract per terminal', () => {
|
|
145
|
+
let projectDir: string;
|
|
146
|
+
let session: IPtySession | undefined;
|
|
147
|
+
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'agent-pty-ime-matrix-')));
|
|
150
|
+
writeTuiProviderSettings(projectDir);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(async () => {
|
|
154
|
+
await session?.disposeAsync();
|
|
155
|
+
session = undefined;
|
|
156
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
for (const cell of REPRESENTATIVE_IME_PTY_CELLS) {
|
|
160
|
+
const profile = TERMINAL_PROFILES.find((candidate) => candidate.id === cell.profileId);
|
|
161
|
+
if (!profile) {
|
|
162
|
+
throw new Error(`Unknown representative terminal profile: ${cell.profileId}`);
|
|
163
|
+
}
|
|
164
|
+
const { override } = cell;
|
|
165
|
+
const enabled = expectedImeCursorEnabled(profile, override);
|
|
166
|
+
const verdict = enabled ? 'positions on the input row' : 'never shows the cursor';
|
|
167
|
+
|
|
168
|
+
it(`${cellLabel(profile, override)} → ${verdict}`, async () => {
|
|
169
|
+
const env = profileEnv(profile, override);
|
|
170
|
+
|
|
171
|
+
const wide = await probe(projectDir, 24, env, (s) => (session = s));
|
|
172
|
+
if (!enabled) {
|
|
173
|
+
expect(wide.compositionShows).toEqual([]);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
expectPositionedOnInputRow(wide);
|
|
177
|
+
await session?.disposeAsync();
|
|
178
|
+
session = undefined;
|
|
179
|
+
|
|
180
|
+
// I2, checked wherever the gate would otherwise allow positioning: a frame that fills the
|
|
181
|
+
// viewport is never positioned into, however capable the terminal is. (For a gated-off
|
|
182
|
+
// cell the 24-row probe above already proves nothing is ever shown.)
|
|
183
|
+
const narrow = await probe(projectDir, 5, env, (s) => (session = s));
|
|
184
|
+
expect(narrow.compositionShows).toEqual([]);
|
|
185
|
+
}, 90_000);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated HOME for PTY children (HARNESS-025).
|
|
3
|
+
*
|
|
4
|
+
* A PTY test that forwards the developer's real `HOME` runs its child against whatever happens to
|
|
5
|
+
* be in that home directory — a `~/state` settings file, a shell rc, an `$EDITOR` preference.
|
|
6
|
+
* The suite then passes or fails depending on the machine it runs on: green on CI (empty HOME),
|
|
7
|
+
* red on a developer box whose real config contradicts the fixture. That is the conditional flake
|
|
8
|
+
* this module removes.
|
|
9
|
+
*
|
|
10
|
+
* Every PTY child gets a throwaway directory instead. The directories are tracked and removed on
|
|
11
|
+
* process exit, so a test that forgets to dispose leaks nothing beyond the run.
|
|
12
|
+
*
|
|
13
|
+
* `packages/agent-ui-terminal/src/__tests__/pty/pty-driver.ts` already took an explicit
|
|
14
|
+
* `homeDir`; this generalizes that discipline to every `spawnPty` caller, including the default.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const createdHomes: string[] = [];
|
|
21
|
+
let exitHookInstalled = false;
|
|
22
|
+
|
|
23
|
+
function installExitHook(): void {
|
|
24
|
+
if (exitHookInstalled) return;
|
|
25
|
+
exitHookInstalled = true;
|
|
26
|
+
process.on('exit', disposeIsolatedHomes);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create a throwaway HOME directory for a PTY child and return its absolute path.
|
|
31
|
+
*
|
|
32
|
+
* The directory is empty: the child sees no `~/state`, no shell rc, no user preferences. Callers
|
|
33
|
+
* that need seeded content write it themselves after creating the directory.
|
|
34
|
+
*/
|
|
35
|
+
export function createIsolatedHome(): string {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), 'agent-pty-home-'));
|
|
37
|
+
createdHomes.push(dir);
|
|
38
|
+
installExitHook();
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Remove every directory handed out by {@link createIsolatedHome}. Idempotent. */
|
|
43
|
+
export function disposeIsolatedHomes(): void {
|
|
44
|
+
for (const dir of createdHomes.splice(0)) {
|
|
45
|
+
rmSync(dir, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the environment for a PTY child: an isolated HOME, the real PATH (the child still needs to
|
|
51
|
+
* find `sh`, `node`, …), and a known TERM.
|
|
52
|
+
*
|
|
53
|
+
* The environment is deliberately minimal rather than a copy of `process.env` — a PTY run must not
|
|
54
|
+
* inherit real provider API keys, and an explicit base makes every variable the child can see
|
|
55
|
+
* visible at the call site. Pass `overrides` for anything else the fixture needs (e.g. `EDITOR`).
|
|
56
|
+
*/
|
|
57
|
+
export function createPtyEnv(overrides: Readonly<NodeJS.ProcessEnv> = {}): NodeJS.ProcessEnv {
|
|
58
|
+
const home = typeof overrides.HOME === 'string' ? overrides.HOME : createIsolatedHome();
|
|
59
|
+
return {
|
|
60
|
+
PATH: process.env['PATH'] ?? '',
|
|
61
|
+
HOME: home,
|
|
62
|
+
TERM: 'xterm-256color',
|
|
63
|
+
...fixtureProductEnvironment(home),
|
|
64
|
+
...overrides,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Product identity and state roots for the compiled CLI's isolated PTY fixtures. */
|
|
69
|
+
export function fixtureProductEnvironment(home: string): Record<string, string> {
|
|
70
|
+
const id = 'fixture-agent';
|
|
71
|
+
const stateRoot = join(home, 'state');
|
|
72
|
+
return {
|
|
73
|
+
PRODUCT_ID: id,
|
|
74
|
+
PRODUCT_DISPLAY_NAME: 'Fixture Agent',
|
|
75
|
+
PRODUCT_CLI_NAME: id,
|
|
76
|
+
PRODUCT_ENV_PREFIX: 'FIXTURE_AGENT_',
|
|
77
|
+
PRODUCT_PACKAGE_SCOPE: '@fixture-agent',
|
|
78
|
+
PRODUCT_APP_ID: 'org.example.fixture-agent',
|
|
79
|
+
PRODUCT_PROTOCOL_SCHEME: id,
|
|
80
|
+
PRODUCT_TELEMETRY_SERVICE_NAME: `${id}.agent`,
|
|
81
|
+
PRODUCT_DAEMON_NAMESPACE: `${id}.daemon`,
|
|
82
|
+
PRODUCT_DESKTOP_EXECUTABLE: `${id}-runtime`,
|
|
83
|
+
PRODUCT_MCP_CLIENT_NAME: `${id}-client`,
|
|
84
|
+
PRODUCT_MODEL_TOOL_PREFIX: 'fixture_agent_command_',
|
|
85
|
+
PRODUCT_PROMPT_TAG: 'fixture_agent_references',
|
|
86
|
+
PRODUCT_EDITOR_TEMP_PREFIX: `${id}-editor-`,
|
|
87
|
+
PRODUCT_USER_STATE_DIR: stateRoot,
|
|
88
|
+
PRODUCT_PROJECT_STATE_DIR: '.fixture-agent',
|
|
89
|
+
PRODUCT_CACHE_DIR: join(stateRoot, 'cache'),
|
|
90
|
+
PRODUCT_LOG_DIR: join(stateRoot, 'logs'),
|
|
91
|
+
PRODUCT_BROWSER_NAMESPACE: `${id}.browser`,
|
|
92
|
+
PRODUCT_BROWSER_CREDENTIAL_DATABASE: `${id}-credentials`,
|
|
93
|
+
PRODUCT_CREDENTIAL_SERVICE: `org.example.${id}.credentials`,
|
|
94
|
+
PRODUCT_CRYPTO_NAMESPACE: `${id}.crypto`,
|
|
95
|
+
SECURITY_MASTER_KEY_DERIVATION_PATH: '[123,0]',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-PTY organization-policy engineering regression scenario (CLI-083).
|
|
3
|
+
*
|
|
4
|
+
* Drives the built Fixture Agent CLI with an isolated HOME. Nothing injects a policy object into the
|
|
5
|
+
* command module: the refusal can only come from the shipped `loadOrgPolicy()` startup path. The
|
|
6
|
+
* update check is disabled so this isolated scenario performs no startup network request.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, realpathSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import { spawnTui } from './pty-driver.js';
|
|
16
|
+
|
|
17
|
+
import type { IPtySession } from './pty-driver.js';
|
|
18
|
+
|
|
19
|
+
function writeOrgPolicyFixture(homeDir: string): void {
|
|
20
|
+
const settingsDir = join(homeDir, 'state');
|
|
21
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
22
|
+
writeFileSync(
|
|
23
|
+
join(settingsDir, 'settings.json'),
|
|
24
|
+
JSON.stringify({
|
|
25
|
+
currentProvider: 'anthropic',
|
|
26
|
+
providers: {
|
|
27
|
+
anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'pty-dummy-key' },
|
|
28
|
+
openai: { type: 'openai', model: 'gpt-test-model', apiKey: 'pty-dummy-key' },
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
'utf8',
|
|
32
|
+
);
|
|
33
|
+
writeFileSync(
|
|
34
|
+
join(settingsDir, 'org-policy.json'),
|
|
35
|
+
JSON.stringify({ allowedProviders: ['anthropic'], adminContact: 'ops@example.com' }),
|
|
36
|
+
'utf8',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('organization policy through the built CLI (CLI-083)', () => {
|
|
41
|
+
let projectDir: string;
|
|
42
|
+
let homeDir: string;
|
|
43
|
+
let session: IPtySession | undefined;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'agent-pty-org-policy-')));
|
|
47
|
+
homeDir = join(projectDir, 'home');
|
|
48
|
+
writeOrgPolicyFixture(homeDir);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await session?.disposeAsync();
|
|
53
|
+
session = undefined;
|
|
54
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('blocks a provider switch forbidden only by the policy loaded from disk', async () => {
|
|
58
|
+
session = spawnTui({ projectDir, homeDir, args: ['--disable-update-check'] });
|
|
59
|
+
await session.waitFor(/Type a message or \/help/);
|
|
60
|
+
|
|
61
|
+
const since = session.outputOffset();
|
|
62
|
+
await session.sendKeys('/provider switch openai');
|
|
63
|
+
await session.pressEnter();
|
|
64
|
+
|
|
65
|
+
await session.waitForSince(
|
|
66
|
+
since,
|
|
67
|
+
/Provider "openai" is not allowed by your organization policy/,
|
|
68
|
+
);
|
|
69
|
+
expect(session.snapshotSince(since)).toContain('Allowed: anthropic');
|
|
70
|
+
}, 60_000);
|
|
71
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-PTY provider-setup ask suite (CMD-004 TC-09 done-gate).
|
|
3
|
+
*
|
|
4
|
+
* Drives the BUILT CLI binary through `/provider add` so the unified ask seam renders end-to-end on
|
|
5
|
+
* a real terminal: the provider command self-asks (via the injected `askHandler` → `TuiInteractionChannel.askUser`)
|
|
6
|
+
* and the TUI renders the request through `PendingActionPrompt`. Proves the masked secret field hides
|
|
7
|
+
* the typed API key on the real CLI — the User Execution evidence the done-gate requires (no human
|
|
8
|
+
* needed; the PTY drives the binary). Run via `pnpm --filter @robota-sdk/agent-ui-terminal test:pty`
|
|
9
|
+
* against a freshly built CLI.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
|
|
19
|
+
|
|
20
|
+
import type { IPtySession } from './pty-driver.js';
|
|
21
|
+
|
|
22
|
+
const SECRET = 'topsecretapikey';
|
|
23
|
+
|
|
24
|
+
describe('provider setup ask through a real PTY (CMD-004 TC-09)', () => {
|
|
25
|
+
let projectDir: string;
|
|
26
|
+
let session: IPtySession | undefined;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'agent-pty-provider-')));
|
|
30
|
+
writeTuiProviderSettings(projectDir);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
await session?.disposeAsync();
|
|
35
|
+
session = undefined;
|
|
36
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('TC-09: /provider add asks the masked API-key field and never echoes the secret', async () => {
|
|
40
|
+
session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
|
|
41
|
+
await session.waitFor(/Type a message or \/help/);
|
|
42
|
+
|
|
43
|
+
// The provider command self-asks; the first anthropic setup step is the masked API key.
|
|
44
|
+
await session.sendKeys('/provider add anthropic');
|
|
45
|
+
await session.pressEnter();
|
|
46
|
+
await session.waitFor(/Anthropic API key/);
|
|
47
|
+
|
|
48
|
+
// Type the secret at human key rate; the masked field must render `*`, not the plaintext.
|
|
49
|
+
await session.sendKeys(SECRET);
|
|
50
|
+
await session.waitFor(new RegExp(`\\*{${SECRET.length}}`));
|
|
51
|
+
|
|
52
|
+
const masked = session.snapshot();
|
|
53
|
+
expect(masked).not.toContain(SECRET);
|
|
54
|
+
expect(masked).toContain('*'.repeat(SECRET.length));
|
|
55
|
+
|
|
56
|
+
// Esc cancels the wizard (no settings write / restart effect); the secret stays off-screen.
|
|
57
|
+
session.pressEscape();
|
|
58
|
+
await session.waitFor(/Provider setup cancelled\./);
|
|
59
|
+
expect(session.snapshot()).not.toContain(SECRET);
|
|
60
|
+
}, 60_000);
|
|
61
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY TUI driver (CLI-074 TC-07/08).
|
|
3
|
+
*
|
|
4
|
+
* Built-CLI convenience over the shared `spawnPty` harness (TEST-007): spawns the built CLI in
|
|
5
|
+
* a real pseudo-terminal so Ink renders exactly as in a user terminal, with per-key paced input
|
|
6
|
+
* (expect(1)-style burst input gets bundled as a bracketed paste — the failure mode this driver
|
|
7
|
+
* exists to avoid). Test-only; lives in a dedicated vitest project (*.ptytest.ts).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { spawnPty } from './spawn-pty.js';
|
|
14
|
+
import { fixtureProductEnvironment } from './isolated-home.js';
|
|
15
|
+
|
|
16
|
+
import type { IPtyRunSession } from './spawn-pty.js';
|
|
17
|
+
|
|
18
|
+
const REPO_ROOT = resolve(__dirname, '../../../../..');
|
|
19
|
+
const AGENT_BIN = join(REPO_ROOT, 'packages/agent-cli/bin/agent.cjs');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Signal the PTY child and await its ACTUAL exit before the caller removes the project dir.
|
|
23
|
+
* `dispose()` sends SIGTERM (then SIGKILL after the grace window) but returns synchronously;
|
|
24
|
+
* `expectExit()` resolves once the child has exited and **throws** if it has not within the timeout.
|
|
25
|
+
* Awaiting this closes the INFRA-026 race where a dying child writes under the selected state root between
|
|
26
|
+
* `rmSync`'s readdir and rmdir. A child that refuses to die fails the test loudly — the exit is awaited
|
|
27
|
+
* at the source, never masked by a removal fallback.
|
|
28
|
+
* Extracted so the compose is unit-testable with a fake session.
|
|
29
|
+
*/
|
|
30
|
+
export async function killAndAwaitExit(
|
|
31
|
+
session: Pick<IPtyRunSession, 'dispose' | 'expectExit'>,
|
|
32
|
+
timeoutMs?: number,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
session.dispose();
|
|
35
|
+
await session.expectExit(timeoutMs);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Driving session for a built-CLI PTY run. Mirrors the historical `spawnTui` surface (kept for the
|
|
40
|
+
* existing `*.ptytest.ts` suites) on top of the shared `IPtyRunSession`.
|
|
41
|
+
*/
|
|
42
|
+
export interface IPtySession {
|
|
43
|
+
sendKeys(text: string, perKeyDelayMs?: number): Promise<void>;
|
|
44
|
+
pressEnter(): Promise<void>;
|
|
45
|
+
/** Send a raw Escape keystroke (cancels a pending Ink prompt). */
|
|
46
|
+
pressEscape(): void;
|
|
47
|
+
/**
|
|
48
|
+
* Write bytes verbatim, with no per-character pacing. An escape SEQUENCE must come this way:
|
|
49
|
+
* `sendKeys` paces character by character, so `\x1b[B` arrives as a lone Escape — which an
|
|
50
|
+
* overlay reads as "cancel" — followed by the literal text `[B` typed into the composer.
|
|
51
|
+
*/
|
|
52
|
+
writeRaw(data: string): void;
|
|
53
|
+
waitFor(pattern: RegExp, timeoutMs?: number): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Mark for `waitForSince`/`snapshotSince`. `snapshot()` is a CUMULATIVE transcript (old frames +
|
|
56
|
+
* the echo of your own typed input included) — take a mark before acting and assert on what
|
|
57
|
+
* arrived after it, or a pattern can match stale frames / your own prompt text.
|
|
58
|
+
*/
|
|
59
|
+
outputOffset(): number;
|
|
60
|
+
/** Like `waitFor`, but only matches output that arrived after the `since` mark. */
|
|
61
|
+
waitForSince(since: number, pattern: RegExp, timeoutMs?: number): Promise<void>;
|
|
62
|
+
/** ANSI-stripped output that arrived after the `since` mark. */
|
|
63
|
+
snapshotSince(since: number): string;
|
|
64
|
+
snapshot(): string;
|
|
65
|
+
/** Raw (un-stripped) cumulative output — for cursor/escape-sequence assertions (CLI-062). */
|
|
66
|
+
raw(): string;
|
|
67
|
+
expectExit(timeoutMs?: number): Promise<number>;
|
|
68
|
+
kill(): void;
|
|
69
|
+
/**
|
|
70
|
+
* Teardown that awaits ACTUAL child exit: signals the PTY child (`kill()`/`dispose()`) then awaits
|
|
71
|
+
* `expectExit`, which resolves once the child has exited and **throws** if it has not within the
|
|
72
|
+
* timeout (default ≥ the SIGTERM→SIGKILL grace). Await this before removing the project dir so the
|
|
73
|
+
* dying child cannot write under the selected state root between `rmSync`'s readdir and rmdir (INFRA-026).
|
|
74
|
+
*/
|
|
75
|
+
disposeAsync(timeoutMs?: number): Promise<void>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ISpawnTuiOptions {
|
|
79
|
+
/** Project cwd. The fixture provider profile lives in the isolated user HOME. */
|
|
80
|
+
projectDir: string;
|
|
81
|
+
/** Isolated HOME directory. */
|
|
82
|
+
homeDir: string;
|
|
83
|
+
cols?: number;
|
|
84
|
+
rows?: number;
|
|
85
|
+
/** Extra CLI args appended after the binary (e.g. `['--session-log', path]`). */
|
|
86
|
+
args?: readonly string[];
|
|
87
|
+
/**
|
|
88
|
+
* Extra environment variables merged over the driver's minimal base env
|
|
89
|
+
* (e.g. `{ NO_COLOR: '1' }` for the SCREEN-006 no-color scenario). The base stays
|
|
90
|
+
* explicit — real provider keys are never inherited into PTY runs.
|
|
91
|
+
*/
|
|
92
|
+
env?: Readonly<Record<string, string>>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function writeTuiProviderSettings(projectDir: string): void {
|
|
96
|
+
// ARCH-042: a capabilityless built CLI is Restricted and must not read project settings. PTY
|
|
97
|
+
// fixtures therefore configure the host-owned user layer under the same isolated HOME that
|
|
98
|
+
// `spawnTui` derives from the fixture root.
|
|
99
|
+
const settingsDir = join(projectDir, 'home', 'state');
|
|
100
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
101
|
+
writeFileSync(
|
|
102
|
+
join(settingsDir, 'settings.json'),
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
currentProvider: 'anthropic',
|
|
105
|
+
providers: {
|
|
106
|
+
// Boot/slash/exit make zero model calls — the key is never used.
|
|
107
|
+
anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'pty-dummy-key' },
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
'utf8',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function spawnTui(options: ISpawnTuiOptions): IPtySession {
|
|
115
|
+
mkdirSync(options.homeDir, { recursive: true });
|
|
116
|
+
|
|
117
|
+
// An explicit session name suppresses LLM auto-naming, which fires a provider call on the first
|
|
118
|
+
// user message — with a replay/scripted provider that call silently CONSUMES the next recorded
|
|
119
|
+
// response and desyncs the fixture (lesson 2026-07-02). Tests that assert naming pass their own.
|
|
120
|
+
const args = options.args ?? [];
|
|
121
|
+
const defaultedArgs = args.includes('--name') ? args : [...args, '--name', 'pty-fixture'];
|
|
122
|
+
|
|
123
|
+
const session: IPtyRunSession = spawnPty({
|
|
124
|
+
command: process.execPath,
|
|
125
|
+
args: [AGENT_BIN, ...defaultedArgs],
|
|
126
|
+
cwd: options.projectDir,
|
|
127
|
+
env: {
|
|
128
|
+
PATH: process.env['PATH'] ?? '',
|
|
129
|
+
HOME: options.homeDir,
|
|
130
|
+
TERM: 'xterm-256color',
|
|
131
|
+
...fixtureProductEnvironment(options.homeDir),
|
|
132
|
+
// Never inherit real provider keys into PTY runs.
|
|
133
|
+
...(options.env ?? {}),
|
|
134
|
+
},
|
|
135
|
+
...(options.cols !== undefined ? { cols: options.cols } : {}),
|
|
136
|
+
...(options.rows !== undefined ? { rows: options.rows } : {}),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
sendKeys: (text, perKeyDelayMs): Promise<void> => session.sendKeys(text, perKeyDelayMs),
|
|
141
|
+
pressEnter: (): Promise<void> => session.pressEnter(),
|
|
142
|
+
pressEscape: (): void => session.write('\x1b'),
|
|
143
|
+
writeRaw: (data): void => session.write(data),
|
|
144
|
+
waitFor: (pattern, timeoutMs): Promise<void> => session.waitFor(pattern, timeoutMs),
|
|
145
|
+
outputOffset: (): number => session.outputOffset(),
|
|
146
|
+
waitForSince: (since, pattern, timeoutMs): Promise<void> =>
|
|
147
|
+
session.waitForSince(since, pattern, timeoutMs),
|
|
148
|
+
snapshotSince: (since): string => session.snapshotSince(since),
|
|
149
|
+
snapshot: (): string => session.snapshot(),
|
|
150
|
+
raw: (): string => session.raw(),
|
|
151
|
+
expectExit: (timeoutMs): Promise<number> => session.expectExit(timeoutMs),
|
|
152
|
+
kill: (): void => session.dispose(),
|
|
153
|
+
disposeAsync: (timeoutMs): Promise<void> => killAndAwaitExit(session, timeoutMs),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INFRA-018: deterministic conversation through the real binary via `--session-log` (replay).
|
|
3
|
+
*
|
|
4
|
+
* Runs in the PTY vitest project against the BUILT CLI. Boots with
|
|
5
|
+
* `--session-log <fixture>` so the INFRA-017 ReplayProvider answers from a recorded log (no model
|
|
6
|
+
* key, no network), sends a user message, and asserts the recorded assistant response renders and
|
|
7
|
+
* commits to `<Static>` scrollback while the input stays pinned — closing SCREEN-010 TC-02/03
|
|
8
|
+
* end-to-end on the real binary.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
|
|
19
|
+
|
|
20
|
+
import type { IPtySession } from './pty-driver.js';
|
|
21
|
+
|
|
22
|
+
const FIXTURE = join(
|
|
23
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
24
|
+
'fixtures',
|
|
25
|
+
'replay-conversation.jsonl',
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
describe('replay conversation through the real binary (INFRA-018)', () => {
|
|
29
|
+
let projectDir: string;
|
|
30
|
+
let session: IPtySession | undefined;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'agent-replay-pty-')));
|
|
34
|
+
writeTuiProviderSettings(projectDir); // dummy provider profile so the CLI boots; key never used
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await session?.disposeAsync();
|
|
39
|
+
session = undefined;
|
|
40
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('TC-01/02/03: replays a recorded assistant turn — streams, commits to scrollback, input pinned', async () => {
|
|
44
|
+
session = spawnTui({
|
|
45
|
+
projectDir,
|
|
46
|
+
homeDir: join(projectDir, 'home'),
|
|
47
|
+
args: ['--session-log', FIXTURE],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// TC-01: boots with the replay provider (no model key used).
|
|
51
|
+
await session.waitFor(/Type a message or \/help/);
|
|
52
|
+
await session.waitFor(/Idle/);
|
|
53
|
+
|
|
54
|
+
// Send a user message → the framework calls the provider → ReplayProvider returns the recorded
|
|
55
|
+
// assistant response.
|
|
56
|
+
await session.sendKeys('hello');
|
|
57
|
+
await session.pressEnter();
|
|
58
|
+
|
|
59
|
+
// TC-02: the recorded assistant response renders and commits to scrollback.
|
|
60
|
+
await session.waitFor(/REPLAYED_ANSWER_42/, 20_000);
|
|
61
|
+
|
|
62
|
+
// TC-03: the input prompt re-renders at the bottom (live region) below the committed reply.
|
|
63
|
+
await session.waitFor(/Type a message or \/help/, 20_000);
|
|
64
|
+
const snap = session.snapshot();
|
|
65
|
+
expect(snap).toContain('REPLAYED_ANSWER_42');
|
|
66
|
+
expect(snap.lastIndexOf('Type a message')).toBeGreaterThan(snap.indexOf('REPLAYED_ANSWER_42'));
|
|
67
|
+
}, 60_000);
|
|
68
|
+
});
|