@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +858 -0
  2. package/README.md +5 -1
  3. package/dist/node/index.d.ts +189 -54
  4. package/dist/node/index.d.ts.map +1 -1
  5. package/dist/node/index.js +21 -21
  6. package/dist/node/index.js.map +1 -1
  7. package/package.json +16 -21
  8. package/src/App.tsx +14 -3
  9. package/src/AppPresentation.tsx +1 -1
  10. package/src/PermissionPrompt.tsx +50 -17
  11. package/src/SessionPicker.tsx +27 -7
  12. package/src/SessionStatusBar.tsx +6 -2
  13. package/src/StatusBar.tsx +24 -0
  14. package/src/SupervisedSessionView.tsx +479 -157
  15. package/src/TuiInteractionChannel.ts +8 -53
  16. package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
  17. package/src/__tests__/SupervisedSessionView.test.tsx +453 -82
  18. package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +32 -22
  19. package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +10 -2
  20. package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
  21. package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
  22. package/src/__tests__/command-handoff-pty-e2e.test.ts +2 -2
  23. package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +3 -1
  24. package/src/__tests__/fixtures/command-handoff-driver.tsx +8 -5
  25. package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
  26. package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
  27. package/src/__tests__/palette-consistency.test.ts +0 -1
  28. package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
  29. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
  30. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
  31. package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
  32. package/src/__tests__/render-channel-options.test.ts +6 -6
  33. package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
  34. package/src/__tests__/screen-reader-menus.test.tsx +1 -1
  35. package/src/__tests__/session-picker.test.tsx +93 -0
  36. package/src/__tests__/status-bar.test.tsx +8 -0
  37. package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
  38. package/src/__tests__/wire-tui-channel.test.ts +1445 -0
  39. package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
  40. package/src/app-view-model.ts +6 -2
  41. package/src/attached-session-connection.ts +14 -0
  42. package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
  43. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
  44. package/src/hooks/useAppController.ts +6 -2
  45. package/src/hooks/useAppInputBindings.ts +20 -2
  46. package/src/hooks/useAppInteractionState.ts +3 -0
  47. package/src/hooks/useAppOverlays.ts +20 -4
  48. package/src/hooks/useNumberedSelection.ts +19 -2
  49. package/src/hooks/useTuiChannel.ts +13 -1
  50. package/src/index.ts +17 -3
  51. package/src/render.tsx +218 -27
  52. package/src/short-session-id.ts +9 -0
  53. package/src/slash-command-input.ts +11 -0
  54. package/src/tui-app-channel-port.ts +29 -1
  55. package/src/tui-channel-options.ts +11 -2
  56. package/src/tui-session-event-projector.ts +0 -2
  57. package/src/tui-session-events.ts +6 -0
  58. package/src/tui-session-options.ts +9 -0
  59. package/src/tui-state-manager.ts +17 -3
  60. package/src/waiting-loop-stop-notice.ts +23 -0
  61. package/src/wire-history-sync.ts +159 -0
  62. package/src/wire-tui-channel.ts +962 -0
  63. package/src/wire-tui-client-commands.ts +142 -0
  64. package/src/wire-tui-projection.ts +136 -0
  65. package/dist/node/index.cjs +0 -32
  66. package/dist/node/index.d.cts +0 -1066
  67. package/dist/node/index.d.cts.map +0 -1
@@ -13,7 +13,11 @@
13
13
  * - every history assertion checks entry.type (= role) explicitly
14
14
  * - timing: user message visible BEFORE complete fires
15
15
  */
16
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16
+ import { mkdtempSync, rmSync } from 'node:fs';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
17
21
 
