@robota-sdk/agent-ui-terminal 3.0.0-beta.82 → 3.0.0-beta.84

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 (75) hide show
  1. package/CHANGELOG.md +893 -0
  2. package/README.md +34 -11
  3. package/dist/node/index.d.ts +205 -55
  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 +19 -23
  8. package/src/App.tsx +14 -3
  9. package/src/AppPresentation.tsx +1 -1
  10. package/src/PermissionPrompt.tsx +73 -23
  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 +568 -161
  15. package/src/TuiInteractionChannel.ts +17 -54
  16. package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
  17. package/src/__tests__/SupervisedSessionView.test.tsx +561 -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__/helpers/terminal-profiles.ts +4 -4
  27. package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
  28. package/src/__tests__/palette-consistency.test.ts +0 -1
  29. package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
  30. package/src/__tests__/permission-prompt-requester.test.tsx +34 -0
  31. package/src/__tests__/pty/behavior-2437-git.ptytest.ts +11 -3
  32. package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
  33. package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
  34. package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
  35. package/src/__tests__/pty/trust-prompt.ptytest.ts +134 -0
  36. package/src/__tests__/render-channel-options.test.ts +6 -6
  37. package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
  38. package/src/__tests__/screen-reader-menus.test.tsx +1 -1
  39. package/src/__tests__/session-picker.test.tsx +93 -0
  40. package/src/__tests__/status-bar.test.tsx +8 -0
  41. package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
  42. package/src/__tests__/tui-interaction-queues.test.ts +10 -0
  43. package/src/__tests__/tui-state-manager.test.ts +31 -0
  44. package/src/__tests__/wire-tui-channel.test.ts +1457 -0
  45. package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
  46. package/src/app-view-model.ts +6 -2
  47. package/src/attached-session-connection.ts +14 -0
  48. package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
  49. package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
  50. package/src/hooks/useAppController.ts +6 -2
  51. package/src/hooks/useAppInputBindings.ts +20 -2
  52. package/src/hooks/useAppInteractionState.ts +3 -0
  53. package/src/hooks/useAppOverlays.ts +20 -4
  54. package/src/hooks/useNumberedSelection.ts +19 -2
  55. package/src/hooks/useTuiChannel.ts +13 -1
  56. package/src/index.ts +19 -3
  57. package/src/render.tsx +218 -27
  58. package/src/short-session-id.ts +9 -0
  59. package/src/slash-command-input.ts +11 -0
  60. package/src/tui-app-channel-port.ts +29 -1
  61. package/src/tui-channel-options.ts +11 -2
  62. package/src/tui-interaction-queues.ts +9 -1
  63. package/src/tui-session-event-projector.ts +12 -3
  64. package/src/tui-session-events.ts +6 -0
  65. package/src/tui-session-options.ts +9 -0
  66. package/src/tui-state-manager.ts +25 -5
  67. package/src/types.ts +3 -1
  68. package/src/waiting-loop-stop-notice.ts +23 -0
  69. package/src/wire-history-sync.ts +159 -0
  70. package/src/wire-tui-channel.ts +963 -0
  71. package/src/wire-tui-client-commands.ts +142 -0
  72. package/src/wire-tui-projection.ts +136 -0
  73. package/dist/node/index.cjs +0 -32
  74. package/dist/node/index.d.cts +0 -1066
  75. package/dist/node/index.d.cts.map +0 -1
