@aiwg/cockpit 2026.7.11 → 2026.7.13
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/README.md +80 -12
- package/bridge/src/server.mjs +301 -21
- package/bridge/src/smoke.mjs +3 -3
- package/package.json +2 -2
- package/runtime-docs/README.md +3 -2
- package/shell-core/keychain.mjs +35 -5
- package/vscode/extension.js +13 -26
- package/vscode/smoke.mjs +47 -0
- package/web/src/App.test.tsx +119 -1
- package/web/src/App.tsx +58 -27
- package/web/src/components/Actions.tsx +1 -1
- package/web/src/components/Inventory.tsx +61 -14
- package/web/src/components/Library.tsx +1 -1
- package/web/src/components/Sessions.test.tsx +248 -10
- package/web/src/components/Sessions.tsx +236 -65
- package/web/src/components/StartSessionModal.test.tsx +1 -1
- package/web/src/components/StartSessionModal.tsx +7 -1
- package/web/src/components/Welcome.tsx +11 -10
- package/web/src/sessionMonitor.test.tsx +85 -0
- package/web/src/sessionMonitor.ts +72 -0
- package/web/src/sessionRegistry.test.ts +112 -0
- package/web/src/sessionRegistry.ts +192 -0
- package/web/src/styles.css +20 -2
- package/web/src/types.ts +23 -1
- package/web/src/useSession.test.tsx +207 -7
- package/web/src/useSession.ts +353 -193
- package/web/src/util.ts +8 -1
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { renderHook, act } from '@testing-library/react';
|
|
3
|
-
import { stripTerminalAutoResponses, useSession } from './useSession';
|
|
3
|
+
import { sessionTargetFromAttachUrl, stripTerminalAutoResponses, useSession } from './useSession';
|
|
4
|
+
|
|
5
|
+
const terminalWrites = vi.hoisted(() => [] as string[]);
|
|
6
|
+
|
|
7
|
+
vi.mock('@xterm/xterm', () => ({
|
|
8
|
+
Terminal: class {
|
|
9
|
+
options: Record<string, unknown>;
|
|
10
|
+
cols = 80;
|
|
11
|
+
rows = 24;
|
|
12
|
+
constructor(options: Record<string, unknown>) { this.options = options; }
|
|
13
|
+
loadAddon() {}
|
|
14
|
+
onData(_fn: (data: string) => void) {}
|
|
15
|
+
onResize(_fn: (size: { cols: number; rows: number }) => void) {}
|
|
16
|
+
open() {}
|
|
17
|
+
write(data: Uint8Array | string) {
|
|
18
|
+
terminalWrites.push(typeof data === 'string' ? data : new TextDecoder().decode(data));
|
|
19
|
+
}
|
|
20
|
+
reset() { terminalWrites.length = 0; }
|
|
21
|
+
dispose() {}
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('@xterm/addon-fit', () => ({
|
|
26
|
+
FitAddon: class { fit() {} },
|
|
27
|
+
}));
|
|
4
28
|
|
|
5
29
|
// Minimal WebSocket double: records every constructed socket and lets the test
|
|
6
30
|
// drive open/close/message. Mirrors the readiness-race timing (#1669).
|
|
@@ -18,6 +42,7 @@ class MockWS {
|
|
|
18
42
|
|
|
19
43
|
beforeEach(() => {
|
|
20
44
|
MockWS.instances = [];
|
|
45
|
+
terminalWrites.length = 0;
|
|
21
46
|
(globalThis as unknown as { WebSocket: unknown }).WebSocket = MockWS as unknown;
|
|
22
47
|
vi.useFakeTimers();
|
|
23
48
|
});
|
|
@@ -34,6 +59,15 @@ describe('stripTerminalAutoResponses', () => {
|
|
|
34
59
|
});
|
|
35
60
|
});
|
|
36
61
|
|
|
62
|
+
describe('sessionTargetFromAttachUrl', () => {
|
|
63
|
+
it('parses executor attach URLs into explicit injection targets', () => {
|
|
64
|
+
expect(sessionTargetFromAttachUrl('ws://x/agents/inst%201/sessions/sess%2F1/attach')).toEqual({
|
|
65
|
+
instanceId: 'inst 1',
|
|
66
|
+
sessionId: 'sess/1',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
37
71
|
describe('useSession — retry through the PTY-readiness window (#1669)', () => {
|
|
38
72
|
it('reconnects on an early empty close instead of giving up', () => {
|
|
39
73
|
const { result } = renderHook(() => useSession());
|
|
@@ -89,20 +123,23 @@ describe('useSession — retry through the PTY-readiness window (#1669)', () =>
|
|
|
89
123
|
expect(MockWS.instances.length).toBeLessThanOrEqual(7);
|
|
90
124
|
});
|
|
91
125
|
|
|
92
|
-
it('
|
|
126
|
+
it('keeps a backgrounded session alive on switch; the active session drives UI state (#1749)', () => {
|
|
93
127
|
const { result } = renderHook(() => useSession());
|
|
94
128
|
act(() => { result.current.attach('ws://x/agents/i/sessions/old/attach', false, 'controller'); });
|
|
95
129
|
const old = MockWS.instances[0];
|
|
96
130
|
act(() => { result.current.attach('ws://x/agents/i/sessions/new/attach', false, 'observer'); });
|
|
97
131
|
const current = MockWS.instances[1];
|
|
98
132
|
|
|
133
|
+
// The previous session is NOT torn down when we switch — it keeps its own
|
|
134
|
+
// socket and joins/streams in the background so its scrollback survives.
|
|
99
135
|
act(() => {
|
|
100
136
|
old.emit('open');
|
|
101
137
|
old.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
|
|
102
138
|
old.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
|
|
103
|
-
old.emit('close');
|
|
104
139
|
});
|
|
105
|
-
expect(old.sent).toEqual(
|
|
140
|
+
expect(JSON.parse(old.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'controller', replay_from: 0 } });
|
|
141
|
+
// ...but the active (foreground) session is the new one; the backgrounded
|
|
142
|
+
// session's role does not leak into the UI state.
|
|
106
143
|
expect(result.current.state.url).toBe('ws://x/agents/i/sessions/new/attach');
|
|
107
144
|
expect(result.current.state.role).toBeNull();
|
|
108
145
|
|
|
@@ -111,10 +148,59 @@ describe('useSession — retry through the PTY-readiness window (#1669)', () =>
|
|
|
111
148
|
current.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
|
|
112
149
|
current.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'observer' } }) });
|
|
113
150
|
});
|
|
114
|
-
expect(JSON.parse(current.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'observer' } });
|
|
151
|
+
expect(JSON.parse(current.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'observer', replay_from: 0 } });
|
|
115
152
|
expect(result.current.state.role).toBe('observer');
|
|
116
153
|
});
|
|
117
154
|
|
|
155
|
+
it('switching back to an already-attached session does not reconnect it (#1749)', () => {
|
|
156
|
+
const { result } = renderHook(() => useSession());
|
|
157
|
+
act(() => { result.current.attach('ws://x/agents/i/sessions/a/attach', false, 'controller'); });
|
|
158
|
+
act(() => {
|
|
159
|
+
MockWS.instances[0].emit('open');
|
|
160
|
+
MockWS.instances[0].emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
|
|
161
|
+
});
|
|
162
|
+
act(() => { result.current.attach('ws://x/agents/i/sessions/b/attach', false, 'controller'); });
|
|
163
|
+
act(() => {
|
|
164
|
+
MockWS.instances[1].emit('open');
|
|
165
|
+
MockWS.instances[1].emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
|
|
166
|
+
});
|
|
167
|
+
expect(MockWS.instances).toHaveLength(2);
|
|
168
|
+
|
|
169
|
+
// Switch back to A (same role, not a replay) — it should just re-show, using
|
|
170
|
+
// the socket that's still open. No third WebSocket, no terminal reset.
|
|
171
|
+
act(() => { result.current.attach('ws://x/agents/i/sessions/a/attach', false, 'controller'); });
|
|
172
|
+
expect(MockWS.instances).toHaveLength(2);
|
|
173
|
+
expect(result.current.state.url).toBe('ws://x/agents/i/sessions/a/attach');
|
|
174
|
+
expect(result.current.state.role).toBe('controller');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('requests a bounded ring replay on fresh attach joins (#1744)', () => {
|
|
178
|
+
const { result } = renderHook(() => useSession());
|
|
179
|
+
act(() => { result.current.attach('ws://x/session', false, 'observer'); });
|
|
180
|
+
const ws = MockWS.instances[0];
|
|
181
|
+
|
|
182
|
+
act(() => { ws.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) }); });
|
|
183
|
+
|
|
184
|
+
expect(JSON.parse(ws.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'observer', replay_from: 0 } });
|
|
185
|
+
expect(ws.url).toBe('ws://x/session');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('paints replayed prior output from the joined stream on a fresh attach (#1744)', () => {
|
|
189
|
+
const { result } = renderHook(() => useSession());
|
|
190
|
+
const host = document.createElement('div');
|
|
191
|
+
document.body.appendChild(host);
|
|
192
|
+
act(() => { result.current.openTerminal(host); });
|
|
193
|
+
|
|
194
|
+
act(() => { result.current.attach('ws://x/session', false, 'observer'); });
|
|
195
|
+
const ws = MockWS.instances[0];
|
|
196
|
+
act(() => {
|
|
197
|
+
ws.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
|
|
198
|
+
ws.emit('message', { data: JSON.stringify({ op: 'output', seq: 8, payload: { data: btoa('prior output') } }) });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(terminalWrites.join('')).toContain('prior output');
|
|
202
|
+
});
|
|
203
|
+
|
|
118
204
|
it('does not let a stale close clear the active controller role', () => {
|
|
119
205
|
const { result } = renderHook(() => useSession());
|
|
120
206
|
act(() => { result.current.attach('ws://x/old', false, 'observer'); });
|
|
@@ -134,7 +220,7 @@ describe('useSession — retry through the PTY-readiness window (#1669)', () =>
|
|
|
134
220
|
expect(result.current.state.role).toBe('controller');
|
|
135
221
|
});
|
|
136
222
|
|
|
137
|
-
it('reattaches for replay immediately and
|
|
223
|
+
it('reattaches for replay immediately and requests replay_from in the join payload', () => {
|
|
138
224
|
const { result } = renderHook(() => useSession());
|
|
139
225
|
act(() => { result.current.attach('ws://x/session', false, 'controller'); });
|
|
140
226
|
const first = MockWS.instances[0];
|
|
@@ -146,9 +232,123 @@ describe('useSession — retry through the PTY-readiness window (#1669)', () =>
|
|
|
146
232
|
act(() => { result.current.replay('ws://x/session', 'controller'); });
|
|
147
233
|
expect(MockWS.instances).toHaveLength(2);
|
|
148
234
|
const replay = MockWS.instances[1];
|
|
149
|
-
expect(replay.url).toBe('ws://x/session
|
|
235
|
+
expect(replay.url).toBe('ws://x/session');
|
|
150
236
|
|
|
151
237
|
act(() => { replay.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) }); });
|
|
152
238
|
expect(JSON.parse(replay.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'controller', replay_from: 12 } });
|
|
153
239
|
});
|
|
240
|
+
|
|
241
|
+
it('requests a keyframe when a controller socket opens but stays silent (#1746)', () => {
|
|
242
|
+
const { result } = renderHook(() => useSession());
|
|
243
|
+
const host = document.createElement('div');
|
|
244
|
+
document.body.appendChild(host);
|
|
245
|
+
act(() => { result.current.openTerminal(host); });
|
|
246
|
+
act(() => { result.current.attach('ws://x/session', false, 'controller'); });
|
|
247
|
+
const ws = MockWS.instances[0];
|
|
248
|
+
|
|
249
|
+
act(() => {
|
|
250
|
+
ws.emit('open');
|
|
251
|
+
ws.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
|
|
252
|
+
ws.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
|
|
253
|
+
vi.advanceTimersByTime(2000);
|
|
254
|
+
});
|
|
255
|
+
expect(terminalWrites.join('')).toContain('[attached — no output yet]');
|
|
256
|
+
|
|
257
|
+
act(() => {
|
|
258
|
+
vi.advanceTimersByTime(4000);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(terminalWrites.join('')).toContain('[attached — no output after 4s; requesting repaint]');
|
|
262
|
+
expect(ws.sent.map((s) => JSON.parse(s)).filter((m) => m.op === 'pty.request_keyframe')).toHaveLength(1);
|
|
263
|
+
expect(MockWS.instances).toHaveLength(1);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('reconnects once with replay_from zero when an observer socket stays silent (#1746)', () => {
|
|
267
|
+
const { result } = renderHook(() => useSession());
|
|
268
|
+
const host = document.createElement('div');
|
|
269
|
+
document.body.appendChild(host);
|
|
270
|
+
act(() => { result.current.openTerminal(host); });
|
|
271
|
+
act(() => { result.current.attach('ws://x/session', false, 'observer'); });
|
|
272
|
+
const first = MockWS.instances[0];
|
|
273
|
+
|
|
274
|
+
act(() => {
|
|
275
|
+
first.emit('open');
|
|
276
|
+
first.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
|
|
277
|
+
first.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'observer' } }) });
|
|
278
|
+
vi.advanceTimersByTime(4000);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(terminalWrites.join('')).toContain('[attached — no output after 4s; requesting repaint]');
|
|
282
|
+
expect(MockWS.instances).toHaveLength(2);
|
|
283
|
+
const replay = MockWS.instances[1];
|
|
284
|
+
act(() => { replay.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) }); });
|
|
285
|
+
expect(JSON.parse(replay.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'observer', replay_from: 0 } });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('does not recover when the first frame arrives before the deadline (#1746)', () => {
|
|
289
|
+
const { result } = renderHook(() => useSession());
|
|
290
|
+
const host = document.createElement('div');
|
|
291
|
+
document.body.appendChild(host);
|
|
292
|
+
act(() => { result.current.openTerminal(host); });
|
|
293
|
+
act(() => { result.current.attach('ws://x/session', false, 'controller'); });
|
|
294
|
+
const ws = MockWS.instances[0];
|
|
295
|
+
|
|
296
|
+
act(() => {
|
|
297
|
+
ws.emit('open');
|
|
298
|
+
ws.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
|
|
299
|
+
vi.advanceTimersByTime(1000);
|
|
300
|
+
ws.emit('message', { data: JSON.stringify({ op: 'output', seq: 1, payload: { data: btoa('ready') } }) });
|
|
301
|
+
vi.advanceTimersByTime(4000);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
expect(terminalWrites.join('')).toContain('ready');
|
|
305
|
+
expect(terminalWrites.join('')).not.toContain('no output after');
|
|
306
|
+
expect(terminalWrites.join('')).not.toContain('no output yet');
|
|
307
|
+
expect(ws.sent.map((s) => JSON.parse(s)).filter((m) => m.op === 'pty.request_keyframe')).toHaveLength(0);
|
|
308
|
+
expect(MockWS.instances).toHaveLength(1);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('cleans up the first-frame deadline on user detach (#1746)', () => {
|
|
312
|
+
const { result } = renderHook(() => useSession());
|
|
313
|
+
const host = document.createElement('div');
|
|
314
|
+
document.body.appendChild(host);
|
|
315
|
+
act(() => { result.current.openTerminal(host); });
|
|
316
|
+
act(() => { result.current.attach('ws://x/session', false, 'controller'); });
|
|
317
|
+
const ws = MockWS.instances[0];
|
|
318
|
+
|
|
319
|
+
act(() => {
|
|
320
|
+
ws.emit('open');
|
|
321
|
+
ws.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
|
|
322
|
+
result.current.detach();
|
|
323
|
+
vi.advanceTimersByTime(4000);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(terminalWrites.join('')).not.toContain('no output after');
|
|
327
|
+
expect(terminalWrites.join('')).not.toContain('no output yet');
|
|
328
|
+
expect(ws.sent.map((s) => JSON.parse(s)).filter((m) => m.op === 'pty.request_keyframe')).toHaveLength(0);
|
|
329
|
+
expect(MockWS.instances).toHaveLength(1);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('refuses targeted input when the requested session does not match the attached socket', () => {
|
|
333
|
+
const { result } = renderHook(() => useSession());
|
|
334
|
+
const host = document.createElement('div');
|
|
335
|
+
document.body.appendChild(host);
|
|
336
|
+
act(() => { result.current.openTerminal(host); });
|
|
337
|
+
act(() => { result.current.attach('ws://x/agents/inst-a/sessions/sess-a/attach', false, 'controller'); });
|
|
338
|
+
const ws = MockWS.instances[0];
|
|
339
|
+
|
|
340
|
+
act(() => {
|
|
341
|
+
ws.emit('open');
|
|
342
|
+
ws.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
let sent = true;
|
|
346
|
+
act(() => {
|
|
347
|
+
sent = result.current.sendInput('rm -rf /', { instanceId: 'inst-b', sessionId: 'sess-b' });
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(sent).toBe(false);
|
|
351
|
+
expect(ws.sent.map((s) => JSON.parse(s)).filter((m) => m.op === 'pty.session_input')).toHaveLength(0);
|
|
352
|
+
expect(terminalWrites.join('')).toContain('inject refused: target inst-b:sess-b does not match attached session inst-a:sess-a');
|
|
353
|
+
});
|
|
154
354
|
});
|