@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,430 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+ import { InteractiveSession } from '@robota-sdk/agent-framework';
6
+ import { createSkillsCommandModule } from '@robota-sdk/agent-command';
7
+ import type { TInteractiveSessionOptions } from '@robota-sdk/agent-framework';
8
+ import { createHeadlessTransport } from '../headless-transport.js';
9
+
10
+ type TStandardSessionOptions = Extract<
11
+ TInteractiveSessionOptions,
12
+ { cwd: string; provider: unknown }
13
+ >;
14
+ type TTestProvider = TStandardSessionOptions['provider'];
15
+ type TChatResponse = Awaited<ReturnType<TTestProvider['chat']>>;
16
+ type TToolCalls = Extract<TChatResponse, { role: 'assistant' }>['toolCalls'];
17
+ type TResolvedConfig = NonNullable<TStandardSessionOptions['config']>;
18
+
19
+ interface IObservedProvider {
20
+ provider: TTestProvider;
21
+ getChatCallCount(): number;
22
+ getFirstCallToolNames(): string[];
23
+ getFirstCallSkillsToolArgsDescription(): string | undefined;
24
+ getFirstPromptContent(): string;
25
+ getToolResultContent(): string;
26
+ }
27
+
28
+ interface IObservedPromptProvider {
29
+ provider: TTestProvider;
30
+ getChatCallCount(): number;
31
+ getPromptContent(): string;
32
+ }
33
+
34
+ interface IObservedUnknownToolProvider {
35
+ provider: TTestProvider;
36
+ getChatCallCount(): number;
37
+ getToolResultContent(): string;
38
+ getForcedInstruction(): string;
39
+ getForcedCallToolNames(): string[] | undefined;
40
+ }
41
+
42
+ function createTempSkill(
43
+ cwd: string,
44
+ name: string,
45
+ description: string,
46
+ body = 'Run this skill with the real skill runtime: $ARGUMENTS',
47
+ ): void {
48
+ const skillDir = join(cwd, '.agents', 'skills', name);
49
+ mkdirSync(skillDir, { recursive: true });
50
+ writeFileSync(
51
+ join(skillDir, 'SKILL.md'),
52
+ ['---', `name: ${name}`, `description: ${description}`, '---', body].join('\n'),
53
+ 'utf8',
54
+ );
55
+ }
56
+
57
+ function createConfig(): TResolvedConfig {
58
+ return {
59
+ defaultTrustLevel: 'moderate',
60
+ language: 'en',
61
+ provider: {
62
+ name: 'headless-test-provider',
63
+ model: 'headless-test-model',
64
+ apiKey: 'test-key',
65
+ },
66
+ permissions: { allow: [], deny: [] },
67
+ env: {},
68
+ };
69
+ }
70
+
71
+ function assistantMessage(content: string | null, toolCalls?: TToolCalls): TChatResponse {
72
+ return {
73
+ id: `assistant-${Math.random().toString(36).slice(2)}`,
74
+ role: 'assistant',
75
+ content,
76
+ state: 'complete',
77
+ timestamp: new Date(),
78
+ ...(toolCalls !== undefined ? { toolCalls } : {}),
79
+ };
80
+ }
81
+
82
+ function createSkillToolCallingProvider(): IObservedProvider {
83
+ let chatCallCount = 0;
84
+ let firstCallToolNames: string[] = [];
85
+ let firstCallSkillsToolArgsDescription: string | undefined;
86
+ let firstPromptContent = '';
87
+ let toolResultContent = '';
88
+
89
+ const provider: TTestProvider = {
90
+ name: 'headless-test-provider',
91
+ version: '1.0.0',
92
+ async chat(messages, options) {
93
+ chatCallCount += 1;
94
+
95
+ if (chatCallCount === 1) {
96
+ firstPromptContent = messages.map((message) => message.content).join('\n');
97
+ firstCallToolNames = options?.tools?.map((tool) => tool.name) ?? [];
98
+ const skillsToolSchema = options?.tools?.find(
99
+ (tool) => tool.name === 'robota_command_skills',
100
+ );
101
+ firstCallSkillsToolArgsDescription =
102
+ skillsToolSchema?.parameters.properties['args']?.description;
103
+ return assistantMessage(null, [
104
+ {
105
+ id: 'call_robota_command_skills',
106
+ type: 'function',
107
+ function: {
108
+ name: 'robota_command_skills',
109
+ arguments: JSON.stringify({ args: 'repo-writing docs/architecture.md' }),
110
+ },
111
+ },
112
+ ]);
113
+ }
114
+
115
+ const toolMessage = messages.find(
116
+ (message) => message.role === 'tool' && message.name === 'robota_command_skills',
117
+ );
118
+ toolResultContent = toolMessage?.content ?? '';
119
+ return assistantMessage('Headless skill activated');
120
+ },
121
+ async generateResponse() {
122
+ return { content: 'unused' };
123
+ },
124
+ supportsTools() {
125
+ return true;
126
+ },
127
+ validateConfig() {
128
+ return true;
129
+ },
130
+ };
131
+
132
+ return {
133
+ provider,
134
+ getChatCallCount: () => chatCallCount,
135
+ getFirstCallToolNames: () => firstCallToolNames,
136
+ getFirstCallSkillsToolArgsDescription: () => firstCallSkillsToolArgsDescription,
137
+ getFirstPromptContent: () => firstPromptContent,
138
+ getToolResultContent: () => toolResultContent,
139
+ };
140
+ }
141
+
142
+ function createPromptObservingProvider(response: string): IObservedPromptProvider {
143
+ let chatCallCount = 0;
144
+ let promptContent = '';
145
+
146
+ const provider: TTestProvider = {
147
+ name: 'headless-test-provider',
148
+ version: '1.0.0',
149
+ async chat(messages) {
150
+ chatCallCount += 1;
151
+ promptContent = messages.map((message) => message.content).join('\n');
152
+ return assistantMessage(response);
153
+ },
154
+ async generateResponse() {
155
+ return { content: 'unused' };
156
+ },
157
+ supportsTools() {
158
+ return true;
159
+ },
160
+ validateConfig() {
161
+ return true;
162
+ },
163
+ };
164
+
165
+ return {
166
+ provider,
167
+ getChatCallCount: () => chatCallCount,
168
+ getPromptContent: () => promptContent,
169
+ };
170
+ }
171
+
172
+ function createUnknownToolCallingProvider(): IObservedUnknownToolProvider {
173
+ let chatCallCount = 0;
174
+ let toolResultContent = '';
175
+ let forcedInstruction = '';
176
+ let forcedCallToolNames: string[] | undefined;
177
+
178
+ const provider: TTestProvider = {
179
+ name: 'headless-test-provider',
180
+ version: '1.0.0',
181
+ async chat(messages, options) {
182
+ chatCallCount += 1;
183
+
184
+ if (chatCallCount <= 2) {
185
+ return assistantMessage(null, [
186
+ {
187
+ id: `call_unknown_agent_${chatCallCount}`,
188
+ type: 'function',
189
+ function: {
190
+ name: 'agent',
191
+ arguments: JSON.stringify({ prompt: `spawn worker ${chatCallCount}` }),
192
+ },
193
+ },
194
+ ]);
195
+ }
196
+
197
+ const toolMessage = [...messages]
198
+ .reverse()
199
+ .find((message) => message.role === 'tool' && message.name === 'agent');
200
+ toolResultContent = toolMessage?.content ?? '';
201
+ const userMessage = [...messages].reverse().find((message) => message.role === 'user');
202
+ forcedInstruction = userMessage?.content ?? '';
203
+ forcedCallToolNames = options?.tools?.map((tool) => tool.name);
204
+ return assistantMessage('The agent tool was not executed because it is not registered.');
205
+ },
206
+ async generateResponse() {
207
+ return { content: 'unused' };
208
+ },
209
+ supportsTools() {
210
+ return true;
211
+ },
212
+ validateConfig() {
213
+ return true;
214
+ },
215
+ };
216
+
217
+ return {
218
+ provider,
219
+ getChatCallCount: () => chatCallCount,
220
+ getToolResultContent: () => toolResultContent,
221
+ getForcedInstruction: () => forcedInstruction,
222
+ getForcedCallToolNames: () => forcedCallToolNames,
223
+ };
224
+ }
225
+
226
+ function captureStdout(): { writes: string[]; restore(): void } {
227
+ const writes: string[] = [];
228
+ const originalWrite = process.stdout.write;
229
+
230
+ process.stdout.write = ((chunk: string | Uint8Array, encodingOrCallback?: unknown) => {
231
+ writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
232
+ if (typeof encodingOrCallback === 'function') {
233
+ encodingOrCallback();
234
+ }
235
+ return true;
236
+ }) as typeof process.stdout.write;
237
+
238
+ return {
239
+ writes,
240
+ restore() {
241
+ process.stdout.write = originalWrite;
242
+ },
243
+ };
244
+ }
245
+
246
+ function parseJsonObject(input: string): Record<string, unknown> {
247
+ const parsed: unknown = JSON.parse(input);
248
+ expect(parsed).toBeTypeOf('object');
249
+ expect(parsed).not.toBeNull();
250
+ return parsed as Record<string, unknown>;
251
+ }
252
+
253
+ describe('headless transport skill activation integration', () => {
254
+ let cwd: string | undefined;
255
+
256
+ afterEach(() => {
257
+ if (cwd) rmSync(cwd, { recursive: true, force: true });
258
+ cwd = undefined;
259
+ });
260
+
261
+ it('executes model-invocable skills through the skills built-in command in a headless session', async () => {
262
+ cwd = mkdtempSync(join(tmpdir(), 'robota-headless-skill-'));
263
+ createTempSkill(
264
+ cwd,
265
+ 'repo-writing',
266
+ 'Repository writing rules',
267
+ 'Apply repository writing rules with the real skill runtime: $ARGUMENTS',
268
+ );
269
+ const observed = createSkillToolCallingProvider();
270
+ const session = new InteractiveSession({
271
+ cwd,
272
+ provider: observed.provider,
273
+ config: createConfig(),
274
+ permissionMode: 'bypassPermissions',
275
+ bare: true,
276
+ commandModules: [createSkillsCommandModule({ cwd })],
277
+ });
278
+ const stdout = captureStdout();
279
+
280
+ try {
281
+ const transport = createHeadlessTransport({
282
+ outputFormat: 'json',
283
+ prompt: 'Use repository writing rules for docs/architecture.md',
284
+ });
285
+
286
+ session.attachTransport(transport);
287
+ await transport.start();
288
+
289
+ const output = parseJsonObject(stdout.writes.join('').trim());
290
+ expect(transport.getExitCode()).toBe(0);
291
+ expect(output).toMatchObject({
292
+ type: 'result',
293
+ result: 'Headless skill activated',
294
+ subtype: 'success',
295
+ });
296
+ expect(output['session_id']).toBeTypeOf('string');
297
+ expect(observed.getChatCallCount()).toBe(2);
298
+ expect(observed.getFirstCallToolNames()).toContain('robota_command_skills');
299
+ expect(observed.getFirstCallToolNames()).not.toContain('ExecuteCommand');
300
+ expect(observed.getFirstCallToolNames()).not.toContain('ExecuteSkill');
301
+ expect(observed.getFirstCallSkillsToolArgsDescription()).toContain(
302
+ '[list | <skill-name> [args]]',
303
+ );
304
+ expect(observed.getFirstPromptContent()).toContain('## Built-in Commands');
305
+ expect(observed.getFirstPromptContent()).toContain('skills [list | <skill-name> [args]]');
306
+ expect(observed.getFirstPromptContent()).toContain('## Skills');
307
+ expect(observed.getFirstPromptContent()).toContain(
308
+ '- repo-writing: Repository writing rules',
309
+ );
310
+ expect(observed.getToolResultContent()).toContain('"success":true');
311
+ expect(observed.getToolResultContent()).toContain('<skill name=\\"repo-writing\\">');
312
+ expect(session.getSkillActivationEvents().map((event) => event.invocation)).toEqual([
313
+ 'model-tool',
314
+ 'model-tool',
315
+ ]);
316
+ expect(session.getSkillActivationEvents().map((event) => event.status)).toEqual([
317
+ 'started',
318
+ 'completed',
319
+ ]);
320
+ } finally {
321
+ stdout.restore();
322
+ await session.shutdown({
323
+ reason: 'prompt_input_exit',
324
+ message: 'Headless skill activation test complete',
325
+ });
326
+ }
327
+ });
328
+
329
+ it('executes explicit slash skills through SDK skill loading in a headless session', async () => {
330
+ cwd = mkdtempSync(join(tmpdir(), 'robota-headless-slash-skill-'));
331
+ createTempSkill(
332
+ cwd,
333
+ 'audit',
334
+ 'Audit one file',
335
+ 'Audit this file with the real skill runtime: $ARGUMENTS',
336
+ );
337
+ const observed = createPromptObservingProvider('Headless slash skill activated');
338
+ const session = new InteractiveSession({
339
+ cwd,
340
+ provider: observed.provider,
341
+ config: createConfig(),
342
+ permissionMode: 'bypassPermissions',
343
+ bare: true,
344
+ commandModules: [createSkillsCommandModule({ cwd })],
345
+ });
346
+ const stdout = captureStdout();
347
+
348
+ try {
349
+ const transport = createHeadlessTransport({
350
+ outputFormat: 'json',
351
+ prompt: '/audit src/index.ts',
352
+ });
353
+
354
+ session.attachTransport(transport);
355
+ await transport.start();
356
+
357
+ const output = parseJsonObject(stdout.writes.join('').trim());
358
+ expect(transport.getExitCode()).toBe(0);
359
+ expect(output).toMatchObject({
360
+ type: 'result',
361
+ result: 'Headless slash skill activated',
362
+ subtype: 'success',
363
+ });
364
+ expect(observed.getChatCallCount()).toBe(1);
365
+ expect(observed.getPromptContent()).toContain(
366
+ 'Audit this file with the real skill runtime: src/index.ts',
367
+ );
368
+ expect(observed.getPromptContent()).toContain('<skill name="audit">');
369
+ expect(session.getSkillActivationEvents().map((event) => event.invocation)).toEqual([
370
+ 'user-slash',
371
+ 'user-slash',
372
+ ]);
373
+ expect(session.getSkillActivationEvents().map((event) => event.status)).toEqual([
374
+ 'started',
375
+ 'completed',
376
+ ]);
377
+ } finally {
378
+ stdout.restore();
379
+ await session.shutdown({
380
+ reason: 'prompt_input_exit',
381
+ message: 'Headless slash skill activation test complete',
382
+ });
383
+ }
384
+ });
385
+
386
+ it('reports skipped unknown native tool calls through the headless execution path', async () => {
387
+ cwd = mkdtempSync(join(tmpdir(), 'robota-headless-unknown-tool-'));
388
+ const observed = createUnknownToolCallingProvider();
389
+ const session = new InteractiveSession({
390
+ cwd,
391
+ provider: observed.provider,
392
+ config: createConfig(),
393
+ permissionMode: 'bypassPermissions',
394
+ bare: true,
395
+ });
396
+ const stdout = captureStdout();
397
+
398
+ try {
399
+ const transport = createHeadlessTransport({
400
+ outputFormat: 'json',
401
+ prompt: 'Spawn agents in parallel',
402
+ });
403
+
404
+ session.attachTransport(transport);
405
+ await transport.start();
406
+
407
+ const output = parseJsonObject(stdout.writes.join('').trim());
408
+ expect(transport.getExitCode()).toBe(0);
409
+ expect(output).toMatchObject({
410
+ type: 'result',
411
+ result: 'The agent tool was not executed because it is not registered.',
412
+ subtype: 'success',
413
+ });
414
+ expect(observed.getChatCallCount()).toBe(3);
415
+ expect(observed.getToolResultContent()).toContain('not registered');
416
+ expect(observed.getToolResultContent()).toContain('not executed');
417
+ expect(observed.getForcedInstruction()).toContain(
418
+ 'Those tool calls were not executed because they are not registered tools.',
419
+ );
420
+ expect(observed.getForcedInstruction()).toContain('agent');
421
+ expect(observed.getForcedCallToolNames()).toBeUndefined();
422
+ } finally {
423
+ stdout.restore();
424
+ await session.shutdown({
425
+ reason: 'prompt_input_exit',
426
+ message: 'Headless unknown tool test complete',
427
+ });
428
+ }
429
+ });
430
+ });
@@ -0,0 +1,268 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createHeadlessTransport } from '../headless-transport.js';
3
+ import type { IInteractiveSession } from '@robota-sdk/agent-framework';
4
+ import type { IExecutionResult } from '@robota-sdk/agent-framework';
5
+
6
+ function createMockSession(): IInteractiveSession {
7
+ return {
8
+ submit: vi.fn(),
9
+ abort: vi.fn(),
10
+ cancelQueue: vi.fn(),
11
+ getMessages: vi.fn().mockReturnValue([]),
12
+ getContextState: vi
13
+ .fn()
14
+ .mockReturnValue({ usedPercentage: 0, usedTokens: 0, maxTokens: 200000 }),
15
+ isExecuting: vi.fn().mockReturnValue(false),
16
+ getPendingPrompt: vi.fn().mockReturnValue(null),
17
+ executeCommand: vi.fn().mockResolvedValue({ message: 'ok', success: true }),
18
+ listCommands: vi.fn().mockReturnValue([]),
19
+ getSession: vi.fn().mockReturnValue({ getSessionId: () => 'test-session-id' }),
20
+ on: vi.fn(),
21
+ off: vi.fn(),
22
+ } as unknown as IInteractiveSession;
23
+ }
24
+
25
+ function createEventDrivenMockSession(
26
+ behavior: 'complete' | 'error' | 'interrupted' = 'complete',
27
+ options?: { response?: string; textDeltas?: string[] },
28
+ ): IInteractiveSession {
29
+ const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
30
+ const response = options?.response ?? 'test output';
31
+ const textDeltas = options?.textDeltas;
32
+
33
+ return {
34
+ submit: vi.fn(async () => {
35
+ if (textDeltas) {
36
+ for (const delta of textDeltas) {
37
+ for (const h of listeners.get('text_delta') ?? []) {
38
+ h(delta);
39
+ }
40
+ }
41
+ }
42
+
43
+ if (behavior === 'complete') {
44
+ const result: IExecutionResult = {
45
+ response,
46
+ history: [],
47
+ toolSummaries: [],
48
+ contextState: {} as IExecutionResult['contextState'],
49
+ };
50
+ for (const h of listeners.get('complete') ?? []) {
51
+ h(result);
52
+ }
53
+ } else if (behavior === 'interrupted') {
54
+ const result: IExecutionResult = {
55
+ response,
56
+ history: [],
57
+ toolSummaries: [],
58
+ contextState: {} as IExecutionResult['contextState'],
59
+ };
60
+ for (const h of listeners.get('interrupted') ?? []) {
61
+ h(result);
62
+ }
63
+ } else if (behavior === 'error') {
64
+ for (const h of listeners.get('error') ?? []) {
65
+ h(new Error('test error'));
66
+ }
67
+ }
68
+ }),
69
+ abort: vi.fn(),
70
+ cancelQueue: vi.fn(),
71
+ getMessages: vi.fn().mockReturnValue([]),
72
+ getContextState: vi
73
+ .fn()
74
+ .mockReturnValue({ usedPercentage: 0, usedTokens: 0, maxTokens: 200000 }),
75
+ isExecuting: vi.fn().mockReturnValue(false),
76
+ getPendingPrompt: vi.fn().mockReturnValue(null),
77
+ executeCommand: vi.fn().mockResolvedValue({ message: 'ok', success: true }),
78
+ listCommands: vi.fn().mockReturnValue([]),
79
+ getSession: vi.fn().mockReturnValue({ getSessionId: () => 'test-id' }),
80
+ on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
81
+ if (!listeners.has(event)) listeners.set(event, []);
82
+ listeners.get(event)!.push(handler);
83
+ }),
84
+ off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
85
+ const handlers = listeners.get(event);
86
+ if (handlers) {
87
+ const idx = handlers.indexOf(handler);
88
+ if (idx >= 0) handlers.splice(idx, 1);
89
+ }
90
+ }),
91
+ } as unknown as IInteractiveSession;
92
+ }
93
+
94
+ describe('createHeadlessTransport', () => {
95
+ it('returns an adapter with name "headless"', () => {
96
+ const transport = createHeadlessTransport({ outputFormat: 'text', prompt: 'hello' });
97
+ expect(transport.name).toBe('headless');
98
+ });
99
+
100
+ it('throws if start() is called without attach()', async () => {
101
+ const transport = createHeadlessTransport({ outputFormat: 'text', prompt: 'hello' });
102
+ await expect(transport.start()).rejects.toThrow('No session attached');
103
+ });
104
+
105
+ it('returns exit code 0 by default', () => {
106
+ const transport = createHeadlessTransport({ outputFormat: 'text', prompt: 'hello' });
107
+ expect(transport.getExitCode()).toBe(0);
108
+ });
109
+
110
+ it('full lifecycle: attach → start → text output → exit code', async () => {
111
+ const mockSession = createEventDrivenMockSession();
112
+
113
+ // Capture stdout
114
+ const writes: string[] = [];
115
+ const originalWrite = process.stdout.write;
116
+ process.stdout.write = vi.fn((chunk: string) => {
117
+ writes.push(chunk);
118
+ return true;
119
+ }) as never;
120
+
121
+ try {
122
+ const transport = createHeadlessTransport({ outputFormat: 'text', prompt: 'hello' });
123
+ transport.attach(mockSession as never);
124
+ await transport.start();
125
+
126
+ expect(transport.getExitCode()).toBe(0);
127
+ expect(writes.join('')).toContain('test output');
128
+ } finally {
129
+ process.stdout.write = originalWrite;
130
+ }
131
+ });
132
+ });
133
+
134
+ describe('createHeadlessTransport (json adapter)', () => {
135
+ let stdoutWriteSpy: any; // allow-any: vi.spyOn process.stdout.write has incompatible MockInstance generic bounds
136
+
137
+ beforeEach(() => {
138
+ stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
139
+ });
140
+
141
+ afterEach(() => {
142
+ stdoutWriteSpy.mockRestore();
143
+ });
144
+
145
+ it('full lifecycle: attach → start → JSON output → exit code', async () => {
146
+ const mockSession = createEventDrivenMockSession('complete', {
147
+ response: 'JSON adapter result',
148
+ });
149
+
150
+ const transport = createHeadlessTransport({ outputFormat: 'json', prompt: 'test prompt' });
151
+ transport.attach(mockSession as never);
152
+ await transport.start();
153
+
154
+ expect(transport.getExitCode()).toBe(0);
155
+ expect(stdoutWriteSpy).toHaveBeenCalledTimes(1);
156
+
157
+ const output = (stdoutWriteSpy.mock.calls[0] as [string])[0];
158
+ const parsed: unknown = JSON.parse(output.trim());
159
+ expect(parsed).toEqual({
160
+ type: 'result',
161
+ result: 'JSON adapter result',
162
+ session_id: 'test-id',
163
+ subtype: 'success',
164
+ });
165
+ });
166
+ });
167
+
168
+ describe('createHeadlessTransport (stream-json adapter)', () => {
169
+ let stdoutWriteSpy: any; // allow-any: vi.spyOn process.stdout.write has incompatible MockInstance generic bounds
170
+
171
+ beforeEach(() => {
172
+ stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
173
+ });
174
+
175
+ afterEach(() => {
176
+ stdoutWriteSpy.mockRestore();
177
+ });
178
+
179
+ it('full lifecycle: attach → start → stream events + result', async () => {
180
+ const mockSession = createEventDrivenMockSession('complete', {
181
+ response: 'Hello world',
182
+ textDeltas: ['Hello', ' world'],
183
+ });
184
+
185
+ const transport = createHeadlessTransport({
186
+ outputFormat: 'stream-json',
187
+ prompt: 'test prompt',
188
+ });
189
+ transport.attach(mockSession as never);
190
+ await transport.start();
191
+
192
+ expect(transport.getExitCode()).toBe(0);
193
+
194
+ const lines = stdoutWriteSpy.mock.calls.map((call: unknown[]) => (call as [string])[0].trim());
195
+ const parsed = lines.map((line: string) => JSON.parse(line) as Record<string, unknown>);
196
+
197
+ // 2 stream_event lines + 1 final result line
198
+ expect(parsed).toHaveLength(3);
199
+
200
+ const streamEvents = parsed.filter(
201
+ (p: Record<string, unknown>) => p['type'] === 'stream_event',
202
+ );
203
+ expect(streamEvents).toHaveLength(2);
204
+
205
+ for (const evt of streamEvents) {
206
+ expect(evt['session_id']).toBe('test-id');
207
+ expect(evt['uuid']).toBeDefined();
208
+ const inner = evt['event'] as Record<string, unknown>;
209
+ expect(inner['type']).toBe('content_block_delta');
210
+ const delta = inner['delta'] as Record<string, unknown>;
211
+ expect(delta['type']).toBe('text_delta');
212
+ }
213
+
214
+ const firstDelta = (streamEvents[0]!['event'] as Record<string, unknown>)['delta'] as Record<
215
+ string,
216
+ unknown
217
+ >;
218
+ const secondDelta = (streamEvents[1]!['event'] as Record<string, unknown>)['delta'] as Record<
219
+ string,
220
+ unknown
221
+ >;
222
+ expect(firstDelta['text']).toBe('Hello');
223
+ expect(secondDelta['text']).toBe(' world');
224
+
225
+ const resultLine = parsed.find((p: Record<string, unknown>) => p['type'] === 'result');
226
+ expect(resultLine).toEqual({
227
+ type: 'result',
228
+ result: 'Hello world',
229
+ session_id: 'test-id',
230
+ subtype: 'success',
231
+ });
232
+ });
233
+ });
234
+
235
+ describe('createHeadlessTransport (error and interrupted)', () => {
236
+ let stdoutWriteSpy: any; // allow-any: vi.spyOn process.stdout.write has incompatible MockInstance generic bounds
237
+
238
+ beforeEach(() => {
239
+ stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
240
+ });
241
+
242
+ afterEach(() => {
243
+ stdoutWriteSpy.mockRestore();
244
+ });
245
+
246
+ it('returns exit code 1 on error', async () => {
247
+ const mockSession = createEventDrivenMockSession('error');
248
+
249
+ const transport = createHeadlessTransport({ outputFormat: 'text', prompt: 'test prompt' });
250
+ transport.attach(mockSession as never);
251
+ await transport.start();
252
+
253
+ expect(transport.getExitCode()).toBe(1);
254
+ });
255
+
256
+ it('returns exit code 0 on interrupted', async () => {
257
+ const mockSession = createEventDrivenMockSession('interrupted', {
258
+ response: 'partial output',
259
+ });
260
+
261
+ const transport = createHeadlessTransport({ outputFormat: 'text', prompt: 'test prompt' });
262
+ transport.attach(mockSession as never);
263
+ await transport.start();
264
+
265
+ expect(transport.getExitCode()).toBe(0);
266
+ expect(stdoutWriteSpy).toHaveBeenCalledWith('partial output\n');
267
+ });
268
+ });