@@ -0,0 +1,41 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import MultiSelectList from '../MultiSelectList.js';
6
+ import { ScreenReaderProvider } from '../screen-reader-context.js';
7
+
8
+ const OPTIONS = [
9
+ { value: 'a', label: 'Alpha' },
10
+ { value: 'b', label: 'Beta' },
11
+ ];
12
+
13
+ describe('numbered selection with chunked input', () => {
14
+ it('acts on the first keystroke of a chunk and leaves the checklist usable', async () => {
15
+ const onConfirm = vi.fn();
16
+ const view = render(
17
+ <ScreenReaderProvider enabled>
18
+ <MultiSelectList
19
+ title="Pick"
20
+ options={OPTIONS}
21
+ minSelect={1}
22
+ maxSelect={2}
23
+ onConfirm={onConfirm}
24
+ onCancel={() => undefined}
25
+ />
26
+ </ScreenReaderProvider>,
27
+ );
28
+ try {
29
+ await vi.waitFor(() => expect(view.lastFrame()).toContain('Alpha'));
30
+ // Toggle row 1 and commit, delivered as one chunk: the toggle lands, the rest is dropped.
31
+ view.stdin.write('1\r\r');
32
+ await new Promise((resolve) => setTimeout(resolve, 50));
33
+ expect(onConfirm).not.toHaveBeenCalled();
34
+ // A later Enter commits with the toggled row, so the checklist is not stuck.
35
+ view.stdin.write('\r');
36
+ await vi.waitFor(() => expect(onConfirm).toHaveBeenCalledExactlyOnceWith(['a']));
37
+ } finally {
38
+ view.unmount();
39
+ }
40
+ });
41
+ });
@@ -215,7 +215,6 @@ describe('SCREEN-006 palette consistency floor', () => {
215
215
  (line) =>
216
216
  RAW_RENDERER_SPECIFIER.test(line) && !/^\s*(?:import|export)\s+type\b/u.test(line),
217
217
  );
218
- const RAW_RENDERER_IMPORT = /(?<!import type )(?:from|import\()\s*'[^']*render-markdown\.js'/u;
219
218
 
220
219
  it('SCREEN-2002: AppView feeds the theme provider all three resolved inputs', () => {
221
220
  const element = themeProviderElement(readFileSync(join(SRC_ROOT, 'AppView.tsx'), 'utf8'));
@@ -0,0 +1,153 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import PermissionPrompt, { PERMISSION_PROMPT_ARM_DELAY_MS } from '../PermissionPrompt.js';
6
+ import { ScreenReaderProvider } from '../screen-reader-context.js';
7
+
8
+ import type { IPendingPermissionRequest } from '../types.js';
9
+
10
+ /**
11
+ * #3189: the permission prompt replaces the composer while the user may still be typing, and its
12
+ * keys are ordinary letters and digits. A key typed as it appears must not answer it; keys answer
13
+ * only once it is armed.
14
+ */
15
+
16
+ function request(resolve: IPendingPermissionRequest['resolve']): IPendingPermissionRequest {
17
+ return { toolName: 'Bash', toolArgs: { command: 'ls' }, resolve };
18
+ }
19
+
20
+ /**
21
+ * Lets Ink register its input handlers, deliver the written keys and commit a render. A handler
22
+ * switched on by a render (the prompt arming) subscribes one tick later, hence two.
23
+ */
24
+ async function settle(): Promise<void> {
25
+ await vi.advanceTimersByTimeAsync(0);
26
+ await vi.advanceTimersByTimeAsync(0);
27
+ }
28
+
29
+ async function waitOutArming(): Promise<void> {
30
+ await vi.advanceTimersByTimeAsync(PERMISSION_PROMPT_ARM_DELAY_MS);
31
+ await settle();
32
+ }
33
+
34
+ describe('the permission prompt arms before its keys answer it', () => {
35
+ beforeEach(() => {
36
+ vi.useFakeTimers();
37
+ });
38
+ afterEach(() => {
39
+ vi.useRealTimers();
40
+ });
41
+
42
+ it('a key pressed as the prompt appears does not answer it; one pressed after the delay does', async () => {
43
+ const resolve = vi.fn();
44
+ const { stdin, lastFrame, unmount } = render(<PermissionPrompt request={request(resolve)} />);
45
+ await settle();
46
+
47
+ stdin.write('y');
48
+ await settle();
49
+ stdin.write('1');
50
+ await settle();
51
+ stdin.write('\r');
52
+ await settle();
53
+ expect(resolve).not.toHaveBeenCalled();
54
+ expect(lastFrame()).toContain('keys answer in a moment');
55
+ expect(lastFrame()).not.toContain('> Allow [y]');
56
+
57
+ await waitOutArming();
58
+ expect(lastFrame()).not.toContain('keys answer in a moment');
59
+ expect(lastFrame()).toContain('> Allow [y]');
60
+ stdin.write('y');
61
+ await settle();
62
+ unmount();
63
+
64
+ expect(resolve).toHaveBeenCalledWith(true);
65
+ });
66
+
67
+ it('a new request arms again', async () => {
68
+ const first = vi.fn();
69
+ const second = vi.fn();
70
+ const { stdin, rerender, unmount } = render(<PermissionPrompt request={request(first)} />);
71
+ await waitOutArming();
72
+ stdin.write('n');
73
+ await settle();
74
+ expect(first).toHaveBeenCalledWith(false);
75
+
76
+ rerender(<PermissionPrompt request={request(second)} />);
77
+ await settle();
78
+ stdin.write('a');
79
+ await settle();
80
+ expect(second).not.toHaveBeenCalled();
81
+
82
+ await waitOutArming();
83
+ stdin.write('n');
84
+ await settle();
85
+ unmount();
86
+
87
+ expect(second).toHaveBeenCalledTimes(1);
88
+ expect(second).toHaveBeenCalledWith(false);
89
+ });
90
+
91
+ it('in screen-reader mode a number typed while arming is neither taken nor kept for Enter', async () => {
92
+ const resolve = vi.fn();
93
+ const { stdin, unmount } = render(
94
+ <ScreenReaderProvider enabled={true}>
95
+ <PermissionPrompt request={request(resolve)} />
96
+ </ScreenReaderProvider>,
97
+ );
98
+ await settle();
99
+
100
+ stdin.write('1');
101
+ await settle();
102
+ stdin.write('\r');
103
+ await settle();
104
+ expect(resolve).not.toHaveBeenCalled();
105
+
106
+ await waitOutArming();
107
+ stdin.write('\r');
108
+ await settle();
109
+ expect(resolve).not.toHaveBeenCalled();
110
+
111
+ stdin.write('4');
112
+ await settle();
113
+ stdin.write('\r');
114
+ await settle();
115
+ unmount();
116
+
117
+ // Option 4 is Deny.
118
+ expect(resolve).toHaveBeenCalledWith(false);
119
+ });
120
+
121
+ it('in screen-reader mode a number typed for one request is not committed on the next', async () => {
122
+ const first = vi.fn();
123
+ const second = vi.fn();
124
+ const { stdin, rerender, unmount } = render(
125
+ <ScreenReaderProvider enabled={true}>
126
+ <PermissionPrompt request={request(first)} />
127
+ </ScreenReaderProvider>,
128
+ );
129
+ await waitOutArming();
130
+ stdin.write('1');
131
+ await settle();
132
+
133
+ rerender(
134
+ <ScreenReaderProvider enabled={true}>
135
+ <PermissionPrompt request={request(second)} />
136
+ </ScreenReaderProvider>,
137
+ );
138
+ await waitOutArming();
139
+ stdin.write('\r');
140
+ await settle();
141
+ expect(first).not.toHaveBeenCalled();
142
+ expect(second).not.toHaveBeenCalled();
143
+
144
+ stdin.write('4');
145
+ await settle();
146
+ stdin.write('\r');
147
+ await settle();
148
+ unmount();
149
+
150
+ // Option 4 is Deny.
151
+ expect(second).toHaveBeenCalledWith(false);
152
+ });
153
+ });
@@ -0,0 +1,34 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import PermissionPrompt from '../PermissionPrompt.js';
6
+
7
+ describe('issue #3288 §1: a background agent names itself on its own permission request', () => {
8
+ it('says which background agent is asking, instead of an unattributed prompt', () => {
9
+ const frame = render(
10
+ <PermissionPrompt
11
+ request={{
12
+ toolName: 'Glob',
13
+ toolArgs: { pattern: '**/*' },
14
+ requester: { kind: 'background-agent', label: 'general-purpose', taskId: 'agent_1' },
15
+ resolve: () => {},
16
+ }}
17
+ />,
18
+ ).lastFrame();
19
+
20
+ expect(frame).toContain('Background agent');
21
+ expect(frame).toContain('general-purpose');
22
+ expect(frame).toContain('Glob');
23
+ });
24
+
25
+ it('reads as an ordinary ask when no requester is present', () => {
26
+ const frame = render(
27
+ <PermissionPrompt
28
+ request={{ toolName: 'Bash', toolArgs: { command: 'ls' }, resolve: () => {} }}
29
+ />,
30
+ ).lastFrame();
31
+
32
+ expect(frame).not.toContain('Background agent');
33
+ });
34
+ });
@@ -224,6 +224,14 @@ async function expectCommitOnYes(session: IPtySession): Promise<void> {
224
224
  await session.waitForSince(since, /Committed: \[main [0-9a-f]{7}\] feat: add greeting/, WAIT_MS);
225
225
  }
226
226
 
227
+ /** The fixture repository is not trusted, so the TUI asks first (issue #3268); these scenarios run Restricted. */
228
+ async function startRestricted(session: IPtySession): Promise<void> {
229
+ await session.waitFor(/Trust this folder\? \[y\/N\]/, WAIT_MS);
230
+ await session.sendKeys('n');
231
+ await session.pressEnter();
232
+ await session.waitFor(/Type a message or \/help/, WAIT_MS);
233
+ }
234
+
227
235
  async function exitTui(session: IPtySession): Promise<number> {
228
236
  await run(session, '/exit');
229
237
  await session.waitFor(/Exit the session\?/, WAIT_MS);
@@ -251,7 +259,7 @@ describe('/git through the real binary (BEHAVIOR-2437 TC-07)', () => {
251
259
  async () => {
252
260
  fixture = makeFixture(true);
253
261
  session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
254
- await session.waitFor(/Type a message or \/help/, WAIT_MS);
262
+ await startRestricted(session);
255
263
  await session.waitFor(/Idle/, WAIT_MS);
256
264
 
257
265
  await expectReadSteps(session);
@@ -272,7 +280,7 @@ describe('/git through the real binary (BEHAVIOR-2437 TC-07)', () => {
272
280
  async () => {
273
281
  fixture = makeFixture(true);
274
282
  session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
275
- await session.waitFor(/Type a message or \/help/, WAIT_MS);
283
+ await startRestricted(session);
276
284
  await session.waitFor(/Idle/, WAIT_MS);
277
285
 
278
286
  await expectSubjectRefusal(session, fixture);
@@ -296,7 +304,7 @@ describe('/git through the real binary (BEHAVIOR-2437 TC-07)', () => {
296
304
  async () => {
297
305
  fixture = makeFixture(false);
298
306
  session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
299
- await session.waitFor(/Type a message or \/help/, WAIT_MS);
307
+ await startRestricted(session);
300
308
  await session.waitFor(/Idle/, WAIT_MS);
301
309
 
302
310
  const since = await run(session, '/git commit feat: nothing to commit');
@@ -127,7 +127,8 @@ describe('SCREEN-005 prompt footers through the real binary', () => {
127
127
  expect(afterEsc).not.toContain('AFTER_PERMISSION_FLOW');
128
128
  expect(afterEsc).toContain('Allow [y]'); // prompt still displayed, still unresolved
129
129
 
130
- // Resolve explicitly (allow) → the tool runs and the turn continues.
130
+ // Resolve explicitly (allow) once the prompt is armed → the tool runs and the turn continues.
131
+ await session.waitFor(/> Allow \[y\]/, 10_000);
131
132
  await session.sendKeys('y');
132
133
  await session.waitFor(/AFTER_PERMISSION_FLOW/, 20_000);
133
134
  }, 60_000);
@@ -108,6 +108,8 @@ describe('SCREEN-006 color/motion through the real binary', () => {
108
108
 
109
109
  // The replayed Shell call needs approval in default permission mode → allow it.
110
110
  await session.waitFor(/Permission Required/, 20_000);
111
+ // Its keys answer once it is armed, which the selection cursor shows.
112
+ await session.waitFor(/> Allow \[y\]/, 10_000);
111
113
  await session.sendKeys('y');
112
114
 
113
115
  // The turn continues: the second replayed response carries the markdown diff block,
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `robota session attach` end to end on the BUILT binary, in a real terminal: start a supervised
3
+ * session, attach and confirm on the controlling terminal, submit, detach with `/exit`, see it still
4
+ * running, attach again read-only and find the earlier prompt, detach with Ctrl-C, peek from
5
+ * `session view` and land back in the view, then stop it.
6
+ *
7
+ * The provider points at a closed local port, so the submitted turn fails without any network call
8
+ * leaving this machine. `XDG_RUNTIME_DIR` is a short directory so the control socket path fits.
9
+ *
10
+ * Runs under `test:pty` against the built CLI (`pnpm --filter @robota-sdk/agent-cli build` first).
11
+ */
12
+
13
+ import { execFileSync, spawnSync } from 'node:child_process';
14
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ import { afterEach, describe, expect, it } from 'vitest';
20
+
21
+ import { createIsolatedHome, createPtyEnv } from './isolated-home.js';
22
+ import { spawnPty } from './spawn-pty.js';
23
+
24
+ import type { IPtyRunSession } from './spawn-pty.js';
25
+
26
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..');
27
+ const ROBOTA_BIN = join(REPO_ROOT, 'packages/agent-cli/bin/robota.cjs');
28
+ const WAIT_MS = 20_000;
29
+
30
+ const cleanups: (() => void)[] = [];
31
+ afterEach(() => {
32
+ for (const cleanup of cleanups.splice(0).reverse()) cleanup();
33
+ });
34
+
35
+ function robota(args: readonly string[], cwd: string, env: NodeJS.ProcessEnv): { status: number | null; stdout: string; stderr: string } {
36
+ const result = spawnSync(process.execPath, [ROBOTA_BIN, ...args], { cwd, env, encoding: 'utf8', timeout: WAIT_MS });
37
+ return { status: result.status, stdout: result.stdout, stderr: result.stderr };
38
+ }
39
+
40
+ function attach(id: string, cwd: string, env: NodeJS.ProcessEnv, observe = false): IPtyRunSession {
41
+ const session = spawnPty({
42
+ command: process.execPath,
43
+ args: [ROBOTA_BIN, 'session', 'attach', id, ...(observe ? ['--observe'] : [])],
44
+ cwd,
45
+ env,
46
+ });
47
+ cleanups.push(() => session.dispose());
48
+ return session;
49
+ }
50
+
51
+ describe('robota session attach on the built binary', () => {
52
+ it('attaches, submits, detaches without stopping the session, and re-attaches', async () => {
53
+ const project = realpathSync(mkdtempSync(join(tmpdir(), 'robota-attach-pty-')));
54
+ // `/tmp` rather than the platform temp directory: the control socket path must stay short.
55
+ const runtime = mkdtempSync('/tmp/rsa-');
56
+ cleanups.push(() => rmSync(project, { recursive: true, force: true }));
57
+ cleanups.push(() => rmSync(runtime, { recursive: true, force: true }));
58
+ const home = createIsolatedHome();
59
+ mkdirSync(join(home, '.robota'), { recursive: true });
60
+ writeFileSync(join(home, '.robota', 'onboarded'), new Date().toISOString(), 'utf8');
61
+ writeFileSync(
62
+ join(home, '.robota', 'settings.json'),
63
+ JSON.stringify({
64
+ currentProvider: 'anthropic',
65
+ providers: {
66
+ anthropic: {
67
+ type: 'anthropic',
68
+ model: 'claude-test-model',
69
+ apiKey: 'pty-dummy-key',
70
+ baseURL: 'http://127.0.0.1:9',
71
+ },
72
+ },
73
+ }),
74
+ 'utf8',
75
+ );
76
+ const env = createPtyEnv({ HOME: home, XDG_RUNTIME_DIR: runtime });
77
+
78
+ // Workspace trust is recorded for a repository, as a user would grant it before a background start.
79
+ const git = (...args: string[]): void => {
80
+ execFileSync('git', args, { cwd: project, env, stdio: ['ignore', 'ignore', 'pipe'] });
81
+ };
82
+ git('init', '-q', '-b', 'main');
83
+ git('config', 'user.name', 'Robota Attach');
84
+ git('config', 'user.email', 'attach@example.invalid');
85
+ writeFileSync(join(project, 'README.md'), 'fixture\n');
86
+ git('add', '--', 'README.md');
87
+ git('commit', '-q', '-m', 'chore: fixture');
88
+ const trusted = robota(['trust', '--yes'], project, env);
89
+ expect(trusted.status, trusted.stderr).toBe(0);
90
+ const started = robota(['session', 'start', '--background', '--name', 'pty-attach'], project, env);
91
+ expect(started.status, started.stderr).toBe(0);
92
+ const id = /Supervised session: ([0-9a-f-]{36})/u.exec(started.stdout)?.[1];
93
+ expect(id).toBeDefined();
94
+ cleanups.push(() => { robota(['session', 'stop', id!], project, env); });
95
+
96
+ const drive = attach(id!, project, env);
97
+ await drive.waitFor('Attach? Type yes', WAIT_MS);
98
+ expect(drive.snapshot()).toContain('drive (send prompts, answer its questions)');
99
+ await drive.sendKeys('yes');
100
+ await drive.pressEnter();
101
+ await drive.waitFor('Attached to pty-attach — drive', WAIT_MS);
102
+ await drive.sendKeys('hello from the pty');
103
+ await drive.pressEnter();
104
+ await drive.waitFor('Error:', WAIT_MS);
105
+ await drive.sendKeys('/exit');
106
+ await drive.pressEnter();
107
+ await drive.waitFor('keeps running', WAIT_MS);
108
+ expect(await drive.expectExit(WAIT_MS)).toBe(0);
109
+
110
+ const listed = robota(['session', 'list', '--format', 'json'], project, env);
111
+ const supervised = (JSON.parse(listed.stdout) as {
112
+ supervised: { sessions: { id: string; liveness: string; control: string }[] };
113
+ }).supervised.sessions;
114
+ expect(supervised).toContainEqual(expect.objectContaining({ id, liveness: 'alive', control: 'available' }));
115
+
116
+ const observe = attach(id!, project, env, true);
117
+ await observe.waitFor('Attach? Type yes', WAIT_MS);
118
+ expect(observe.snapshot()).toContain('observe (read only)');
119
+ await observe.sendKeys('yes');
120
+ await observe.pressEnter();
121
+ await observe.waitFor('Observing pty-attach — read only', WAIT_MS);
122
+ await observe.waitFor('hello from the pty', WAIT_MS);
123
+ observe.write('\x03');
124
+ await observe.waitFor('keeps running', WAIT_MS);
125
+ expect(await observe.expectExit(WAIT_MS)).toBe(0);
126
+
127
+ // From the session view: peek at the row, confirm there, detach, and land back in the view.
128
+ const view = spawnPty({ command: process.execPath, args: [ROBOTA_BIN, 'session', 'view'], cwd: project, env });
129
+ cleanups.push(() => view.dispose());
130
+ await view.waitFor('a Attach p Peek', WAIT_MS);
131
+ view.write('p');
132
+ await view.waitFor('to observe (read only)?', WAIT_MS);
133
+ view.write('y');
134
+ await view.waitFor('Observing pty-attach — read only', WAIT_MS);
135
+ await view.waitFor('hello from the pty', WAIT_MS);
136
+ const beforeDetach = view.outputOffset();
137
+ view.write('\x1d');
138
+ await view.waitForSince(beforeDetach, 'Background sessions across projects', WAIT_MS);
139
+ view.write('q');
140
+ expect(await view.expectExit(WAIT_MS)).toBe(0);
141
+
142
+ expect(robota(['session', 'stop', id!], project, env).status).toBe(0);
143
+ }, 120_000);
144
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Issue #3268 — an interactive start in an untrusted repository asks whether to trust it, on the
3
+ * BUILT binary in a real PTY. A yes records the grant and starts Trusted, so the next start does not
4
+ * ask; a no starts Restricted and records nothing, so the next start asks again.
5
+ *
6
+ * Runs under `test:pty` against the built CLI (`pnpm --filter @robota-sdk/agent-cli build` first).
7
+ */
8
+
9
+ import { execFileSync } from 'node:child_process';
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ realpathSync,
16
+ rmSync,
17
+ writeFileSync,
18
+ } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ import { afterEach, describe, expect, it } from 'vitest';
23
+
24
+ import { spawnTui } from './pty-driver.js';
25
+
26
+ import type { IPtySession } from './pty-driver.js';
27
+
28
+ const WAIT_MS = 20_000;
29
+ const CASE_TIMEOUT_MS = 120_000;
30
+ const QUESTION = /Trust this folder\? \[y\/N\]/;
31
+ const READY = /Type a message or \/help/;
32
+
33
+ interface IFixture {
34
+ readonly root: string;
35
+ readonly home: string;
36
+ readonly repo: string;
37
+ }
38
+
39
+ function makeFixture(): IFixture {
40
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'robota-trust-prompt-')));
41
+ const home = join(root, 'home');
42
+ const repo = join(root, 'repo');
43
+ mkdirSync(join(home, '.robota'), { recursive: true });
44
+ // Skip the first-run welcome and provider setup: neither is what this scenario observes.
45
+ writeFileSync(join(home, '.robota', 'onboarded'), new Date().toISOString(), 'utf8');
46
+ writeFileSync(
47
+ join(home, '.robota', 'settings.json'),
48
+ JSON.stringify({
49
+ currentProvider: 'anthropic',
50
+ providers: {
51
+ anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'pty-dummy-key' },
52
+ },
53
+ }),
54
+ 'utf8',
55
+ );
56
+ execFileSync('git', ['init', '-q', repo]);
57
+ return { root, home, repo };
58
+ }
59
+
60
+ function trustedGrants(fixture: IFixture): number {
61
+ const store = join(fixture.home, '.robota', 'workspace-trust.json');
62
+ if (!existsSync(store)) return 0;
63
+ const parsed = JSON.parse(readFileSync(store, 'utf8')) as { grants: { state: string }[] };
64
+ return parsed.grants.filter((grant) => grant.state === 'trusted').length;
65
+ }
66
+
67
+ async function answerAndExit(session: IPtySession, answer: 'y' | 'n'): Promise<void> {
68
+ await session.waitFor(QUESTION, WAIT_MS);
69
+ await session.sendKeys(answer);
70
+ await session.pressEnter();
71
+ await session.waitFor(READY, WAIT_MS);
72
+ await session.sendKeys('/exit');
73
+ await session.pressEnter();
74
+ await session.waitFor(/Exit the session\?/, WAIT_MS);
75
+ await session.pressEnter();
76
+ await session.expectExit(WAIT_MS);
77
+ }
78
+
79
+ describe('the trust question at an interactive start (issue #3268)', () => {
80
+ let fixture: IFixture | undefined;
81
+ let session: IPtySession | undefined;
82
+
83
+ afterEach(async () => {
84
+ await session?.disposeAsync();
85
+ session = undefined;
86
+ if (fixture) rmSync(fixture.root, { recursive: true, force: true });
87
+ fixture = undefined;
88
+ });
89
+
90
+ it(
91
+ 'a yes records the grant, and the next start is Trusted without asking',
92
+ async () => {
93
+ fixture = makeFixture();
94
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
95
+ await answerAndExit(session, 'y');
96
+ expect(trustedGrants(fixture)).toBe(1);
97
+
98
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
99
+ await session.waitFor(READY, WAIT_MS);
100
+ expect(session.snapshot()).not.toMatch(QUESTION);
101
+ },
102
+ CASE_TIMEOUT_MS,
103
+ );
104
+
105
+ it.each([
106
+ ['--safe-mode', ['--safe-mode']],
107
+ // A resumed or continued session keeps the store it was saved in, so it is not asked.
108
+ ['--continue', ['--continue']],
109
+ ])(
110
+ 'does not ask with %s',
111
+ async (_case, args) => {
112
+ fixture = makeFixture();
113
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home, args: [...args] });
114
+ await session.waitFor(READY, WAIT_MS);
115
+ expect(session.snapshot()).not.toMatch(QUESTION);
116
+ expect(trustedGrants(fixture)).toBe(0);
117
+ },
118
+ CASE_TIMEOUT_MS,
119
+ );
120
+
121
+ it(
122
+ 'a no starts Restricted, records nothing, and the next start asks again',
123
+ async () => {
124
+ fixture = makeFixture();
125
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
126
+ await answerAndExit(session, 'n');
127
+ expect(trustedGrants(fixture)).toBe(0);
128
+
129
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
130
+ await session.waitFor(QUESTION, WAIT_MS);
131
+ },
132
+ CASE_TIMEOUT_MS,
133
+ );
134
+ });
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
2
2
  import type { IAIProvider } from '@robota-sdk/agent-core';
3
3
  import { createNodeHostSettingsSource } from '@robota-sdk/agent-framework';
4
4
  import { createNodeHostContributionSource } from '@robota-sdk/agent-framework';
5
- import type { EditCheckpointStore, IPromptHistoryOptions } from '@robota-sdk/agent-framework';
5
+ import type { IPromptHistoryOptions } from '@robota-sdk/agent-framework';
6
6
  import type { ITuiCliAdapter } from '../tui-cli-adapter.js';
7
7
  import { toChannelOptions } from '../render.js';
8
8
  import { buildTuiSessionOptions } from '../tui-session-options.js';
@@ -161,16 +161,16 @@ describe('toChannelOptions', () => {
161
161
  expect(channelOptions.model).toBeUndefined();
162
162
  });
163
163
 
164
- it('threads the explicit checkpoint capability without deriving it from cwd', () => {
165
- const editCheckpointStore = {} as EditCheckpointStore;
164
+ it('carries the host external-event verifier factory to the session options', () => {
165
+ const factory = () => ({ verify: async () => ({ admitted: true as const }) });
166
166
  const channelOptions = toChannelOptions({
167
167
  cwd: '/tmp/project',
168
168
  provider: {} as IAIProvider,
169
169
  cliAdapter: {} as ITuiCliAdapter,
170
- editCheckpointStore,
170
+ externalEventVerifierFactory: factory,
171
171
  });
172
-
173
- expect(channelOptions.editCheckpointStore).toBe(editCheckpointStore);
172
+ expect(channelOptions.externalEventVerifierFactory).toBe(factory);
173
+ expect(buildTuiSessionOptions(channelOptions).externalEventVerifierFactory).toBe(factory);
174
174
  });
175
175
 
176
176
  it('SCREEN-1993: projects the prompt-history writer to the channel and on to the session options', () => {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Issue #3242: the TUI session consults the same sandbox the shell tools run under, so a confined
3
+ * command the sandbox approves runs without a prompt.
4
+ */
5
+
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ import { toChannelOptions } from '../render.js';
9
+ import { buildTuiSessionOptions } from '../tui-session-options.js';
10
+
11
+ import type { IRenderOptions } from '../render.js';
12
+
13
+ type TSandboxClient = NonNullable<IRenderOptions['sandboxClient']>;
14
+
15
+ const sandboxClient = { filesystem: 'shared', autoApproves: () => true } as unknown as TSandboxClient;
16
+
17
+ function renderOptions(extra: Partial<IRenderOptions> = {}): IRenderOptions {
18
+ return { cwd: '/work', provider: {} as never, ...extra } as IRenderOptions;
19
+ }
20
+
21
+ describe('the TUI projection carries the sandbox client', () => {
22
+ it('passes the supplied sandbox through the channel into the session', () => {
23
+ const channel = toChannelOptions(renderOptions({ sandboxClient }));
24
+
25
+ expect((buildTuiSessionOptions(channel) as { sandboxClient?: unknown }).sandboxClient).toBe(
26
+ sandboxClient,
27
+ );
28
+ });
29
+
30
+ it('carries none when none was supplied', () => {
31
+ const channel = toChannelOptions(renderOptions());
32
+
33
+ expect(
34
+ (buildTuiSessionOptions(channel) as { sandboxClient?: unknown }).sandboxClient,
35
+ ).toBeUndefined();
36
+ });
37
+ });
@@ -135,7 +135,7 @@ describe('TC-07: PermissionPrompt and ConfirmPrompt', () => {
135
135
  it('a typed number resolves the permission ask', async () => {
136
136
  const resolve = vi.fn();
137
137
  const { stdin, unmount } = renderInMode(
138
- <PermissionPrompt request={permissionRequest(resolve)} />,
138
+ <PermissionPrompt request={permissionRequest(resolve)} armDelayMs={0} />,
139
139
  );
140
140
  await tick();
141
141
  stdin.write('4');