@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.
- 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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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('
|
|
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).
|
|
151
|
-
expect(
|
|
157
|
+
expect(chat).not.toHaveBeenCalled();
|
|
158
|
+
expect(channel.sessionName).toBeUndefined();
|
|
152
159
|
await channel.stop();
|
|
153
160
|
});
|
|
154
161
|
|
|
155
|
-
it('Esc
|
|
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.
|
|
165
|
+
session.stopWaitingSelfPacedLoop.mockResolvedValue({ kind: 'stopped', loopId: 'loop_one' });
|
|
159
166
|
await channel.stopWaitingSelfPacedLoop();
|
|
160
|
-
expect(session.
|
|
161
|
-
expect(channel.stateManager.history.
|
|
162
|
-
|
|
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
|
|
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(
|
|
174
|
-
|
|
175
|
-
|
|
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 {
|
|
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:
|
|
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
|
|
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: '
|
|
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
|
|
13
|
-
*
|
|
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
|
-
|
|
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,
|
|
82
|
-
: await executeShellCommand(ctx,
|
|
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
|
+
});
|