@robota-sdk/agent-ui-terminal 3.0.0-beta.81 → 3.0.0-beta.83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +858 -0
- package/README.md +5 -1
- package/dist/node/index.d.ts +189 -54
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +21 -21
- package/dist/node/index.js.map +1 -1
- package/package.json +16 -21
- package/src/App.tsx +14 -3
- package/src/AppPresentation.tsx +1 -1
- package/src/PermissionPrompt.tsx +50 -17
- package/src/SessionPicker.tsx +27 -7
- package/src/SessionStatusBar.tsx +6 -2
- package/src/StatusBar.tsx +24 -0
- package/src/SupervisedSessionView.tsx +479 -157
- package/src/TuiInteractionChannel.ts +8 -53
- package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +453 -82
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +32 -22
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +10 -2
- package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
- package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +2 -2
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +3 -1
- package/src/__tests__/fixtures/command-handoff-driver.tsx +8 -5
- package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
- package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
- package/src/__tests__/palette-consistency.test.ts +0 -1
- package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
- package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
- package/src/__tests__/render-channel-options.test.ts +6 -6
- package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
- package/src/__tests__/screen-reader-menus.test.tsx +1 -1
- package/src/__tests__/session-picker.test.tsx +93 -0
- package/src/__tests__/status-bar.test.tsx +8 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
- package/src/__tests__/wire-tui-channel.test.ts +1445 -0
- package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
- package/src/app-view-model.ts +6 -2
- package/src/attached-session-connection.ts +14 -0
- package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
- package/src/hooks/useAppController.ts +6 -2
- package/src/hooks/useAppInputBindings.ts +20 -2
- package/src/hooks/useAppInteractionState.ts +3 -0
- package/src/hooks/useAppOverlays.ts +20 -4
- package/src/hooks/useNumberedSelection.ts +19 -2
- package/src/hooks/useTuiChannel.ts +13 -1
- package/src/index.ts +17 -3
- package/src/render.tsx +218 -27
- package/src/short-session-id.ts +9 -0
- package/src/slash-command-input.ts +11 -0
- package/src/tui-app-channel-port.ts +29 -1
- package/src/tui-channel-options.ts +11 -2
- package/src/tui-session-event-projector.ts +0 -2
- package/src/tui-session-events.ts +6 -0
- package/src/tui-session-options.ts +9 -0
- package/src/tui-state-manager.ts +17 -3
- package/src/waiting-loop-stop-notice.ts +23 -0
- package/src/wire-history-sync.ts +159 -0
- package/src/wire-tui-channel.ts +962 -0
- package/src/wire-tui-client-commands.ts +142 -0
- package/src/wire-tui-projection.ts +136 -0
- package/dist/node/index.cjs +0 -32
- package/dist/node/index.d.cts +0 -1066
- package/dist/node/index.d.cts.map +0 -1
|
@@ -0,0 +1,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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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 {
|
|
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('
|
|
165
|
-
const
|
|
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
|
-
|
|
170
|
+
externalEventVerifierFactory: factory,
|
|
171
171
|
});
|
|
172
|
-
|
|
173
|
-
expect(channelOptions.
|
|
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');
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { render } from 'ink-testing-library';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import SessionPicker from '../SessionPicker.js';
|
|
6
|
+
import { shortSessionId } from '../short-session-id.js';
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
IResumableSessionSummary,
|
|
10
|
+
ISessionListingEntry,
|
|
11
|
+
} from '@robota-sdk/agent-interface-session';
|
|
12
|
+
|
|
13
|
+
function summary(id: string, name?: string): IResumableSessionSummary {
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
cwd: '/w',
|
|
17
|
+
updatedAt: '2026-09-26T10:00:00.000Z',
|
|
18
|
+
messageCount: 2,
|
|
19
|
+
preview: '',
|
|
20
|
+
...(name !== undefined ? { name } : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('session ids on screen', () => {
|
|
25
|
+
it('shortens an id to the part that tells sessions apart, not the prefix they share', () => {
|
|
26
|
+
expect(shortSessionId('session_07d291bc-4d43-4d26-9375-7d8ca0e60e6f')).toBe('07d291bc');
|
|
27
|
+
expect(shortSessionId('session_1773862349776_rnecma5lu')).toBe('17738623');
|
|
28
|
+
expect(shortSessionId('host-session-id')).toBe('host-ses');
|
|
29
|
+
expect(shortSessionId('session_')).toBe('session_');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('lists unnamed sessions by distinct ids, and named ones by name', () => {
|
|
33
|
+
const view = render(
|
|
34
|
+
<SessionPicker
|
|
35
|
+
sessions={[
|
|
36
|
+
summary('session_07d291bc-4d43-4d26-9375-7d8ca0e60e6f'),
|
|
37
|
+
summary('session_15f05245-1f06-4375-8f29-db19e622afdd'),
|
|
38
|
+
summary('session_aaaaaaaa-0000-0000-0000-000000000000', 'auth work'),
|
|
39
|
+
]}
|
|
40
|
+
onSelect={() => undefined}
|
|
41
|
+
onCancel={() => undefined}
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
const frame = view.lastFrame() ?? '';
|
|
45
|
+
expect(frame).toContain('07d291bc');
|
|
46
|
+
expect(frame).toContain('15f05245');
|
|
47
|
+
expect(frame).toContain('auth work');
|
|
48
|
+
expect(frame).not.toMatch(/session_\s/);
|
|
49
|
+
view.unmount();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('live sessions in the picker (#3189)', () => {
|
|
54
|
+
function entry(id: string, name: string, extra: Partial<ISessionListingEntry>): ISessionListingEntry {
|
|
55
|
+
return { ...summary(id, name), ...extra };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it('marks a session the host runs now, and how many clients are on it', () => {
|
|
59
|
+
const view = render(
|
|
60
|
+
<SessionPicker
|
|
61
|
+
sessions={[
|
|
62
|
+
entry('session_a', 'busy one', { live: true, clients: 2 }),
|
|
63
|
+
entry('session_b', 'solo one', { live: true, clients: 1 }),
|
|
64
|
+
entry('session_c', 'stored one', { live: false, clients: 0 }),
|
|
65
|
+
]}
|
|
66
|
+
onSelect={() => undefined}
|
|
67
|
+
onCancel={() => undefined}
|
|
68
|
+
/>,
|
|
69
|
+
);
|
|
70
|
+
const lines = (view.lastFrame() ?? '').split('\n');
|
|
71
|
+
const lineOf = (name: string): string => lines.find((line) => line.includes(name)) ?? '';
|
|
72
|
+
expect(lineOf('busy one')).toContain('● live · 2 clients');
|
|
73
|
+
expect(lineOf('solo one')).toContain('● live · 1 client');
|
|
74
|
+
expect(lineOf('solo one')).not.toContain('1 clients');
|
|
75
|
+
expect(lineOf('stored one')).not.toContain('live');
|
|
76
|
+
expect(lineOf('stored one')).not.toContain('client');
|
|
77
|
+
view.unmount();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('shows neither for an older host, which sends neither', () => {
|
|
81
|
+
const view = render(
|
|
82
|
+
<SessionPicker
|
|
83
|
+
sessions={[summary('session_a', 'plain')]}
|
|
84
|
+
onSelect={() => undefined}
|
|
85
|
+
onCancel={() => undefined}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
const frame = view.lastFrame() ?? '';
|
|
89
|
+
expect(frame).not.toContain('live');
|
|
90
|
+
expect(frame).not.toContain('client');
|
|
91
|
+
view.unmount();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -47,6 +47,14 @@ describe('StatusBar', () => {
|
|
|
47
47
|
}
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
it('says an observing terminal is read only, and nothing of the kind otherwise', () => {
|
|
51
|
+
const observing = render(<StatusBar {...baseProps} readOnly />);
|
|
52
|
+
expect(observing.lastFrame()).toContain('Observing — read only');
|
|
53
|
+
observing.unmount();
|
|
54
|
+
const driving = render(<StatusBar {...baseProps} />);
|
|
55
|
+
expect(driving.lastFrame()).not.toContain('Observing');
|
|
56
|
+
});
|
|
57
|
+
|
|
50
58
|
it('renders session name when provided', () => {
|
|
51
59
|
const { lastFrame } = render(<StatusBar {...baseProps} sessionName="my-feature" />);
|
|
52
60
|
const frame = lastFrame()!;
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
6
|
import type { IAIProvider } from '@robota-sdk/agent-core';
|
|
3
7
|
|
|
4
8
|
vi.mock('@robota-sdk/agent-framework', async (importOriginal) => {
|
|
@@ -32,6 +36,10 @@ vi.mock('@robota-sdk/agent-framework', async (importOriginal) => {
|
|
|
32
36
|
|
|
33
37
|
import { TuiInteractionChannel } from '../TuiInteractionChannel.js';
|
|
34
38
|
|
|
39
|
+
/** The session's working directory: private to this run, never a fixed name under /tmp. */
|
|
40
|
+
const PROJECT_DIR = mkdtempSync(join(tmpdir(), 'robota-tui-init-failure-'));
|
|
41
|
+
afterAll(() => rmSync(PROJECT_DIR, { recursive: true, force: true }));
|
|
42
|
+
|
|
35
43
|
describe('TuiInteractionChannel init failure surfacing', () => {
|
|
36
44
|
beforeEach(() => {
|
|
37
45
|
vi.useFakeTimers();
|
|
@@ -43,7 +51,7 @@ describe('TuiInteractionChannel init failure surfacing', () => {
|
|
|
43
51
|
|
|
44
52
|
it('TC-05: a real init error records a session-init-error entry and sets error state', async () => {
|
|
45
53
|
const channel = new TuiInteractionChannel({
|
|
46
|
-
cwd:
|
|
54
|
+
cwd: PROJECT_DIR,
|
|
47
55
|
provider: {} as IAIProvider,
|
|
48
56
|
});
|
|
49
57
|
await channel.start();
|