@robota-sdk/agent-transport 3.0.0-beta.64

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.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/dist/node/headless/index.cjs +1 -0
  3. package/dist/node/headless/index.d.ts +2 -0
  4. package/dist/node/headless/index.js +1 -0
  5. package/dist/node/headless-CWEpJXFK.js +7 -0
  6. package/dist/node/headless-CWEpJXFK.js.map +1 -0
  7. package/dist/node/headless-CsZFelG9.cjs +6 -0
  8. package/dist/node/http/index.cjs +1 -0
  9. package/dist/node/http/index.d.ts +2 -0
  10. package/dist/node/http/index.js +1 -0
  11. package/dist/node/http-CM3TJhrF.cjs +1 -0
  12. package/dist/node/http-DwO1AHG-.js +2 -0
  13. package/dist/node/http-DwO1AHG-.js.map +1 -0
  14. package/dist/node/index--Ti9NzQX.d.ts +64 -0
  15. package/dist/node/index--Ti9NzQX.d.ts.map +1 -0
  16. package/dist/node/index-B_rcr14p.d.ts +47 -0
  17. package/dist/node/index-B_rcr14p.d.ts.map +1 -0
  18. package/dist/node/index-C9LWCL4l.d.ts +34 -0
  19. package/dist/node/index-C9LWCL4l.d.ts.map +1 -0
  20. package/dist/node/index-CAr3ioVh.d.ts +64 -0
  21. package/dist/node/index-CAr3ioVh.d.ts.map +1 -0
  22. package/dist/node/index-CEs25wVk.d.ts +213 -0
  23. package/dist/node/index-CEs25wVk.d.ts.map +1 -0
  24. package/dist/node/index-CvXLpjJO.d.ts +213 -0
  25. package/dist/node/index-CvXLpjJO.d.ts.map +1 -0
  26. package/dist/node/index-D34WUfFH.d.ts +26 -0
  27. package/dist/node/index-D34WUfFH.d.ts.map +1 -0
  28. package/dist/node/index-Y0zHb1Bz.d.ts +47 -0
  29. package/dist/node/index-Y0zHb1Bz.d.ts.map +1 -0
  30. package/dist/node/index-k3TUjA-T.d.ts +26 -0
  31. package/dist/node/index-k3TUjA-T.d.ts.map +1 -0
  32. package/dist/node/index-nBlMTFkZ.d.ts +34 -0
  33. package/dist/node/index-nBlMTFkZ.d.ts.map +1 -0
  34. package/dist/node/index.cjs +1 -0
  35. package/dist/node/index.d.ts +6 -0
  36. package/dist/node/index.js +1 -0
  37. package/dist/node/mcp/index.cjs +1 -0
  38. package/dist/node/mcp/index.d.ts +2 -0
  39. package/dist/node/mcp/index.js +1 -0
  40. package/dist/node/mcp-BXBwF6Wu.js +2 -0
  41. package/dist/node/mcp-BXBwF6Wu.js.map +1 -0
  42. package/dist/node/mcp-DcHuGokt.cjs +1 -0
  43. package/dist/node/tui/index.cjs +1 -0
  44. package/dist/node/tui/index.d.ts +2 -0
  45. package/dist/node/tui/index.js +1 -0
  46. package/dist/node/tui-CeD_6rSo.cjs +24 -0
  47. package/dist/node/tui-zmDTPk4b.js +25 -0
  48. package/dist/node/tui-zmDTPk4b.js.map +1 -0
  49. package/dist/node/ws/index.cjs +1 -0
  50. package/dist/node/ws/index.d.ts +2 -0
  51. package/dist/node/ws/index.js +1 -0
  52. package/dist/node/ws-B-oRccFl.js +2 -0
  53. package/dist/node/ws-B-oRccFl.js.map +1 -0
  54. package/dist/node/ws-COnIgnmn.cjs +1 -0
  55. package/package.json +141 -0
  56. package/src/headless/__tests__/headless-runner-initialization.test.ts +45 -0
  57. package/src/headless/__tests__/headless-runner.test.ts +484 -0
  58. package/src/headless/__tests__/headless-skill-activation.integration.test.ts +430 -0
  59. package/src/headless/__tests__/headless-transport.test.ts +268 -0
  60. package/src/headless/headless-runner.ts +141 -0
  61. package/src/headless/headless-stream-json.ts +142 -0
  62. package/src/headless/headless-transport.ts +43 -0
  63. package/src/headless/index.ts +4 -0
  64. package/src/http/__tests__/http-transport.test.ts +55 -0
  65. package/src/http/__tests__/routes.test.ts +168 -0
  66. package/src/http/http-transport.ts +42 -0
  67. package/src/http/index.ts +4 -0
  68. package/src/http/routes.ts +151 -0
  69. package/src/index.ts +5 -0
  70. package/src/mcp/__tests__/mcp-server.test.ts +66 -0
  71. package/src/mcp/__tests__/mcp-transport.test.ts +46 -0
  72. package/src/mcp/index.ts +4 -0
  73. package/src/mcp/mcp-server.ts +162 -0
  74. package/src/mcp/mcp-transport.ts +48 -0
  75. package/src/tui/App.tsx +478 -0
  76. package/src/tui/BackgroundTaskPanel.tsx +34 -0
  77. package/src/tui/CjkTextInput.tsx +204 -0
  78. package/src/tui/ConfirmPrompt.tsx +69 -0
  79. package/src/tui/ExecutionWorkspaceDetailPane.tsx +62 -0
  80. package/src/tui/ExecutionWorkspaceSwitcher.tsx +185 -0
  81. package/src/tui/InkTerminal.ts +42 -0
  82. package/src/tui/InputArea.tsx +298 -0
  83. package/src/tui/InteractivePrompt.tsx +57 -0
  84. package/src/tui/ListPicker.tsx +94 -0
  85. package/src/tui/MenuSelect.tsx +103 -0
  86. package/src/tui/MessageList.tsx +282 -0
  87. package/src/tui/PermissionPrompt.tsx +84 -0
  88. package/src/tui/PluginTUI.tsx +256 -0
  89. package/src/tui/SessionPicker.tsx +66 -0
  90. package/src/tui/SessionStatusBar.tsx +66 -0
  91. package/src/tui/SlashAutocomplete.tsx +110 -0
  92. package/src/tui/StatusBar.tsx +213 -0
  93. package/src/tui/StreamingIndicator.tsx +91 -0
  94. package/src/tui/TextPrompt.tsx +80 -0
  95. package/src/tui/ToolCommandOutput.tsx +37 -0
  96. package/src/tui/ToolDiffBlock.tsx +30 -0
  97. package/src/tui/TransportTUI.tsx +116 -0
  98. package/src/tui/UpdateNotice.tsx +14 -0
  99. package/src/tui/UsageSummaryEntry.tsx +38 -0
  100. package/src/tui/WaveText.tsx +44 -0
  101. package/src/tui/__tests__/InteractivePrompt.test.tsx +82 -0
  102. package/src/tui/__tests__/ListPicker.test.tsx +159 -0
  103. package/src/tui/__tests__/MenuSelect.test.tsx +103 -0
  104. package/src/tui/__tests__/PluginTUI.test.tsx +167 -0
  105. package/src/tui/__tests__/SlashAutocomplete.test.tsx +140 -0
  106. package/src/tui/__tests__/TextPrompt.test.tsx +98 -0
  107. package/src/tui/__tests__/UpdateNotice.test.tsx +15 -0
  108. package/src/tui/__tests__/abort-after-permission.test.tsx +169 -0
  109. package/src/tui/__tests__/abort-streaming-e2e.test.tsx +183 -0
  110. package/src/tui/__tests__/background-task-panel.test.tsx +53 -0
  111. package/src/tui/__tests__/background-task-row-format.test.ts +59 -0
  112. package/src/tui/__tests__/cjk-text-input-flow.test.ts +109 -0
  113. package/src/tui/__tests__/cjk-text-input.test.ts +191 -0
  114. package/src/tui/__tests__/command-effect-handler.test.ts +128 -0
  115. package/src/tui/__tests__/command-output-summary.test.ts +95 -0
  116. package/src/tui/__tests__/compact-event-bridge.test.ts +20 -0
  117. package/src/tui/__tests__/confirm-permission-flow.test.ts +91 -0
  118. package/src/tui/__tests__/confirm-prompt.test.tsx +87 -0
  119. package/src/tui/__tests__/execution-workspace-switcher.test.tsx +110 -0
  120. package/src/tui/__tests__/execution-workspace-view-model.test.ts +93 -0
  121. package/src/tui/__tests__/fixtures/provider-setup-prompt-driver.tsx +122 -0
  122. package/src/tui/__tests__/input-area-flow.test.ts +152 -0
  123. package/src/tui/__tests__/message-list-rendering.test.tsx +353 -0
  124. package/src/tui/__tests__/model-change-side-effect.test.ts +91 -0
  125. package/src/tui/__tests__/prompt-queue.test.tsx +255 -0
  126. package/src/tui/__tests__/provider-setup-pty-e2e.test.ts +233 -0
  127. package/src/tui/__tests__/render-markdown.test.ts +72 -0
  128. package/src/tui/__tests__/selection-flow.test.ts +61 -0
  129. package/src/tui/__tests__/slash-routing-effects.test.ts +225 -0
  130. package/src/tui/__tests__/status-activity.test.ts +71 -0
  131. package/src/tui/__tests__/status-bar.test.tsx +157 -0
  132. package/src/tui/__tests__/streaming-indicator.test.tsx +137 -0
  133. package/src/tui/__tests__/text-prompt-flow.test.ts +77 -0
  134. package/src/tui/__tests__/tui-state-manager.test.ts +401 -0
  135. package/src/tui/background-task-row-format.ts +52 -0
  136. package/src/tui/command-output-summary.ts +122 -0
  137. package/src/tui/execution-workspace-view-model.ts +123 -0
  138. package/src/tui/flows/cjk-text-input-flow.ts +285 -0
  139. package/src/tui/flows/confirm-prompt-flow.ts +45 -0
  140. package/src/tui/flows/input-area-flow.ts +186 -0
  141. package/src/tui/flows/permission-prompt-flow.ts +76 -0
  142. package/src/tui/flows/selection-flow.ts +126 -0
  143. package/src/tui/flows/text-prompt-flow.ts +98 -0
  144. package/src/tui/hooks/command-effect-handler.ts +98 -0
  145. package/src/tui/hooks/command-effect-queue.ts +39 -0
  146. package/src/tui/hooks/model-change-side-effect.ts +63 -0
  147. package/src/tui/hooks/side-effects-types.ts +38 -0
  148. package/src/tui/hooks/use-interactive-session-init.ts +50 -0
  149. package/src/tui/hooks/useAutocomplete.ts +85 -0
  150. package/src/tui/hooks/useInteractiveSession.ts +273 -0
  151. package/src/tui/hooks/usePermissionQueue.ts +51 -0
  152. package/src/tui/hooks/usePluginCallbacks.ts +30 -0
  153. package/src/tui/hooks/usePluginScreenData.ts +84 -0
  154. package/src/tui/hooks/useSideEffects.ts +210 -0
  155. package/src/tui/hooks/useSlashRouting.ts +117 -0
  156. package/src/tui/hooks/useStatusLineSettings.ts +35 -0
  157. package/src/tui/index.ts +3 -0
  158. package/src/tui/plugin-tui-handlers.ts +163 -0
  159. package/src/tui/render-markdown.ts +129 -0
  160. package/src/tui/render.tsx +60 -0
  161. package/src/tui/status-activity.ts +63 -0
  162. package/src/tui/tui-cli-adapter-context.tsx +12 -0
  163. package/src/tui/tui-cli-adapter.ts +25 -0
  164. package/src/tui/tui-state-manager.ts +225 -0
  165. package/src/tui/tui-transport.ts +32 -0
  166. package/src/tui/types.ts +14 -0
  167. package/src/tui/utils/__tests__/edit-diff.test.ts +426 -0
  168. package/src/tui/utils/__tests__/paste-detection.test.ts +116 -0
  169. package/src/tui/utils/__tests__/paste-labels.test.ts +46 -0
  170. package/src/tui/utils/__tests__/tool-call-extractor.test.ts +227 -0
  171. package/src/tui/utils/__tests__/tool-diff-summary.test.ts +104 -0
  172. package/src/tui/utils/edit-diff.ts +152 -0
  173. package/src/tui/utils/paste-labels.ts +9 -0
  174. package/src/tui/utils/tool-call-extractor.ts +91 -0
  175. package/src/tui/utils/tool-diff-summary.ts +75 -0
  176. package/src/ws/__tests__/ws-handler.test.ts +407 -0
  177. package/src/ws/__tests__/ws-transport.test.ts +53 -0
  178. package/src/ws/index.ts +13 -0
  179. package/src/ws/ws-background-messages.ts +170 -0
  180. package/src/ws/ws-handler.ts +279 -0
  181. package/src/ws/ws-protocol.ts +76 -0
  182. package/src/ws/ws-transport-configurable.ts +123 -0
  183. package/src/ws/ws-transport.ts +42 -0
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { IHistoryEntry, TSessionEndReason } from '@robota-sdk/agent-core';
3
+ import {
4
+ addModelChangeCancelledMessage,
5
+ applyConfirmedModelChange,
6
+ formatModelChangeConfirmationMessage,
7
+ } from '../hooks/model-change-side-effect.js';
8
+
9
+ interface IShutdownCall {
10
+ reason: TSessionEndReason;
11
+ message: string;
12
+ }
13
+
14
+ interface IModelChangeCall {
15
+ cwd: string;
16
+ modelId: string;
17
+ options?: { providerOverride?: string };
18
+ }
19
+
20
+ function entryContent(entry: IHistoryEntry): string {
21
+ return (entry.data as { content: string }).content;
22
+ }
23
+
24
+ describe('model change side effect', () => {
25
+ it('persists the selected model for the active provider override before exiting', () => {
26
+ const entries: IHistoryEntry[] = [];
27
+ const shutdowns: IShutdownCall[] = [];
28
+ const calls: IModelChangeCall[] = [];
29
+ const applyModelChange = (
30
+ cwd: string,
31
+ modelId: string,
32
+ options?: { providerOverride?: string },
33
+ ): { applied: boolean } => {
34
+ calls.push({ cwd, modelId, ...(options !== undefined && { options }) });
35
+ return { applied: true };
36
+ };
37
+
38
+ applyConfirmedModelChange({
39
+ cwd: '/tmp/project',
40
+ modelId: 'gpt-4.1',
41
+ providerOverride: 'openai',
42
+ addEntry: (entry) => entries.push(entry),
43
+ requestShutdown: (reason, message) => shutdowns.push({ reason, message }),
44
+ applyModelChange,
45
+ });
46
+
47
+ expect(calls).toEqual([
48
+ {
49
+ cwd: '/tmp/project',
50
+ modelId: 'gpt-4.1',
51
+ options: { providerOverride: 'openai' },
52
+ },
53
+ ]);
54
+ expect(entryContent(entries[0]!)).toBe(
55
+ 'Model changed to gpt-4.1. Exiting so the next session uses it.',
56
+ );
57
+ expect(shutdowns).toEqual([{ reason: 'other', message: 'Model change applied' }]);
58
+ });
59
+
60
+ it('does not exit when persistence fails', () => {
61
+ const entries: IHistoryEntry[] = [];
62
+ const shutdowns: IShutdownCall[] = [];
63
+
64
+ applyConfirmedModelChange({
65
+ cwd: '/tmp/project',
66
+ modelId: 'gpt-4.1',
67
+ addEntry: (entry) => entries.push(entry),
68
+ requestShutdown: (reason, message) => shutdowns.push({ reason, message }),
69
+ applyModelChange: () => {
70
+ throw new Error('settings write failed');
71
+ },
72
+ });
73
+
74
+ expect(entryContent(entries[0]!)).toBe('Failed: settings write failed');
75
+ expect(shutdowns).toEqual([]);
76
+ });
77
+
78
+ it('reports cancellation without applying settings or exiting', () => {
79
+ const entries: IHistoryEntry[] = [];
80
+
81
+ addModelChangeCancelledMessage((entry) => entries.push(entry));
82
+
83
+ expect(entryContent(entries[0]!)).toBe('Model change cancelled.');
84
+ });
85
+
86
+ it('describes model changes as an exit for the next session', () => {
87
+ expect(formatModelChangeConfirmationMessage('gpt-4.1')).toBe(
88
+ 'Change model to gpt-4.1? This will exit the current session so the next session uses it.',
89
+ );
90
+ });
91
+ });
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Tests for prompt queue — submit during execution queues prompt,
3
+ * auto-executes after completion, backspace cancels queue, ESC aborts.
4
+ */
5
+
6
+ import React, { useState, useCallback, useEffect, useRef } from 'react';
7
+ import { cleanup, render } from 'ink-testing-library';
8
+ import { Box, Text, useInput } from 'ink';
9
+ import { afterEach, describe, it, expect, vi } from 'vitest';
10
+
11
+ interface IQueueTestController {
12
+ completeCurrent?: () => void;
13
+ }
14
+
15
+ const WAIT_FOR_ASSERTION_TIMEOUT_MS = 10000;
16
+ const WAIT_FOR_ASSERTION_INTERVAL_MS = 20;
17
+
18
+ async function waitForAssertion(
19
+ assertion: () => void,
20
+ timeoutMs = WAIT_FOR_ASSERTION_TIMEOUT_MS,
21
+ ): Promise<void> {
22
+ const startedAt = Date.now();
23
+ let lastError: Error | undefined;
24
+
25
+ while (Date.now() - startedAt < timeoutMs) {
26
+ try {
27
+ assertion();
28
+ return;
29
+ } catch (error) {
30
+ lastError = error instanceof Error ? error : new Error(String(error));
31
+ await new Promise((resolve) => setTimeout(resolve, WAIT_FOR_ASSERTION_INTERVAL_MS));
32
+ }
33
+ }
34
+
35
+ if (lastError) throw lastError;
36
+ throw new Error('Timed out waiting for assertion');
37
+ }
38
+
39
+ /**
40
+ * Minimal App that simulates the prompt queue behavior.
41
+ */
42
+ function QueueTestApp({
43
+ onExecute,
44
+ onAbort,
45
+ controller,
46
+ }: {
47
+ onExecute: (prompt: string) => void;
48
+ onAbort?: () => void;
49
+ controller?: IQueueTestController;
50
+ }): React.ReactElement {
51
+ const [isThinking, setIsThinking] = useState(false);
52
+ const thinkingRef = useRef(false);
53
+ const [pendingPrompt, setPendingPrompt] = useState<string | null>(null);
54
+ const pendingRef = useRef<string | null>(null);
55
+ const [isAborting, setIsAborting] = useState(false);
56
+ const [log, setLog] = useState<string[]>([]);
57
+
58
+ const executePrompt = useCallback(
59
+ async (input: string) => {
60
+ thinkingRef.current = true;
61
+ setIsThinking(true);
62
+ setLog((prev) => [...prev, `exec:${input}`]);
63
+ onExecute(input);
64
+ await new Promise<void>((resolve) => {
65
+ if (controller) {
66
+ controller.completeCurrent = () => {
67
+ controller.completeCurrent = undefined;
68
+ resolve();
69
+ };
70
+ return;
71
+ }
72
+ setTimeout(resolve, 300);
73
+ });
74
+ thinkingRef.current = false;
75
+ setIsThinking(false);
76
+ },
77
+ [controller, onExecute],
78
+ );
79
+
80
+ const handleSubmit = useCallback(
81
+ async (input: string) => {
82
+ if (thinkingRef.current) {
83
+ setPendingPrompt(input);
84
+ pendingRef.current = input;
85
+ setLog((prev) => [...prev, `queued:${input}`]);
86
+ return;
87
+ }
88
+ await executePrompt(input);
89
+ },
90
+ [executePrompt],
91
+ );
92
+
93
+ useInput((_input, key) => {
94
+ // ESC always aborts + clears queue
95
+ if (key.escape && thinkingRef.current) {
96
+ setIsAborting(true);
97
+ setPendingPrompt(null);
98
+ pendingRef.current = null;
99
+ onAbort?.();
100
+ }
101
+ // Backspace cancels queue only
102
+ if ((key.backspace || key.delete) && pendingRef.current) {
103
+ setPendingPrompt(null);
104
+ pendingRef.current = null;
105
+ setLog((prev) => [...prev, 'queue-cleared']);
106
+ }
107
+ });
108
+
109
+ // Auto-execute queued prompt when thinking ends
110
+ useEffect(() => {
111
+ if (!isThinking) {
112
+ setIsAborting(false);
113
+ if (pendingRef.current) {
114
+ const prompt = pendingRef.current;
115
+ setPendingPrompt(null);
116
+ pendingRef.current = null;
117
+ setTimeout(() => executePrompt(prompt), 0);
118
+ }
119
+ }
120
+ }, [isThinking, executePrompt]);
121
+
122
+ return (
123
+ <Box flexDirection="column">
124
+ <Text>thinking={String(isThinking)}</Text>
125
+ <Text>pending={pendingPrompt ?? 'none'}</Text>
126
+ <Text>aborting={String(isAborting)}</Text>
127
+ <Text>log={log.join(',')}</Text>
128
+ <SubmitTrigger onSubmit={handleSubmit} />
129
+ </Box>
130
+ );
131
+ }
132
+
133
+ function SubmitTrigger({
134
+ onSubmit,
135
+ }: {
136
+ onSubmit: (input: string) => Promise<void>;
137
+ }): React.ReactElement {
138
+ useInput((input) => {
139
+ if (input.startsWith('s:')) {
140
+ onSubmit(input.slice(2));
141
+ }
142
+ });
143
+ return <></>;
144
+ }
145
+
146
+ describe('Prompt Queue', () => {
147
+ afterEach(() => {
148
+ cleanup();
149
+ vi.clearAllMocks();
150
+ });
151
+
152
+ it('executes prompt normally when not thinking', async () => {
153
+ const onExecute = vi.fn();
154
+ const { stdin } = render(<QueueTestApp onExecute={onExecute} />);
155
+
156
+ stdin.write('s:hello');
157
+ await waitForAssertion(() => expect(onExecute).toHaveBeenCalledWith('hello'));
158
+
159
+ expect(onExecute).toHaveBeenCalledWith('hello');
160
+ });
161
+
162
+ it('queues prompt when thinking, auto-executes after completion', async () => {
163
+ const onExecute = vi.fn();
164
+ const controller: IQueueTestController = {};
165
+ const { stdin, lastFrame } = render(
166
+ <QueueTestApp onExecute={onExecute} controller={controller} />,
167
+ );
168
+
169
+ stdin.write('s:first');
170
+ await waitForAssertion(() => expect(lastFrame()!).toContain('thinking=true'));
171
+
172
+ stdin.write('s:second');
173
+ await waitForAssertion(() => expect(lastFrame()!).toContain('pending=second'));
174
+
175
+ controller.completeCurrent?.();
176
+ await waitForAssertion(() => expect(onExecute).toHaveBeenCalledWith('second'));
177
+
178
+ expect(onExecute).toHaveBeenCalledWith('first');
179
+ expect(onExecute).toHaveBeenCalledWith('second');
180
+ });
181
+
182
+ it('only queues 1 prompt — last one wins', async () => {
183
+ const onExecute = vi.fn();
184
+ const controller: IQueueTestController = {};
185
+ const { stdin, lastFrame } = render(
186
+ <QueueTestApp onExecute={onExecute} controller={controller} />,
187
+ );
188
+
189
+ stdin.write('s:first');
190
+ await waitForAssertion(() => expect(lastFrame()!).toContain('thinking=true'));
191
+
192
+ stdin.write('s:second');
193
+ await new Promise((r) => setTimeout(r, 5));
194
+ stdin.write('s:third');
195
+ await waitForAssertion(() => expect(lastFrame()!).toContain('pending=third'));
196
+
197
+ controller.completeCurrent?.();
198
+ await waitForAssertion(() => expect(onExecute).toHaveBeenCalledWith('third'));
199
+
200
+ expect(onExecute).toHaveBeenCalledWith('first');
201
+ expect(onExecute).toHaveBeenCalledWith('third');
202
+ expect(onExecute).not.toHaveBeenCalledWith('second');
203
+ });
204
+
205
+ it('ESC aborts execution and clears queue', async () => {
206
+ const onExecute = vi.fn();
207
+ const onAbort = vi.fn();
208
+ const controller: IQueueTestController = {};
209
+ const { stdin, lastFrame } = render(
210
+ <QueueTestApp onExecute={onExecute} onAbort={onAbort} controller={controller} />,
211
+ );
212
+
213
+ stdin.write('s:first');
214
+ await waitForAssertion(() => expect(lastFrame()!).toContain('thinking=true'));
215
+
216
+ stdin.write('s:queued');
217
+ await waitForAssertion(() => expect(lastFrame()!).toContain('pending=queued'));
218
+
219
+ stdin.write('\x1B');
220
+ await waitForAssertion(() => expect(lastFrame()!).toContain('pending=none'));
221
+
222
+ expect(onAbort).toHaveBeenCalled();
223
+
224
+ controller.completeCurrent?.();
225
+ await new Promise((r) => setTimeout(r, 50));
226
+ expect(onExecute).not.toHaveBeenCalledWith('queued');
227
+ });
228
+
229
+ it('Backspace cancels queue without aborting', async () => {
230
+ const onExecute = vi.fn();
231
+ const onAbort = vi.fn();
232
+ const controller: IQueueTestController = {};
233
+ const { stdin, lastFrame } = render(
234
+ <QueueTestApp onExecute={onExecute} onAbort={onAbort} controller={controller} />,
235
+ );
236
+
237
+ stdin.write('s:first');
238
+ await waitForAssertion(() => expect(lastFrame()!).toContain('thinking=true'));
239
+
240
+ stdin.write('s:queued');
241
+ await waitForAssertion(() => expect(lastFrame()!).toContain('pending=queued'));
242
+
243
+ stdin.write('\x7F'); // backspace
244
+ await waitForAssertion(() => expect(lastFrame()!).toContain('pending=none'));
245
+
246
+ expect(lastFrame()!).toContain('queue-cleared');
247
+ expect(onAbort).not.toHaveBeenCalled();
248
+
249
+ // Execution continues normally
250
+ controller.completeCurrent?.();
251
+ await new Promise((r) => setTimeout(r, 50));
252
+ expect(onExecute).toHaveBeenCalledWith('first');
253
+ expect(onExecute).not.toHaveBeenCalledWith('queued');
254
+ });
255
+ });
@@ -0,0 +1,233 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import * as pty from '@homebridge/node-pty-prebuilt-multiarch';
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const TSX_ESM_HOOK_PATH: string = require.resolve('tsx/esm');
11
+
12
+ const openaiDefaults = {
13
+ model: 'gpt-4o',
14
+ apiKey: '$ENV:OPENAI_API_KEY',
15
+ };
16
+
17
+ const DRIVER_PATH = fileURLToPath(
18
+ new URL('./fixtures/provider-setup-prompt-driver.tsx', import.meta.url),
19
+ );
20
+ const TEST_TIMEOUT_MS = 30000;
21
+ const WAIT_TIMEOUT_MS = 15000;
22
+ const INPUT_SETTLE_MS = 75;
23
+ const OUTPUT_TAIL_LENGTH = 2000;
24
+
25
+ interface IPtyHarness {
26
+ submit(input?: string): Promise<void>;
27
+ waitFor(text: string): Promise<void>;
28
+ waitForExit(): Promise<number>;
29
+ dispose(): void;
30
+ }
31
+
32
+ const tempDirs: string[] = [];
33
+ const activeHarnesses: IPtyHarness[] = [];
34
+
35
+ afterEach(() => {
36
+ for (const harness of activeHarnesses.splice(0)) {
37
+ harness.dispose();
38
+ }
39
+ for (const dir of tempDirs.splice(0)) {
40
+ rmSync(dir, { recursive: true, force: true });
41
+ }
42
+ });
43
+
44
+ describe('provider setup interaction PTY E2E', () => {
45
+ it(
46
+ 'submits OpenAI values through a real pseudo terminal',
47
+ async () => {
48
+ const { harness, outputPath } = spawnProviderSetupDriver('openai');
49
+
50
+ await harness.waitFor('OpenAI model');
51
+ await harness.waitFor('https://platform.openai.com/api-keys');
52
+ await harness.submit(openaiDefaults.model);
53
+ await harness.waitFor('OpenAI API key');
54
+ await harness.submit();
55
+
56
+ expect(await harness.waitForExit()).toBe(0);
57
+ harness.dispose();
58
+ expect(readResult(outputPath)).toEqual({
59
+ profile: 'openai',
60
+ type: 'openai',
61
+ model: openaiDefaults.model,
62
+ apiKey: openaiDefaults.apiKey,
63
+ setCurrent: true,
64
+ });
65
+ },
66
+ TEST_TIMEOUT_MS,
67
+ );
68
+
69
+ it(
70
+ 'submits Anthropic values typed through a real pseudo terminal',
71
+ async () => {
72
+ const { harness, outputPath } = spawnProviderSetupDriver('anthropic');
73
+
74
+ await harness.waitFor('Anthropic API key');
75
+ await harness.submit('sk-test');
76
+ await harness.waitFor('Anthropic model');
77
+ await harness.submit('claude-test');
78
+
79
+ expect(await harness.waitForExit()).toBe(0);
80
+ harness.dispose();
81
+ expect(readResult(outputPath)).toEqual({
82
+ profile: 'anthropic',
83
+ type: 'anthropic',
84
+ model: 'claude-test',
85
+ apiKey: 'sk-test',
86
+ setCurrent: true,
87
+ });
88
+ },
89
+ TEST_TIMEOUT_MS,
90
+ );
91
+ });
92
+
93
+ function spawnProviderSetupDriver(type: 'openai' | 'anthropic'): {
94
+ harness: IPtyHarness;
95
+ outputPath: string;
96
+ } {
97
+ const dir = mkdtempSync(join(tmpdir(), 'robota-provider-pty-'));
98
+ tempDirs.push(dir);
99
+ const outputPath = join(dir, 'result.json');
100
+ const proc = pty.spawn(
101
+ process.execPath,
102
+ ['--import', TSX_ESM_HOOK_PATH, DRIVER_PATH, outputPath, type],
103
+ {
104
+ cols: 120,
105
+ rows: 40,
106
+ cwd: fileURLToPath(new URL('../../../../..', import.meta.url)),
107
+ env: createPtyEnv(),
108
+ },
109
+ );
110
+ const harness = createHarness(proc);
111
+ activeHarnesses.push(harness);
112
+ return { harness, outputPath };
113
+ }
114
+
115
+ function createHarness(proc: pty.IPty): IPtyHarness {
116
+ let output = '';
117
+ let exitCode: number | undefined;
118
+ const waiters: Array<() => void> = [];
119
+
120
+ proc.onData((data) => {
121
+ output += data;
122
+ notifyWaiters(waiters);
123
+ });
124
+ proc.onExit((event) => {
125
+ exitCode = event.exitCode;
126
+ notifyWaiters(waiters);
127
+ });
128
+
129
+ return {
130
+ async submit(input = '') {
131
+ await sleep(INPUT_SETTLE_MS);
132
+ if (input) {
133
+ proc.write(input);
134
+ await sleep(INPUT_SETTLE_MS);
135
+ }
136
+ proc.write('\r');
137
+ },
138
+ waitFor(text: string) {
139
+ return waitUntil(
140
+ () => output.includes(text),
141
+ waiters,
142
+ () => {
143
+ return new Error(`Timed out waiting for "${text}". Output:\n${tail(output)}`);
144
+ },
145
+ );
146
+ },
147
+ waitForExit() {
148
+ return waitUntil(
149
+ () => exitCode !== undefined,
150
+ waiters,
151
+ () => {
152
+ return new Error(`Timed out waiting for PTY exit. Output:\n${tail(output)}`);
153
+ },
154
+ ).then(() => exitCode ?? -1);
155
+ },
156
+ dispose() {
157
+ if (exitCode === undefined) {
158
+ proc.kill();
159
+ }
160
+ },
161
+ };
162
+ }
163
+
164
+ function sleep(ms: number): Promise<void> {
165
+ return new Promise((resolve) => {
166
+ setTimeout(resolve, ms);
167
+ });
168
+ }
169
+
170
+ function createPtyEnv(): NodeJS.ProcessEnv {
171
+ const env: NodeJS.ProcessEnv = {
172
+ ...process.env,
173
+ FORCE_COLOR: '0',
174
+ TERM: 'xterm-256color',
175
+ };
176
+ delete env.CI;
177
+ return env;
178
+ }
179
+
180
+ function waitUntil(
181
+ predicate: () => boolean,
182
+ waiters: Array<() => void>,
183
+ createTimeoutError: () => Error,
184
+ ): Promise<void> {
185
+ if (predicate()) {
186
+ return Promise.resolve();
187
+ }
188
+ return new Promise((resolve, reject) => {
189
+ let settled = false;
190
+ const removeWaiter = (waiter: () => void): void => {
191
+ const index = waiters.indexOf(waiter);
192
+ if (index >= 0) {
193
+ waiters.splice(index, 1);
194
+ }
195
+ };
196
+ const check = () => {
197
+ if (settled) {
198
+ return;
199
+ }
200
+ if (!predicate()) {
201
+ return;
202
+ }
203
+ settled = true;
204
+ clearTimeout(deadline);
205
+ removeWaiter(check);
206
+ resolve();
207
+ };
208
+ const deadline = setTimeout(() => {
209
+ if (settled) {
210
+ return;
211
+ }
212
+ settled = true;
213
+ removeWaiter(check);
214
+ reject(createTimeoutError());
215
+ }, WAIT_TIMEOUT_MS);
216
+ waiters.push(check);
217
+ });
218
+ }
219
+
220
+ function notifyWaiters(waiters: Array<() => void>): void {
221
+ for (const waiter of [...waiters]) {
222
+ waiter();
223
+ }
224
+ }
225
+
226
+ function readResult(outputPath: string): Record<string, string | boolean> {
227
+ expect(existsSync(outputPath)).toBe(true);
228
+ return JSON.parse(readFileSync(outputPath, 'utf8')) as Record<string, string | boolean>;
229
+ }
230
+
231
+ function tail(output: string): string {
232
+ return output.slice(-OUTPUT_TAIL_LENGTH);
233
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { renderMarkdown } from '../render-markdown.js';
3
+
4
+ const ANSI_LIGHT_RED = '\u001b[38;5;210m';
5
+ const ANSI_LIGHT_GREEN = '\u001b[38;5;120m';
6
+ const ANSI_DARK_RED_BACKGROUND = '\u001b[48;5;52m';
7
+ const ANSI_DARK_GREEN_BACKGROUND = '\u001b[48;5;22m';
8
+ const ANSI_RESET = '\u001b[0m';
9
+ const CODE_BLOCK_INDENT = ' ';
10
+
11
+ describe('renderMarkdown', () => {
12
+ it('renders diff fenced code blocks with addition and removal colors', () => {
13
+ const output = renderMarkdown(
14
+ ['Before', '', '```diff', '- const oldValue = true;', '+ const newValue = true;', '```'].join(
15
+ '\n',
16
+ ),
17
+ { color: true },
18
+ );
19
+
20
+ expect(output).toContain(`${ANSI_DARK_RED_BACKGROUND}${ANSI_LIGHT_RED}`);
21
+ expect(output).toContain(`${CODE_BLOCK_INDENT}- const oldValue = true;`);
22
+ expect(output).toContain(`${ANSI_DARK_GREEN_BACKGROUND}${ANSI_LIGHT_GREEN}`);
23
+ expect(output).toContain(`${CODE_BLOCK_INDENT}+ const newValue = true;`);
24
+ });
25
+
26
+ it('pads added and removed diff rows before applying background colors', () => {
27
+ const codeBlockWidth = 24;
28
+ const removedRow = `${CODE_BLOCK_INDENT}- removed`.padEnd(codeBlockWidth);
29
+ const addedRow = `${CODE_BLOCK_INDENT}+ added`.padEnd(codeBlockWidth);
30
+ const output = renderMarkdown(['```diff', '- removed', '+ added', '```'].join('\n'), {
31
+ color: true,
32
+ codeBlockWidth,
33
+ });
34
+
35
+ expect(output).toContain(
36
+ `${ANSI_DARK_RED_BACKGROUND}${ANSI_LIGHT_RED}${removedRow}${ANSI_RESET}`,
37
+ );
38
+ expect(output).toContain(
39
+ `${ANSI_DARK_GREEN_BACKGROUND}${ANSI_LIGHT_GREEN}${addedRow}${ANSI_RESET}`,
40
+ );
41
+ });
42
+
43
+ it('keeps diff fenced code block content readable when color is disabled', () => {
44
+ const output = renderMarkdown(
45
+ ['```diff', '- removed line', '+ added line', ' unchanged line', '```'].join('\n'),
46
+ { color: false },
47
+ );
48
+
49
+ expect(output).toContain('- removed line');
50
+ expect(output).toContain('+ added line');
51
+ expect(output).toContain(' unchanged line');
52
+ expect(output).not.toContain(ANSI_LIGHT_RED);
53
+ expect(output).not.toContain(ANSI_LIGHT_GREEN);
54
+ expect(output).not.toContain(ANSI_DARK_RED_BACKGROUND);
55
+ expect(output).not.toContain(ANSI_DARK_GREEN_BACKGROUND);
56
+ });
57
+
58
+ it('keeps regular fenced code blocks as code output', () => {
59
+ const output = renderMarkdown(['```ts', 'const value: string = "ok";', '```'].join('\n'), {
60
+ color: false,
61
+ });
62
+
63
+ expect(output).toContain('const value: string = "ok";');
64
+ });
65
+
66
+ it('keeps inline markdown formatting readable', () => {
67
+ const output = renderMarkdown('Use **bold** and `code` here.', { color: false });
68
+
69
+ expect(output).toContain('bold');
70
+ expect(output).toContain('code');
71
+ });
72
+ });