@robota-sdk/agent-ui-terminal 3.0.0-beta.82 → 3.0.0-beta.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +858 -0
- package/README.md +5 -1
- package/dist/node/index.d.ts +189 -54
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +21 -21
- package/dist/node/index.js.map +1 -1
- package/package.json +16 -21
- package/src/App.tsx +14 -3
- package/src/AppPresentation.tsx +1 -1
- package/src/PermissionPrompt.tsx +50 -17
- package/src/SessionPicker.tsx +27 -7
- package/src/SessionStatusBar.tsx +6 -2
- package/src/StatusBar.tsx +24 -0
- package/src/SupervisedSessionView.tsx +479 -157
- package/src/TuiInteractionChannel.ts +8 -53
- package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +453 -82
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +32 -22
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +10 -2
- package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
- package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +2 -2
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +3 -1
- package/src/__tests__/fixtures/command-handoff-driver.tsx +8 -5
- package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
- package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
- package/src/__tests__/palette-consistency.test.ts +0 -1
- package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
- package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
- package/src/__tests__/render-channel-options.test.ts +6 -6
- package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
- package/src/__tests__/screen-reader-menus.test.tsx +1 -1
- package/src/__tests__/session-picker.test.tsx +93 -0
- package/src/__tests__/status-bar.test.tsx +8 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
- package/src/__tests__/wire-tui-channel.test.ts +1445 -0
- package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
- package/src/app-view-model.ts +6 -2
- package/src/attached-session-connection.ts +14 -0
- package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
- package/src/hooks/useAppController.ts +6 -2
- package/src/hooks/useAppInputBindings.ts +20 -2
- package/src/hooks/useAppInteractionState.ts +3 -0
- package/src/hooks/useAppOverlays.ts +20 -4
- package/src/hooks/useNumberedSelection.ts +19 -2
- package/src/hooks/useTuiChannel.ts +13 -1
- package/src/index.ts +17 -3
- package/src/render.tsx +218 -27
- package/src/short-session-id.ts +9 -0
- package/src/slash-command-input.ts +11 -0
- package/src/tui-app-channel-port.ts +29 -1
- package/src/tui-channel-options.ts +11 -2
- package/src/tui-session-event-projector.ts +0 -2
- package/src/tui-session-events.ts +6 -0
- package/src/tui-session-options.ts +9 -0
- package/src/tui-state-manager.ts +17 -3
- package/src/waiting-loop-stop-notice.ts +23 -0
- package/src/wire-history-sync.ts +159 -0
- package/src/wire-tui-channel.ts +962 -0
- package/src/wire-tui-client-commands.ts +142 -0
- package/src/wire-tui-projection.ts +136 -0
- package/dist/node/index.cjs +0 -32
- package/dist/node/index.d.cts +0 -1066
- package/dist/node/index.d.cts.map +0 -1
|
@@ -0,0 +1,1445 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { WireTuiChannel } from '../wire-tui-channel.js';
|
|
4
|
+
|
|
5
|
+
import type { IAttachedSessionConnection } from '../attached-session-connection.js';
|
|
6
|
+
import type { ITuiClientCommand, ITuiClientCommands } from '../wire-tui-client-commands.js';
|
|
7
|
+
import type { TAttachedSessionEnd } from '../attached-session-connection.js';
|
|
8
|
+
import type { IHistoryEntry } from '@robota-sdk/agent-core';
|
|
9
|
+
import type { ISessionStatusSnapshot, IUiIntentEvent } from '@robota-sdk/agent-interface-session';
|
|
10
|
+
import type {
|
|
11
|
+
IWireHistoryEntry,
|
|
12
|
+
TClientMessage,
|
|
13
|
+
TServerMessage,
|
|
14
|
+
} from '@robota-sdk/agent-transport/client';
|
|
15
|
+
|
|
16
|
+
interface IScriptedConnection extends IAttachedSessionConnection {
|
|
17
|
+
readonly sent: TClientMessage[];
|
|
18
|
+
push(message: TServerMessage): void;
|
|
19
|
+
close(): void;
|
|
20
|
+
readonly listenerCount: () => number;
|
|
21
|
+
/** From now on `send` throws, as a connection that broke does. */
|
|
22
|
+
failSends(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function scriptedConnection(): IScriptedConnection {
|
|
26
|
+
const listeners = new Set<(message: TServerMessage) => void>();
|
|
27
|
+
const closers = new Set<() => void>();
|
|
28
|
+
const sent: TClientMessage[] = [];
|
|
29
|
+
let failing = false;
|
|
30
|
+
return {
|
|
31
|
+
sent,
|
|
32
|
+
send: (message) => {
|
|
33
|
+
if (failing) throw new Error('socket closed');
|
|
34
|
+
sent.push(message);
|
|
35
|
+
},
|
|
36
|
+
failSends: () => {
|
|
37
|
+
failing = true;
|
|
38
|
+
},
|
|
39
|
+
subscribe: (listener) => {
|
|
40
|
+
listeners.add(listener);
|
|
41
|
+
return () => listeners.delete(listener);
|
|
42
|
+
},
|
|
43
|
+
onClose: (listener) => {
|
|
44
|
+
closers.add(listener);
|
|
45
|
+
return () => closers.delete(listener);
|
|
46
|
+
},
|
|
47
|
+
push: (message) => {
|
|
48
|
+
for (const listener of [...listeners]) listener(message);
|
|
49
|
+
},
|
|
50
|
+
close: () => {
|
|
51
|
+
for (const closer of [...closers]) closer();
|
|
52
|
+
},
|
|
53
|
+
listenerCount: () => listeners.size,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
58
|
+
|
|
59
|
+
const SNAPSHOT_TYPES = [
|
|
60
|
+
'get-history',
|
|
61
|
+
'get-context',
|
|
62
|
+
'get-commands',
|
|
63
|
+
'get-status',
|
|
64
|
+
'get-executing',
|
|
65
|
+
'get-pending',
|
|
66
|
+
'get-execution-workspace',
|
|
67
|
+
'get-prompts',
|
|
68
|
+
'list-sessions',
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
async function attached(options: { onEnd?: (reason: TAttachedSessionEnd) => void } = {}): Promise<{
|
|
72
|
+
channel: WireTuiChannel;
|
|
73
|
+
link: IScriptedConnection;
|
|
74
|
+
}> {
|
|
75
|
+
const link = scriptedConnection();
|
|
76
|
+
const channel = new WireTuiChannel({
|
|
77
|
+
connection: link,
|
|
78
|
+
driverId: 'attach:1',
|
|
79
|
+
sessionName: 'daemon',
|
|
80
|
+
...(options.onEnd ? { onEnd: options.onEnd } : {}),
|
|
81
|
+
});
|
|
82
|
+
await channel.start();
|
|
83
|
+
return { channel, link };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A page of the host's history: `entries` from `startIndex`, of `total` entries in all. */
|
|
87
|
+
function page(
|
|
88
|
+
entries: IWireHistoryEntry[],
|
|
89
|
+
startIndex = 0,
|
|
90
|
+
total = startIndex + entries.length,
|
|
91
|
+
): Extract<TServerMessage, { type: 'history' }> {
|
|
92
|
+
return { type: 'history', startIndex, total, entries };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Answer the attach-time history request so the next one is sent, not queued behind it. */
|
|
96
|
+
function answerHistory(link: IScriptedConnection): void {
|
|
97
|
+
link.push(page([]));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const LATER = (): string => new Date(Date.now() + 60_000).toISOString();
|
|
101
|
+
|
|
102
|
+
function chat(id: string, role: 'user' | 'assistant', content: string): IWireHistoryEntry {
|
|
103
|
+
return {
|
|
104
|
+
id,
|
|
105
|
+
timestamp: LATER(),
|
|
106
|
+
category: 'chat',
|
|
107
|
+
type: role,
|
|
108
|
+
data: { id: `m-${id}`, role, content, state: 'complete', timestamp: LATER() },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function contents(history: readonly IHistoryEntry[]): unknown[] {
|
|
113
|
+
return history.map((entry) => (entry.data as { content?: unknown } | undefined)?.content);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What the terminal prints: the transcript is committed once, and only the items past the count
|
|
118
|
+
* already printed are printed on each render (Ink `<Static>`); a new generation prints from the start.
|
|
119
|
+
*/
|
|
120
|
+
function printer(channel: WireTuiChannel): { render: () => void; printed: unknown[] } {
|
|
121
|
+
const printed: unknown[] = [];
|
|
122
|
+
let count = 0;
|
|
123
|
+
let generation: number | undefined;
|
|
124
|
+
return {
|
|
125
|
+
printed,
|
|
126
|
+
render: () => {
|
|
127
|
+
const snapshot = channel.getSnapshot();
|
|
128
|
+
if (snapshot.transcriptGeneration !== generation) {
|
|
129
|
+
generation = snapshot.transcriptGeneration;
|
|
130
|
+
count = 0;
|
|
131
|
+
}
|
|
132
|
+
printed.push(...contents(snapshot.history.slice(count)));
|
|
133
|
+
count = snapshot.history.length;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A turn's result as the host sends it. */
|
|
139
|
+
function completed(response: string): TServerMessage {
|
|
140
|
+
return {
|
|
141
|
+
type: 'complete',
|
|
142
|
+
result: {
|
|
143
|
+
response,
|
|
144
|
+
toolSummaries: [],
|
|
145
|
+
contextState: { usedTokens: 1, maxTokens: 10, usedPercentage: 10 } as never,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function historyRequests(sent: readonly TClientMessage[]): TClientMessage[] {
|
|
151
|
+
return sent.filter((message) => message.type === 'get-history');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function lastCommandRequestId(sent: readonly TClientMessage[]): string {
|
|
155
|
+
const commands = sent.filter(
|
|
156
|
+
(message): message is Extract<TClientMessage, { type: 'command' }> =>
|
|
157
|
+
message.type === 'command',
|
|
158
|
+
);
|
|
159
|
+
return commands.at(-1)?.requestId ?? '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function status(overrides: Partial<ISessionStatusSnapshot> = {}): ISessionStatusSnapshot {
|
|
163
|
+
return {
|
|
164
|
+
sessionId: 'session_0123456789ab',
|
|
165
|
+
model: 'claude-test',
|
|
166
|
+
permissionMode: 'acceptEdits',
|
|
167
|
+
effort: 'high',
|
|
168
|
+
context: { usedTokens: 10, maxTokens: 100, usedPercentage: 10 } as never,
|
|
169
|
+
goal: null,
|
|
170
|
+
...overrides,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function types(sent: readonly TClientMessage[]): string[] {
|
|
175
|
+
return sent.map((message) => message.type);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function lastListRequestId(sent: readonly TClientMessage[]): string {
|
|
179
|
+
const requests = sent.filter(
|
|
180
|
+
(message): message is Extract<TClientMessage, { type: 'list-sessions' }> =>
|
|
181
|
+
message.type === 'list-sessions',
|
|
182
|
+
);
|
|
183
|
+
return requests.at(-1)?.requestId ?? '';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
describe('WireTuiChannel', () => {
|
|
187
|
+
it('asks the host for everything the full TUI shows when it starts', async () => {
|
|
188
|
+
const { link } = await attached();
|
|
189
|
+
expect(types(link.sent)).toEqual(SNAPSHOT_TYPES);
|
|
190
|
+
const list = link.sent.find((message) => message.type === 'list-sessions');
|
|
191
|
+
expect(list).toMatchObject({ type: 'list-sessions', requestId: expect.any(String) });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('projects streamed turn frames into the snapshot and re-reads history on complete', async () => {
|
|
195
|
+
const { channel, link } = await attached();
|
|
196
|
+
answerHistory(link);
|
|
197
|
+
link.sent.length = 0;
|
|
198
|
+
|
|
199
|
+
link.push({ type: 'thinking', isThinking: true });
|
|
200
|
+
link.push({ type: 'text_delta', delta: 'Hello ' });
|
|
201
|
+
link.push({ type: 'text_delta', delta: 'there' });
|
|
202
|
+
link.push({
|
|
203
|
+
type: 'tool_start',
|
|
204
|
+
state: { toolName: 'Bash', firstArg: 'ls', isRunning: true },
|
|
205
|
+
});
|
|
206
|
+
let snapshot = channel.getSnapshot();
|
|
207
|
+
expect(snapshot.isThinking).toBe(true);
|
|
208
|
+
expect(snapshot.streamingText).toBe('Hello there');
|
|
209
|
+
expect(snapshot.activeTools).toEqual([{ toolName: 'Bash', firstArg: 'ls', isRunning: true }]);
|
|
210
|
+
|
|
211
|
+
link.push({
|
|
212
|
+
type: 'complete',
|
|
213
|
+
result: {
|
|
214
|
+
response: 'Hello there',
|
|
215
|
+
history: [],
|
|
216
|
+
toolSummaries: [],
|
|
217
|
+
contextState: { usedTokens: 50, maxTokens: 200, usedPercentage: 25 },
|
|
218
|
+
} as never,
|
|
219
|
+
});
|
|
220
|
+
snapshot = channel.getSnapshot();
|
|
221
|
+
expect(snapshot.streamingText).toBe('');
|
|
222
|
+
expect(snapshot.activeTools).toEqual([]);
|
|
223
|
+
expect(snapshot.contextState).toEqual({ percentage: 25, usedTokens: 50, maxTokens: 200 });
|
|
224
|
+
// Only what came after the entries known is read: none are known yet.
|
|
225
|
+
expect(historyRequests(link.sent)).toEqual([{ type: 'get-history' }]);
|
|
226
|
+
await channel.stop();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('keeps one history request in flight and one queued behind it', async () => {
|
|
230
|
+
const { channel, link } = await attached();
|
|
231
|
+
link.sent.length = 0;
|
|
232
|
+
link.push({ type: 'history_changed' });
|
|
233
|
+
link.push({ type: 'history_changed' });
|
|
234
|
+
link.push({ type: 'history_changed' });
|
|
235
|
+
// The attach-time request is still unanswered: the rest wait behind it as one.
|
|
236
|
+
expect(types(link.sent)).toEqual([]);
|
|
237
|
+
answerHistory(link);
|
|
238
|
+
expect(types(link.sent)).toEqual(['get-history']);
|
|
239
|
+
answerHistory(link);
|
|
240
|
+
expect(types(link.sent)).toEqual(['get-history']);
|
|
241
|
+
await channel.stop();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('syncs history frames with Date timestamps, inside chat entries too', async () => {
|
|
245
|
+
const { channel, link } = await attached();
|
|
246
|
+
const at = LATER();
|
|
247
|
+
const prompt = (id: string, content: string, driverId: string, timestamp = at) => ({
|
|
248
|
+
id,
|
|
249
|
+
timestamp,
|
|
250
|
+
category: 'chat',
|
|
251
|
+
type: 'user',
|
|
252
|
+
data: {
|
|
253
|
+
id: `m-${id}`,
|
|
254
|
+
role: 'user',
|
|
255
|
+
content,
|
|
256
|
+
state: 'complete',
|
|
257
|
+
timestamp,
|
|
258
|
+
metadata: { driverId },
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const before = '2026-01-01T00:00:00.000Z';
|
|
262
|
+
link.push(
|
|
263
|
+
page([
|
|
264
|
+
prompt('e1', 'mine', 'attach:1'),
|
|
265
|
+
prompt('e2', 'theirs', 'attach:2'),
|
|
266
|
+
// The host numbered another terminal attach:1 before it last started.
|
|
267
|
+
prompt('e3', 'older', 'attach:1', before),
|
|
268
|
+
{
|
|
269
|
+
id: 'e4',
|
|
270
|
+
timestamp: at,
|
|
271
|
+
category: 'event',
|
|
272
|
+
type: 'skill-activation',
|
|
273
|
+
data: { name: 'review', timestamp: at },
|
|
274
|
+
},
|
|
275
|
+
]),
|
|
276
|
+
);
|
|
277
|
+
const [mine, theirs, older, event] = channel.getSnapshot().history;
|
|
278
|
+
expect(mine?.timestamp).toBeInstanceOf(Date);
|
|
279
|
+
expect(mine?.timestamp.toISOString()).toBe(at);
|
|
280
|
+
const mineData = mine?.data as { timestamp: unknown; metadata: { driverId: string } };
|
|
281
|
+
expect(mineData.timestamp).toBeInstanceOf(Date);
|
|
282
|
+
// This terminal's own prompts read as the user's; another client's keep their driver.
|
|
283
|
+
expect(mineData.metadata.driverId).toBe('owner');
|
|
284
|
+
expect((theirs?.data as { metadata: { driverId: string } }).metadata.driverId).toBe('attach:2');
|
|
285
|
+
// A prompt from before this terminal attached is not this terminal's, whatever its driver id.
|
|
286
|
+
expect((older?.data as { metadata: { driverId: string } }).metadata.driverId).toBe('attach:1');
|
|
287
|
+
// An event entry's data is the event as recorded: its timestamp stays the string it was.
|
|
288
|
+
expect(event?.timestamp).toBeInstanceOf(Date);
|
|
289
|
+
expect(event?.data).toEqual({ name: 'review', timestamp: at });
|
|
290
|
+
await channel.stop();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('shows a permission request and sends the answer as permission-response', async () => {
|
|
294
|
+
const { channel, link } = await attached();
|
|
295
|
+
link.sent.length = 0;
|
|
296
|
+
link.push({
|
|
297
|
+
type: 'permission_request',
|
|
298
|
+
event: { id: 'p1', toolName: 'Bash', toolArgs: { command: 'ls' } },
|
|
299
|
+
});
|
|
300
|
+
const request = channel.getSnapshot().permissionRequest;
|
|
301
|
+
expect(request).toMatchObject({ toolName: 'Bash', toolArgs: { command: 'ls' } });
|
|
302
|
+
request?.resolve('allow-session');
|
|
303
|
+
await flush();
|
|
304
|
+
expect(link.sent).toEqual([{ type: 'permission-response', id: 'p1', result: 'allow-session' }]);
|
|
305
|
+
expect(channel.getSnapshot().permissionRequest).toBeNull();
|
|
306
|
+
await channel.stop();
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('answers an ask through resolveUserAction as ask-response', async () => {
|
|
310
|
+
const { channel, link } = await attached();
|
|
311
|
+
link.sent.length = 0;
|
|
312
|
+
link.push({
|
|
313
|
+
type: 'ask_request',
|
|
314
|
+
event: { id: 'a1', request: { kind: 'text', title: 'Name?' } as never },
|
|
315
|
+
});
|
|
316
|
+
const pending = channel.getSnapshot().pendingUserAction;
|
|
317
|
+
expect(pending).toMatchObject({ title: 'Name?' });
|
|
318
|
+
channel.resolveUserAction(pending!, { type: 'answer', values: [], text: 'Robota' });
|
|
319
|
+
await flush();
|
|
320
|
+
expect(link.sent).toEqual([
|
|
321
|
+
{
|
|
322
|
+
type: 'ask-response',
|
|
323
|
+
id: 'a1',
|
|
324
|
+
response: { type: 'answer', values: [], text: 'Robota' },
|
|
325
|
+
},
|
|
326
|
+
]);
|
|
327
|
+
await channel.stop();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('takes a question another client answered off the screen without answering it', async () => {
|
|
331
|
+
const { channel, link } = await attached();
|
|
332
|
+
link.sent.length = 0;
|
|
333
|
+
link.push({
|
|
334
|
+
type: 'permission_request',
|
|
335
|
+
event: { id: 'p1', toolName: 'Bash', toolArgs: {} },
|
|
336
|
+
});
|
|
337
|
+
link.push({ type: 'prompt_resolved', event: { id: 'p1', answererDriverId: 'attach:2' } });
|
|
338
|
+
await flush();
|
|
339
|
+
expect(channel.getSnapshot().permissionRequest).toBeNull();
|
|
340
|
+
expect(link.sent).toEqual([]);
|
|
341
|
+
await channel.stop();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('sends plain input as submit and a slash command as command', async () => {
|
|
345
|
+
const { channel, link } = await attached();
|
|
346
|
+
link.sent.length = 0;
|
|
347
|
+
await channel.handleInput('hello');
|
|
348
|
+
// The host answers a submit with its queue once it has taken the prompt.
|
|
349
|
+
expect(link.sent).toEqual([{ type: 'submit', prompt: 'hello' }]);
|
|
350
|
+
|
|
351
|
+
link.sent.length = 0;
|
|
352
|
+
let settled = false;
|
|
353
|
+
const command = channel.handleInput('/Help me').then(() => {
|
|
354
|
+
settled = true;
|
|
355
|
+
});
|
|
356
|
+
expect(link.sent).toEqual([
|
|
357
|
+
{ type: 'command', name: 'help', args: 'me', requestId: expect.any(String) },
|
|
358
|
+
]);
|
|
359
|
+
await flush();
|
|
360
|
+
// The command is on screen only once the host answers.
|
|
361
|
+
expect(settled).toBe(false);
|
|
362
|
+
link.push({
|
|
363
|
+
type: 'command_result',
|
|
364
|
+
name: 'help',
|
|
365
|
+
message: 'Available commands',
|
|
366
|
+
success: true,
|
|
367
|
+
requestId: lastCommandRequestId(link.sent),
|
|
368
|
+
});
|
|
369
|
+
await command;
|
|
370
|
+
const last = channel.getSnapshot().history.at(-1);
|
|
371
|
+
expect(last?.data).toMatchObject({ role: 'system', content: 'Available commands' });
|
|
372
|
+
// The host may have changed the status, context or catalog.
|
|
373
|
+
expect(types(link.sent)).toEqual(
|
|
374
|
+
expect.arrayContaining(['get-context', 'get-status', 'get-commands']),
|
|
375
|
+
);
|
|
376
|
+
await channel.stop();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('offers the host commands and user-invocable skills to the / menu', async () => {
|
|
380
|
+
const { channel, link } = await attached();
|
|
381
|
+
link.push({
|
|
382
|
+
type: 'commands',
|
|
383
|
+
commands: [{ name: 'help', description: 'Help', modelInvocable: false, runner: 'runtime' }],
|
|
384
|
+
skills: [
|
|
385
|
+
{
|
|
386
|
+
name: 'hello',
|
|
387
|
+
description: 'Greets',
|
|
388
|
+
source: 'skill',
|
|
389
|
+
modelInvocable: true,
|
|
390
|
+
userInvocable: true,
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
name: 'hidden',
|
|
394
|
+
description: 'Model only',
|
|
395
|
+
source: 'skill',
|
|
396
|
+
modelInvocable: true,
|
|
397
|
+
userInvocable: false,
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
});
|
|
401
|
+
const port = channel.getCommandQueryPort();
|
|
402
|
+
expect(port.getCommands().map((command) => command.name)).toEqual(['help', 'hello']);
|
|
403
|
+
expect(port.getCommands('he').map((command) => command.name)).toEqual(['help', 'hello']);
|
|
404
|
+
expect(port.getSubcommands('help')).toEqual([]);
|
|
405
|
+
await channel.stop();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("completes a host command's subcommands and argument hint from the catalog", async () => {
|
|
409
|
+
const { channel, link } = await attached();
|
|
410
|
+
link.push({
|
|
411
|
+
type: 'commands',
|
|
412
|
+
commands: [
|
|
413
|
+
{
|
|
414
|
+
name: 'loop',
|
|
415
|
+
description: 'Loops',
|
|
416
|
+
modelInvocable: false,
|
|
417
|
+
runner: 'runtime',
|
|
418
|
+
argumentHint: '<interval> <prompt>',
|
|
419
|
+
subcommands: [
|
|
420
|
+
{ name: 'list', description: 'List loops' },
|
|
421
|
+
{ name: 'stop', description: 'Stop a loop', argumentHint: '<id>', displayName: 'Stop' },
|
|
422
|
+
],
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
skills: [],
|
|
426
|
+
});
|
|
427
|
+
const port = channel.getCommandQueryPort();
|
|
428
|
+
expect(port.getCommands()[0]).toMatchObject({ argumentHint: '<interval> <prompt>' });
|
|
429
|
+
expect(port.getSubcommands('LOOP')).toEqual([
|
|
430
|
+
{ name: 'list', description: 'List loops', source: 'builtin' },
|
|
431
|
+
{
|
|
432
|
+
name: 'stop',
|
|
433
|
+
description: 'Stop a loop',
|
|
434
|
+
source: 'builtin',
|
|
435
|
+
displayName: 'Stop',
|
|
436
|
+
argumentHint: '<id>',
|
|
437
|
+
},
|
|
438
|
+
]);
|
|
439
|
+
await channel.stop();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('leaves on /exit without sending anything to the session', async () => {
|
|
443
|
+
const onEnd = vi.fn();
|
|
444
|
+
const { channel, link } = await attached({ onEnd });
|
|
445
|
+
link.sent.length = 0;
|
|
446
|
+
await channel.handleInput('/exit');
|
|
447
|
+
expect(link.sent).toEqual([]);
|
|
448
|
+
expect(onEnd).toHaveBeenCalledWith('user');
|
|
449
|
+
await channel.stop();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('stop() and shutdown() only detach: nothing is sent and an open question is left open', async () => {
|
|
453
|
+
const { channel, link } = await attached();
|
|
454
|
+
link.push({
|
|
455
|
+
type: 'permission_request',
|
|
456
|
+
event: { id: 'p1', toolName: 'Bash', toolArgs: {} },
|
|
457
|
+
});
|
|
458
|
+
link.sent.length = 0;
|
|
459
|
+
await channel.shutdown({ reason: 'prompt_input_exit' });
|
|
460
|
+
await channel.stop();
|
|
461
|
+
await flush();
|
|
462
|
+
expect(link.sent).toEqual([]);
|
|
463
|
+
expect(link.listenerCount()).toBe(0);
|
|
464
|
+
link.push({ type: 'text_delta', delta: 'ignored' });
|
|
465
|
+
expect(channel.getSnapshot().streamingText).toBe('');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('resets and asks again for everything when the host switches sessions', async () => {
|
|
469
|
+
const { channel, link } = await attached();
|
|
470
|
+
answerHistory(link);
|
|
471
|
+
link.push({ type: 'history_changed' });
|
|
472
|
+
link.push(
|
|
473
|
+
page([
|
|
474
|
+
{
|
|
475
|
+
id: 'old',
|
|
476
|
+
timestamp: '2026-09-26T10:00:00.000Z',
|
|
477
|
+
category: 'chat',
|
|
478
|
+
type: 'user',
|
|
479
|
+
data: { id: 'm', role: 'user', content: 'old session', state: 'complete' },
|
|
480
|
+
},
|
|
481
|
+
]),
|
|
482
|
+
);
|
|
483
|
+
link.push({ type: 'text_delta', delta: 'streaming' });
|
|
484
|
+
link.push({
|
|
485
|
+
type: 'permission_request',
|
|
486
|
+
event: { id: 'p1', toolName: 'Bash', toolArgs: {} },
|
|
487
|
+
});
|
|
488
|
+
link.sent.length = 0;
|
|
489
|
+
|
|
490
|
+
link.push({ type: 'session_switched', event: { sessionId: 'session-2' } });
|
|
491
|
+
await flush();
|
|
492
|
+
const snapshot = channel.getSnapshot();
|
|
493
|
+
expect(snapshot.history.some((entry) => entry.id === 'old')).toBe(false);
|
|
494
|
+
expect(snapshot.streamingText).toBe('');
|
|
495
|
+
expect(snapshot.permissionRequest).toBeNull();
|
|
496
|
+
// The old session's question is not answered from here; the new session is read from scratch.
|
|
497
|
+
expect(link.sent.some((message) => message.type === 'permission-response')).toBe(false);
|
|
498
|
+
expect(types(link.sent)).toEqual(SNAPSHOT_TYPES);
|
|
499
|
+
// The switch is announced at the top of the new session's transcript.
|
|
500
|
+
link.push(page([]));
|
|
501
|
+
expect(channel.getSnapshot().history.map((entry) => entry.data)).toEqual([
|
|
502
|
+
expect.objectContaining({ content: 'Switched to session session-2.' }),
|
|
503
|
+
]);
|
|
504
|
+
await channel.stop();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('asks the host to switch sessions and lists its sessions for the picker', async () => {
|
|
508
|
+
const { channel, link } = await attached();
|
|
509
|
+
const list = link.sent.find((message) => message.type === 'list-sessions');
|
|
510
|
+
const requestId = list?.type === 'list-sessions' ? list.requestId : '';
|
|
511
|
+
link.push({
|
|
512
|
+
type: 'sessions',
|
|
513
|
+
requestId,
|
|
514
|
+
listing: {
|
|
515
|
+
currentSessionId: 's1',
|
|
516
|
+
sessions: [
|
|
517
|
+
{
|
|
518
|
+
id: 's2',
|
|
519
|
+
cwd: '/w',
|
|
520
|
+
updatedAt: '2026-09-26T10:00:00.000Z',
|
|
521
|
+
messageCount: 2,
|
|
522
|
+
preview: 'hi',
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
unreadableSessionIds: [],
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
expect(channel.getSnapshot().hostSessions?.map((session) => session.id)).toEqual(['s2']);
|
|
529
|
+
link.sent.length = 0;
|
|
530
|
+
const switched = channel.requestSessionSwitch('s2');
|
|
531
|
+
expect(link.sent).toEqual([
|
|
532
|
+
{ type: 'switch-session', sessionId: 's2', requestId: expect.any(String) },
|
|
533
|
+
]);
|
|
534
|
+
// The picker's switch settles on the host's answer.
|
|
535
|
+
link.push({ type: 'session_switched', event: { sessionId: 's2' } });
|
|
536
|
+
await switched;
|
|
537
|
+
await channel.stop();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("opens the session picker on the host's answer to the listing it asked for", async () => {
|
|
541
|
+
const { channel, link } = await attached();
|
|
542
|
+
const intents: IUiIntentEvent[] = [];
|
|
543
|
+
channel.getSessionUiEventPort().on('ui_intent', (event) => intents.push(event));
|
|
544
|
+
link.push({
|
|
545
|
+
type: 'sessions',
|
|
546
|
+
requestId: lastListRequestId(link.sent),
|
|
547
|
+
listing: { currentSessionId: 's1', sessions: [], unreadableSessionIds: [] },
|
|
548
|
+
});
|
|
549
|
+
link.sent.length = 0;
|
|
550
|
+
|
|
551
|
+
link.push({
|
|
552
|
+
type: 'ui_intent',
|
|
553
|
+
event: { intent: { type: 'show-session-picker' }, requesterDriverId: 'attach:1' },
|
|
554
|
+
});
|
|
555
|
+
// What the terminal held since it attached is not what the host has now.
|
|
556
|
+
expect(intents).toEqual([]);
|
|
557
|
+
link.push({
|
|
558
|
+
type: 'sessions',
|
|
559
|
+
requestId: lastListRequestId(link.sent),
|
|
560
|
+
listing: {
|
|
561
|
+
currentSessionId: 's1',
|
|
562
|
+
sessions: [
|
|
563
|
+
{
|
|
564
|
+
id: 's1',
|
|
565
|
+
cwd: '/w',
|
|
566
|
+
updatedAt: '2026-09-26T10:00:00.000Z',
|
|
567
|
+
messageCount: 0,
|
|
568
|
+
preview: '',
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
id: 's2',
|
|
572
|
+
cwd: '/w',
|
|
573
|
+
updatedAt: '2026-09-26T09:00:00.000Z',
|
|
574
|
+
messageCount: 2,
|
|
575
|
+
preview: 'hi',
|
|
576
|
+
},
|
|
577
|
+
],
|
|
578
|
+
unreadableSessionIds: [],
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
expect(channel.getSnapshot().hostSessions?.map((session) => session.id)).toEqual(['s1', 's2']);
|
|
582
|
+
expect(intents).toEqual([
|
|
583
|
+
{ intent: { type: 'show-session-picker' }, requesterDriverId: 'owner' },
|
|
584
|
+
]);
|
|
585
|
+
await channel.stop();
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('opens no session picker when the host cannot list its sessions, and says why', async () => {
|
|
589
|
+
const { channel, link } = await attached();
|
|
590
|
+
const intents: IUiIntentEvent[] = [];
|
|
591
|
+
channel.getSessionUiEventPort().on('ui_intent', (event) => intents.push(event));
|
|
592
|
+
link.sent.length = 0;
|
|
593
|
+
|
|
594
|
+
link.push({
|
|
595
|
+
type: 'ui_intent',
|
|
596
|
+
event: { intent: { type: 'show-session-picker' }, requesterDriverId: 'attach:1' },
|
|
597
|
+
});
|
|
598
|
+
link.push({
|
|
599
|
+
type: 'sessions_error',
|
|
600
|
+
requestId: lastListRequestId(link.sent),
|
|
601
|
+
code: 'not_available',
|
|
602
|
+
message: 'Session listing is not available on this host.',
|
|
603
|
+
});
|
|
604
|
+
expect(intents).toEqual([]);
|
|
605
|
+
expect(channel.getSnapshot().hostSessions).toBeUndefined();
|
|
606
|
+
expect(channel.getSnapshot().history.at(-1)?.data).toMatchObject({
|
|
607
|
+
content: 'Session listing is not available on this host.',
|
|
608
|
+
});
|
|
609
|
+
await channel.stop();
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('hands this terminal its own UI intents and names the screens it cannot show', async () => {
|
|
613
|
+
const { channel, link } = await attached();
|
|
614
|
+
const intents: IUiIntentEvent[] = [];
|
|
615
|
+
channel.getSessionUiEventPort().on('ui_intent', (event) => intents.push(event));
|
|
616
|
+
link.push({
|
|
617
|
+
type: 'ui_intent',
|
|
618
|
+
event: { intent: { type: 'show-theme-picker' }, requesterDriverId: 'attach:1' },
|
|
619
|
+
});
|
|
620
|
+
link.push({
|
|
621
|
+
type: 'ui_intent',
|
|
622
|
+
event: { intent: { type: 'show-plugin-manager' }, requesterDriverId: 'attach:1' },
|
|
623
|
+
});
|
|
624
|
+
expect(intents).toEqual([
|
|
625
|
+
{ intent: { type: 'show-theme-picker' }, requesterDriverId: 'owner' },
|
|
626
|
+
]);
|
|
627
|
+
expect(channel.getSnapshot().history.at(-1)?.data).toMatchObject({
|
|
628
|
+
content: expect.stringContaining('not available while attached'),
|
|
629
|
+
});
|
|
630
|
+
await channel.stop();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('shows a protocol error as a notice, not a failure', async () => {
|
|
634
|
+
const { channel, link } = await attached();
|
|
635
|
+
link.push({ type: 'protocol_error', message: 'A turn is running.' });
|
|
636
|
+
expect(channel.getSnapshot().history.at(-1)?.data).toMatchObject({
|
|
637
|
+
content: 'A turn is running.',
|
|
638
|
+
});
|
|
639
|
+
await channel.stop();
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('ends with a notice when the connection closes, and stops listening', async () => {
|
|
643
|
+
const onEnd = vi.fn();
|
|
644
|
+
const { channel, link } = await attached({ onEnd });
|
|
645
|
+
link.sent.length = 0;
|
|
646
|
+
link.close();
|
|
647
|
+
expect(onEnd).toHaveBeenCalledWith('closed');
|
|
648
|
+
expect(channel.getSnapshot().history.at(-1)?.data).toMatchObject({
|
|
649
|
+
content: expect.stringContaining('connection to the session closed'),
|
|
650
|
+
});
|
|
651
|
+
await channel.handleInput('hello');
|
|
652
|
+
expect(link.sent).toEqual([]);
|
|
653
|
+
expect(link.listenerCount()).toBe(0);
|
|
654
|
+
await channel.stop();
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
describe('WireTuiChannel history, read in pages (#3189)', () => {
|
|
659
|
+
it('asks for the next page after each one, and shows the history once all of it arrived', async () => {
|
|
660
|
+
const { channel, link } = await attached();
|
|
661
|
+
link.sent.length = 0;
|
|
662
|
+
|
|
663
|
+
link.push(page([chat('e0', 'user', 'first'), chat('e1', 'assistant', 'second')], 0, 3));
|
|
664
|
+
expect(historyRequests(link.sent)).toEqual([{ type: 'get-history', fromIndex: 2 }]);
|
|
665
|
+
// Half a history is never shown: the transcript is printed once, from its start.
|
|
666
|
+
expect(channel.getSnapshot().history).toEqual([]);
|
|
667
|
+
|
|
668
|
+
link.push(page([chat('e2', 'user', 'third')], 2, 3));
|
|
669
|
+
expect(contents(channel.getSnapshot().history)).toEqual(['first', 'second', 'third']);
|
|
670
|
+
expect(historyRequests(link.sent)).toHaveLength(1);
|
|
671
|
+
await channel.stop();
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it('reads only what came after the entries it knows when a turn completes, fails or stops', async () => {
|
|
675
|
+
const { channel, link } = await attached();
|
|
676
|
+
link.push(page([chat('e0', 'user', 'hello'), chat('e1', 'assistant', 'hi')]));
|
|
677
|
+
link.sent.length = 0;
|
|
678
|
+
|
|
679
|
+
link.push(completed('ok'));
|
|
680
|
+
expect(historyRequests(link.sent)).toEqual([{ type: 'get-history', fromIndex: 2 }]);
|
|
681
|
+
link.push(page([chat('e2', 'user', 'again'), chat('e3', 'assistant', 'ok')], 2));
|
|
682
|
+
expect(contents(channel.getSnapshot().history)).toEqual(['hello', 'hi', 'again', 'ok']);
|
|
683
|
+
|
|
684
|
+
link.sent.length = 0;
|
|
685
|
+
link.push({ type: 'error', message: 'provider down' });
|
|
686
|
+
expect(historyRequests(link.sent)).toEqual([{ type: 'get-history', fromIndex: 4 }]);
|
|
687
|
+
link.push(page([chat('e4', 'assistant', 'partial')], 4));
|
|
688
|
+
|
|
689
|
+
link.sent.length = 0;
|
|
690
|
+
link.push({ type: 'interrupted', result: { response: 'partial' } as never });
|
|
691
|
+
expect(historyRequests(link.sent)).toEqual([{ type: 'get-history', fromIndex: 5 }]);
|
|
692
|
+
await channel.stop();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it('reads everything again when the host holds fewer entries than it knows', async () => {
|
|
696
|
+
const { channel, link } = await attached();
|
|
697
|
+
link.push(page([chat('e0', 'user', 'hello'), chat('e1', 'assistant', 'hi')]));
|
|
698
|
+
link.sent.length = 0;
|
|
699
|
+
|
|
700
|
+
link.push(completed('ok'));
|
|
701
|
+
// The host's answer to the read from 2: its history now ends at 1.
|
|
702
|
+
link.push(page([], 1, 1));
|
|
703
|
+
expect(historyRequests(link.sent)).toEqual([
|
|
704
|
+
{ type: 'get-history', fromIndex: 2 },
|
|
705
|
+
{ type: 'get-history' },
|
|
706
|
+
]);
|
|
707
|
+
link.push(page([chat('x0', 'user', 'replaced')]));
|
|
708
|
+
expect(contents(channel.getSnapshot().history)).toEqual(['replaced']);
|
|
709
|
+
await channel.stop();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('reads everything again on history_changed and history_cleared', async () => {
|
|
713
|
+
const { channel, link } = await attached();
|
|
714
|
+
link.push(page([chat('e0', 'user', 'hello')]));
|
|
715
|
+
link.sent.length = 0;
|
|
716
|
+
|
|
717
|
+
link.push({ type: 'history_changed' });
|
|
718
|
+
expect(historyRequests(link.sent)).toEqual([{ type: 'get-history' }]);
|
|
719
|
+
link.push(page([chat('e0', 'user', 'hello'), chat('c1', 'assistant', 'compacted')]));
|
|
720
|
+
expect(contents(channel.getSnapshot().history)).toEqual(['hello', 'compacted']);
|
|
721
|
+
|
|
722
|
+
link.sent.length = 0;
|
|
723
|
+
link.push({ type: 'history_cleared' });
|
|
724
|
+
expect(channel.getSnapshot().history).toEqual([]);
|
|
725
|
+
expect(historyRequests(link.sent)).toEqual([{ type: 'get-history' }]);
|
|
726
|
+
link.push(page([]));
|
|
727
|
+
expect(channel.getSnapshot().history).toEqual([]);
|
|
728
|
+
await channel.stop();
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('never waits forever on a reply: a switch or a resume gap reads again at once', async () => {
|
|
732
|
+
const { channel, link } = await attached();
|
|
733
|
+
link.sent.length = 0;
|
|
734
|
+
// The attach-time read is still unanswered.
|
|
735
|
+
link.push({ type: 'resume_gap' });
|
|
736
|
+
expect(historyRequests(link.sent)).toEqual([{ type: 'get-history' }]);
|
|
737
|
+
expect(types(link.sent)).toEqual(SNAPSHOT_TYPES);
|
|
738
|
+
|
|
739
|
+
link.sent.length = 0;
|
|
740
|
+
link.push({ type: 'session_switched', event: { sessionId: 'session-2' } });
|
|
741
|
+
expect(historyRequests(link.sent)).toEqual([{ type: 'get-history' }]);
|
|
742
|
+
|
|
743
|
+
// A reply to a read given up on that starts elsewhere is not taken for this one.
|
|
744
|
+
link.push(page([chat('s0', 'user', 'stale')], 5, 6));
|
|
745
|
+
link.push(page([chat('n0', 'user', 'new session')]));
|
|
746
|
+
expect(contents(channel.getSnapshot().history)).toEqual([
|
|
747
|
+
'Switched to session session-2.',
|
|
748
|
+
'new session',
|
|
749
|
+
]);
|
|
750
|
+
await channel.stop();
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("prints a finished turn's answer when the next turn starts before the history arrives", async () => {
|
|
754
|
+
const { channel, link } = await attached();
|
|
755
|
+
const screen = printer(channel);
|
|
756
|
+
const push = (frame: TServerMessage): void => {
|
|
757
|
+
link.push(frame);
|
|
758
|
+
screen.render();
|
|
759
|
+
};
|
|
760
|
+
push(page([chat('e0', 'user', 'earlier')]));
|
|
761
|
+
push({ type: 'user_message', content: 'prompt 1', driverId: 'attach:1' });
|
|
762
|
+
push({ type: 'thinking', isThinking: true });
|
|
763
|
+
push({ type: 'text_delta', delta: 'answer 1' });
|
|
764
|
+
push(completed('answer 1'));
|
|
765
|
+
// A queued prompt starts on the host before this terminal read the finished turn.
|
|
766
|
+
push({ type: 'user_message', content: 'prompt 2', driverId: 'attach:1' });
|
|
767
|
+
push(
|
|
768
|
+
page(
|
|
769
|
+
[
|
|
770
|
+
chat('e1', 'user', 'prompt 1'),
|
|
771
|
+
chat('e2', 'assistant', 'answer 1'),
|
|
772
|
+
chat('e3', 'user', 'prompt 2'),
|
|
773
|
+
],
|
|
774
|
+
1,
|
|
775
|
+
),
|
|
776
|
+
);
|
|
777
|
+
expect(screen.printed).toEqual(['earlier', 'prompt 1', 'answer 1', 'prompt 2']);
|
|
778
|
+
await channel.stop();
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it('shows a held echo after the history it waited for, and replaces it with the host entry', async () => {
|
|
782
|
+
const { channel, link } = await attached();
|
|
783
|
+
const screen = printer(channel);
|
|
784
|
+
const push = (frame: TServerMessage): void => {
|
|
785
|
+
link.push(frame);
|
|
786
|
+
screen.render();
|
|
787
|
+
};
|
|
788
|
+
push(page([chat('e0', 'user', 'prompt 1')]));
|
|
789
|
+
push(completed('answer 1'));
|
|
790
|
+
push({ type: 'user_message', content: 'prompt 2' });
|
|
791
|
+
// The host has not recorded the second prompt yet.
|
|
792
|
+
push(page([chat('e1', 'assistant', 'answer 1')], 1));
|
|
793
|
+
push(completed('answer 2'));
|
|
794
|
+
push(page([chat('e2', 'user', 'prompt 2'), chat('e3', 'assistant', 'answer 2')], 2));
|
|
795
|
+
expect(screen.printed).toEqual(['prompt 1', 'answer 1', 'prompt 2', 'answer 2']);
|
|
796
|
+
expect(contents(channel.getSnapshot().history)).toEqual([
|
|
797
|
+
'prompt 1',
|
|
798
|
+
'answer 1',
|
|
799
|
+
'prompt 2',
|
|
800
|
+
'answer 2',
|
|
801
|
+
]);
|
|
802
|
+
await channel.stop();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it('prints the new session from its start when a switch and its history arrive together', async () => {
|
|
806
|
+
const { channel, link } = await attached();
|
|
807
|
+
const screen = printer(channel);
|
|
808
|
+
link.push(page([chat('o0', 'user', 'old 1'), chat('o1', 'assistant', 'old 2')]));
|
|
809
|
+
link.push({ type: 'user_message', content: 'old 3' });
|
|
810
|
+
screen.render();
|
|
811
|
+
const generation = channel.getSnapshot().transcriptGeneration;
|
|
812
|
+
|
|
813
|
+
link.push({ type: 'session_switched', event: { sessionId: 'session-2' } });
|
|
814
|
+
link.push(page([chat('n0', 'user', 'new 1')]));
|
|
815
|
+
screen.render();
|
|
816
|
+
|
|
817
|
+
expect(channel.getSnapshot().transcriptGeneration).not.toBe(generation);
|
|
818
|
+
expect(screen.printed).toEqual([
|
|
819
|
+
'old 1',
|
|
820
|
+
'old 2',
|
|
821
|
+
'old 3',
|
|
822
|
+
'Switched to session session-2.',
|
|
823
|
+
'new 1',
|
|
824
|
+
]);
|
|
825
|
+
await channel.stop();
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
describe('WireTuiChannel session state (#3189)', () => {
|
|
830
|
+
it("shows the host's status, and labels the session by its name or else its id", async () => {
|
|
831
|
+
const { channel, link } = await attached();
|
|
832
|
+
const renames: string[] = [];
|
|
833
|
+
channel.getSessionUiEventPort().on('session_renamed', (event) => renames.push(event.name));
|
|
834
|
+
expect(channel.getRuntimeStatusSnapshot('default')).toEqual({
|
|
835
|
+
permissionMode: 'default',
|
|
836
|
+
sessionId: '',
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
link.push({ type: 'session_status', status: status() });
|
|
840
|
+
expect(channel.getRuntimeStatusSnapshot('default')).toEqual({
|
|
841
|
+
permissionMode: 'acceptEdits',
|
|
842
|
+
sessionId: 'session_0123456789ab',
|
|
843
|
+
effort: 'high',
|
|
844
|
+
modelId: 'claude-test',
|
|
845
|
+
});
|
|
846
|
+
// Not the host's own label ('daemon'): the session's id, without the prefix every id shares.
|
|
847
|
+
expect(channel.sessionName).toBe('01234567');
|
|
848
|
+
|
|
849
|
+
link.push({ type: 'session_status', status: status({ sessionName: 'auth work' }) });
|
|
850
|
+
expect(channel.sessionName).toBe('auth work');
|
|
851
|
+
|
|
852
|
+
link.push({ type: 'session_switched', event: { sessionId: 'session_fedcba987654' } });
|
|
853
|
+
expect(channel.sessionName).toBe('fedcba98');
|
|
854
|
+
expect(channel.getRuntimeStatusSnapshot('default').sessionId).toBe('');
|
|
855
|
+
link.push({ type: 'session_status', status: status({ sessionId: 'session_fedcba987654' }) });
|
|
856
|
+
expect(channel.sessionName).toBe('fedcba98');
|
|
857
|
+
expect(renames).toEqual(['01234567', 'auth work', 'fedcba98']);
|
|
858
|
+
await channel.stop();
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('shows the queued prompt and how many wait, as the host reports them', async () => {
|
|
862
|
+
const { channel, link } = await attached();
|
|
863
|
+
link.push({ type: 'pending', pending: 'follow-up', pendingCount: 2 });
|
|
864
|
+
expect(channel.getSnapshot()).toMatchObject({ pendingPrompt: 'follow-up', pendingCount: 2 });
|
|
865
|
+
link.push({ type: 'pending', pending: null, pendingCount: 0 });
|
|
866
|
+
expect(channel.getSnapshot()).toMatchObject({ pendingPrompt: null, pendingCount: 0 });
|
|
867
|
+
await channel.stop();
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('counts the one queued prompt it can see when an older host sends no count', async () => {
|
|
871
|
+
// A daemon from before the count still reports the prompt; the footer must not lose the number.
|
|
872
|
+
const { channel, link } = await attached();
|
|
873
|
+
link.push({ type: 'pending', pending: 'follow-up' });
|
|
874
|
+
expect(channel.getSnapshot()).toMatchObject({ pendingPrompt: 'follow-up', pendingCount: 1 });
|
|
875
|
+
link.push({ type: 'pending', pending: null });
|
|
876
|
+
expect(channel.getSnapshot()).toMatchObject({ pendingPrompt: null, pendingCount: 0 });
|
|
877
|
+
await channel.stop();
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it('asks for the pending queue when a turn starts or ends, and follows a turn running at attach', async () => {
|
|
881
|
+
const { channel, link } = await attached();
|
|
882
|
+
link.push({ type: 'executing', executing: true });
|
|
883
|
+
expect(channel.getSnapshot().isThinking).toBe(true);
|
|
884
|
+
link.sent.length = 0;
|
|
885
|
+
link.push({ type: 'thinking', isThinking: false });
|
|
886
|
+
expect(link.sent).toEqual([{ type: 'get-pending' }]);
|
|
887
|
+
expect(channel.getSnapshot().isThinking).toBe(false);
|
|
888
|
+
await channel.stop();
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it("echoes this terminal's prompt as the user's and another client's under its driver", async () => {
|
|
892
|
+
const { channel, link } = await attached();
|
|
893
|
+
answerHistory(link);
|
|
894
|
+
link.push({ type: 'user_message', content: 'mine', driverId: 'attach:1' });
|
|
895
|
+
link.push({ type: 'user_message', content: 'theirs', driverId: 'attach:2' });
|
|
896
|
+
const drivers = channel
|
|
897
|
+
.getSnapshot()
|
|
898
|
+
.history.map((entry) => (entry.data as { metadata?: { driverId?: string } }).metadata);
|
|
899
|
+
expect(drivers).toEqual([{ driverId: 'owner' }, { driverId: 'attach:2' }]);
|
|
900
|
+
await channel.stop();
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('hands an unattributed UI intent to the App as it came', async () => {
|
|
904
|
+
const { channel, link } = await attached();
|
|
905
|
+
const intents: IUiIntentEvent[] = [];
|
|
906
|
+
channel.getSessionUiEventPort().on('ui_intent', (event) => intents.push(event));
|
|
907
|
+
link.push({ type: 'ui_intent', event: { intent: { type: 'show-theme-picker' } } });
|
|
908
|
+
expect(intents).toEqual([{ intent: { type: 'show-theme-picker' } }]);
|
|
909
|
+
await channel.stop();
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it('says nothing when the listing asked for at attach fails', async () => {
|
|
913
|
+
const { channel, link } = await attached();
|
|
914
|
+
const before = channel.getSnapshot().history.length;
|
|
915
|
+
link.push({
|
|
916
|
+
type: 'sessions_error',
|
|
917
|
+
requestId: lastListRequestId(link.sent),
|
|
918
|
+
code: 'not_available',
|
|
919
|
+
message: 'Session listing is not available on this host.',
|
|
920
|
+
});
|
|
921
|
+
expect(channel.getSnapshot().history).toHaveLength(before);
|
|
922
|
+
await channel.stop();
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
describe('WireTuiChannel questions open at attach (#3189)', () => {
|
|
927
|
+
it('shows a question the host sends again only once, and answers it once', async () => {
|
|
928
|
+
const { channel, link } = await attached();
|
|
929
|
+
expect(types(link.sent)).toContain('get-prompts');
|
|
930
|
+
link.sent.length = 0;
|
|
931
|
+
const question = { id: 'p1', toolName: 'Bash', toolArgs: { command: 'ls' } };
|
|
932
|
+
// Asked live, then sent again in answer to `get-prompts`.
|
|
933
|
+
link.push({ type: 'permission_request', event: question });
|
|
934
|
+
link.push({ type: 'permission_request', event: question });
|
|
935
|
+
|
|
936
|
+
channel.getSnapshot().permissionRequest?.resolve(true);
|
|
937
|
+
await flush();
|
|
938
|
+
await flush();
|
|
939
|
+
expect(channel.getSnapshot().permissionRequest).toBeNull();
|
|
940
|
+
expect(link.sent).toEqual([{ type: 'permission-response', id: 'p1', result: true }]);
|
|
941
|
+
await channel.stop();
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
describe('WireTuiChannel commands (#3189)', () => {
|
|
946
|
+
it('settles a command only on its own answer; a failure without a request settles nothing', async () => {
|
|
947
|
+
const { channel, link } = await attached();
|
|
948
|
+
link.sent.length = 0;
|
|
949
|
+
const settled: string[] = [];
|
|
950
|
+
const first = channel.handleInput('/compact').then(() => settled.push('compact'));
|
|
951
|
+
const firstId = lastCommandRequestId(link.sent);
|
|
952
|
+
const second = channel.handleInput('/broken').then(() => settled.push('broken'));
|
|
953
|
+
const secondId = lastCommandRequestId(link.sent);
|
|
954
|
+
expect(firstId).not.toBe(secondId);
|
|
955
|
+
|
|
956
|
+
// A refused switch, say: it answers no command.
|
|
957
|
+
link.push({ type: 'protocol_error', message: 'Stop the running turn first.' });
|
|
958
|
+
await flush();
|
|
959
|
+
expect(settled).toEqual([]);
|
|
960
|
+
expect(channel.getSnapshot().history.at(-1)?.data).toMatchObject({
|
|
961
|
+
content: 'Stop the running turn first.',
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
link.push({ type: 'protocol_error', message: 'broken failed', requestId: secondId });
|
|
965
|
+
await second;
|
|
966
|
+
expect(settled).toEqual(['broken']);
|
|
967
|
+
|
|
968
|
+
link.push({
|
|
969
|
+
type: 'command_result',
|
|
970
|
+
name: 'compact',
|
|
971
|
+
message: 'Compacted.',
|
|
972
|
+
success: true,
|
|
973
|
+
requestId: firstId,
|
|
974
|
+
});
|
|
975
|
+
await first;
|
|
976
|
+
expect(settled).toEqual(['broken', 'compact']);
|
|
977
|
+
await channel.stop();
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it('shows nothing for a command that started a turn, and asks for the queue instead', async () => {
|
|
981
|
+
const { channel, link } = await attached();
|
|
982
|
+
const before = channel.getSnapshot().history.length;
|
|
983
|
+
link.sent.length = 0;
|
|
984
|
+
const command = channel.handleInput('/hello');
|
|
985
|
+
link.push({
|
|
986
|
+
type: 'command_result',
|
|
987
|
+
name: 'hello',
|
|
988
|
+
message: 'Skill hello invoked.',
|
|
989
|
+
success: true,
|
|
990
|
+
data: { skill: 'hello', sessionExecution: true },
|
|
991
|
+
requestId: lastCommandRequestId(link.sent),
|
|
992
|
+
});
|
|
993
|
+
await command;
|
|
994
|
+
expect(channel.getSnapshot().history).toHaveLength(before);
|
|
995
|
+
expect(types(link.sent)).toContain('get-pending');
|
|
996
|
+
await channel.stop();
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it('settles a command it could not send, with a notice', async () => {
|
|
1000
|
+
const { channel, link } = await attached();
|
|
1001
|
+
link.failSends();
|
|
1002
|
+
await channel.handleInput('/help');
|
|
1003
|
+
expect(channel.getSnapshot().history.at(-1)?.data).toMatchObject({
|
|
1004
|
+
content: 'Could not reach the session: socket closed',
|
|
1005
|
+
});
|
|
1006
|
+
await channel.stop();
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
describe('WireTuiChannel abort and queue cancel (#3189)', () => {
|
|
1011
|
+
it('abort stops the host turn and turns down the questions it shows', async () => {
|
|
1012
|
+
const { channel, link } = await attached();
|
|
1013
|
+
link.push({ type: 'permission_request', event: { id: 'p1', toolName: 'Bash', toolArgs: {} } });
|
|
1014
|
+
link.push({
|
|
1015
|
+
type: 'ask_request',
|
|
1016
|
+
event: { id: 'a1', request: { kind: 'text', title: 'Name?' } as never },
|
|
1017
|
+
});
|
|
1018
|
+
link.sent.length = 0;
|
|
1019
|
+
|
|
1020
|
+
channel.abort();
|
|
1021
|
+
await flush();
|
|
1022
|
+
|
|
1023
|
+
expect(channel.getSnapshot()).toMatchObject({
|
|
1024
|
+
isAborting: true,
|
|
1025
|
+
permissionRequest: null,
|
|
1026
|
+
pendingUserAction: null,
|
|
1027
|
+
});
|
|
1028
|
+
expect(link.sent[0]).toEqual({ type: 'abort' });
|
|
1029
|
+
// The host drains its prompts on abort too; these answers find them settled.
|
|
1030
|
+
expect(link.sent.slice(1)).toEqual(
|
|
1031
|
+
expect.arrayContaining([
|
|
1032
|
+
{ type: 'permission-response', id: 'p1', result: false },
|
|
1033
|
+
{ type: 'ask-response', id: 'a1', response: { type: 'cancelled' } },
|
|
1034
|
+
]),
|
|
1035
|
+
);
|
|
1036
|
+
expect(link.sent).toHaveLength(3);
|
|
1037
|
+
await channel.stop();
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
it('cancelQueue drops the queued prompt on the host and here', async () => {
|
|
1041
|
+
const { channel, link } = await attached();
|
|
1042
|
+
link.push({ type: 'pending', pending: 'follow-up', pendingCount: 1 });
|
|
1043
|
+
link.push({ type: 'permission_request', event: { id: 'p1', toolName: 'Bash', toolArgs: {} } });
|
|
1044
|
+
link.sent.length = 0;
|
|
1045
|
+
|
|
1046
|
+
channel.cancelQueue();
|
|
1047
|
+
await flush();
|
|
1048
|
+
|
|
1049
|
+
expect(channel.getSnapshot()).toMatchObject({
|
|
1050
|
+
pendingPrompt: null,
|
|
1051
|
+
pendingCount: 0,
|
|
1052
|
+
permissionRequest: null,
|
|
1053
|
+
});
|
|
1054
|
+
expect(link.sent).toEqual([
|
|
1055
|
+
{ type: 'cancel-queue' },
|
|
1056
|
+
{ type: 'permission-response', id: 'p1', result: false },
|
|
1057
|
+
]);
|
|
1058
|
+
await channel.stop();
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
describe('WireTuiChannel terminal-owned commands (#3189)', () => {
|
|
1063
|
+
/** Stands in for `/shell`: it runs where this terminal is. */
|
|
1064
|
+
const shell: ITuiClientCommand = {
|
|
1065
|
+
name: 'shell',
|
|
1066
|
+
execute: (host, args) => ({ success: true, message: `${args} ran in ${host.getCwd()}` }),
|
|
1067
|
+
};
|
|
1068
|
+
/** Stands in for `/theme`: bare opens the picker, a name asks for an appearance patch. */
|
|
1069
|
+
const theme: ITuiClientCommand = {
|
|
1070
|
+
name: 'theme',
|
|
1071
|
+
execute: (_host, args) =>
|
|
1072
|
+
args === ''
|
|
1073
|
+
? {
|
|
1074
|
+
success: true,
|
|
1075
|
+
message: 'Opening the theme picker...',
|
|
1076
|
+
uiIntents: [{ type: 'show-theme-picker' }],
|
|
1077
|
+
}
|
|
1078
|
+
: {
|
|
1079
|
+
success: true,
|
|
1080
|
+
message: `Theme set to ${args}.`,
|
|
1081
|
+
hostActions: [{ type: 'appearance-settings-patch', patch: { theme: args } }],
|
|
1082
|
+
},
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
async function withClientCommands(
|
|
1086
|
+
writeAppearanceSettings: ITuiClientCommands['writeAppearanceSettings'] = () => undefined,
|
|
1087
|
+
extra: readonly ITuiClientCommand[] = [],
|
|
1088
|
+
): Promise<{ channel: WireTuiChannel; link: IScriptedConnection }> {
|
|
1089
|
+
const link = scriptedConnection();
|
|
1090
|
+
const channel = new WireTuiChannel({
|
|
1091
|
+
connection: link,
|
|
1092
|
+
driverId: 'attach:1',
|
|
1093
|
+
sessionName: 'daemon',
|
|
1094
|
+
cwd: '/work/here',
|
|
1095
|
+
clientCommands: { commands: [shell, theme, ...extra], writeAppearanceSettings },
|
|
1096
|
+
});
|
|
1097
|
+
await channel.start();
|
|
1098
|
+
link.sent.length = 0;
|
|
1099
|
+
return { channel, link };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
it('runs /shell in this terminal, in its working directory, and sends the daemon nothing', async () => {
|
|
1103
|
+
const { channel, link } = await withClientCommands();
|
|
1104
|
+
await channel.handleInput('/shell ls');
|
|
1105
|
+
expect(link.sent).toEqual([]);
|
|
1106
|
+
expect(channel.getSnapshot().history.at(-1)?.data).toMatchObject({
|
|
1107
|
+
role: 'system',
|
|
1108
|
+
content: 'ls ran in /work/here',
|
|
1109
|
+
});
|
|
1110
|
+
await channel.stop();
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
it('opens the theme picker for a bare /theme as this terminal owner’s screen', async () => {
|
|
1114
|
+
const { channel, link } = await withClientCommands();
|
|
1115
|
+
const intents: IUiIntentEvent[] = [];
|
|
1116
|
+
channel.getSessionUiEventPort().on('ui_intent', (event) => intents.push(event));
|
|
1117
|
+
await channel.handleInput('/theme');
|
|
1118
|
+
expect(intents).toEqual([
|
|
1119
|
+
{ intent: { type: 'show-theme-picker' }, requesterDriverId: 'owner' },
|
|
1120
|
+
]);
|
|
1121
|
+
expect(link.sent).toEqual([]);
|
|
1122
|
+
await channel.stop();
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
it('settles /theme light only after the appearance settings are written', async () => {
|
|
1126
|
+
let finishWrite: () => void = () => undefined;
|
|
1127
|
+
const write = vi.fn(
|
|
1128
|
+
() =>
|
|
1129
|
+
new Promise<void>((resolve) => {
|
|
1130
|
+
finishWrite = resolve;
|
|
1131
|
+
}),
|
|
1132
|
+
);
|
|
1133
|
+
const { channel, link } = await withClientCommands(write);
|
|
1134
|
+
let settled = false;
|
|
1135
|
+
const command = channel.handleInput('/theme light').then(() => {
|
|
1136
|
+
settled = true;
|
|
1137
|
+
});
|
|
1138
|
+
await flush();
|
|
1139
|
+
expect(write).toHaveBeenCalledWith({ theme: 'light' });
|
|
1140
|
+
expect(settled).toBe(false);
|
|
1141
|
+
finishWrite();
|
|
1142
|
+
await command;
|
|
1143
|
+
expect(channel.getSnapshot().history.at(-1)?.data).toMatchObject({
|
|
1144
|
+
content: 'Theme set to light.',
|
|
1145
|
+
});
|
|
1146
|
+
expect(link.sent).toEqual([]);
|
|
1147
|
+
await channel.stop();
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
it('shows a client command that throws as a failure notice', async () => {
|
|
1151
|
+
const broken: ITuiClientCommand = {
|
|
1152
|
+
name: 'editor',
|
|
1153
|
+
execute: () => {
|
|
1154
|
+
throw new Error('no editor');
|
|
1155
|
+
},
|
|
1156
|
+
};
|
|
1157
|
+
const { channel, link } = await withClientCommands(undefined, [broken]);
|
|
1158
|
+
await channel.handleInput('/editor');
|
|
1159
|
+
expect(link.sent).toEqual([]);
|
|
1160
|
+
expect(channel.getSnapshot().history.at(-1)?.data).toMatchObject({
|
|
1161
|
+
content: expect.stringContaining('no editor'),
|
|
1162
|
+
});
|
|
1163
|
+
await channel.stop();
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
it('still sends any other command to the daemon', async () => {
|
|
1167
|
+
const { channel, link } = await withClientCommands();
|
|
1168
|
+
const command = channel.handleInput('/mode plan');
|
|
1169
|
+
expect(link.sent).toEqual([
|
|
1170
|
+
{ type: 'command', name: 'mode', args: 'plan', requestId: expect.any(String) },
|
|
1171
|
+
]);
|
|
1172
|
+
link.push({
|
|
1173
|
+
type: 'command_result',
|
|
1174
|
+
name: 'mode',
|
|
1175
|
+
message: 'Mode: plan',
|
|
1176
|
+
success: true,
|
|
1177
|
+
requestId: lastCommandRequestId(link.sent),
|
|
1178
|
+
});
|
|
1179
|
+
await command;
|
|
1180
|
+
await channel.stop();
|
|
1181
|
+
});
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
function lastSwitchRequestId(sent: readonly TClientMessage[]): string {
|
|
1185
|
+
const switches = sent.filter(
|
|
1186
|
+
(message): message is Extract<TClientMessage, { type: 'switch-session' }> =>
|
|
1187
|
+
message.type === 'switch-session',
|
|
1188
|
+
);
|
|
1189
|
+
return switches.at(-1)?.requestId ?? '';
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
describe('WireTuiChannel refused session changes (#3189 step 5)', () => {
|
|
1193
|
+
it('shows why a switch was refused and settles the switch, but no command', async () => {
|
|
1194
|
+
const { channel, link } = await attached();
|
|
1195
|
+
link.sent.length = 0;
|
|
1196
|
+
const settled: string[] = [];
|
|
1197
|
+
const command = channel.handleInput('/compact').then(() => settled.push('compact'));
|
|
1198
|
+
const commandId = lastCommandRequestId(link.sent);
|
|
1199
|
+
const switched = channel.requestSessionSwitch('s2').then(() => settled.push('switch'));
|
|
1200
|
+
const switchId = lastSwitchRequestId(link.sent);
|
|
1201
|
+
expect(switchId).not.toBe('');
|
|
1202
|
+
expect(switchId).not.toBe(commandId);
|
|
1203
|
+
|
|
1204
|
+
link.push({
|
|
1205
|
+
type: 'session_change_failed',
|
|
1206
|
+
code: 'prompt_pending',
|
|
1207
|
+
message: 'Answer the open question first.',
|
|
1208
|
+
requestId: switchId,
|
|
1209
|
+
});
|
|
1210
|
+
await switched;
|
|
1211
|
+
expect(settled).toEqual(['switch']);
|
|
1212
|
+
expect(channel.getSnapshot().history.at(-1)?.data).toMatchObject({
|
|
1213
|
+
content: 'Answer the open question first.',
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
// Even one naming the command's request answers no command.
|
|
1217
|
+
link.push({
|
|
1218
|
+
type: 'session_change_failed',
|
|
1219
|
+
code: 'failed',
|
|
1220
|
+
message: 'not a command answer',
|
|
1221
|
+
requestId: commandId,
|
|
1222
|
+
});
|
|
1223
|
+
await flush();
|
|
1224
|
+
expect(settled).toEqual(['switch']);
|
|
1225
|
+
|
|
1226
|
+
link.push({
|
|
1227
|
+
type: 'command_result',
|
|
1228
|
+
name: 'compact',
|
|
1229
|
+
message: 'Compacted.',
|
|
1230
|
+
success: true,
|
|
1231
|
+
requestId: commandId,
|
|
1232
|
+
});
|
|
1233
|
+
await command;
|
|
1234
|
+
expect(settled).toEqual(['switch', 'compact']);
|
|
1235
|
+
await channel.stop();
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
it("settles a switch an older host refuses with a protocol error that names no request", async () => {
|
|
1239
|
+
const { channel, link } = await attached();
|
|
1240
|
+
const switched = channel.requestSessionSwitch('s2');
|
|
1241
|
+
link.push({ type: 'protocol_error', message: 'Stop the running turn first.' });
|
|
1242
|
+
await switched;
|
|
1243
|
+
expect(channel.getSnapshot().history.at(-1)?.data).toMatchObject({
|
|
1244
|
+
content: 'Stop the running turn first.',
|
|
1245
|
+
});
|
|
1246
|
+
await channel.stop();
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
it('does not wait on a switch to the session it already shows', async () => {
|
|
1250
|
+
const { channel, link } = await attached();
|
|
1251
|
+
link.push({ type: 'session_status', status: status({ sessionId: 's1' }) });
|
|
1252
|
+
link.sent.length = 0;
|
|
1253
|
+
await channel.requestSessionSwitch('s1');
|
|
1254
|
+
expect(link.sent.some((message) => message.type === 'switch-session')).toBe(false);
|
|
1255
|
+
await channel.stop();
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it('settles a pending switch when this terminal leaves', async () => {
|
|
1259
|
+
const { channel } = await attached();
|
|
1260
|
+
const switched = channel.requestSessionSwitch('s2');
|
|
1261
|
+
await channel.stop();
|
|
1262
|
+
await switched;
|
|
1263
|
+
});
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
function lastRequestId(sent: readonly TClientMessage[], type: TClientMessage['type']): string {
|
|
1267
|
+
const message = sent.filter((entry) => entry.type === type).at(-1);
|
|
1268
|
+
return message !== undefined && 'requestId' in message ? (message.requestId ?? '') : '';
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function lastNotice(channel: WireTuiChannel): unknown {
|
|
1272
|
+
return (channel.getSnapshot().history.at(-1)?.data as { content?: unknown } | undefined)?.content;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
describe('WireTuiChannel background work and loops (#3189 step 3b)', () => {
|
|
1276
|
+
it("reads a workspace entry's detail page from the host, and fails with the host's reason", async () => {
|
|
1277
|
+
const { channel, link } = await attached();
|
|
1278
|
+
const page = { entryId: 'task-1', items: [], nextCursor: undefined } as never;
|
|
1279
|
+
const read = channel.readExecutionWorkspaceDetail('task-1');
|
|
1280
|
+
expect(link.sent.at(-1)).toMatchObject({ type: 'read-execution-detail', entryId: 'task-1' });
|
|
1281
|
+
const requestId = lastRequestId(link.sent, 'read-execution-detail');
|
|
1282
|
+
// An answer to another request settles nothing.
|
|
1283
|
+
link.push({ type: 'execution_detail', requestId: 'other', page });
|
|
1284
|
+
link.push({ type: 'execution_detail', requestId, page });
|
|
1285
|
+
await expect(read).resolves.toBe(page);
|
|
1286
|
+
|
|
1287
|
+
const failing = channel.readExecutionWorkspaceDetail('gone');
|
|
1288
|
+
link.push({
|
|
1289
|
+
type: 'execution_detail_error',
|
|
1290
|
+
requestId: lastRequestId(link.sent, 'read-execution-detail'),
|
|
1291
|
+
message: 'No entry gone.',
|
|
1292
|
+
});
|
|
1293
|
+
await expect(failing).rejects.toThrow('No entry gone.');
|
|
1294
|
+
await channel.stop();
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it('fails a detail read still waiting when this terminal leaves', async () => {
|
|
1298
|
+
const { channel } = await attached();
|
|
1299
|
+
const read = channel.readExecutionWorkspaceDetail('task-1');
|
|
1300
|
+
await channel.stop();
|
|
1301
|
+
await expect(read).rejects.toThrow('Detached');
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it("sends input to a background task and settles on the host's control result for it", async () => {
|
|
1305
|
+
const { channel, link } = await attached();
|
|
1306
|
+
let settled = false;
|
|
1307
|
+
const sending = channel.sendAgentJob('task-1', 'more please').then(() => {
|
|
1308
|
+
settled = true;
|
|
1309
|
+
});
|
|
1310
|
+
expect(link.sent.at(-1)).toEqual({
|
|
1311
|
+
type: 'send-background-task',
|
|
1312
|
+
taskId: 'task-1',
|
|
1313
|
+
input: { prompt: 'more please' },
|
|
1314
|
+
});
|
|
1315
|
+
link.push({ type: 'background_task_control_result', action: 'cancel', taskId: 'task-1', success: true });
|
|
1316
|
+
link.push({ type: 'background_task_control_result', action: 'send', taskId: 'task-2', success: true });
|
|
1317
|
+
await flush();
|
|
1318
|
+
expect(settled).toBe(false);
|
|
1319
|
+
link.push({ type: 'background_task_control_result', action: 'send', taskId: 'task-1', success: true });
|
|
1320
|
+
await sending;
|
|
1321
|
+
|
|
1322
|
+
const refused = channel.sendAgentJob('task-1', 'again');
|
|
1323
|
+
link.push({
|
|
1324
|
+
type: 'background_task_control_result',
|
|
1325
|
+
action: 'send',
|
|
1326
|
+
taskId: 'task-1',
|
|
1327
|
+
success: false,
|
|
1328
|
+
message: 'Task task-1 is not running.',
|
|
1329
|
+
});
|
|
1330
|
+
await refused;
|
|
1331
|
+
expect(lastNotice(channel)).toBe(
|
|
1332
|
+
'Could not send to background task task-1: Task task-1 is not running.',
|
|
1333
|
+
);
|
|
1334
|
+
await channel.stop();
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
it("asks the host to stop its waiting loop and shows the session's answer", async () => {
|
|
1338
|
+
const { channel, link } = await attached();
|
|
1339
|
+
const stopping = channel.stopWaitingSelfPacedLoop();
|
|
1340
|
+
expect(link.sent.at(-1)).toMatchObject({ type: 'stop-waiting-loop' });
|
|
1341
|
+
link.push({
|
|
1342
|
+
type: 'waiting_loop_stop',
|
|
1343
|
+
requestId: lastRequestId(link.sent, 'stop-waiting-loop'),
|
|
1344
|
+
outcome: { kind: 'stopped', loopId: 'loop_one', message: 'Loop loop_one stopped.' },
|
|
1345
|
+
});
|
|
1346
|
+
await stopping;
|
|
1347
|
+
expect(lastNotice(channel)).toBe('Loop loop_one stopped by Esc.');
|
|
1348
|
+
|
|
1349
|
+
const several = channel.stopWaitingSelfPacedLoop();
|
|
1350
|
+
link.push({
|
|
1351
|
+
type: 'waiting_loop_stop',
|
|
1352
|
+
requestId: lastRequestId(link.sent, 'stop-waiting-loop'),
|
|
1353
|
+
outcome: { kind: 'several', message: 'Use /loop stop <id> to choose one.' },
|
|
1354
|
+
});
|
|
1355
|
+
await several;
|
|
1356
|
+
expect(lastNotice(channel)).toBe('Use /loop stop <id> to choose one.');
|
|
1357
|
+
|
|
1358
|
+
const count = channel.getSnapshot().history.length;
|
|
1359
|
+
const none = channel.stopWaitingSelfPacedLoop();
|
|
1360
|
+
link.push({
|
|
1361
|
+
type: 'waiting_loop_stop',
|
|
1362
|
+
requestId: lastRequestId(link.sent, 'stop-waiting-loop'),
|
|
1363
|
+
outcome: { kind: 'none' },
|
|
1364
|
+
});
|
|
1365
|
+
await none;
|
|
1366
|
+
expect(channel.getSnapshot().history).toHaveLength(count);
|
|
1367
|
+
await channel.stop();
|
|
1368
|
+
});
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
describe('WireTuiChannel observing (#3189 step 3b)', () => {
|
|
1372
|
+
async function observing(): Promise<{ channel: WireTuiChannel; link: IScriptedConnection }> {
|
|
1373
|
+
const link = scriptedConnection();
|
|
1374
|
+
const channel = new WireTuiChannel({
|
|
1375
|
+
connection: link,
|
|
1376
|
+
driverId: 'attach:1',
|
|
1377
|
+
sessionName: 'daemon',
|
|
1378
|
+
role: 'observe',
|
|
1379
|
+
cwd: '/work/here',
|
|
1380
|
+
clientCommands: {
|
|
1381
|
+
commands: [{ name: 'shell', execute: () => ({ success: true, message: 'ran here' }) }],
|
|
1382
|
+
writeAppearanceSettings: () => undefined,
|
|
1383
|
+
},
|
|
1384
|
+
});
|
|
1385
|
+
await channel.start();
|
|
1386
|
+
return { channel, link };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
it('asks for no open questions, and says it is read only', async () => {
|
|
1390
|
+
const { channel, link } = await observing();
|
|
1391
|
+
expect(types(link.sent)).toEqual(SNAPSHOT_TYPES.filter((type) => type !== 'get-prompts'));
|
|
1392
|
+
expect(channel.getSnapshot().readOnly).toBe(true);
|
|
1393
|
+
await channel.stop();
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
it("refuses prompts and the host's commands with a notice, but runs /exit and its own commands", async () => {
|
|
1397
|
+
const onEnd = vi.fn();
|
|
1398
|
+
const link = scriptedConnection();
|
|
1399
|
+
const channel = new WireTuiChannel({
|
|
1400
|
+
connection: link,
|
|
1401
|
+
role: 'observe',
|
|
1402
|
+
cwd: '/work/here',
|
|
1403
|
+
clientCommands: {
|
|
1404
|
+
commands: [{ name: 'shell', execute: () => ({ success: true, message: 'ran here' }) }],
|
|
1405
|
+
writeAppearanceSettings: () => undefined,
|
|
1406
|
+
},
|
|
1407
|
+
onEnd,
|
|
1408
|
+
});
|
|
1409
|
+
await channel.start();
|
|
1410
|
+
link.sent.length = 0;
|
|
1411
|
+
await channel.handleInput('hello');
|
|
1412
|
+
expect(lastNotice(channel)).toEqual(expect.stringContaining('Read only'));
|
|
1413
|
+
await channel.handleInput('/mode plan');
|
|
1414
|
+
expect(lastNotice(channel)).toEqual(expect.stringContaining('Read only'));
|
|
1415
|
+
await channel.handleInput('/shell ls');
|
|
1416
|
+
expect(lastNotice(channel)).toBe('ran here');
|
|
1417
|
+
expect(link.sent).toEqual([]);
|
|
1418
|
+
await channel.handleInput('/exit');
|
|
1419
|
+
expect(onEnd).toHaveBeenCalledWith('user');
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
it('changes nothing on the session: no abort, queue cancel, loop stop, task input or switch', async () => {
|
|
1423
|
+
const { channel, link } = await observing();
|
|
1424
|
+
link.sent.length = 0;
|
|
1425
|
+
channel.abort();
|
|
1426
|
+
expect(channel.getSnapshot().isAborting).toBe(false);
|
|
1427
|
+
channel.cancelQueue();
|
|
1428
|
+
await channel.stopWaitingSelfPacedLoop();
|
|
1429
|
+
await channel.sendAgentJob('task-1', 'more');
|
|
1430
|
+
await channel.requestSessionSwitch('session_other');
|
|
1431
|
+
expect(link.sent).toEqual([]);
|
|
1432
|
+
expect(lastNotice(channel)).toEqual(expect.stringContaining('Read only'));
|
|
1433
|
+
await channel.stop();
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
it('sends only what an observer may send, detail reads included', async () => {
|
|
1437
|
+
const { channel, link } = await observing();
|
|
1438
|
+
link.sent.length = 0;
|
|
1439
|
+
const read = channel.readExecutionWorkspaceDetail('task-1');
|
|
1440
|
+
link.push({ type: 'thinking', isThinking: true });
|
|
1441
|
+
expect(types(link.sent)).toEqual(['read-execution-detail', 'get-pending']);
|
|
1442
|
+
await channel.stop();
|
|
1443
|
+
await expect(read).rejects.toThrow('Detached');
|
|
1444
|
+
});
|
|
1445
|
+
});
|