18
22
  vi.mock('@robota-sdk/agent-framework', async () => {
19
23
  const actual = await vi.importActual<typeof import('@robota-sdk/agent-framework')>(
@@ -42,8 +46,7 @@ vi.mock('@robota-sdk/agent-framework', async () => {
42
46
  getPendingPrompt: vi.fn().mockReturnValue(null),
43
47
  abort: vi.fn(),
44
48
  cancelQueue: vi.fn(),
45
- listSelfPacedLoops: vi.fn().mockReturnValue([]),
46
- stopSelfPacedLoop: vi.fn().mockResolvedValue(undefined),
49
+ stopWaitingSelfPacedLoop: vi.fn().mockResolvedValue({ kind: 'none' }),
47
50
  getContextState: vi.fn().mockReturnValue({
48
51
  usedPercentage: 0,
49
52
  usedTokens: 0,
@@ -77,12 +80,15 @@ import { TuiInteractionChannel } from '../TuiInteractionChannel.js';
77
80
  import type { IAIProvider, IHistoryEntry } from '@robota-sdk/agent-core';
78
81
  import type { IExecutionResult } from '@robota-sdk/agent-interface-session';
79
82
 
83
+ /** The session's working directory: private to this run, never a fixed name under /tmp. */
84
+ const SESSION_CWD = mkdtempSync(join(tmpdir(), 'robota-tui-display-'));
85
+ afterAll(() => rmSync(SESSION_CWD, { recursive: true, force: true }));
86
+
80
87
  // ── Helpers ───────────────────────────────────────────────────────────────────
81
88
 
82
89
  type MockSession = {
83
90
  getFullHistory: ReturnType<typeof vi.fn>;
84
- listSelfPacedLoops: ReturnType<typeof vi.fn>;
85
- stopSelfPacedLoop: ReturnType<typeof vi.fn>;
91
+ stopWaitingSelfPacedLoop: ReturnType<typeof vi.fn>;
86
92
  on: ReturnType<typeof vi.fn>;
87
93
  emit: (event: string, ...args: unknown[]) => void;
88
94
  };
@@ -97,7 +103,7 @@ function emitSessionEvent(channel: TuiInteractionChannel, event: string, ...args
97
103
 
98
104
  function makeChannel(provider: IAIProvider = {} as IAIProvider): TuiInteractionChannel {
99
105
  return new TuiInteractionChannel({
100
- cwd: '/tmp/test',
106
+ cwd: SESSION_CWD,
101
107
  provider,
102
108
  });
103
109
  }
@@ -139,40 +145,44 @@ afterEach(() => {
139
145
  // ── Group D: display contract (what the user sees) ────────────────────────────
140
146
 
141
147
  describe('Group D — display contract: history entries and active tools', () => {
142
- it('keeps automatic naming text-only when an external event starts the session', async () => {
148
+ it('leaves naming to the session: a first prompt asks the provider for no name', async () => {
143
149
  const chat = vi.fn().mockResolvedValue({ role: 'assistant', content: 'Event summary' });
144
150
  const channel = makeChannel({ chat } as unknown as IAIProvider);
145
151
  await channel.start();
146
152
 
147
153
  emitSessionEvent(channel, 'turn_source', 'external');
148
154
  emitSessionEvent(channel, 'user_message', 'external event');
155
+ await vi.runOnlyPendingTimersAsync();
149
156
 
150
- expect(chat).toHaveBeenCalledTimes(1);
151
- expect(chat.mock.calls[0]?.[1]?.toolChoice).toBe('none');
157
+ expect(chat).not.toHaveBeenCalled();
158
+ expect(channel.sessionName).toBeUndefined();
152
159
  await channel.stop();
153
160
  });
154
161
 
155
- it('Esc stops the only waiting self-paced loop without aborting the session', async () => {
162
+ it('Esc asks the session to stop its waiting loop and shows what it did', async () => {
156
163
  const channel = makeChannel();
157
164
  const session = getMockSession(channel);
158
- session.listSelfPacedLoops.mockReturnValue([{ loopId: 'loop_one', phase: 'waiting' }]);
165
+ session.stopWaitingSelfPacedLoop.mockResolvedValue({ kind: 'stopped', loopId: 'loop_one' });
159
166
  await channel.stopWaitingSelfPacedLoop();
160
- expect(session.stopSelfPacedLoop).toHaveBeenCalledWith('loop_one', 'Loop stopped by Esc');
161
- expect(channel.stateManager.history.some((entry) =>
162
- JSON.stringify(entry).includes('loop_one'))).toBe(true);
167
+ expect(session.stopWaitingSelfPacedLoop).toHaveBeenCalledWith('Loop stopped by Esc');
168
+ expect(channel.stateManager.history.at(-1)?.data).toMatchObject({
169
+ content: 'Loop loop_one stopped by Esc.',
170
+ });
163
171
  });
164
172
 
165
- it('Esc does not guess which loop to stop when several are waiting', async () => {
173
+ it('Esc shows the session\'s word when several loops wait, and nothing when none does', async () => {
166
174
  const channel = makeChannel();
167
175
  const session = getMockSession(channel);
168
- session.listSelfPacedLoops.mockReturnValue([
169
- { loopId: 'loop_one', phase: 'waiting' },
170
- { loopId: 'loop_two', phase: 'waiting' },
171
- ]);
172
176
  await channel.stopWaitingSelfPacedLoop();
173
- expect(session.stopSelfPacedLoop).not.toHaveBeenCalled();
174
- expect(channel.stateManager.history.some((entry) =>
175
- JSON.stringify(entry).includes('/loop stop'))).toBe(true);
177
+ expect(channel.stateManager.history).toHaveLength(0);
178
+ session.stopWaitingSelfPacedLoop.mockResolvedValue({
179
+ kind: 'several',
180
+ message: 'Several self-paced loops are waiting. Use /loop list and /loop stop <id> to choose one.',
181
+ });
182
+ await channel.stopWaitingSelfPacedLoop();
183
+ expect(channel.stateManager.history.at(-1)?.data).toMatchObject({
184
+ content: expect.stringContaining('/loop stop'),
185
+ });
176
186
  });
177
187
 
178
188
  it('D1 (CLI-B05): user_message event immediately adds role=user entry before complete', async () => {
@@ -4,7 +4,11 @@
4
4
  *
5
5
  * No Ink rendering, no PTY — pure TypeScript.
6
6
  */
7
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
+ import { mkdtempSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
12
 
9
13
  vi.mock('@robota-sdk/agent-framework', async () => {
10
14
  const actual = await vi.importActual<typeof import('@robota-sdk/agent-framework')>(
@@ -126,13 +130,17 @@ function makeMockTransportRegistry(): {
126
130
  };
127
131
  }
128
132
 
133
+ /** The session's working directory: private to this run, never a fixed name under /tmp. */
134
+ const SESSION_CWD = mkdtempSync(join(tmpdir(), 'robota-tui-lifecycle-'));
135
+ afterAll(() => rmSync(SESSION_CWD, { recursive: true, force: true }));
136
+
129
137
  function makeChannel(opts?: {
130
138
  transportRegistry?: ITransportRegistryView;
131
139
  bindTransports?: (session: IInteractiveSession) => void | Promise<void>;
132
140
  onSessionEventDeliveryError?: (error: Error, event: TInteractiveEventName) => void;
133
141
  }): TuiInteractionChannel {
134
142
  return new TuiInteractionChannel({
135
- cwd: '/tmp/test',
143
+ cwd: SESSION_CWD,
136
144
  provider: {} as IAIProvider,
137
145
  ...opts,
138
146
  });
@@ -0,0 +1,317 @@
1
+ /**
2
+ * #3189: the full App on a wire channel. The session picker lists the host's sessions, and choosing
3
+ * one asks the host to switch instead of building a new channel.
4
+ */
5
+
6
+ import { mkdtempSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ import { render } from 'ink-testing-library';
11
+ import React from 'react';
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
+
14
+ import App from '../App.js';
15
+ import { WireTuiChannel } from '../wire-tui-channel.js';
16
+
17
+ import type { IAttachedSessionConnection } from '../attached-session-connection.js';
18
+ import type { ITuiCliAdapter } from '../tui-cli-adapter.js';
19
+ import type { TClientMessage, TServerMessage } from '@robota-sdk/agent-transport/client';
20
+
21
+ const TICK_MS = 30;
22
+ const FRAME_DEADLINE_MS = 3000;
23
+
24
+ function tick(ms = TICK_MS): Promise<void> {
25
+ return new Promise((resolve) => setTimeout(resolve, ms));
26
+ }
27
+
28
+ async function waitFor(predicate: () => boolean, describeFailure: () => string): Promise<void> {
29
+ const deadline = Date.now() + FRAME_DEADLINE_MS;
30
+ while (Date.now() < deadline) {
31
+ if (predicate()) return;
32
+ await tick(10);
33
+ }
34
+ throw new Error(`waitFor timeout\n${describeFailure()}`);
35
+ }
36
+
37
+ function scriptedConnection(): IAttachedSessionConnection & {
38
+ sent: TClientMessage[];
39
+ push: (message: TServerMessage) => void;
40
+ } {
41
+ const listeners = new Set<(message: TServerMessage) => void>();
42
+ const sent: TClientMessage[] = [];
43
+ return {
44
+ sent,
45
+ send: (message) => {
46
+ sent.push(message);
47
+ },
48
+ subscribe: (listener) => {
49
+ listeners.add(listener);
50
+ return () => listeners.delete(listener);
51
+ },
52
+ onClose: () => () => undefined,
53
+ push: (message) => {
54
+ for (const listener of [...listeners]) listener(message);
55
+ },
56
+ };
57
+ }
58
+
59
+ function cliAdapter(settingsPath: string): ITuiCliAdapter {
60
+ return {
61
+ getUserSettingsPath: () => settingsPath,
62
+ readSettings: () => ({}),
63
+ reloadPluginCommandSource: vi.fn(),
64
+ applyActiveModelChange: vi.fn().mockReturnValue({ applied: true }),
65
+ getGitBranch: vi.fn().mockReturnValue(undefined),
66
+ getProviderDisplayName: vi.fn((type: string) => type),
67
+ };
68
+ }
69
+
70
+ function lastListRequestId(sent: readonly TClientMessage[]): string {
71
+ const requests = sent.filter(
72
+ (message): message is Extract<TClientMessage, { type: 'list-sessions' }> =>
73
+ message.type === 'list-sessions',
74
+ );
75
+ return requests.at(-1)?.requestId ?? '';
76
+ }
77
+
78
+ describe('the full App attached to a host over the wire', () => {
79
+ let cwd: string;
80
+
81
+ beforeEach(() => {
82
+ cwd = mkdtempSync(join(tmpdir(), 'robota-attached-app-'));
83
+ });
84
+
85
+ afterEach(() => {
86
+ rmSync(cwd, { recursive: true, force: true });
87
+ });
88
+
89
+ it("lists the host's sessions in the picker and asks the host to switch", async () => {
90
+ const link = scriptedConnection();
91
+ const channel = new WireTuiChannel({ connection: link, driverId: 'attach:1' });
92
+ const createChannel = vi.fn(() => channel);
93
+ const view = render(
94
+ <App
95
+ cwd={cwd}
96
+ createChannel={createChannel}
97
+ requestSessionSwitch={(sessionId) => channel.requestSessionSwitch(sessionId)}
98
+ cliAdapter={cliAdapter(join(cwd, 'settings.json'))}
99
+ />,
100
+ );
101
+ try {
102
+ await waitFor(
103
+ () => link.sent.some((message) => message.type === 'list-sessions'),
104
+ () => JSON.stringify(link.sent),
105
+ );
106
+ // `/resume` ran on the host, which routes the picker back to this terminal only.
107
+ link.push({
108
+ type: 'ui_intent',
109
+ event: { intent: { type: 'show-session-picker' }, requesterDriverId: 'attach:1' },
110
+ });
111
+ link.push({
112
+ type: 'sessions',
113
+ requestId: lastListRequestId(link.sent),
114
+ listing: {
115
+ currentSessionId: 'host-current',
116
+ sessions: [
117
+ {
118
+ id: 'host-other-session',
119
+ name: 'Host session',
120
+ cwd,
121
+ updatedAt: '2026-09-26T10:00:00.000Z',
122
+ messageCount: 4,
123
+ preview: 'kept by the daemon',
124
+ },
125
+ ],
126
+ unreadableSessionIds: [],
127
+ },
128
+ });
129
+ await waitFor(
130
+ () => (view.lastFrame() ?? '').includes('Host session'),
131
+ () => view.lastFrame() ?? '<none>',
132
+ );
133
+ expect(view.lastFrame()).toContain('Select a session to resume');
134
+ await tick();
135
+
136
+ link.sent.length = 0;
137
+ view.stdin.write('\r');
138
+ await waitFor(
139
+ () => link.sent.some((message) => message.type === 'switch-session'),
140
+ () => JSON.stringify(link.sent),
141
+ );
142
+ expect(link.sent).toContainEqual({
143
+ type: 'switch-session',
144
+ sessionId: 'host-other-session',
145
+ requestId: expect.any(String),
146
+ });
147
+ // The App keeps its channel; the host's `session_switched` is what moves it.
148
+ expect(createChannel).toHaveBeenCalledTimes(1);
149
+ // A refusal is shown where the terminal shows what happened, and the switch is over.
150
+ const request = link.sent.find((message) => message.type === 'switch-session');
151
+ link.push({
152
+ type: 'session_change_failed',
153
+ code: 'limit',
154
+ message: 'Four sessions are live; none can be closed.',
155
+ ...(request?.type === 'switch-session' && request.requestId !== undefined
156
+ ? { requestId: request.requestId }
157
+ : {}),
158
+ });
159
+ await waitFor(
160
+ () => (view.lastFrame() ?? '').includes('Four sessions are live; none can be closed.'),
161
+ () => view.lastFrame() ?? '<none>',
162
+ );
163
+ } finally {
164
+ view.unmount();
165
+ await channel.stop();
166
+ }
167
+ });
168
+
169
+ async function resumeAgainst(
170
+ answer: (requestId: string) => TServerMessage,
171
+ notice: string,
172
+ ): Promise<string> {
173
+ const link = scriptedConnection();
174
+ const channel = new WireTuiChannel({ connection: link, driverId: 'attach:1' });
175
+ const view = render(
176
+ <App
177
+ cwd={cwd}
178
+ createChannel={() => channel}
179
+ requestSessionSwitch={(sessionId) => channel.requestSessionSwitch(sessionId)}
180
+ cliAdapter={cliAdapter(join(cwd, 'settings.json'))}
181
+ />,
182
+ );
183
+ try {
184
+ await waitFor(
185
+ () => (view.lastFrame() ?? '').includes('Type a message'),
186
+ () => view.lastFrame() ?? '<none>',
187
+ );
188
+ link.sent.length = 0;
189
+ link.push({
190
+ type: 'ui_intent',
191
+ event: { intent: { type: 'show-session-picker' }, requesterDriverId: 'attach:1' },
192
+ });
193
+ link.push(answer(lastListRequestId(link.sent)));
194
+ await waitFor(
195
+ () => (view.lastFrame() ?? '').includes(notice),
196
+ () => view.lastFrame() ?? '<none>',
197
+ );
198
+ await tick();
199
+ return view.lastFrame() ?? '';
200
+ } finally {
201
+ view.unmount();
202
+ await channel.stop();
203
+ }
204
+ }
205
+
206
+ it('gives the prompt back with the reason when the host cannot list its sessions', async () => {
207
+ const frame = await resumeAgainst(
208
+ (requestId) => ({
209
+ type: 'sessions_error',
210
+ requestId,
211
+ code: 'not_available',
212
+ message: 'Session listing is not available on this host.',
213
+ }),
214
+ 'Session listing is not available on this host.',
215
+ );
216
+ expect(frame).not.toContain('Select a session to resume');
217
+ expect(frame).not.toContain('Waiting for response');
218
+ expect(frame).toContain('Type a message');
219
+ });
220
+
221
+ it("prints the new session's transcript when the switch and its history arrive together", async () => {
222
+ const link = scriptedConnection();
223
+ const channel = new WireTuiChannel({ connection: link, driverId: 'attach:1' });
224
+ const view = render(
225
+ <App
226
+ cwd={cwd}
227
+ createChannel={() => channel}
228
+ requestSessionSwitch={(sessionId) => channel.requestSessionSwitch(sessionId)}
229
+ cliAdapter={cliAdapter(join(cwd, 'settings.json'))}
230
+ />,
231
+ );
232
+ const entry = (id: string, content: string) => ({
233
+ id,
234
+ timestamp: '2026-09-26T10:00:00.000Z',
235
+ category: 'chat',
236
+ type: 'user',
237
+ data: { id: `m-${id}`, role: 'user', content, state: 'complete' },
238
+ });
239
+ try {
240
+ await waitFor(
241
+ () => link.sent.some((message) => message.type === 'get-history'),
242
+ () => JSON.stringify(link.sent),
243
+ );
244
+ link.push({
245
+ type: 'history',
246
+ startIndex: 0,
247
+ total: 3,
248
+ entries: [entry('o1', 'old one'), entry('o2', 'old two'), entry('o3', 'old three')],
249
+ });
250
+ await waitFor(
251
+ () => (view.lastFrame() ?? '').includes('old three'),
252
+ () => view.lastFrame() ?? '<none>',
253
+ );
254
+ // One chunk from the host: the switch and the new session's history, handled in one pass.
255
+ link.push({ type: 'session_switched', event: { sessionId: 'session-2' } });
256
+ link.push({
257
+ type: 'history',
258
+ startIndex: 0,
259
+ total: 1,
260
+ entries: [entry('n1', 'new session prompt')],
261
+ });
262
+ await waitFor(
263
+ () => (view.lastFrame() ?? '').includes('new session prompt'),
264
+ () => view.lastFrame() ?? '<none>',
265
+ );
266
+ expect(view.lastFrame()).toContain('Switched to session session-2.');
267
+ } finally {
268
+ view.unmount();
269
+ await channel.stop();
270
+ }
271
+ });
272
+
273
+ it('detaches on Ctrl-] the way it does on Ctrl-C and /exit', async () => {
274
+ const link = scriptedConnection();
275
+ const channel = new WireTuiChannel({ connection: link, driverId: 'attach:1' });
276
+ const view = render(
277
+ <App
278
+ cwd={cwd}
279
+ createChannel={() => channel}
280
+ requestSessionSwitch={(sessionId) => channel.requestSessionSwitch(sessionId)}
281
+ cliAdapter={cliAdapter(join(cwd, 'settings.json'))}
282
+ />,
283
+ );
284
+ try {
285
+ await waitFor(
286
+ () => (view.lastFrame() ?? '').includes('Type a message'),
287
+ () => view.lastFrame() ?? '<none>',
288
+ );
289
+ view.stdin.write('\x1d');
290
+ await waitFor(
291
+ () => channel.getSnapshot().isShuttingDown,
292
+ () => view.lastFrame() ?? '<none>',
293
+ );
294
+ expect(JSON.stringify(channel.getSnapshot().history)).toContain(
295
+ 'Detached. The session keeps running.',
296
+ );
297
+ expect(link.sent.some((message) => message.type === 'submit')).toBe(false);
298
+ } finally {
299
+ view.unmount();
300
+ await channel.stop();
301
+ }
302
+ });
303
+
304
+ it('opens no empty picker when the host has no sessions to resume', async () => {
305
+ const frame = await resumeAgainst(
306
+ (requestId) => ({
307
+ type: 'sessions',
308
+ requestId,
309
+ listing: { currentSessionId: 'host-current', sessions: [], unreadableSessionIds: [] },
310
+ }),
311
+ 'No saved sessions to resume',
312
+ );
313
+ expect(frame).not.toContain('Select a session to resume');
314
+ expect(frame).not.toContain('Waiting for response');
315
+ expect(frame).toContain('Type a message');
316
+ });
317
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * #3231: the TUI builds a new channel on every session switch, and it may do so before the old
3
+ * session's turn has ended. A checkpoint store holds one turn in progress, so a store shared across
4
+ * channels would let the old turn close the new session's turn. Each channel gets its own.
5
+ */
6
+
7
+ import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ import { createScriptedProvider } from '@robota-sdk/agent-core/testing';
12
+ import { afterEach, describe, expect, it, vi } from 'vitest';
13
+
14
+ import { createInProcessChannelFactory } from '../render.js';
15
+ import { TerminalHandoffController } from '../terminal-handoff-controller.js';
16
+
17
+ import type { IChannelServices, IRenderOptions } from '../render.js';
18
+ import type { ITuiCliAdapter } from '../tui-cli-adapter.js';
19
+ import type { ITuiAppChannelPort } from '../tui-app-channel-port.js';
20
+ import type { EditCheckpointStore } from '@robota-sdk/agent-framework';
21
+
22
+ const sessionStores = vi.hoisted(() => [] as unknown[]);
23
+
24
+ vi.mock('../tui-session-options.js', async (importOriginal) => {
25
+ const original = await importOriginal<typeof import('../tui-session-options.js')>();
26
+ return {
27
+ buildTuiSessionOptions: (
28
+ ...args: Parameters<typeof original.buildTuiSessionOptions>
29
+ ): ReturnType<typeof original.buildTuiSessionOptions> => {
30
+ const built = original.buildTuiSessionOptions(...args);
31
+ sessionStores.push(built.editCheckpointStore);
32
+ return built;
33
+ },
34
+ };
35
+ });
36
+
37
+ function fakeCliAdapter(settingsPath: string): ITuiCliAdapter {
38
+ return {
39
+ getUserSettingsPath: () => settingsPath,
40
+ readSettings: () => ({}),
41
+ reloadPluginCommandSource: vi.fn(),
42
+ applyActiveModelChange: vi.fn().mockReturnValue({ applied: true }),
43
+ getGitBranch: vi.fn().mockReturnValue(undefined),
44
+ getProviderDisplayName: vi.fn((type: string) => type),
45
+ };
46
+ }
47
+
48
+ describe('#3231: the in-process channel factory', () => {
49
+ const channels: ITuiAppChannelPort[] = [];
50
+ let cwd: string | undefined;
51
+
52
+ afterEach(async () => {
53
+ for (const channel of channels.splice(0)) await channel.stop();
54
+ sessionStores.splice(0);
55
+ if (cwd !== undefined) rmSync(cwd, { recursive: true, force: true });
56
+ cwd = undefined;
57
+ });
58
+
59
+ it('gives each channel, including a switched-to one, its own checkpoint store', () => {
60
+ cwd = realpathSync(mkdtempSync(join(tmpdir(), 'robota-store-per-channel-')));
61
+ const createEditCheckpointStore = vi.fn(() => ({}) as EditCheckpointStore);
62
+ const options: IRenderOptions = {
63
+ cwd,
64
+ provider: createScriptedProvider([{ text: 'unused' }]).provider,
65
+ cliAdapter: fakeCliAdapter(join(cwd, 'settings.json')),
66
+ createEditCheckpointStore,
67
+ };
68
+ const services = {
69
+ terminalHandoff: new TerminalHandoffController(),
70
+ } as unknown as IChannelServices;
71
+ const createChannel = createInProcessChannelFactory(options, services);
72
+
73
+ channels.push(createChannel(), createChannel('switched-to-session'));
74
+
75
+ expect(createEditCheckpointStore).toHaveBeenCalledTimes(2);
76
+ expect(sessionStores).toHaveLength(2);
77
+ expect(sessionStores[0]).toBeDefined();
78
+ expect(sessionStores[1]).toBeDefined();
79
+ expect(sessionStores[0]).not.toBe(sessionStores[1]);
80
+ });
81
+ });
@@ -49,7 +49,7 @@ describe('command handoff PTY E2E', () => {
49
49
  tempDirs.push(dir);
50
50
  const outputPath = join(dir, 'result.json');
51
51
  const session = spawnPtyFixture(FIXTURE, {
52
- argv: ['shell', outputPath, 'IFS= read -r line; printf "SHELL_GOT:[%s]\\n" "$line"'],
52
+ argv: ['shell', outputPath],
53
53
  cwd: PACKAGE_DIR,
54
54
  // HARNESS-025: isolated HOME — the subshell must not read the developer's `~` (a real
55
55
  // shell rc there would otherwise change what the handoff child does).
@@ -81,7 +81,7 @@ describe('command handoff PTY E2E', () => {
81
81
  tempDirs.push(dir);
82
82
  const outputPath = join(dir, 'result.json');
83
83
  const session = spawnPtyFixture(FIXTURE, {
84
- argv: ['editor', outputPath, ''],
84
+ argv: ['editor', outputPath],
85
85
  cwd: PACKAGE_DIR,
86
86
  // HARNESS-025: isolated HOME — only the EDITOR override comes from the test, so a real
87
87
  // `~/.selected_editor` (or similar) on the host cannot influence the assertion.
@@ -34,10 +34,12 @@ it('updates the host-selected settings file when changing the active model', ()
34
34
  });
35
35
 
36
36
  it('keeps the host resume command formatter when one is supplied', () => {
37
+ const root = mkdtempSync(join(tmpdir(), 'tui-host-settings-'));
38
+ roots.push(root);
37
39
  const adapter = createDefaultTuiCliAdapter({
38
40
  providerDefinitions: [],
39
41
  reloadPluginCommandSource: () => undefined,
40
- userSettingsPath: '/tmp/product-settings.json',
42
+ userSettingsPath: join(root, 'product-settings.json'),
41
43
  settingsSources: [],
42
44
  formatResumeCommand: (sessionId) => `product --resume ${sessionId}`,
43
45
  });
@@ -9,8 +9,10 @@
9
9
  * terminal can: the controller releasing raw mode so the child (subshell / `$EDITOR`) owns the TTY,
10
10
  * and `runWithTerminal` returning so the TUI resumes.
11
11
  *
12
- * argv: [mode ('shell'|'editor'), outputPath, commandArg?]. Markers: `@@READY canHandoff=…@@`,
13
- * `@@CMD_DONE@@`, `@@INPUT_AFTER_HANDOFF …@@`. A JSON result
12
+ * argv: [mode ('shell'|'editor'), outputPath]. The `/shell` command text is fixed here rather than
13
+ * taken from argv: argv only selects the mode, so no process input becomes shell text.
14
+ *
15
+ * Markers: `@@READY canHandoff=…@@`, `@@CMD_DONE@@`, `@@INPUT_AFTER_HANDOFF …@@`. A JSON result
14
16
  * `{ success, exitCode?, message }` is written to outputPath.
15
17
  */
16
18
  import { writeFileSync } from 'node:fs';
@@ -30,7 +32,8 @@ import type {
30
32
 
31
33
  const MODE = process.argv[2];
32
34
  const OUTPUT_PATH = process.argv[3];
33
- const COMMAND_ARG = process.argv[4] ?? '';
35
+ /** Reads one line from the handed-off terminal and echoes it back, proving the child owned the TTY. */
36
+ const SHELL_COMMAND = 'IFS= read -r line; printf "SHELL_GOT:[%s]\\n" "$line"';
34
37
 
35
38
  const controller = new TerminalHandoffController();
36
39
  let resolveInputAfterHandoff: ((input: string) => void) | undefined;
@@ -78,8 +81,8 @@ async function main(): Promise<void> {
78
81
  const ctx = createTestCommandHost({ overrides: context });
79
82
  const result =
80
83
  MODE === 'editor'
81
- ? await executeEditorCommand(ctx, COMMAND_ARG)
82
- : await executeShellCommand(ctx, COMMAND_ARG);
84
+ ? await executeEditorCommand(ctx, '')
85
+ : await executeShellCommand(ctx, SHELL_COMMAND);
83
86
 
84
87
  marker('CMD_DONE');
85
88
  const inputAfterHandoff = await new Promise<string>((resolve) => {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * #3189 — the run's external-event grant history reaches every session the TUI builds. It is created
3
+ * once per run so a session switch replays no spent token and resets no rate; dropped on the way
4
+ * (the render projection copies field by field, and a spread skips the excess-property check), each
5
+ * session would open its grants with an empty history.
6
+ */
7
+
8
+ import { createExternalEventGrantHistory } from '@robota-sdk/agent-framework';
9
+ import { describe, expect, it } from 'vitest';
10
+
11
+ import { toChannelOptions } from '../render.js';
12
+ import { buildTuiSessionOptions } from '../tui-session-options.js';
13
+
14
+ import type { IRenderOptions } from '../render.js';
15
+
16
+ describe('#3189: the grant history survives the TUI projection', () => {
17
+ it('carries the same history instance into every session it builds', () => {
18
+ const history = createExternalEventGrantHistory();
19
+ const options = {
20
+ cwd: '/work',
21
+ provider: {} as never,
22
+ externalEventGrantHistory: history,
23
+ } as unknown as IRenderOptions;
24
+
25
+ const first = buildTuiSessionOptions(toChannelOptions(options, undefined));
26
+ const switchedTo = buildTuiSessionOptions(toChannelOptions(options, 'session-b'));
27
+
28
+ expect(first.externalEventGrantHistory).toBe(history);
29
+ expect(switchedTo.externalEventGrantHistory).toBe(history);
30
+ });
31
+ });