@robota-sdk/agent-transport 3.0.0-beta.68 → 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,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TuiInteractionChannel — implements IInteractionChannel for the Ink TUI.
|
|
3
|
+
*
|
|
4
|
+
* Moves session lifecycle (InteractiveSession, CommandRegistry, TuiStateManager)
|
|
5
|
+
* out of React hooks and into a plain TypeScript class.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createSystemMessage,
|
|
10
|
+
createUserMessage,
|
|
11
|
+
messageToHistoryEntry,
|
|
12
|
+
} from '@robota-sdk/agent-core';
|
|
13
|
+
import { InteractiveSession, CommandRegistry } from '@robota-sdk/agent-framework';
|
|
14
|
+
|
|
15
|
+
import { CommandEffectQueue, type ICommandEffectQueue } from './hooks/command-effect-queue.js';
|
|
16
|
+
import { applySystemCommandResult } from './hooks/useSlashRouting.js';
|
|
17
|
+
import { generateSessionName } from './session-naming.js';
|
|
18
|
+
import { TuiStateManager } from './tui-state-manager.js';
|
|
19
|
+
|
|
20
|
+
import type { IPermissionRequest } from './types.js';
|
|
21
|
+
import type { IAIProvider, TPermissionMode, TSessionEndReason } from '@robota-sdk/agent-core';
|
|
22
|
+
import type { TToolArgs } from '@robota-sdk/agent-core';
|
|
23
|
+
import type { IInteractionChannel } from '@robota-sdk/agent-framework';
|
|
24
|
+
import type {
|
|
25
|
+
InteractionEvent,
|
|
26
|
+
IActionRequest,
|
|
27
|
+
IActionResponse,
|
|
28
|
+
ICommandInfo,
|
|
29
|
+
} from '@robota-sdk/agent-framework';
|
|
30
|
+
import type {
|
|
31
|
+
IBackgroundTaskRunner,
|
|
32
|
+
ICommandHostAdapters,
|
|
33
|
+
ICommandModule,
|
|
34
|
+
IInteractiveSession,
|
|
35
|
+
IInteractiveSessionStore,
|
|
36
|
+
TSubagentRunnerFactory,
|
|
37
|
+
IExecutionWorkspaceEvent,
|
|
38
|
+
IExecutionDetailPage,
|
|
39
|
+
IExecutionResult,
|
|
40
|
+
TShellExecFn,
|
|
41
|
+
} from '@robota-sdk/agent-framework';
|
|
42
|
+
import type { TPermissionResultValue } from '@robota-sdk/agent-framework';
|
|
43
|
+
import type { ITransportRegistryView } from '@robota-sdk/agent-interface-transport';
|
|
44
|
+
|
|
45
|
+
const SESSION_INIT_POLL_MS = 200;
|
|
46
|
+
|
|
47
|
+
export interface ITuiInteractionChannelOptions {
|
|
48
|
+
cwd: string;
|
|
49
|
+
provider: IAIProvider;
|
|
50
|
+
permissionMode?: TPermissionMode;
|
|
51
|
+
maxTurns?: number;
|
|
52
|
+
sessionStore?: IInteractiveSessionStore;
|
|
53
|
+
resumeSessionId?: string;
|
|
54
|
+
forkSession?: boolean;
|
|
55
|
+
sessionName?: string;
|
|
56
|
+
onAutoNamed?: (name: string) => void;
|
|
57
|
+
backgroundTaskRunners?: IBackgroundTaskRunner[];
|
|
58
|
+
subagentRunnerFactory?: TSubagentRunnerFactory;
|
|
59
|
+
commandModules?: readonly ICommandModule[];
|
|
60
|
+
commandHostAdapters?: ICommandHostAdapters;
|
|
61
|
+
shellExec?: TShellExecFn;
|
|
62
|
+
transportRegistry?: ITransportRegistryView<IInteractiveSession>;
|
|
63
|
+
language?: string;
|
|
64
|
+
reloadPluginCommandSource?: (registry: CommandRegistry) => void;
|
|
65
|
+
agentName?: string;
|
|
66
|
+
systemPrompt?: string;
|
|
67
|
+
appendSystemPrompt?: string;
|
|
68
|
+
allowedTools?: string[];
|
|
69
|
+
deniedTools?: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class TuiInteractionChannel implements IInteractionChannel {
|
|
73
|
+
readonly stateManager: TuiStateManager;
|
|
74
|
+
|
|
75
|
+
private readonly interactiveSession: InteractiveSession;
|
|
76
|
+
private readonly registry: CommandRegistry;
|
|
77
|
+
private readonly commandEffectQueue: ICommandEffectQueue;
|
|
78
|
+
private readonly opts: ITuiInteractionChannelOptions;
|
|
79
|
+
|
|
80
|
+
private submitHandler: ((text: string) => Promise<void>) | null = null;
|
|
81
|
+
private actionQueue: Array<{
|
|
82
|
+
action: IActionRequest;
|
|
83
|
+
resolve: (response: IActionResponse) => void;
|
|
84
|
+
}> = [];
|
|
85
|
+
private processingAction = false;
|
|
86
|
+
|
|
87
|
+
permissionRequest: IPermissionRequest | null = null;
|
|
88
|
+
pendingAction: IActionRequest | null = null;
|
|
89
|
+
availableCommands: ICommandInfo[] = [];
|
|
90
|
+
isShuttingDown = false;
|
|
91
|
+
sessionName: string | undefined;
|
|
92
|
+
|
|
93
|
+
private autoNameTriggered = false;
|
|
94
|
+
private sessionStarted = false;
|
|
95
|
+
private initCheckInterval: ReturnType<typeof setInterval> | null = null;
|
|
96
|
+
private permissionQueue: Array<{
|
|
97
|
+
toolName: string;
|
|
98
|
+
toolArgs: TToolArgs;
|
|
99
|
+
resolve: (result: TPermissionResultValue) => void;
|
|
100
|
+
}> = [];
|
|
101
|
+
private processingPermission = false;
|
|
102
|
+
|
|
103
|
+
/** Set by React hook to trigger re-render on state change */
|
|
104
|
+
onChange: (() => void) | null = null;
|
|
105
|
+
|
|
106
|
+
constructor(opts: ITuiInteractionChannelOptions) {
|
|
107
|
+
this.opts = opts;
|
|
108
|
+
this.sessionName = opts.sessionName;
|
|
109
|
+
this.stateManager = new TuiStateManager();
|
|
110
|
+
this.stateManager.onChange = () => this.onChange?.();
|
|
111
|
+
|
|
112
|
+
this.interactiveSession = this.createSession();
|
|
113
|
+
this.registry = this.createRegistry();
|
|
114
|
+
this.commandEffectQueue = new CommandEffectQueue();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private createSession(): InteractiveSession {
|
|
118
|
+
const opts = this.opts;
|
|
119
|
+
return new InteractiveSession({
|
|
120
|
+
cwd: opts.cwd,
|
|
121
|
+
provider: opts.provider,
|
|
122
|
+
permissionMode: opts.permissionMode,
|
|
123
|
+
maxTurns: opts.maxTurns,
|
|
124
|
+
permissionHandler: (toolName, toolArgs) => this.handlePermissionRequest(toolName, toolArgs),
|
|
125
|
+
sessionStore: opts.sessionStore,
|
|
126
|
+
resumeSessionId: opts.resumeSessionId,
|
|
127
|
+
forkSession: opts.forkSession,
|
|
128
|
+
sessionName: opts.sessionName,
|
|
129
|
+
backgroundTaskRunners: opts.backgroundTaskRunners,
|
|
130
|
+
subagentRunnerFactory: opts.subagentRunnerFactory,
|
|
131
|
+
commandModules: opts.commandModules,
|
|
132
|
+
commandHostAdapters: opts.commandHostAdapters,
|
|
133
|
+
shellExec: opts.shellExec,
|
|
134
|
+
language: opts.language,
|
|
135
|
+
agentName: opts.agentName,
|
|
136
|
+
systemPrompt: opts.systemPrompt,
|
|
137
|
+
appendSystemPrompt: opts.appendSystemPrompt,
|
|
138
|
+
allowedTools: opts.allowedTools,
|
|
139
|
+
deniedTools: opts.deniedTools,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private createRegistry(): CommandRegistry {
|
|
144
|
+
const registry = new CommandRegistry();
|
|
145
|
+
for (const module of this.opts.commandModules ?? []) {
|
|
146
|
+
registry.addModule(module);
|
|
147
|
+
}
|
|
148
|
+
this.opts.reloadPluginCommandSource?.(registry);
|
|
149
|
+
return registry;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── IInteractionChannel ──────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
onSubmit(handler: (text: string) => Promise<void>): void {
|
|
155
|
+
this.submitHandler = handler;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
write(_event: InteractionEvent): void {
|
|
159
|
+
// Intentionally unused in TUI direct-wiring mode.
|
|
160
|
+
// TuiInteractionChannel subscribes to session events directly via start() →
|
|
161
|
+
// wireSessionEvents(), not through the IInteractionChannel event protocol used
|
|
162
|
+
// by createInteractiveRuntime. The two paths are mutually exclusive.
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async requestAction(action: IActionRequest): Promise<IActionResponse> {
|
|
166
|
+
return new Promise<IActionResponse>((resolve) => {
|
|
167
|
+
this.actionQueue.push({ action, resolve });
|
|
168
|
+
this.processNextAction();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
setAvailableCommands(commands: ICommandInfo[]): void {
|
|
173
|
+
this.availableCommands = commands;
|
|
174
|
+
this.onChange?.();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
setBusy(busy: boolean): void {
|
|
178
|
+
this.stateManager.onThinking(busy);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async start(): Promise<void> {
|
|
182
|
+
if (this.sessionStarted) return;
|
|
183
|
+
this.sessionStarted = true;
|
|
184
|
+
this.wireSessionEvents();
|
|
185
|
+
this.syncRestoredHistory();
|
|
186
|
+
this.startInitCheck();
|
|
187
|
+
|
|
188
|
+
if (this.opts.transportRegistry) {
|
|
189
|
+
await this.opts.transportRegistry.startAll(this.interactiveSession);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async stop(): Promise<void> {
|
|
194
|
+
this.onChange = null;
|
|
195
|
+
this.sessionStarted = false;
|
|
196
|
+
this.stopInitCheck();
|
|
197
|
+
if (this.opts.transportRegistry) {
|
|
198
|
+
await this.opts.transportRegistry.stopAll();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Additional methods for App.tsx ───────────────────────────
|
|
203
|
+
|
|
204
|
+
getSession(): InteractiveSession {
|
|
205
|
+
return this.interactiveSession;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
getRegistry(): CommandRegistry {
|
|
209
|
+
return this.registry;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
getCommandEffectQueue(): ICommandEffectQueue {
|
|
213
|
+
return this.commandEffectQueue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
abort(): void {
|
|
217
|
+
this.stateManager.setAborting(true);
|
|
218
|
+
this.interactiveSession.abort();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
cancelQueue(): void {
|
|
222
|
+
this.interactiveSession.cancelQueue();
|
|
223
|
+
this.stateManager.setPendingPrompt(null);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async shutdown(options?: { reason?: TSessionEndReason }): Promise<void> {
|
|
227
|
+
if (this.isShuttingDown) return;
|
|
228
|
+
this.isShuttingDown = true;
|
|
229
|
+
this.stateManager.addEntry(messageToHistoryEntry(createSystemMessage('Shutting down...')));
|
|
230
|
+
this.onChange?.();
|
|
231
|
+
await this.interactiveSession.shutdown({
|
|
232
|
+
reason: options?.reason ?? 'prompt_input_exit',
|
|
233
|
+
message: 'CLI shutdown',
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
selectExecutionWorkspaceEntry(entryId: string): void {
|
|
238
|
+
this.stateManager.selectExecutionWorkspaceEntry(entryId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async readExecutionWorkspaceDetail(entryId: string): Promise<IExecutionDetailPage> {
|
|
242
|
+
return this.interactiveSession.readExecutionWorkspaceDetail(entryId);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async sendAgentJob(jobId: string, input: string): Promise<void> {
|
|
246
|
+
await this.interactiveSession.sendAgentJob(jobId, input);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
setSessionName(name: string): void {
|
|
250
|
+
this.sessionName = name;
|
|
251
|
+
this.interactiveSession.setName(name);
|
|
252
|
+
this.onChange?.();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
resolveAction(response: IActionResponse): void {
|
|
256
|
+
const pending = this.actionQueue[0];
|
|
257
|
+
if (!pending) return;
|
|
258
|
+
this.actionQueue.shift();
|
|
259
|
+
this.processingAction = false;
|
|
260
|
+
this.pendingAction = null;
|
|
261
|
+
this.onChange?.();
|
|
262
|
+
pending.resolve(response);
|
|
263
|
+
this.processNextAction();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async handleInput(input: string): Promise<void> {
|
|
267
|
+
if (!input.startsWith('/')) {
|
|
268
|
+
await this.interactiveSession.submit(input);
|
|
269
|
+
this.stateManager.setPendingPrompt(this.interactiveSession.getPendingPrompt());
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
await this.handleSlashCommand(input);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async handleSlashCommand(input: string): Promise<void> {
|
|
276
|
+
const parts = input.slice(1).split(/\s+/);
|
|
277
|
+
const cmd = parts[0]?.toLowerCase() ?? '';
|
|
278
|
+
const args = parts.slice(1).join(' ');
|
|
279
|
+
|
|
280
|
+
const result = await this.interactiveSession.executeCommand(cmd, args);
|
|
281
|
+
if (result) {
|
|
282
|
+
if (result.effects?.some((effect) => effect.type === 'session-execution-started')) {
|
|
283
|
+
this.stateManager.setPendingPrompt(this.interactiveSession.getPendingPrompt());
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
applySystemCommandResult(
|
|
287
|
+
result,
|
|
288
|
+
this.interactiveSession,
|
|
289
|
+
this.registry,
|
|
290
|
+
this.stateManager,
|
|
291
|
+
this.commandEffectQueue,
|
|
292
|
+
this.opts.reloadPluginCommandSource,
|
|
293
|
+
);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.stateManager.addEntry(
|
|
298
|
+
messageToHistoryEntry(createSystemMessage(`Unknown command "/${cmd}". Type /help for help.`)),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Private helpers ──────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
private processNextAction(): void {
|
|
305
|
+
if (this.processingAction) return;
|
|
306
|
+
const next = this.actionQueue[0];
|
|
307
|
+
if (!next) {
|
|
308
|
+
this.pendingAction = null;
|
|
309
|
+
this.onChange?.();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
this.processingAction = true;
|
|
313
|
+
this.pendingAction = next.action;
|
|
314
|
+
this.onChange?.();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private handlePermissionRequest(
|
|
318
|
+
toolName: string,
|
|
319
|
+
toolArgs: TToolArgs,
|
|
320
|
+
): Promise<TPermissionResultValue> {
|
|
321
|
+
return new Promise<TPermissionResultValue>((resolve) => {
|
|
322
|
+
this.permissionQueue.push({ toolName, toolArgs, resolve });
|
|
323
|
+
this.processNextPermission();
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private processNextPermission(): void {
|
|
328
|
+
if (this.processingPermission) return;
|
|
329
|
+
const next = this.permissionQueue[0];
|
|
330
|
+
if (!next) {
|
|
331
|
+
this.permissionRequest = null;
|
|
332
|
+
this.onChange?.();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this.processingPermission = true;
|
|
336
|
+
this.permissionRequest = {
|
|
337
|
+
toolName: next.toolName,
|
|
338
|
+
toolArgs: next.toolArgs,
|
|
339
|
+
resolve: (result) => {
|
|
340
|
+
this.permissionQueue.shift();
|
|
341
|
+
this.processingPermission = false;
|
|
342
|
+
this.permissionRequest = null;
|
|
343
|
+
next.resolve(result);
|
|
344
|
+
setTimeout(() => this.processNextPermission(), 0);
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
this.onChange?.();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private wireSessionEvents(): void {
|
|
351
|
+
const session = this.interactiveSession;
|
|
352
|
+
const manager = this.stateManager;
|
|
353
|
+
|
|
354
|
+
const onUserMessage = (content: string): void => {
|
|
355
|
+
this.handleAutoNaming(content);
|
|
356
|
+
manager.addEntry(messageToHistoryEntry(createUserMessage(content)));
|
|
357
|
+
};
|
|
358
|
+
const onComplete = (result: IExecutionResult): void => {
|
|
359
|
+
manager.onComplete(result);
|
|
360
|
+
manager.syncHistory(session.getFullHistory());
|
|
361
|
+
};
|
|
362
|
+
const onError = (): void => {
|
|
363
|
+
manager.onError();
|
|
364
|
+
manager.syncHistory(session.getFullHistory());
|
|
365
|
+
};
|
|
366
|
+
const onCompact = (): void => {
|
|
367
|
+
manager.syncHistory(session.getFullHistory());
|
|
368
|
+
};
|
|
369
|
+
const onSkillActivation = (): void => {
|
|
370
|
+
manager.syncHistory(session.getFullHistory());
|
|
371
|
+
};
|
|
372
|
+
const onExecutionWorkspaceEvent = (event: IExecutionWorkspaceEvent): void => {
|
|
373
|
+
manager.syncExecutionWorkspaceSnapshot(event.snapshot);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
session.on('user_message', onUserMessage);
|
|
377
|
+
session.on('text_delta', manager.onTextDelta);
|
|
378
|
+
session.on('tool_start', manager.onToolStart);
|
|
379
|
+
session.on('tool_end', manager.onToolEnd);
|
|
380
|
+
session.on('thinking', manager.onThinking);
|
|
381
|
+
session.on('complete', onComplete);
|
|
382
|
+
session.on('interrupted', manager.onInterrupted);
|
|
383
|
+
session.on('error', onError);
|
|
384
|
+
session.on('context_update', manager.onContextUpdate);
|
|
385
|
+
session.on('compact', onCompact);
|
|
386
|
+
session.on('skill_activation', onSkillActivation);
|
|
387
|
+
session.on('execution_workspace_event', onExecutionWorkspaceEvent);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private handleAutoNaming(content: string): void {
|
|
391
|
+
if (this.autoNameTriggered) return;
|
|
392
|
+
if (this.opts.sessionName || this.interactiveSession.getName()) return;
|
|
393
|
+
this.autoNameTriggered = true;
|
|
394
|
+
generateSessionName(this.opts.provider, content)
|
|
395
|
+
.then((name) => {
|
|
396
|
+
this.interactiveSession.setName(name);
|
|
397
|
+
this.sessionName = name;
|
|
398
|
+
this.opts.onAutoNamed?.(name);
|
|
399
|
+
this.onChange?.();
|
|
400
|
+
})
|
|
401
|
+
.catch(() => {
|
|
402
|
+
this.autoNameTriggered = false;
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private syncRestoredHistory(): void {
|
|
407
|
+
if (this.stateManager.history.length === 0) {
|
|
408
|
+
const restored = this.interactiveSession.getFullHistory();
|
|
409
|
+
if (restored.length > 0) {
|
|
410
|
+
this.stateManager.syncHistory(restored);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private startInitCheck(): void {
|
|
416
|
+
this.initCheckInterval = setInterval(() => {
|
|
417
|
+
this.runInitCheck();
|
|
418
|
+
}, SESSION_INIT_POLL_MS);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private runInitCheck(): void {
|
|
422
|
+
try {
|
|
423
|
+
const ctx = this.interactiveSession.getContextState();
|
|
424
|
+
this.stateManager.setContextState({
|
|
425
|
+
percentage: ctx.usedPercentage,
|
|
426
|
+
usedTokens: ctx.usedTokens,
|
|
427
|
+
maxTokens: ctx.maxTokens,
|
|
428
|
+
});
|
|
429
|
+
const restored = this.interactiveSession.getFullHistory();
|
|
430
|
+
if (restored.length > 0) {
|
|
431
|
+
this.stateManager.syncHistory(restored);
|
|
432
|
+
}
|
|
433
|
+
this.syncExecutionWorkspace();
|
|
434
|
+
this.stopInitCheck();
|
|
435
|
+
} catch {
|
|
436
|
+
// allow-fallback: session initializes asynchronously; poll until ready
|
|
437
|
+
/* Not yet initialized */
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private stopInitCheck(): void {
|
|
442
|
+
if (this.initCheckInterval !== null) {
|
|
443
|
+
clearInterval(this.initCheckInterval);
|
|
444
|
+
this.initCheckInterval = null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private syncExecutionWorkspace(): void {
|
|
449
|
+
try {
|
|
450
|
+
// allow-fallback: session may not be initialized yet; swallow until ready
|
|
451
|
+
this.stateManager.syncExecutionWorkspaceSnapshot(
|
|
452
|
+
this.interactiveSession.getExecutionWorkspaceSnapshot({
|
|
453
|
+
selectedEntryId: this.stateManager.selectedExecutionEntryId,
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
} catch {
|
|
457
|
+
// allow-fallback: session may not be initialized yet; swallow until ready
|
|
458
|
+
/* Session not initialized yet */
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-contract tests for TuiInteractionChannel.
|
|
3
|
+
*
|
|
4
|
+
* Verifies user-visible state: which entries appear in stateManager.history,
|
|
5
|
+
* with which roles, and in which order.
|
|
6
|
+
*
|
|
7
|
+
* These are deliberately separate from lifecycle.test.ts:
|
|
8
|
+
* lifecycle → event routing / onChange propagation (mechanism layer)
|
|
9
|
+
* this file → what the user sees on screen (display contract layer)
|
|
10
|
+
*
|
|
11
|
+
* Design principles:
|
|
12
|
+
* - user_message assertions do NOT require getFullHistory injection
|
|
13
|
+
* - every history assertion checks entry.type (= role) explicitly
|
|
14
|
+
* - timing: user message visible BEFORE complete fires
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
17
|
+
|
|
18
|
+
vi.mock('@robota-sdk/agent-framework', async () => {
|
|
19
|
+
const actual = await vi.importActual<typeof import('@robota-sdk/agent-framework')>(
|
|
20
|
+
'@robota-sdk/agent-framework',
|
|
21
|
+
);
|
|
22
|
+
return {
|
|
23
|
+
...actual,
|
|
24
|
+
InteractiveSession: vi.fn().mockImplementation(() => {
|
|
25
|
+
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
|
26
|
+
return {
|
|
27
|
+
getFullHistory: vi.fn().mockReturnValue([]),
|
|
28
|
+
setName: vi.fn(),
|
|
29
|
+
getName: vi.fn().mockReturnValue(undefined),
|
|
30
|
+
getPermissionMode: vi.fn().mockReturnValue('default'),
|
|
31
|
+
isInitialized: false,
|
|
32
|
+
on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
|
|
33
|
+
if (!handlers.has(event)) handlers.set(event, []);
|
|
34
|
+
handlers.get(event)!.push(handler);
|
|
35
|
+
}),
|
|
36
|
+
off: vi.fn(),
|
|
37
|
+
emit: (event: string, ...args: unknown[]) => {
|
|
38
|
+
(handlers.get(event) ?? []).forEach((h) => h(...args));
|
|
39
|
+
},
|
|
40
|
+
submit: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
executeCommand: vi.fn().mockResolvedValue(null),
|
|
42
|
+
getPendingPrompt: vi.fn().mockReturnValue(null),
|
|
43
|
+
abort: vi.fn(),
|
|
44
|
+
cancelQueue: vi.fn(),
|
|
45
|
+
getContextState: vi.fn().mockReturnValue({
|
|
46
|
+
usedPercentage: 0,
|
|
47
|
+
usedTokens: 0,
|
|
48
|
+
maxTokens: 100_000,
|
|
49
|
+
}),
|
|
50
|
+
getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
|
|
51
|
+
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
52
|
+
sendAgentJob: vi.fn().mockResolvedValue(undefined),
|
|
53
|
+
readExecutionWorkspaceDetail: vi.fn().mockResolvedValue({}),
|
|
54
|
+
};
|
|
55
|
+
}),
|
|
56
|
+
CommandRegistry: vi.fn().mockImplementation(() => ({
|
|
57
|
+
addModule: vi.fn(),
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
import {
|
|
63
|
+
createAssistantMessage,
|
|
64
|
+
createSystemMessage,
|
|
65
|
+
createUserMessage,
|
|
66
|
+
messageToHistoryEntry,
|
|
67
|
+
} from '@robota-sdk/agent-core';
|
|
68
|
+
import { TuiInteractionChannel } from '../TuiInteractionChannel.js';
|
|
69
|
+
|
|
70
|
+
import type { IAIProvider, IHistoryEntry } from '@robota-sdk/agent-core';
|
|
71
|
+
import type { IExecutionResult } from '@robota-sdk/agent-framework';
|
|
72
|
+
|
|
73
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
type MockSession = {
|
|
76
|
+
getFullHistory: ReturnType<typeof vi.fn>;
|
|
77
|
+
on: ReturnType<typeof vi.fn>;
|
|
78
|
+
emit: (event: string, ...args: unknown[]) => void;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function getMockSession(channel: TuiInteractionChannel): MockSession {
|
|
82
|
+
return (channel as unknown as { interactiveSession: MockSession }).interactiveSession;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function emitSessionEvent(channel: TuiInteractionChannel, event: string, ...args: unknown[]): void {
|
|
86
|
+
getMockSession(channel).emit(event, ...args);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function makeChannel(): TuiInteractionChannel {
|
|
90
|
+
return new TuiInteractionChannel({
|
|
91
|
+
cwd: '/tmp/test',
|
|
92
|
+
provider: {} as IAIProvider,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function makeResult(overrides?: Partial<IExecutionResult>): IExecutionResult {
|
|
97
|
+
return {
|
|
98
|
+
contextState: { usedPercentage: 5, usedTokens: 500, maxTokens: 100_000 },
|
|
99
|
+
response: 'done',
|
|
100
|
+
...overrides,
|
|
101
|
+
} as unknown as IExecutionResult;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const MOCK_TOOL_RUNNING = {
|
|
105
|
+
toolName: 'bash',
|
|
106
|
+
isRunning: true,
|
|
107
|
+
input: '{}',
|
|
108
|
+
startTime: Date.now(),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const MOCK_TOOL_DONE = {
|
|
112
|
+
toolName: 'bash',
|
|
113
|
+
isRunning: false,
|
|
114
|
+
input: '{}',
|
|
115
|
+
startTime: Date.now(),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
function entryRole(entry: IHistoryEntry): string {
|
|
119
|
+
return entry.type;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
vi.useFakeTimers();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
afterEach(() => {
|
|
127
|
+
vi.useRealTimers();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── Group D: display contract (what the user sees) ────────────────────────────
|
|
131
|
+
|
|
132
|
+
describe('Group D — display contract: history entries and active tools', () => {
|
|
133
|
+
it('D1 (CLI-B05): user_message event immediately adds role=user entry before complete', async () => {
|
|
134
|
+
const channel = makeChannel();
|
|
135
|
+
await channel.start();
|
|
136
|
+
|
|
137
|
+
// user message fires BEFORE complete — must be in history immediately
|
|
138
|
+
emitSessionEvent(channel, 'user_message', 'hello');
|
|
139
|
+
|
|
140
|
+
expect(channel.stateManager.history).toHaveLength(1);
|
|
141
|
+
expect(entryRole(channel.stateManager.history[0])).toBe('user');
|
|
142
|
+
expect((channel.stateManager.history[0].data as { content: string }).content).toBe('hello');
|
|
143
|
+
|
|
144
|
+
await channel.stop();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('D2: complete syncs assistant entry into history', async () => {
|
|
148
|
+
const channel = makeChannel();
|
|
149
|
+
const mockSession = getMockSession(channel);
|
|
150
|
+
const assistantEntry = messageToHistoryEntry(createAssistantMessage('Hi!'));
|
|
151
|
+
mockSession.getFullHistory.mockReturnValue([assistantEntry]);
|
|
152
|
+
await channel.start();
|
|
153
|
+
|
|
154
|
+
emitSessionEvent(channel, 'complete', makeResult());
|
|
155
|
+
|
|
156
|
+
const roles = channel.stateManager.history.map(entryRole);
|
|
157
|
+
expect(roles).toContain('assistant');
|
|
158
|
+
await channel.stop();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('D3 (CLI-B06): error event syncs error entry into history — no silent failure', async () => {
|
|
162
|
+
const channel = makeChannel();
|
|
163
|
+
const mockSession = getMockSession(channel);
|
|
164
|
+
const userEntry = messageToHistoryEntry(createUserMessage('hello'));
|
|
165
|
+
const errorEntry = messageToHistoryEntry(createSystemMessage('Error: network failure'));
|
|
166
|
+
mockSession.getFullHistory.mockReturnValue([userEntry, errorEntry]);
|
|
167
|
+
await channel.start();
|
|
168
|
+
|
|
169
|
+
emitSessionEvent(channel, 'user_message', 'hello');
|
|
170
|
+
emitSessionEvent(channel, 'error');
|
|
171
|
+
|
|
172
|
+
// error entry from getFullHistory must be visible
|
|
173
|
+
const roles = channel.stateManager.history.map(entryRole);
|
|
174
|
+
expect(roles).toContain('system');
|
|
175
|
+
await channel.stop();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('D4 (CLI-B07): tool_end marks tool isRunning=false; complete clears all (no stale spinner after complete)', async () => {
|
|
179
|
+
const channel = makeChannel();
|
|
180
|
+
await channel.start();
|
|
181
|
+
|
|
182
|
+
emitSessionEvent(channel, 'tool_start', MOCK_TOOL_RUNNING);
|
|
183
|
+
expect(channel.stateManager.activeTools).toHaveLength(1);
|
|
184
|
+
|
|
185
|
+
// After tool_end: tool stays in activeTools with isRunning:false (shows "ran" status during streaming)
|
|
186
|
+
emitSessionEvent(channel, 'tool_end', MOCK_TOOL_DONE);
|
|
187
|
+
expect(channel.stateManager.activeTools).toHaveLength(1);
|
|
188
|
+
expect(channel.stateManager.activeTools[0]!.isRunning).toBe(false);
|
|
189
|
+
|
|
190
|
+
// After complete: all tools cleared — StreamingIndicator must be gone
|
|
191
|
+
emitSessionEvent(channel, 'complete', makeResult());
|
|
192
|
+
expect(channel.stateManager.activeTools).toHaveLength(0);
|
|
193
|
+
|
|
194
|
+
await channel.stop();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('D5 (CLI-B08): thinking(false) clears activeTools even without complete', async () => {
|
|
198
|
+
const channel = makeChannel();
|
|
199
|
+
await channel.start();
|
|
200
|
+
|
|
201
|
+
emitSessionEvent(channel, 'thinking', true);
|
|
202
|
+
emitSessionEvent(channel, 'tool_start', MOCK_TOOL_RUNNING);
|
|
203
|
+
expect(channel.stateManager.activeTools).toHaveLength(1);
|
|
204
|
+
|
|
205
|
+
// abort path: thinking(false) without complete
|
|
206
|
+
emitSessionEvent(channel, 'thinking', false);
|
|
207
|
+
expect(channel.stateManager.activeTools).toHaveLength(0);
|
|
208
|
+
|
|
209
|
+
await channel.stop();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('D6: full turn — user entry appears first, assistant entry follows after complete', async () => {
|
|
213
|
+
const channel = makeChannel();
|
|
214
|
+
const mockSession = getMockSession(channel);
|
|
215
|
+
const userEntry = messageToHistoryEntry(createUserMessage('hello'));
|
|
216
|
+
const assistantEntry = messageToHistoryEntry(createAssistantMessage('world'));
|
|
217
|
+
// session history after complete includes both
|
|
218
|
+
mockSession.getFullHistory.mockReturnValue([userEntry, assistantEntry]);
|
|
219
|
+
await channel.start();
|
|
220
|
+
|
|
221
|
+
// 1. user message fires — visible immediately
|
|
222
|
+
emitSessionEvent(channel, 'user_message', 'hello');
|
|
223
|
+
expect(entryRole(channel.stateManager.history[0])).toBe('user');
|
|
224
|
+
|
|
225
|
+
// 2. streaming
|
|
226
|
+
emitSessionEvent(channel, 'text_delta', 'world');
|
|
227
|
+
expect(channel.stateManager.streamingText).toBe('world');
|
|
228
|
+
|
|
229
|
+
// 3. complete — syncHistory replaces with authoritative session history
|
|
230
|
+
emitSessionEvent(channel, 'complete', makeResult());
|
|
231
|
+
expect(channel.stateManager.streamingText).toBe('');
|
|
232
|
+
|
|
233
|
+
const roles = channel.stateManager.history.map(entryRole);
|
|
234
|
+
expect(roles[0]).toBe('user');
|
|
235
|
+
expect(roles[1]).toBe('assistant');
|
|
236
|
+
|
|
237
|
+
await channel.stop();
|
|
238
|
+
});
|
|
239
|
+
});
|