@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.
@@ -29,7 +29,7 @@ export function Library({ session, setComposer, goSessions }: { session: Session
29
29
  };
30
30
  const use = (a: LibraryAsset) => {
31
31
  const cmd = capRef(a.type, a.name.replace(/\.(md|markdown|ya?ml|json)$/i, ''));
32
- if (!(session.isController && session.sendInput(cmd))) setComposer(cmd);
32
+ if (!(session.isController && session.state.target && session.sendInput(cmd, session.state.target))) setComposer(cmd);
33
33
  goSessions();
34
34
  };
35
35
 
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { render, screen, waitFor, fireEvent, cleanup, within } from '@testing-library/react';
2
+ import { render, screen, waitFor, fireEvent, cleanup, within, act } from '@testing-library/react';
3
3
  import { Sessions } from './Sessions';
4
4
  import type { SessionApi } from '../useSession';
5
+ import { resetSessionRegistryForTest, updateRegistrySessionSnapshot } from '../sessionRegistry';
5
6
 
6
7
  const INSTANCE = {
7
8
  id: 'inst-1',
@@ -21,10 +22,40 @@ const INSTANCE_NEXT = {
21
22
  id: 'inst-2',
22
23
  launch_context: { name: 'docker-two', loadout: 'agentic-dev' },
23
24
  };
25
+ const STALE_INSTANCE = {
26
+ ...INSTANCE,
27
+ id: 'stale-docker',
28
+ agent_ready: false,
29
+ launch_context: { name: 'stale-docker', loadout: 'agentic-dev' },
30
+ session_backends: [{
31
+ mode: 'managed',
32
+ backend: 'tmux',
33
+ available: false,
34
+ drive: true,
35
+ keyframe: true,
36
+ reason: 'container is running but the agent has not registered; PTY sessions are not ready',
37
+ }],
38
+ };
39
+ const STALE_VM_INSTANCE = {
40
+ ...STALE_INSTANCE,
41
+ id: 'stale-vm',
42
+ runtime: 'vm',
43
+ runtime_posture: { kind: 'vm', isolation: 'strong', label: 'VM / hardware boundary' },
44
+ launch_context: { name: 'stale-vm', loadout: 'agentic-dev' },
45
+ session_backends: [{
46
+ mode: 'managed',
47
+ backend: 'tmux',
48
+ available: false,
49
+ drive: true,
50
+ keyframe: true,
51
+ reason: 'VM is running but the agent has not registered; PTY sessions are not ready',
52
+ }],
53
+ };
24
54
 
25
- function stubSession(): SessionApi {
55
+ function stubSession(state: Partial<SessionApi['state']> = {}): SessionApi {
56
+ const nextState = { attached: true, role: 'controller', url: 'ws://x/agents/inst-1/sessions/sess-1/attach', ...state };
26
57
  return {
27
- state: { attached: true, role: 'controller', url: 'ws://x/agents/inst-1/sessions/sess-1/attach' },
58
+ state: nextState,
28
59
  responseNeeded: { needed: false, prompt: '', since: null, source: 'pty' },
29
60
  attach: vi.fn(),
30
61
  detach: vi.fn(),
@@ -32,12 +63,13 @@ function stubSession(): SessionApi {
32
63
  requestKeyframe: vi.fn(),
33
64
  sendInput: vi.fn(),
34
65
  openTerminal: vi.fn(),
35
- isController: true,
66
+ isController: nextState.role === 'controller',
36
67
  } as unknown as SessionApi;
37
68
  }
38
69
 
39
70
  beforeEach(() => {
40
71
  (window as unknown as { __COCKPIT_TOKEN__: string }).__COCKPIT_TOKEN__ = 'test-token';
72
+ resetSessionRegistryForTest();
41
73
  vi.spyOn(window, 'confirm').mockReturnValue(true);
42
74
  });
43
75
 
@@ -74,11 +106,11 @@ describe('Sessions', () => {
74
106
  expect.stringContaining('/api/instances/inst-1/sessions/sess-1'),
75
107
  expect.objectContaining({ method: 'DELETE' }),
76
108
  ));
77
- expect(session.detach).toHaveBeenCalled();
109
+ await waitFor(() => expect(session.detach).toHaveBeenCalled());
78
110
  });
79
111
 
80
112
  it('refreshes stale recovered inventory and stops offering dead session attach URLs', async () => {
81
- const session = stubSession();
113
+ const session = stubSession({ attached: false, role: null, url: null });
82
114
  const inventories = [
83
115
  { instances: [INSTANCE] },
84
116
  { instances: [INSTANCE_NEXT] },
@@ -121,6 +153,56 @@ describe('Sessions', () => {
121
153
  expect(await within(nav).findByTitle('sess-new')).toBeTruthy();
122
154
  });
123
155
 
156
+ it('keeps a stale running container visible with reconnect guidance', async () => {
157
+ const session = stubSession({ attached: false, role: null, url: null });
158
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
159
+ const url = String(input);
160
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [STALE_INSTANCE] });
161
+ if (url.includes('/api/sessions?instance=stale-docker')) return jsonResponse({ sessions: [] });
162
+ if (url.includes('/api/instances/stale-docker/reconnect') && init?.method === 'POST') {
163
+ return jsonResponse({ state: 'reconnecting' });
164
+ }
165
+ return new Response('{}', { status: 404 });
166
+ });
167
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
168
+
169
+ render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} refreshMs={60_000} />);
170
+
171
+ const nav = screen.getByLabelText('Instances and sessions');
172
+ expect(await within(nav).findByText('stale-docker')).toBeTruthy();
173
+ expect(await screen.findByText(/agent is unreachable while the runtime is still running/i)).toBeTruthy();
174
+ fireEvent.click(screen.getByRole('button', { name: 'Reconnect' }));
175
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
176
+ expect.stringContaining('/api/instances/stale-docker/reconnect'),
177
+ expect.objectContaining({ method: 'POST' }),
178
+ ));
179
+ });
180
+
181
+ it('keeps a stale running VM visible with reconnect guidance (#1778)', async () => {
182
+ const session = stubSession({ attached: false, role: null, url: null });
183
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
184
+ const url = String(input);
185
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [STALE_VM_INSTANCE] });
186
+ if (url.includes('/api/sessions?instance=stale-vm')) return jsonResponse({ sessions: [] });
187
+ if (url.includes('/api/instances/stale-vm/reconnect') && init?.method === 'POST') {
188
+ return jsonResponse({ state: 'reconnecting' });
189
+ }
190
+ return new Response('{}', { status: 404 });
191
+ });
192
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
193
+
194
+ render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} refreshMs={60_000} />);
195
+
196
+ const nav = screen.getByLabelText('Instances and sessions');
197
+ expect(await within(nav).findByText('stale-vm')).toBeTruthy();
198
+ expect(await screen.findByText(/agent is unreachable while the runtime is still running/i)).toBeTruthy();
199
+ fireEvent.click(screen.getByRole('button', { name: 'Reconnect' }));
200
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
201
+ expect.stringContaining('/api/instances/stale-vm/reconnect'),
202
+ expect.objectContaining({ method: 'POST' }),
203
+ ));
204
+ });
205
+
124
206
  it('keeps controller posture when a different session is selected while driving (#1670)', async () => {
125
207
  const session = stubSession(); // currently attached to .../sessions/sess-1/attach as controller
126
208
  const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
@@ -139,7 +221,109 @@ describe('Sessions', () => {
139
221
  fireEvent.click(sessBtn);
140
222
  // Selecting a not-yet-attached session should not silently downgrade an
141
223
  // operator who is already driving another session.
142
- expect(session.attach).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-2/attach', false, 'controller');
224
+ expect(session.attach).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-2/attach', false, 'controller', { instanceId: 'inst-1', sessionId: 'sess-2' });
225
+ });
226
+
227
+ it('does not detach the live session when browsing another instance (#1739)', async () => {
228
+ const session = stubSession();
229
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
230
+ const url = String(input);
231
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE, INSTANCE_NEXT] });
232
+ if (url.includes('instance=inst-1')) return jsonResponse({
233
+ sessions: [{ id: 'sess-1', session_name: 'terminal-main', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-1/attach', mode: 'managed', backend: 'tmux' }],
234
+ });
235
+ if (url.includes('instance=inst-2')) return jsonResponse({
236
+ sessions: [{ id: 'sess-2', session_name: 'terminal-other', instance_id: 'inst-2', attach_url: 'ws://x/agents/inst-2/sessions/sess-2/attach', mode: 'managed', backend: 'tmux' }],
237
+ });
238
+ return new Response('{}', { status: 404 });
239
+ });
240
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
241
+
242
+ render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
243
+
244
+ expect((await screen.findAllByTitle('sess-1')).length).toBeGreaterThan(0);
245
+ fireEvent.click(await screen.findByText('docker-two'));
246
+ expect((await screen.findAllByText('terminal-other')).length).toBeGreaterThan(0);
247
+
248
+ expect(session.detach).not.toHaveBeenCalled();
249
+ });
250
+
251
+ it('treats the instance/session pair as live identity when attach URLs diverge (#1741)', async () => {
252
+ const session = stubSession({ url: 'ws://executor-a/agents/inst-1/sessions/sess-1/attach?from=create' });
253
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
254
+ const url = String(input);
255
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
256
+ if (url.includes('/api/sessions?instance=')) return jsonResponse({
257
+ sessions: [{
258
+ id: 'sess-1',
259
+ session_name: 'terminal-main',
260
+ instance_id: 'inst-1',
261
+ attach_url: 'ws://executor-b/agents/inst-1/sessions/sess-1/attach',
262
+ mode: 'managed',
263
+ backend: 'tmux',
264
+ }],
265
+ });
266
+ return new Response('{}', { status: 404 });
267
+ });
268
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
269
+
270
+ render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
271
+
272
+ const nav = screen.getByLabelText('Instances and sessions');
273
+ expect(await within(nav).findByTitle('Attached here')).toBeTruthy();
274
+ expect(session.detach).not.toHaveBeenCalled();
275
+ });
276
+
277
+ it('ignores stale session-list responses after switching instances (#1740)', async () => {
278
+ const session = stubSession({ attached: false, role: null, url: null });
279
+ let resolveInst1: (value: Response) => void = () => {};
280
+ const inst1Sessions = new Promise<Response>((resolve) => { resolveInst1 = resolve; });
281
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
282
+ const url = String(input);
283
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE, INSTANCE_NEXT] });
284
+ if (url.includes('instance=inst-1')) return inst1Sessions;
285
+ if (url.includes('instance=inst-2')) return jsonResponse({
286
+ sessions: [{ id: 'sess-2', session_name: 'terminal-two', instance_id: 'inst-2', attach_url: 'ws://x/agents/inst-2/sessions/sess-2/attach', mode: 'managed', backend: 'tmux' }],
287
+ });
288
+ return new Response('{}', { status: 404 });
289
+ });
290
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
291
+
292
+ render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} refreshMs={60_000} />);
293
+
294
+ fireEvent.click(await screen.findByText('docker-two'));
295
+ const nav = screen.getByLabelText('Instances and sessions');
296
+ expect(await within(nav).findByTitle('sess-2')).toBeTruthy();
297
+
298
+ resolveInst1(jsonResponse({
299
+ sessions: [{ id: 'sess-1', session_name: 'terminal-one', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-1/attach', mode: 'managed', backend: 'tmux' }],
300
+ }));
301
+
302
+ await waitFor(() => expect(within(nav).queryByText('terminal-one')).toBeNull());
303
+ expect(within(nav).getByText('terminal-two')).toBeTruthy();
304
+ });
305
+
306
+ it('requires two consecutive missing polls before detaching the attached session (#1740)', async () => {
307
+ const session = stubSession();
308
+ const sessionResponses = [
309
+ [{ id: 'sess-1', session_name: 'terminal-main', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-1/attach', mode: 'managed', backend: 'tmux' }],
310
+ [{ id: 'sess-1', session_name: 'terminal-main', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-1/attach', mode: 'managed', backend: 'tmux' }],
311
+ [{ id: 'sess-other', session_name: 'terminal-other', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-other/attach', mode: 'managed', backend: 'tmux' }],
312
+ [{ id: 'sess-other', session_name: 'terminal-other', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-other/attach', mode: 'managed', backend: 'tmux' }],
313
+ ];
314
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
315
+ const url = String(input);
316
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
317
+ if (url.includes('/api/sessions?instance=')) return jsonResponse({ sessions: sessionResponses.shift() ?? sessionResponses[sessionResponses.length - 1] ?? [] });
318
+ return new Response('{}', { status: 404 });
319
+ });
320
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
321
+
322
+ render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} refreshMs={250} />);
323
+ expect(await screen.findByTitle('sess-other')).toBeTruthy();
324
+ expect(session.detach).not.toHaveBeenCalled();
325
+
326
+ await waitFor(() => expect(session.detach).toHaveBeenCalledTimes(1), { timeout: 1_200 });
143
327
  });
144
328
 
145
329
  it('reattaches with replay instead of downgrading when re-selecting the session already attached', async () => {
@@ -160,7 +344,7 @@ describe('Sessions', () => {
160
344
  // Clicking the session we already drive replays/reasserts controller; it
161
345
  // must not downgrade us back to observer.
162
346
  expect(session.attach).not.toHaveBeenCalled();
163
- expect(session.replay).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-1/attach', 'controller');
347
+ expect(session.replay).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-1/attach', 'controller', { instanceId: 'inst-1', sessionId: 'sess-1' });
164
348
  });
165
349
 
166
350
  it('distinguishes sessions by name + backend + viewer count in the nav (#1670)', async () => {
@@ -170,8 +354,8 @@ describe('Sessions', () => {
170
354
  if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
171
355
  if (url.includes('/api/sessions?instance=')) return jsonResponse({
172
356
  sessions: [
173
- { id: 'sess-a', session_name: 'terminal-alpha', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-a/attach', mode: 'managed', backend: 'tmux', controllers: 1, observers: 1 },
174
- { id: 'sess-b', session_name: 'terminal-beta', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-b/attach', mode: 'direct', backend: 'native', members: 0 },
357
+ { id: 'sess-a', session_name: 'terminal-alpha', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-a/attach', session_class: 'managed', session_backend: 'tmux', membership: { controllers: ['c1'], observers: ['o1'], attachment_count: 2 } },
358
+ { id: 'sess-b', session_name: 'terminal-beta', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-b/attach', session_class: 'direct', session_backend: 'native', membership: { controllers: [], observers: [], attachment_count: 0 } },
175
359
  ],
176
360
  });
177
361
  return new Response('{}', { status: 404 });
@@ -191,6 +375,60 @@ describe('Sessions', () => {
191
375
  // sess-a has a controller connected → it carries the ctrl badge; sess-b does not.
192
376
  expect(within(nav).getByTitle('A controller is connected')).toBeTruthy();
193
377
  });
378
+
379
+ it('shows registry unread and response-needed badges and clears unread on view', async () => {
380
+ const session = stubSession({ attached: false, role: null, url: null });
381
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
382
+ const url = String(input);
383
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
384
+ if (url.includes('/api/sessions?instance=')) return jsonResponse({
385
+ sessions: [
386
+ { id: 'sess-a', session_name: 'terminal-alpha', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-a/attach', session_class: 'managed', session_backend: 'tmux' },
387
+ { id: 'sess-b', session_name: 'terminal-beta', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-b/attach', session_class: 'managed', session_backend: 'tmux' },
388
+ ],
389
+ });
390
+ return new Response('{}', { status: 404 });
391
+ });
392
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
393
+
394
+ render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
395
+
396
+ const nav = screen.getByLabelText('Instances and sessions');
397
+ const beta = await within(nav).findByTitle('sess-b');
398
+ act(() => updateRegistrySessionSnapshot('inst-1', 'sess-b', 'Deploy to prod? [y/N]\n'));
399
+
400
+ expect(await within(nav).findByTitle('Unread output')).toBeTruthy();
401
+ expect(within(nav).getByTitle('Response needed')).toBeTruthy();
402
+
403
+ fireEvent.click(beta);
404
+ await waitFor(() => expect(within(nav).queryByTitle('Unread output')).toBeNull());
405
+ expect(within(nav).getByTitle('Response needed')).toBeTruthy();
406
+ });
407
+
408
+ it('omits viewer counts when the session source does not provide membership fields (#1745)', async () => {
409
+ const session = stubSession();
410
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
411
+ const url = String(input);
412
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
413
+ if (url.includes('/api/sessions?instance=')) return jsonResponse({
414
+ sessions: [
415
+ { id: 'sess-bare', session_name: 'terminal-bare', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-bare/attach', session_class: 'managed', session_backend: 'tmux' },
416
+ { id: 'sess-rich', session_name: 'terminal-rich', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-rich/attach', session_class: 'managed', session_backend: 'tmux', membership: { controllers: ['c1'], observers: ['o1'], attachment_count: 2 } },
417
+ ],
418
+ });
419
+ return new Response('{}', { status: 404 });
420
+ });
421
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
422
+
423
+ render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
424
+
425
+ await screen.findByText('terminal-rich');
426
+ const nav = screen.getByLabelText('Instances and sessions');
427
+ expect(within(nav).getAllByText('managed/tmux')).toHaveLength(1);
428
+ expect(within(nav).getByText('managed/tmux · 2 viewers')).toBeTruthy();
429
+ expect(within(nav).getByTitle('A controller is connected')).toBeTruthy();
430
+ expect(within(nav).queryByText('0 viewers')).toBeNull();
431
+ });
194
432
  });
195
433
 
196
434
  function jsonResponse(body: unknown): Response {