@robota-sdk/agent-transport 3.0.0-beta.69 → 3.0.0-beta.70
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/dist/node/headless/index.cjs +1 -1
- package/dist/node/headless/index.d.ts +2 -2
- package/dist/node/headless/index.js +1 -1
- package/dist/node/headless-C6tj35h3.js +15 -0
- package/dist/node/headless-C6tj35h3.js.map +1 -0
- package/dist/node/headless-DCtHvyVf.cjs +14 -0
- package/dist/node/http/index.d.ts +1 -1
- package/dist/node/index-27HV5PJB.d.ts +68 -0
- package/dist/node/index-27HV5PJB.d.ts.map +1 -0
- package/dist/node/index-BRchlFBE.d.ts +68 -0
- package/dist/node/index-BRchlFBE.d.ts.map +1 -0
- package/dist/node/{index-C7DvsmEg.d.ts → index-BRgV_MPB.d.ts} +2 -2
- package/dist/node/{index-C7DvsmEg.d.ts.map → index-BRgV_MPB.d.ts.map} +1 -1
- package/dist/node/{index-D-aT_t_N.d.ts → index-BVNhOeeU.d.ts} +3 -2
- package/dist/node/{index-D-aT_t_N.d.ts.map → index-BVNhOeeU.d.ts.map} +1 -1
- package/dist/node/{index-yvGShbDx.d.ts → index-COWvtBa2.d.ts} +2 -2
- package/dist/node/{index-yvGShbDx.d.ts.map → index-COWvtBa2.d.ts.map} +1 -1
- package/dist/node/{index-ioN9mYAD.d.ts → index-TMAlNHuM.d.ts} +5 -4
- package/dist/node/{index-ioN9mYAD.d.ts.map → index-TMAlNHuM.d.ts.map} +1 -1
- package/dist/node/{index-DOA2KIYt.d.ts → index-nBlMTFkZ.d.ts} +2 -2
- package/dist/node/{index-DOA2KIYt.d.ts.map → index-nBlMTFkZ.d.ts.map} +1 -1
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.ts +7 -7
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/mcp/index.d.ts +1 -1
- package/dist/node/tui/index.cjs +1 -1
- package/dist/node/tui/index.d.ts +2 -2
- package/dist/node/tui/index.js +1 -1
- package/dist/node/tui-Cf1-zocr.js +25 -0
- package/dist/node/tui-Cf1-zocr.js.map +1 -0
- package/dist/node/tui-re-S-CGS.cjs +24 -0
- package/dist/node/ws/index.d.ts +1 -1
- package/package.json +6 -6
- package/src/headless/HeadlessInteractionChannel.ts +84 -0
- package/src/headless/index.ts +2 -0
- package/src/tui/App.tsx +26 -56
- package/src/tui/InputArea.tsx +3 -59
- package/src/tui/StatusBar.tsx +1 -1
- package/src/tui/TuiInteractionChannel.ts +461 -0
- package/src/tui/__tests__/TuiInteractionChannel.display-contract.test.ts +239 -0
- package/src/tui/__tests__/TuiInteractionChannel.lifecycle.test.ts +294 -0
- package/src/tui/__tests__/TuiInteractionChannel.requestAction.test.ts +124 -0
- package/src/tui/__tests__/compact-event-bridge.test.ts +1 -1
- package/src/tui/__tests__/input-area-flow.test.ts +5 -12
- package/src/tui/flows/input-area-flow.ts +10 -15
- package/src/tui/hooks/use-interactive-session-init.ts +37 -2
- package/src/tui/hooks/useSlashRouting.ts +1 -1
- package/src/tui/hooks/useTuiChannel.ts +95 -0
- package/src/tui/index.ts +2 -1
- package/src/tui/interactions/__tests__/CommandConfirm.test.tsx +124 -0
- package/src/tui/interactions/__tests__/CommandPicker.test.tsx +138 -0
- package/src/tui/render.tsx +39 -1
- package/src/tui/tui-state-manager.ts +2 -1
- package/src/tui/tui-transport.ts +1 -1
- package/dist/node/headless-C-Ezlo9U.js +0 -15
- package/dist/node/headless-C-Ezlo9U.js.map +0 -1
- package/dist/node/headless-Cv-igy49.cjs +0 -14
- package/dist/node/index-CP7kaYMg.d.ts +0 -41
- package/dist/node/index-CP7kaYMg.d.ts.map +0 -1
- package/dist/node/index-Gby9H4q2.d.ts +0 -41
- package/dist/node/index-Gby9H4q2.d.ts.map +0 -1
- package/dist/node/tui-87G6pg3z.js +0 -25
- package/dist/node/tui-87G6pg3z.js.map +0 -1
- package/dist/node/tui-BAtwGilM.cjs +0 -24
- package/src/tui/command-interaction-registry.ts +0 -66
- package/src/tui/hooks/useInteractiveSession.ts +0 -299
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for TuiInteractionChannel lifecycle:
|
|
3
|
+
* session event wiring, handleInput roundtrip, onChange propagation.
|
|
4
|
+
*
|
|
5
|
+
* No Ink rendering, no PTY — pure TypeScript.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
8
|
+
|
|
9
|
+
vi.mock('@robota-sdk/agent-framework', async () => {
|
|
10
|
+
const actual = await vi.importActual<typeof import('@robota-sdk/agent-framework')>(
|
|
11
|
+
'@robota-sdk/agent-framework',
|
|
12
|
+
);
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
InteractiveSession: vi.fn().mockImplementation(() => {
|
|
16
|
+
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
|
17
|
+
return {
|
|
18
|
+
getFullHistory: vi.fn().mockReturnValue([]),
|
|
19
|
+
setName: vi.fn(),
|
|
20
|
+
getName: vi.fn().mockReturnValue(undefined),
|
|
21
|
+
getPermissionMode: vi.fn().mockReturnValue('default'),
|
|
22
|
+
isInitialized: false,
|
|
23
|
+
on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
|
|
24
|
+
if (!handlers.has(event)) handlers.set(event, []);
|
|
25
|
+
handlers.get(event)!.push(handler);
|
|
26
|
+
}),
|
|
27
|
+
off: vi.fn(),
|
|
28
|
+
emit: (event: string, ...args: unknown[]) => {
|
|
29
|
+
(handlers.get(event) ?? []).forEach((h) => h(...args));
|
|
30
|
+
},
|
|
31
|
+
submit: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
executeCommand: vi.fn().mockResolvedValue(null),
|
|
33
|
+
getPendingPrompt: vi.fn().mockReturnValue(null),
|
|
34
|
+
abort: vi.fn(),
|
|
35
|
+
cancelQueue: vi.fn(),
|
|
36
|
+
getContextState: vi.fn().mockReturnValue({
|
|
37
|
+
usedPercentage: 0,
|
|
38
|
+
usedTokens: 0,
|
|
39
|
+
maxTokens: 100_000,
|
|
40
|
+
}),
|
|
41
|
+
getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
|
|
42
|
+
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
43
|
+
sendAgentJob: vi.fn().mockResolvedValue(undefined),
|
|
44
|
+
readExecutionWorkspaceDetail: vi.fn().mockResolvedValue({}),
|
|
45
|
+
};
|
|
46
|
+
}),
|
|
47
|
+
CommandRegistry: vi.fn().mockImplementation(() => ({
|
|
48
|
+
addModule: vi.fn(),
|
|
49
|
+
})),
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
import { TuiInteractionChannel } from '../TuiInteractionChannel.js';
|
|
54
|
+
|
|
55
|
+
import type { IAIProvider } from '@robota-sdk/agent-core';
|
|
56
|
+
import type { IExecutionResult, IInteractiveSession } from '@robota-sdk/agent-framework';
|
|
57
|
+
import type { ITransportRegistryView } from '@robota-sdk/agent-interface-transport';
|
|
58
|
+
|
|
59
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
type MockSession = {
|
|
62
|
+
getFullHistory: ReturnType<typeof vi.fn>;
|
|
63
|
+
submit: ReturnType<typeof vi.fn>;
|
|
64
|
+
executeCommand: ReturnType<typeof vi.fn>;
|
|
65
|
+
on: ReturnType<typeof vi.fn>;
|
|
66
|
+
emit: (event: string, ...args: unknown[]) => void;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function getMockSession(channel: TuiInteractionChannel): MockSession {
|
|
70
|
+
return (channel as unknown as { interactiveSession: MockSession }).interactiveSession;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function emitSessionEvent(channel: TuiInteractionChannel, event: string, ...args: unknown[]): void {
|
|
74
|
+
getMockSession(channel).emit(event, ...args);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function makeMockTransportRegistry(): {
|
|
78
|
+
registry: ITransportRegistryView<IInteractiveSession>;
|
|
79
|
+
startAll: ReturnType<typeof vi.fn>;
|
|
80
|
+
stopAll: ReturnType<typeof vi.fn>;
|
|
81
|
+
} {
|
|
82
|
+
const startAll = vi.fn().mockResolvedValue(undefined);
|
|
83
|
+
const stopAll = vi.fn().mockResolvedValue(undefined);
|
|
84
|
+
return {
|
|
85
|
+
registry: { startAll, stopAll } as unknown as ITransportRegistryView<IInteractiveSession>,
|
|
86
|
+
startAll,
|
|
87
|
+
stopAll,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function makeChannel(opts?: {
|
|
92
|
+
transportRegistry?: ITransportRegistryView<IInteractiveSession>;
|
|
93
|
+
}): TuiInteractionChannel {
|
|
94
|
+
return new TuiInteractionChannel({
|
|
95
|
+
cwd: '/tmp/test',
|
|
96
|
+
provider: {} as IAIProvider,
|
|
97
|
+
...opts,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const MOCK_RESULT = {
|
|
102
|
+
contextState: { usedPercentage: 10, usedTokens: 1_000, maxTokens: 100_000 },
|
|
103
|
+
response: 'Hello!',
|
|
104
|
+
} as unknown as IExecutionResult;
|
|
105
|
+
|
|
106
|
+
const MOCK_TOOL = {
|
|
107
|
+
toolName: 'bash',
|
|
108
|
+
isRunning: true,
|
|
109
|
+
input: '{}',
|
|
110
|
+
startTime: Date.now(),
|
|
111
|
+
} as unknown as Parameters<
|
|
112
|
+
InstanceType<typeof TuiInteractionChannel>['stateManager']['onToolStart']
|
|
113
|
+
>[0];
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
vi.useFakeTimers();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
vi.useRealTimers();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── Group A: channel.start() / channel.stop() lifecycle ───────────────────────
|
|
124
|
+
|
|
125
|
+
describe('Group A — channel.start() / channel.stop() lifecycle', () => {
|
|
126
|
+
it('A1: text_delta after start() updates stateManager.streamingText', async () => {
|
|
127
|
+
const channel = makeChannel();
|
|
128
|
+
await channel.start();
|
|
129
|
+
|
|
130
|
+
emitSessionEvent(channel, 'text_delta', 'Hello!');
|
|
131
|
+
|
|
132
|
+
expect(channel.stateManager.streamingText).toBe('Hello!');
|
|
133
|
+
await channel.stop();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('A2: complete after start() clears streaming state and updates contextState', async () => {
|
|
137
|
+
const channel = makeChannel();
|
|
138
|
+
await channel.start();
|
|
139
|
+
|
|
140
|
+
emitSessionEvent(channel, 'text_delta', 'streaming...');
|
|
141
|
+
emitSessionEvent(channel, 'complete', MOCK_RESULT);
|
|
142
|
+
|
|
143
|
+
expect(channel.stateManager.streamingText).toBe('');
|
|
144
|
+
expect(channel.stateManager.contextState.percentage).toBe(10);
|
|
145
|
+
expect(channel.stateManager.contextState.usedTokens).toBe(1_000);
|
|
146
|
+
await channel.stop();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('A3: tool_start after start() adds entry to stateManager.activeTools', async () => {
|
|
150
|
+
const channel = makeChannel();
|
|
151
|
+
await channel.start();
|
|
152
|
+
|
|
153
|
+
emitSessionEvent(channel, 'tool_start', MOCK_TOOL);
|
|
154
|
+
|
|
155
|
+
expect(channel.stateManager.activeTools).toHaveLength(1);
|
|
156
|
+
expect(channel.stateManager.activeTools[0]).toMatchObject({ toolName: 'bash' });
|
|
157
|
+
await channel.stop();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('A4: error after start() clears stateManager.streamingText', async () => {
|
|
161
|
+
const channel = makeChannel();
|
|
162
|
+
await channel.start();
|
|
163
|
+
|
|
164
|
+
emitSessionEvent(channel, 'text_delta', 'partial...');
|
|
165
|
+
emitSessionEvent(channel, 'error');
|
|
166
|
+
|
|
167
|
+
expect(channel.stateManager.streamingText).toBe('');
|
|
168
|
+
await channel.stop();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('A5: calling start() twice does not duplicate subscriptions', async () => {
|
|
172
|
+
const channel = makeChannel();
|
|
173
|
+
await channel.start();
|
|
174
|
+
await channel.start(); // second call is a no-op (sessionStarted guard)
|
|
175
|
+
|
|
176
|
+
emitSessionEvent(channel, 'text_delta', 'hi');
|
|
177
|
+
|
|
178
|
+
expect(channel.stateManager.streamingText).toBe('hi'); // not 'hihi'
|
|
179
|
+
await channel.stop();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('A6: stop() calls transportRegistry.stopAll exactly once', async () => {
|
|
183
|
+
const { registry, stopAll } = makeMockTransportRegistry();
|
|
184
|
+
const channel = makeChannel({ transportRegistry: registry });
|
|
185
|
+
await channel.start();
|
|
186
|
+
await channel.stop();
|
|
187
|
+
|
|
188
|
+
expect(stopAll).toHaveBeenCalledOnce();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── Group B: handleInput() AI-response roundtrip ──────────────────────────────
|
|
193
|
+
|
|
194
|
+
describe('Group B — handleInput() roundtrip', () => {
|
|
195
|
+
it('B1: handleInput("hello") calls session.submit with "hello"', async () => {
|
|
196
|
+
const channel = makeChannel();
|
|
197
|
+
await channel.start();
|
|
198
|
+
|
|
199
|
+
await channel.handleInput('hello');
|
|
200
|
+
|
|
201
|
+
const mockSession = getMockSession(channel);
|
|
202
|
+
expect(mockSession.submit).toHaveBeenCalledWith('hello');
|
|
203
|
+
await channel.stop();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('B2: text_delta + complete syncs history to stateManager', async () => {
|
|
207
|
+
const channel = makeChannel();
|
|
208
|
+
const mockSession = getMockSession(channel);
|
|
209
|
+
const historyEntry = {
|
|
210
|
+
role: 'assistant',
|
|
211
|
+
content: [{ type: 'text', text: 'Hi!' }],
|
|
212
|
+
timestamp: Date.now(),
|
|
213
|
+
};
|
|
214
|
+
mockSession.getFullHistory.mockReturnValue([historyEntry]);
|
|
215
|
+
await channel.start();
|
|
216
|
+
|
|
217
|
+
await channel.handleInput('hello');
|
|
218
|
+
emitSessionEvent(channel, 'text_delta', 'Hi!');
|
|
219
|
+
expect(channel.stateManager.streamingText).toBe('Hi!');
|
|
220
|
+
|
|
221
|
+
emitSessionEvent(channel, 'complete', MOCK_RESULT);
|
|
222
|
+
expect(channel.stateManager.streamingText).toBe('');
|
|
223
|
+
expect(channel.stateManager.history).toHaveLength(1);
|
|
224
|
+
|
|
225
|
+
await channel.stop();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('B3: handleInput("/help") calls executeCommand, not session.submit', async () => {
|
|
229
|
+
const channel = makeChannel();
|
|
230
|
+
await channel.start();
|
|
231
|
+
|
|
232
|
+
await channel.handleInput('/help');
|
|
233
|
+
|
|
234
|
+
const mockSession = getMockSession(channel);
|
|
235
|
+
expect(mockSession.submit).not.toHaveBeenCalled();
|
|
236
|
+
expect(mockSession.executeCommand).toHaveBeenCalledWith('help', '');
|
|
237
|
+
await channel.stop();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('B4: handleInput("hello") triggers channel.onChange at least once', async () => {
|
|
241
|
+
const channel = makeChannel();
|
|
242
|
+
const onChange = vi.fn();
|
|
243
|
+
channel.onChange = onChange;
|
|
244
|
+
await channel.start();
|
|
245
|
+
onChange.mockClear();
|
|
246
|
+
|
|
247
|
+
await channel.handleInput('hello');
|
|
248
|
+
emitSessionEvent(channel, 'text_delta', 'hey');
|
|
249
|
+
|
|
250
|
+
expect(onChange).toHaveBeenCalled();
|
|
251
|
+
await channel.stop();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── Group C: onChange propagation invariant ───────────────────────────────────
|
|
256
|
+
|
|
257
|
+
describe('Group C — onChange propagation invariant', () => {
|
|
258
|
+
it('C1: session event after start() causes channel.onChange to fire', async () => {
|
|
259
|
+
const channel = makeChannel();
|
|
260
|
+
const onChange = vi.fn();
|
|
261
|
+
channel.onChange = onChange;
|
|
262
|
+
await channel.start();
|
|
263
|
+
onChange.mockClear();
|
|
264
|
+
|
|
265
|
+
// tool_start calls notify() directly (not debounced), so onChange fires immediately
|
|
266
|
+
emitSessionEvent(channel, 'tool_start', MOCK_TOOL);
|
|
267
|
+
|
|
268
|
+
expect(onChange).toHaveBeenCalled();
|
|
269
|
+
await channel.stop();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('C2: channel.onChange does not fire for events before start()', () => {
|
|
273
|
+
const channel = makeChannel();
|
|
274
|
+
const onChange = vi.fn();
|
|
275
|
+
channel.onChange = onChange;
|
|
276
|
+
// Do NOT call channel.start() — handlers not registered yet
|
|
277
|
+
emitSessionEvent(channel, 'text_delta', 'hello'); // no-op: no handlers
|
|
278
|
+
|
|
279
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('C3: channel.onChange does not fire for events after stop()', async () => {
|
|
283
|
+
const channel = makeChannel();
|
|
284
|
+
const onChange = vi.fn();
|
|
285
|
+
channel.onChange = onChange;
|
|
286
|
+
await channel.start();
|
|
287
|
+
await channel.stop(); // sets this.onChange = null
|
|
288
|
+
onChange.mockClear();
|
|
289
|
+
|
|
290
|
+
emitSessionEvent(channel, 'text_delta', 'hello');
|
|
291
|
+
|
|
292
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for TuiInteractionChannel.requestAction() promise protocol.
|
|
3
|
+
*
|
|
4
|
+
* Tests the queue-based action resolution mechanism in isolation —
|
|
5
|
+
* no Ink rendering, no InteractiveSession, no real provider required.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
8
|
+
|
|
9
|
+
vi.mock('@robota-sdk/agent-framework', async () => {
|
|
10
|
+
const actual = await vi.importActual<typeof import('@robota-sdk/agent-framework')>(
|
|
11
|
+
'@robota-sdk/agent-framework',
|
|
12
|
+
);
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
InteractiveSession: vi.fn().mockImplementation(() => ({
|
|
16
|
+
getFullHistory: vi.fn().mockReturnValue([]),
|
|
17
|
+
setName: vi.fn(),
|
|
18
|
+
getSessionId: vi.fn().mockReturnValue('test-id'),
|
|
19
|
+
isInitialized: false,
|
|
20
|
+
on: vi.fn(),
|
|
21
|
+
off: vi.fn(),
|
|
22
|
+
})),
|
|
23
|
+
CommandRegistry: vi.fn().mockImplementation(() => ({
|
|
24
|
+
addModule: vi.fn(),
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
import { TuiInteractionChannel } from '../TuiInteractionChannel.js';
|
|
30
|
+
|
|
31
|
+
import type { IAIProvider } from '@robota-sdk/agent-core';
|
|
32
|
+
import type { IActionRequest } from '@robota-sdk/agent-framework';
|
|
33
|
+
|
|
34
|
+
function makeChannel(): TuiInteractionChannel {
|
|
35
|
+
return new TuiInteractionChannel({
|
|
36
|
+
cwd: '/tmp',
|
|
37
|
+
provider: {} as IAIProvider,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const PICK_ACTION: IActionRequest = {
|
|
42
|
+
type: 'pick',
|
|
43
|
+
id: 'mode',
|
|
44
|
+
title: '/mode',
|
|
45
|
+
items: [
|
|
46
|
+
{ label: 'plan', value: 'plan' },
|
|
47
|
+
{ label: 'default', value: 'default' },
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const CONFIRM_ACTION: IActionRequest = {
|
|
52
|
+
type: 'confirm',
|
|
53
|
+
id: 'exit',
|
|
54
|
+
message: 'Exit the session?',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe('TuiInteractionChannel.requestAction', () => {
|
|
58
|
+
let channel: TuiInteractionChannel;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
channel = makeChannel();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('sets pendingAction when requestAction is called', () => {
|
|
65
|
+
void channel.requestAction(PICK_ACTION);
|
|
66
|
+
expect(channel.pendingAction).toMatchObject({ type: 'pick', id: 'mode' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('resolves pick response when resolveAction is called', async () => {
|
|
70
|
+
const responsePromise = channel.requestAction(PICK_ACTION);
|
|
71
|
+
channel.resolveAction({ type: 'pick', item: { label: 'plan', value: 'plan' } });
|
|
72
|
+
const response = await responsePromise;
|
|
73
|
+
expect(response).toEqual({ type: 'pick', item: { label: 'plan', value: 'plan' } });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('clears pendingAction after resolveAction', async () => {
|
|
77
|
+
const responsePromise = channel.requestAction(PICK_ACTION);
|
|
78
|
+
channel.resolveAction({ type: 'cancelled' });
|
|
79
|
+
await responsePromise;
|
|
80
|
+
expect(channel.pendingAction).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('resolves confirm response when resolveAction is called', async () => {
|
|
84
|
+
const responsePromise = channel.requestAction(CONFIRM_ACTION);
|
|
85
|
+
channel.resolveAction({ type: 'confirm', confirmed: true });
|
|
86
|
+
const response = await responsePromise;
|
|
87
|
+
expect(response).toEqual({ type: 'confirm', confirmed: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('resolves cancelled when resolveAction is called with cancelled', async () => {
|
|
91
|
+
const responsePromise = channel.requestAction(PICK_ACTION);
|
|
92
|
+
channel.resolveAction({ type: 'cancelled' });
|
|
93
|
+
const response = await responsePromise;
|
|
94
|
+
expect(response).toEqual({ type: 'cancelled' });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('queues multiple actions and processes them sequentially', async () => {
|
|
98
|
+
const p1 = channel.requestAction(PICK_ACTION);
|
|
99
|
+
const p2 = channel.requestAction(CONFIRM_ACTION);
|
|
100
|
+
|
|
101
|
+
// First action is pending immediately
|
|
102
|
+
expect(channel.pendingAction).toMatchObject({ type: 'pick' });
|
|
103
|
+
|
|
104
|
+
// Resolve first
|
|
105
|
+
channel.resolveAction({ type: 'pick', item: { label: 'plan', value: 'plan' } });
|
|
106
|
+
await p1;
|
|
107
|
+
|
|
108
|
+
// Second action becomes pending after first resolves
|
|
109
|
+
expect(channel.pendingAction).toMatchObject({ type: 'confirm' });
|
|
110
|
+
|
|
111
|
+
// Resolve second
|
|
112
|
+
channel.resolveAction({ type: 'confirm', confirmed: false });
|
|
113
|
+
const r2 = await p2;
|
|
114
|
+
expect(r2).toEqual({ type: 'confirm', confirmed: false });
|
|
115
|
+
expect(channel.pendingAction).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('calls onChange when pendingAction changes', () => {
|
|
119
|
+
const onChange = vi.fn();
|
|
120
|
+
channel.onChange = onChange;
|
|
121
|
+
void channel.requestAction(PICK_ACTION);
|
|
122
|
+
expect(onChange).toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { createSystemMessage, messageToHistoryEntry } from '@robota-sdk/agent-core';
|
|
3
3
|
import { TuiStateManager } from '../tui-state-manager.js';
|
|
4
|
-
import { applyCompactEventToManager } from '../hooks/
|
|
4
|
+
import { applyCompactEventToManager } from '../hooks/useTuiChannel.js';
|
|
5
5
|
|
|
6
6
|
describe('compact event bridge', () => {
|
|
7
7
|
it('syncs session history so automatic compaction notifications render', () => {
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
shouldSubmitInput,
|
|
15
15
|
} from '../flows/input-area-flow.js';
|
|
16
16
|
import type { ICommand } from '@robota-sdk/agent-framework';
|
|
17
|
-
import type { ITuiPickerInteraction } from '../command-interaction.js';
|
|
18
17
|
import {
|
|
19
18
|
createAssistantMessage,
|
|
20
19
|
createSystemMessage,
|
|
@@ -67,20 +66,14 @@ describe('input area flow', () => {
|
|
|
67
66
|
expect(result).toEqual({ type: 'submit', value: '/help' });
|
|
68
67
|
});
|
|
69
68
|
|
|
70
|
-
it('Given
|
|
71
|
-
const result = resolveEnterCommandSelection('/ex', command('exit')
|
|
72
|
-
onMissingArgs: 'confirm',
|
|
73
|
-
});
|
|
69
|
+
it('Given command with no args and no subcommands When enter selects Then submits', () => {
|
|
70
|
+
const result = resolveEnterCommandSelection('/ex', command('exit'));
|
|
74
71
|
|
|
75
|
-
expect(result).toEqual({ type: '
|
|
72
|
+
expect(result).toEqual({ type: 'submit', value: '/exit' });
|
|
76
73
|
});
|
|
77
74
|
|
|
78
|
-
it('Given
|
|
79
|
-
const
|
|
80
|
-
onMissingArgs: 'picker',
|
|
81
|
-
getItems: () => [],
|
|
82
|
-
};
|
|
83
|
-
const result = resolveEnterCommandSelection('/mode plan', command('plan'), pickerInteraction);
|
|
75
|
+
it('Given subcommand selected (args present) When enter selects Then submits', () => {
|
|
76
|
+
const result = resolveEnterCommandSelection('/mode plan', command('plan'));
|
|
84
77
|
|
|
85
78
|
expect(result).toEqual({ type: 'submit', value: '/mode plan' });
|
|
86
79
|
});
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isSlashCommand, tokeniseSlashCommand } from '@robota-sdk/agent-framework';
|
|
2
2
|
|
|
3
|
-
import type { ITuiCommandInteraction } from '../command-interaction.js';
|
|
4
3
|
import type { IHistoryEntry, TUniversalValue } from '@robota-sdk/agent-core';
|
|
5
4
|
import type { ICommand } from '@robota-sdk/agent-framework';
|
|
6
5
|
|
|
@@ -19,8 +18,7 @@ export type TPromptHistoryInputAction = 'previous' | 'next';
|
|
|
19
18
|
|
|
20
19
|
export type TCommandSelectionResult =
|
|
21
20
|
| { type: 'insert'; value: string; selectedIndex?: number }
|
|
22
|
-
| { type: 'submit'; value: string }
|
|
23
|
-
| { type: 'open-interaction'; commandName: string };
|
|
21
|
+
| { type: 'submit'; value: string };
|
|
24
22
|
|
|
25
23
|
export interface IPasteLabelChange {
|
|
26
24
|
value: string;
|
|
@@ -144,9 +142,10 @@ export function moveAutocompleteSelection(
|
|
|
144
142
|
}
|
|
145
143
|
|
|
146
144
|
export function resolveTabCompletion(value: string, command: ICommand): TCommandSelectionResult {
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
|
|
145
|
+
// Subcommand mode: '/parent filter' — space present after command name
|
|
146
|
+
if (isSlashCommand(value) && value.slice(1).includes(' ')) {
|
|
147
|
+
const { name } = tokeniseSlashCommand(value);
|
|
148
|
+
return { type: 'insert', value: `/${name} ${command.name} ` };
|
|
150
149
|
}
|
|
151
150
|
if (command.subcommands && command.subcommands.length > 0) {
|
|
152
151
|
return { type: 'insert', value: `/${command.name} `, selectedIndex: 0 };
|
|
@@ -157,15 +156,11 @@ export function resolveTabCompletion(value: string, command: ICommand): TCommand
|
|
|
157
156
|
export function resolveEnterCommandSelection(
|
|
158
157
|
value: string,
|
|
159
158
|
command: ICommand,
|
|
160
|
-
interaction?: ITuiCommandInteraction,
|
|
161
159
|
): TCommandSelectionResult {
|
|
162
|
-
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// parentCommand is empty → no args provided beyond the command name itself
|
|
167
|
-
if (interaction?.onMissingArgs) {
|
|
168
|
-
return { type: 'open-interaction', commandName: command.name };
|
|
160
|
+
// Subcommand mode: '/parent filter' — space present after command name
|
|
161
|
+
if (isSlashCommand(value) && value.slice(1).includes(' ')) {
|
|
162
|
+
const { name } = tokeniseSlashCommand(value);
|
|
163
|
+
return { type: 'submit', value: `/${name} ${command.name}` };
|
|
169
164
|
}
|
|
170
165
|
if (command.subcommands && command.subcommands.length > 0) {
|
|
171
166
|
return { type: 'insert', value: `/${command.name} `, selectedIndex: 0 };
|
|
@@ -3,9 +3,44 @@ import { InteractiveSession, CommandRegistry } from '@robota-sdk/agent-framework
|
|
|
3
3
|
import { TuiStateManager } from '../tui-state-manager.js';
|
|
4
4
|
import { CommandEffectQueue, type ICommandEffectQueue } from './command-effect-queue.js';
|
|
5
5
|
|
|
6
|
-
import type {
|
|
6
|
+
import type { IAIProvider, TPermissionMode } from '@robota-sdk/agent-core';
|
|
7
7
|
import type { TToolArgs } from '@robota-sdk/agent-core';
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
IBackgroundTaskRunner,
|
|
10
|
+
ICommandHostAdapters,
|
|
11
|
+
ICommandModule,
|
|
12
|
+
IInteractiveSession,
|
|
13
|
+
IInteractiveSessionStore,
|
|
14
|
+
TSubagentRunnerFactory,
|
|
15
|
+
TShellExecFn,
|
|
16
|
+
TPermissionResultValue,
|
|
17
|
+
} from '@robota-sdk/agent-framework';
|
|
18
|
+
import type { ITransportRegistryView } from '@robota-sdk/agent-interface-transport';
|
|
19
|
+
|
|
20
|
+
export interface IInteractiveSessionProps {
|
|
21
|
+
cwd: string;
|
|
22
|
+
provider: IAIProvider;
|
|
23
|
+
permissionMode?: TPermissionMode;
|
|
24
|
+
maxTurns?: number;
|
|
25
|
+
sessionStore?: IInteractiveSessionStore;
|
|
26
|
+
resumeSessionId?: string;
|
|
27
|
+
forkSession?: boolean;
|
|
28
|
+
sessionName?: string;
|
|
29
|
+
onAutoNamed?: (name: string) => void;
|
|
30
|
+
backgroundTaskRunners?: IBackgroundTaskRunner[];
|
|
31
|
+
subagentRunnerFactory?: TSubagentRunnerFactory;
|
|
32
|
+
commandModules?: readonly ICommandModule[];
|
|
33
|
+
commandHostAdapters?: ICommandHostAdapters;
|
|
34
|
+
shellExec?: TShellExecFn;
|
|
35
|
+
transportRegistry?: ITransportRegistryView<IInteractiveSession>;
|
|
36
|
+
language?: string;
|
|
37
|
+
reloadPluginCommandSource?: (registry: CommandRegistry) => void;
|
|
38
|
+
agentName?: string;
|
|
39
|
+
systemPrompt?: string;
|
|
40
|
+
appendSystemPrompt?: string;
|
|
41
|
+
allowedTools?: string[];
|
|
42
|
+
deniedTools?: string[];
|
|
43
|
+
}
|
|
9
44
|
|
|
10
45
|
export interface IInitState {
|
|
11
46
|
interactiveSession: InteractiveSession;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTuiChannel — React hook that subscribes to TuiInteractionChannel state changes.
|
|
3
|
+
*
|
|
4
|
+
* Returns the same shape as the former IInteractiveSessionState so that App.tsx
|
|
5
|
+
* changes are minimal.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect } from 'react';
|
|
9
|
+
|
|
10
|
+
import type { TuiInteractionChannel } from '../TuiInteractionChannel.js';
|
|
11
|
+
import type { ICommandEffectQueue } from './command-effect-queue.js';
|
|
12
|
+
import type { IPermissionRequest } from '../types.js';
|
|
13
|
+
import type { IHistoryEntry, TSessionEndReason } from '@robota-sdk/agent-core';
|
|
14
|
+
import type { InteractiveSession, CommandRegistry } from '@robota-sdk/agent-framework';
|
|
15
|
+
import type {
|
|
16
|
+
IToolState,
|
|
17
|
+
IExecutionWorkspaceSnapshot,
|
|
18
|
+
IExecutionDetailPage,
|
|
19
|
+
} from '@robota-sdk/agent-framework';
|
|
20
|
+
|
|
21
|
+
export interface IInteractiveSessionState {
|
|
22
|
+
interactiveSession: InteractiveSession;
|
|
23
|
+
registry: CommandRegistry;
|
|
24
|
+
commandEffectQueue: ICommandEffectQueue;
|
|
25
|
+
history: IHistoryEntry[];
|
|
26
|
+
addEntry: (entry: IHistoryEntry) => void;
|
|
27
|
+
streamingText: string;
|
|
28
|
+
activeTools: IToolState[];
|
|
29
|
+
isThinking: boolean;
|
|
30
|
+
isAborting: boolean;
|
|
31
|
+
isShuttingDown: boolean;
|
|
32
|
+
pendingPrompt: string | null;
|
|
33
|
+
executionWorkspaceSnapshot: IExecutionWorkspaceSnapshot | null;
|
|
34
|
+
selectedExecutionEntryId?: string;
|
|
35
|
+
permissionRequest: IPermissionRequest | null;
|
|
36
|
+
contextState: { percentage: number; usedTokens: number; maxTokens: number };
|
|
37
|
+
handleSubmit: (input: string) => Promise<void>;
|
|
38
|
+
handleAbort: () => void;
|
|
39
|
+
handleCancelQueue: () => void;
|
|
40
|
+
handleShutdown: (reason?: TSessionEndReason) => Promise<void>;
|
|
41
|
+
selectExecutionWorkspaceEntry: (entryId: string) => void;
|
|
42
|
+
readExecutionWorkspaceDetail: (entryId: string) => Promise<IExecutionDetailPage>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface IHistoryReadableSession {
|
|
46
|
+
getFullHistory(): IHistoryEntry[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface IHistorySyncManager {
|
|
50
|
+
syncHistory(entries: IHistoryEntry[]): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function applyCompactEventToManager(
|
|
54
|
+
interactiveSession: IHistoryReadableSession,
|
|
55
|
+
manager: IHistorySyncManager,
|
|
56
|
+
): void {
|
|
57
|
+
manager.syncHistory(interactiveSession.getFullHistory());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useTuiChannel(channel: TuiInteractionChannel): IInteractiveSessionState {
|
|
61
|
+
const [, forceRender] = useState(0);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
channel.onChange = () => forceRender((n) => n + 1);
|
|
65
|
+
return () => {
|
|
66
|
+
channel.onChange = null;
|
|
67
|
+
};
|
|
68
|
+
}, [channel]);
|
|
69
|
+
|
|
70
|
+
const manager = channel.stateManager;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
interactiveSession: channel.getSession(),
|
|
74
|
+
registry: channel.getRegistry(),
|
|
75
|
+
commandEffectQueue: channel.getCommandEffectQueue(),
|
|
76
|
+
history: manager.history,
|
|
77
|
+
addEntry: (e) => manager.addEntry(e),
|
|
78
|
+
streamingText: manager.streamingText,
|
|
79
|
+
activeTools: manager.activeTools,
|
|
80
|
+
isThinking: manager.isThinking,
|
|
81
|
+
isAborting: manager.isAborting,
|
|
82
|
+
isShuttingDown: channel.isShuttingDown,
|
|
83
|
+
pendingPrompt: manager.pendingPrompt,
|
|
84
|
+
executionWorkspaceSnapshot: manager.executionWorkspaceSnapshot,
|
|
85
|
+
selectedExecutionEntryId: manager.selectedExecutionEntryId,
|
|
86
|
+
permissionRequest: channel.permissionRequest,
|
|
87
|
+
contextState: manager.contextState,
|
|
88
|
+
handleSubmit: (input) => channel.handleInput(input),
|
|
89
|
+
handleAbort: () => channel.abort(),
|
|
90
|
+
handleCancelQueue: () => channel.cancelQueue(),
|
|
91
|
+
handleShutdown: (reason) => channel.shutdown({ reason }),
|
|
92
|
+
selectExecutionWorkspaceEntry: (id) => channel.selectExecutionWorkspaceEntry(id),
|
|
93
|
+
readExecutionWorkspaceDetail: (id) => channel.readExecutionWorkspaceDetail(id),
|
|
94
|
+
};
|
|
95
|
+
}
|
package/src/tui/index.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export { TuiTransport } from './tui-transport.js';
|
|
2
|
+
export { renderApp } from './render.js';
|
|
3
|
+
export type { IRenderOptions } from './render.js';
|
|
2
4
|
export type { ITuiCliAdapter } from './tui-cli-adapter.js';
|
|
3
5
|
export type { IDefaultTuiCliAdapterOptions } from './create-default-tui-cli-adapter.js';
|
|
4
6
|
export { createDefaultTuiCliAdapter } from './create-default-tui-cli-adapter.js';
|
|
5
|
-
export type { IRenderOptions } from './render.js';
|
|
6
7
|
export type {
|
|
7
8
|
TOnMissingArgsAction,
|
|
8
9
|
ITuiPickerItem,
|