@robota-sdk/agent-transport 3.0.0-beta.72 → 3.0.0-beta.74
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/dist/node/headless/index.cjs +1 -1
- package/dist/node/headless/index.d.ts +1 -1
- package/dist/node/headless/index.js +1 -1
- package/dist/node/{headless-DCtHvyVf.cjs → headless-BeHAOlIM.cjs} +4 -3
- package/dist/node/{headless-C6tj35h3.js → headless-D02zUEGh.js} +4 -3
- package/dist/node/headless-D02zUEGh.js.map +1 -0
- package/dist/node/http/index.cjs +1 -1
- package/dist/node/http/index.d.ts +1 -1
- package/dist/node/http/index.js +1 -1
- package/dist/node/{http-Br10Ps8m.js → http-2Jiuflc1.js} +1 -1
- package/dist/node/http-2Jiuflc1.js.map +1 -0
- package/dist/node/http-CBAvefLw.cjs +1 -0
- package/dist/node/{index-BVNhOeeU.d.ts → index-BQLN_Lc9.d.ts} +5 -3
- package/dist/node/index-BQLN_Lc9.d.ts.map +1 -0
- package/dist/node/{index-C9LWCL4l.d.ts → index-BnAGE-u9.d.ts} +2 -3
- package/dist/node/index-BnAGE-u9.d.ts.map +1 -0
- package/dist/node/{index-COWvtBa2.d.ts → index-BrQ4gGw0.d.ts} +3 -3
- package/dist/node/index-BrQ4gGw0.d.ts.map +1 -0
- package/dist/node/{index-X2Zg8FEY.d.ts → index-CoeBF21y.d.ts} +3 -3
- package/dist/node/index-CoeBF21y.d.ts.map +1 -0
- package/dist/node/{index-27HV5PJB.d.ts → index-DE3-dHqw.d.ts} +8 -3
- package/dist/node/index-DE3-dHqw.d.ts.map +1 -0
- package/dist/node/{index-BRgV_MPB.d.ts → index-DHt-2VQ-.d.ts} +2 -3
- package/dist/node/index-DHt-2VQ-.d.ts.map +1 -0
- package/dist/node/{index-nBlMTFkZ.d.ts → index-DMwKN5Le.d.ts} +2 -3
- package/dist/node/index-DMwKN5Le.d.ts.map +1 -0
- package/dist/node/{index-TMAlNHuM.d.ts → index-IvYaYY6v.d.ts} +5 -3
- package/dist/node/index-IvYaYY6v.d.ts.map +1 -0
- package/dist/node/{index-BRchlFBE.d.ts → index-WKTgvhlg.d.ts} +8 -3
- package/dist/node/index-WKTgvhlg.d.ts.map +1 -0
- package/dist/node/{index-C5KNEBO9.d.ts → index-c0M42fsA.d.ts} +2 -3
- package/dist/node/index-c0M42fsA.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.ts +6 -7
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/mcp/index.cjs +1 -1
- package/dist/node/mcp/index.d.ts +1 -1
- package/dist/node/mcp/index.js +1 -1
- package/dist/node/mcp-BOglBJNy.cjs +1 -0
- package/dist/node/{mcp-BAujHOMr.js → mcp-D3BBVK7C.js} +1 -1
- package/dist/node/mcp-D3BBVK7C.js.map +1 -0
- package/dist/node/{chunk-Bmb41Sf3.cjs → rolldown-runtime-CMqjfN_6.cjs} +1 -1
- package/dist/node/testing/index.cjs +1 -0
- package/dist/node/testing/index.d.ts +21 -0
- package/dist/node/testing/index.d.ts.map +1 -0
- package/dist/node/testing/index.js +2 -0
- package/dist/node/testing/index.js.map +1 -0
- package/dist/node/tui/index.cjs +1 -1
- package/dist/node/tui/index.d.ts +1 -1
- package/dist/node/tui/index.js +1 -1
- package/dist/node/{tui-4hA-SMtS.js → tui-Btb1q88j.js} +5 -5
- package/dist/node/tui-Btb1q88j.js.map +1 -0
- package/dist/node/tui-SbUT7Zlt.cjs +24 -0
- package/dist/node/ws/index.cjs +1 -1
- package/dist/node/ws/index.d.ts +1 -1
- package/dist/node/ws/index.js +1 -1
- package/dist/node/{ws-BWel8nzl.js → ws-Dc2RUwVs.js} +1 -1
- package/dist/node/ws-Dc2RUwVs.js.map +1 -0
- package/dist/node/ws-QNMQn5kg.cjs +1 -0
- package/package.json +35 -22
- package/src/headless/HeadlessInteractionChannel.ts +9 -1
- package/src/headless/__tests__/headless-channel-options.test.ts +106 -0
- package/src/headless/__tests__/headless-provider-failure.integration.test.ts +143 -0
- package/src/headless/__tests__/headless-runner-initialization.test.ts +1 -1
- package/src/headless/__tests__/headless-runner.test.ts +24 -3
- package/src/headless/__tests__/headless-transport.test.ts +1 -2
- package/src/headless/headless-runner.ts +3 -2
- package/src/headless/headless-stream-json.ts +5 -5
- package/src/headless/headless-transport.ts +1 -2
- package/src/http/__tests__/http-transport.test.ts +1 -1
- package/src/http/__tests__/routes.test.ts +1 -1
- package/src/http/http-transport.ts +1 -2
- package/src/http/routes.ts +1 -1
- package/src/mcp/__tests__/mcp-server.test.ts +1 -1
- package/src/mcp/__tests__/mcp-transport.test.ts +1 -1
- package/src/mcp/mcp-server.ts +1 -1
- package/src/mcp/mcp-transport.ts +1 -2
- package/src/testing/__tests__/scripted-provider.test.ts +73 -0
- package/src/testing/index.ts +7 -0
- package/src/testing/scripted-provider.ts +73 -0
- package/src/transport-registry.ts +1 -1
- package/src/tui/App.tsx +38 -29
- package/src/tui/BackgroundTaskPanel.tsx +1 -1
- package/src/tui/CjkTextInput.tsx +4 -8
- package/src/tui/ExecutionWorkspaceDetailPane.tsx +1 -1
- package/src/tui/ExecutionWorkspaceSwitcher.tsx +1 -1
- package/src/tui/InputArea.tsx +15 -7
- package/src/tui/InteractivePrompt.tsx +2 -2
- package/src/tui/PluginTUI.tsx +1 -1
- package/src/tui/SessionPicker.tsx +1 -1
- package/src/tui/SessionStatusBar.tsx +1 -1
- package/src/tui/SlashAutocomplete.tsx +1 -1
- package/src/tui/StatusBar.tsx +1 -7
- package/src/tui/StreamingIndicator.tsx +1 -1
- package/src/tui/TransportTUI.tsx +1 -1
- package/src/tui/TuiInteractionChannel.ts +60 -38
- package/src/tui/UsageSummaryEntry.tsx +1 -1
- package/src/tui/__tests__/PluginTUI.test.tsx +1 -1
- package/src/tui/__tests__/SlashAutocomplete.test.tsx +1 -1
- package/src/tui/__tests__/TuiInteractionChannel.display-contract.test.ts +1 -1
- package/src/tui/__tests__/TuiInteractionChannel.lifecycle.test.ts +5 -2
- package/src/tui/__tests__/TuiInteractionChannel.requestAction.test.ts +1 -1
- package/src/tui/__tests__/background-task-panel.test.tsx +1 -1
- package/src/tui/__tests__/background-task-row-format.test.ts +1 -1
- package/src/tui/__tests__/channel-factory-integration.test.ts +138 -0
- package/src/tui/__tests__/execution-workspace-switcher.test.tsx +1 -1
- package/src/tui/__tests__/execution-workspace-view-model.test.ts +1 -1
- package/src/tui/__tests__/fixtures/provider-setup-prompt-driver.tsx +1 -1
- package/src/tui/__tests__/input-area-flow.test.ts +1 -1
- package/src/tui/__tests__/pty/pty-driver.ts +135 -0
- package/src/tui/__tests__/pty/tui-pty.ptytest.ts +61 -0
- package/src/tui/__tests__/render-channel-options.test.ts +32 -0
- package/src/tui/__tests__/session-init-poller.test.ts +102 -0
- package/src/tui/__tests__/session-switch-channel.test.tsx +307 -0
- package/src/tui/__tests__/slash-routing-effects.test.ts +4 -1
- package/src/tui/__tests__/status-activity.test.ts +3 -3
- package/src/tui/__tests__/status-bar.test.tsx +7 -6
- package/src/tui/__tests__/tui-channel-init-failure.test.ts +57 -0
- package/src/tui/__tests__/tui-state-manager.test.ts +1 -1
- package/src/tui/background-task-row-format.ts +1 -1
- package/src/tui/execution-workspace-view-model.ts +1 -1
- package/src/tui/flows/input-area-flow.ts +1 -1
- package/src/tui/flows/permission-prompt-flow.ts +1 -1
- package/src/tui/flows/session-init-poller.ts +77 -0
- package/src/tui/hooks/command-effect-handler.ts +4 -1
- package/src/tui/hooks/command-effect-queue.ts +1 -1
- package/src/tui/hooks/side-effects-types.ts +2 -2
- package/src/tui/hooks/useAutocomplete.ts +3 -2
- package/src/tui/hooks/usePluginCallbacks.ts +1 -1
- package/src/tui/hooks/usePluginScreenData.ts +1 -1
- package/src/tui/hooks/useSideEffects.ts +1 -1
- package/src/tui/hooks/useSlashRouting.ts +3 -3
- package/src/tui/hooks/useStatusLineSettings.ts +1 -1
- package/src/tui/hooks/useTuiChannel.ts +3 -3
- package/src/tui/plugin-tui-handlers.ts +1 -1
- package/src/tui/render.tsx +38 -25
- package/src/tui/status-activity.ts +2 -2
- package/src/tui/tui-cli-adapter.ts +3 -3
- package/src/tui/tui-state-manager.ts +2 -2
- package/src/tui/tui-transport.ts +4 -2
- package/src/ws/__tests__/ws-handler.test.ts +6 -4
- package/src/ws/__tests__/ws-transport.test.ts +1 -1
- package/src/ws/ws-background-messages.ts +1 -1
- package/src/ws/ws-handler.ts +4 -4
- package/src/ws/ws-protocol.ts +6 -4
- package/src/ws/ws-transport-configurable.ts +4 -2
- package/src/ws/ws-transport.ts +1 -2
- package/dist/node/headless-C6tj35h3.js.map +0 -1
- package/dist/node/http-Br10Ps8m.js.map +0 -1
- package/dist/node/http-Da6Kw4oy.cjs +0 -1
- package/dist/node/index-27HV5PJB.d.ts.map +0 -1
- package/dist/node/index-BRchlFBE.d.ts.map +0 -1
- package/dist/node/index-BRgV_MPB.d.ts.map +0 -1
- package/dist/node/index-BVNhOeeU.d.ts.map +0 -1
- package/dist/node/index-C5KNEBO9.d.ts.map +0 -1
- package/dist/node/index-C9LWCL4l.d.ts.map +0 -1
- package/dist/node/index-COWvtBa2.d.ts.map +0 -1
- package/dist/node/index-TMAlNHuM.d.ts.map +0 -1
- package/dist/node/index-X2Zg8FEY.d.ts.map +0 -1
- package/dist/node/index-nBlMTFkZ.d.ts.map +0 -1
- package/dist/node/mcp-BAujHOMr.js.map +0 -1
- package/dist/node/mcp-Bl8jUfev.cjs +0 -1
- package/dist/node/tui-4hA-SMtS.js.map +0 -1
- package/dist/node/tui-CcLmEJ1r.cjs +0 -24
- package/dist/node/ws-BWel8nzl.js.map +0 -1
- package/dist/node/ws-tCjj2gPu.cjs +0 -1
- package/src/tui/InkTerminal.ts +0 -42
- package/src/tui/hooks/use-interactive-session-init.ts +0 -91
- package/src/tui/hooks/usePermissionQueue.ts +0 -52
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY TUI driver (CLI-074 TC-07/08).
|
|
3
|
+
*
|
|
4
|
+
* Spawns the built robota CLI in a real pseudo-terminal so Ink renders exactly
|
|
5
|
+
* as in a user terminal, with per-key paced input (expect(1)-style burst input
|
|
6
|
+
* gets bundled as a bracketed paste — the failure mode this driver exists to
|
|
7
|
+
* avoid). Test-only; lives in a dedicated vitest project (*.ptytest.ts).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { spawn } from '@homebridge/node-pty-prebuilt-multiarch';
|
|
14
|
+
|
|
15
|
+
import type { IPty } from '@homebridge/node-pty-prebuilt-multiarch';
|
|
16
|
+
|
|
17
|
+
const REPO_ROOT = resolve(__dirname, '../../../../../..');
|
|
18
|
+
const ROBOTA_BIN = join(REPO_ROOT, 'packages/agent-cli/bin/robota.cjs');
|
|
19
|
+
|
|
20
|
+
// eslint-disable-next-line no-control-regex
|
|
21
|
+
const ANSI_PATTERN = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][B0]|[\x00-\x08\x0b-\x1f]/g;
|
|
22
|
+
|
|
23
|
+
export interface IPtySession {
|
|
24
|
+
/** Type text one key at a time (default 35ms/key — human-ish, avoids paste bundling). */
|
|
25
|
+
sendKeys(text: string, perKeyDelayMs?: number): Promise<void>;
|
|
26
|
+
/** Press Enter as a single keystroke. */
|
|
27
|
+
pressEnter(): Promise<void>;
|
|
28
|
+
/** Wait until the ANSI-stripped output matches; throws with a snapshot on timeout. */
|
|
29
|
+
waitFor(pattern: RegExp, timeoutMs?: number): Promise<void>;
|
|
30
|
+
/** Current ANSI-stripped output. */
|
|
31
|
+
snapshot(): string;
|
|
32
|
+
/** Wait for process exit; throws with a snapshot on timeout. */
|
|
33
|
+
expectExit(timeoutMs?: number): Promise<number>;
|
|
34
|
+
/** Force-kill (cleanup). */
|
|
35
|
+
kill(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ISpawnTuiOptions {
|
|
39
|
+
/** Project cwd (a provider profile settings.json is written here). */
|
|
40
|
+
projectDir: string;
|
|
41
|
+
/** Isolated HOME directory. */
|
|
42
|
+
homeDir: string;
|
|
43
|
+
cols?: number;
|
|
44
|
+
rows?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function writeTuiProviderSettings(projectDir: string): void {
|
|
48
|
+
const settingsDir = join(projectDir, '.robota');
|
|
49
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(settingsDir, 'settings.json'),
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
currentProvider: 'anthropic',
|
|
54
|
+
providers: {
|
|
55
|
+
// Boot/slash/exit make zero model calls — the key is never used.
|
|
56
|
+
anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'pty-dummy-key' },
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
'utf8',
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sleep(ms: number): Promise<void> {
|
|
64
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function spawnTui(options: ISpawnTuiOptions): IPtySession {
|
|
68
|
+
mkdirSync(options.homeDir, { recursive: true });
|
|
69
|
+
let output = '';
|
|
70
|
+
let exitCode: number | undefined;
|
|
71
|
+
|
|
72
|
+
const pty: IPty = spawn(process.execPath, [ROBOTA_BIN], {
|
|
73
|
+
name: 'xterm-256color',
|
|
74
|
+
cols: options.cols ?? 100,
|
|
75
|
+
rows: options.rows ?? 32,
|
|
76
|
+
cwd: options.projectDir,
|
|
77
|
+
env: {
|
|
78
|
+
PATH: process.env['PATH'] ?? '',
|
|
79
|
+
HOME: options.homeDir,
|
|
80
|
+
TERM: 'xterm-256color',
|
|
81
|
+
// Never inherit real provider keys into PTY runs.
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
pty.onData((data) => {
|
|
86
|
+
output += data;
|
|
87
|
+
});
|
|
88
|
+
pty.onExit(({ exitCode: code }) => {
|
|
89
|
+
exitCode = code;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const stripped = (): string => output.replace(ANSI_PATTERN, '');
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
async sendKeys(text: string, perKeyDelayMs = 35): Promise<void> {
|
|
96
|
+
for (const ch of text) {
|
|
97
|
+
pty.write(ch);
|
|
98
|
+
await sleep(perKeyDelayMs);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
async pressEnter(): Promise<void> {
|
|
102
|
+
await sleep(120);
|
|
103
|
+
pty.write('\r');
|
|
104
|
+
await sleep(120);
|
|
105
|
+
},
|
|
106
|
+
async waitFor(pattern: RegExp, timeoutMs = 15_000): Promise<void> {
|
|
107
|
+
const deadline = Date.now() + timeoutMs;
|
|
108
|
+
while (Date.now() < deadline) {
|
|
109
|
+
if (pattern.test(stripped())) return;
|
|
110
|
+
await sleep(100);
|
|
111
|
+
}
|
|
112
|
+
throw new Error(
|
|
113
|
+
`PTY waitFor timeout (${timeoutMs}ms) for ${String(pattern)}\n--- snapshot ---\n${stripped().slice(-2000)}`,
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
snapshot: stripped,
|
|
117
|
+
async expectExit(timeoutMs = 10_000): Promise<number> {
|
|
118
|
+
const deadline = Date.now() + timeoutMs;
|
|
119
|
+
while (Date.now() < deadline) {
|
|
120
|
+
if (exitCode !== undefined) return exitCode;
|
|
121
|
+
await sleep(100);
|
|
122
|
+
}
|
|
123
|
+
throw new Error(
|
|
124
|
+
`PTY process did not exit within ${timeoutMs}ms\n--- snapshot ---\n${stripped().slice(-2000)}`,
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
kill(): void {
|
|
128
|
+
try {
|
|
129
|
+
pty.kill();
|
|
130
|
+
} catch {
|
|
131
|
+
// allow-fallback: process already exited — kill on a dead pty is a no-op by design
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-PTY TUI suites (CLI-074 TC-07/08).
|
|
3
|
+
*
|
|
4
|
+
* Runs in the dedicated PTY vitest project (vitest.pty.config.ts) against the
|
|
5
|
+
* BUILT robota binary — `pnpm --filter @robota-sdk/agent-cli build` first.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
|
|
15
|
+
|
|
16
|
+
import type { IPtySession } from './pty-driver.js';
|
|
17
|
+
|
|
18
|
+
describe('TUI through a real PTY (CLI-074)', () => {
|
|
19
|
+
let projectDir: string;
|
|
20
|
+
let session: IPtySession | undefined;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
projectDir = mkdtempSync(join(tmpdir(), 'robota-pty-'));
|
|
24
|
+
writeTuiProviderSettings(projectDir);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
session?.kill();
|
|
29
|
+
session = undefined;
|
|
30
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('TC-07: boots, opens slash autocomplete, and executes /help as a command', async () => {
|
|
34
|
+
session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
|
|
35
|
+
|
|
36
|
+
// Boot: prompt + status bar render.
|
|
37
|
+
await session.waitFor(/Type a message or \/help/);
|
|
38
|
+
await session.waitFor(/Idle/);
|
|
39
|
+
|
|
40
|
+
// '/' opens the autocomplete dropdown listing commands.
|
|
41
|
+
await session.sendKeys('/');
|
|
42
|
+
await session.waitFor(/\/help\s+Show available commands/);
|
|
43
|
+
|
|
44
|
+
// Typing the rest at human key rate must stay a command, not a paste.
|
|
45
|
+
await session.sendKeys('help');
|
|
46
|
+
await session.pressEnter();
|
|
47
|
+
await session.waitFor(/Available commands|\/cost|\/clear/i, 20_000);
|
|
48
|
+
expect(session.snapshot()).not.toContain('[Pasted text');
|
|
49
|
+
}, 60_000);
|
|
50
|
+
|
|
51
|
+
it('TC-08: /exit reaches process exit within 10s', async () => {
|
|
52
|
+
session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
|
|
53
|
+
await session.waitFor(/Type a message or \/help/);
|
|
54
|
+
|
|
55
|
+
await session.sendKeys('/exit');
|
|
56
|
+
await session.pressEnter();
|
|
57
|
+
|
|
58
|
+
const exitCode = await session.expectExit(10_000);
|
|
59
|
+
expect(exitCode).toBe(0);
|
|
60
|
+
}, 60_000);
|
|
61
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { IAIProvider } from '@robota-sdk/agent-core';
|
|
3
|
+
import type { ITuiCliAdapter } from '../tui-cli-adapter.js';
|
|
4
|
+
import { toChannelOptions } from '../render.js';
|
|
5
|
+
import type { IRenderOptions } from '../render.js';
|
|
6
|
+
|
|
7
|
+
describe('toChannelOptions', () => {
|
|
8
|
+
it('TC-02: threads allowedTools and deniedTools into the channel options', () => {
|
|
9
|
+
const renderOptions: IRenderOptions = {
|
|
10
|
+
cwd: '/tmp/project',
|
|
11
|
+
provider: {} as IAIProvider,
|
|
12
|
+
cliAdapter: {} as ITuiCliAdapter,
|
|
13
|
+
allowedTools: ['Read'],
|
|
14
|
+
deniedTools: ['Bash'],
|
|
15
|
+
};
|
|
16
|
+
const channelOptions = toChannelOptions(renderOptions, 'session-1');
|
|
17
|
+
expect(channelOptions.allowedTools).toEqual(['Read']);
|
|
18
|
+
expect(channelOptions.deniedTools).toEqual(['Bash']);
|
|
19
|
+
expect(channelOptions.resumeSessionId).toBe('session-1');
|
|
20
|
+
expect(channelOptions.cwd).toBe('/tmp/project');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('leaves tool filters undefined when not provided', () => {
|
|
24
|
+
const channelOptions = toChannelOptions({
|
|
25
|
+
cwd: '/tmp/project',
|
|
26
|
+
provider: {} as IAIProvider,
|
|
27
|
+
cliAdapter: {} as ITuiCliAdapter,
|
|
28
|
+
});
|
|
29
|
+
expect(channelOptions.allowedTools).toBeUndefined();
|
|
30
|
+
expect(channelOptions.deniedTools).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createSessionInitPoller } from '../flows/session-init-poller.js';
|
|
4
|
+
|
|
5
|
+
describe('createSessionInitPoller', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('TC-04: calls onReady and stops once the check succeeds', () => {
|
|
15
|
+
const onReady = vi.fn();
|
|
16
|
+
const onFailure = vi.fn();
|
|
17
|
+
let ready = false;
|
|
18
|
+
const poller = createSessionInitPoller({
|
|
19
|
+
check: () => {
|
|
20
|
+
if (!ready) throw new Error('InteractiveSession not initialized. Call submit().');
|
|
21
|
+
},
|
|
22
|
+
intervalMs: 200,
|
|
23
|
+
timeoutMs: 15000,
|
|
24
|
+
onReady,
|
|
25
|
+
onFailure,
|
|
26
|
+
});
|
|
27
|
+
poller.start();
|
|
28
|
+
vi.advanceTimersByTime(600);
|
|
29
|
+
expect(onReady).not.toHaveBeenCalled();
|
|
30
|
+
ready = true;
|
|
31
|
+
vi.advanceTimersByTime(200);
|
|
32
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
33
|
+
vi.advanceTimersByTime(2000);
|
|
34
|
+
expect(onReady).toHaveBeenCalledTimes(1);
|
|
35
|
+
expect(onFailure).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('TC-04: benign not-initialized errors poll until timeout, then fail with timeout kind', () => {
|
|
39
|
+
const onReady = vi.fn();
|
|
40
|
+
const onFailure = vi.fn();
|
|
41
|
+
const poller = createSessionInitPoller({
|
|
42
|
+
check: () => {
|
|
43
|
+
throw new Error('InteractiveSession not initialized. Call submit().');
|
|
44
|
+
},
|
|
45
|
+
intervalMs: 200,
|
|
46
|
+
timeoutMs: 1000,
|
|
47
|
+
onReady,
|
|
48
|
+
onFailure,
|
|
49
|
+
});
|
|
50
|
+
poller.start();
|
|
51
|
+
vi.advanceTimersByTime(900);
|
|
52
|
+
expect(onFailure).not.toHaveBeenCalled();
|
|
53
|
+
vi.advanceTimersByTime(400);
|
|
54
|
+
expect(onFailure).toHaveBeenCalledTimes(1);
|
|
55
|
+
expect(onFailure.mock.calls[0]?.[0]).toMatchObject({ kind: 'timeout' });
|
|
56
|
+
expect(onReady).not.toHaveBeenCalled();
|
|
57
|
+
vi.advanceTimersByTime(2000);
|
|
58
|
+
expect(onFailure).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('TC-04: a real error fails immediately with the error attached', () => {
|
|
62
|
+
const onFailure = vi.fn();
|
|
63
|
+
const poller = createSessionInitPoller({
|
|
64
|
+
check: () => {
|
|
65
|
+
throw new Error('ENOENT: session store unreadable');
|
|
66
|
+
},
|
|
67
|
+
intervalMs: 200,
|
|
68
|
+
timeoutMs: 15000,
|
|
69
|
+
onReady: vi.fn(),
|
|
70
|
+
onFailure,
|
|
71
|
+
});
|
|
72
|
+
poller.start();
|
|
73
|
+
vi.advanceTimersByTime(200);
|
|
74
|
+
expect(onFailure).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(onFailure.mock.calls[0]?.[0]).toMatchObject({ kind: 'error' });
|
|
76
|
+
expect(String((onFailure.mock.calls[0]?.[0] as { error: Error }).error.message)).toContain(
|
|
77
|
+
'ENOENT',
|
|
78
|
+
);
|
|
79
|
+
vi.advanceTimersByTime(2000);
|
|
80
|
+
expect(onFailure).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('stop() cancels polling without callbacks', () => {
|
|
84
|
+
const onReady = vi.fn();
|
|
85
|
+
const onFailure = vi.fn();
|
|
86
|
+
const poller = createSessionInitPoller({
|
|
87
|
+
check: () => {
|
|
88
|
+
throw new Error('InteractiveSession not initialized.');
|
|
89
|
+
},
|
|
90
|
+
intervalMs: 200,
|
|
91
|
+
timeoutMs: 1000,
|
|
92
|
+
onReady,
|
|
93
|
+
onFailure,
|
|
94
|
+
});
|
|
95
|
+
poller.start();
|
|
96
|
+
vi.advanceTimersByTime(400);
|
|
97
|
+
poller.stop();
|
|
98
|
+
vi.advanceTimersByTime(5000);
|
|
99
|
+
expect(onReady).not.toHaveBeenCalled();
|
|
100
|
+
expect(onFailure).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-B11 TC-01/03/05 + CLI-B12 TC-01/02/04: session-switch channel ownership
|
|
3
|
+
* at the App boundary.
|
|
4
|
+
*
|
|
5
|
+
* The 2026-05-31 context-loss bug lived between render.tsx, App.tsx and
|
|
6
|
+
* TuiInteractionChannel — InteractiveSession-level tests stayed green through it.
|
|
7
|
+
* These tests render the REAL App with a mocked createChannel factory and drive
|
|
8
|
+
* switches through the real SessionPicker, pinning the factory-call contract.
|
|
9
|
+
* Since CLI-B12 the factory is the SOLE channel source: App creates the initial
|
|
10
|
+
* channel in its useState initializer and replaces it on every switch.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { render } from 'ink-testing-library';
|
|
18
|
+
import React from 'react';
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
20
|
+
|
|
21
|
+
import App from '../App.js';
|
|
22
|
+
import { CommandEffectQueue } from '../hooks/command-effect-queue.js';
|
|
23
|
+
import { TuiStateManager } from '../tui-state-manager.js';
|
|
24
|
+
|
|
25
|
+
import type { ICommandEffectQueue } from '../hooks/command-effect-queue.js';
|
|
26
|
+
import type { ITuiCliAdapter } from '../tui-cli-adapter.js';
|
|
27
|
+
import type { TuiInteractionChannel } from '../TuiInteractionChannel.js';
|
|
28
|
+
import type {
|
|
29
|
+
IInteractiveSessionRecord,
|
|
30
|
+
IInteractiveSessionStore,
|
|
31
|
+
} from '@robota-sdk/agent-interface-transport';
|
|
32
|
+
|
|
33
|
+
const TICK_MS = 30;
|
|
34
|
+
const FRAME_DEADLINE_MS = 3000;
|
|
35
|
+
|
|
36
|
+
function tick(ms = TICK_MS): Promise<void> {
|
|
37
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function waitForFrame(
|
|
41
|
+
lastFrame: () => string | undefined,
|
|
42
|
+
predicate: (frame: string) => boolean,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
const deadline = Date.now() + FRAME_DEADLINE_MS;
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
const frame = lastFrame();
|
|
47
|
+
if (frame !== undefined && predicate(frame)) return;
|
|
48
|
+
await tick(10);
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`waitForFrame timeout\n--- frame ---\n${lastFrame() ?? '<none>'}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface IFakeChannel {
|
|
54
|
+
sessionName: string | undefined;
|
|
55
|
+
stateManager: TuiStateManager;
|
|
56
|
+
onChange: (() => void) | null;
|
|
57
|
+
isShuttingDown: boolean;
|
|
58
|
+
permissionRequest: null;
|
|
59
|
+
start: ReturnType<typeof vi.fn>;
|
|
60
|
+
stop: ReturnType<typeof vi.fn>;
|
|
61
|
+
handleInput: ReturnType<typeof vi.fn>;
|
|
62
|
+
abort: ReturnType<typeof vi.fn>;
|
|
63
|
+
cancelQueue: ReturnType<typeof vi.fn>;
|
|
64
|
+
shutdown: ReturnType<typeof vi.fn>;
|
|
65
|
+
selectExecutionWorkspaceEntry: ReturnType<typeof vi.fn>;
|
|
66
|
+
readExecutionWorkspaceDetail: ReturnType<typeof vi.fn>;
|
|
67
|
+
getSession: () => unknown;
|
|
68
|
+
getRegistry: () => unknown;
|
|
69
|
+
getCommandEffectQueue: () => ICommandEffectQueue;
|
|
70
|
+
/** Test handle: the queue backing getCommandEffectQueue. */
|
|
71
|
+
effectQueue: CommandEffectQueue;
|
|
72
|
+
/** Test handle: which resumeSessionId this channel was created for. */
|
|
73
|
+
createdFor: string | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createFakeChannel(createdFor: string | undefined): IFakeChannel {
|
|
77
|
+
const effectQueue = new CommandEffectQueue();
|
|
78
|
+
const fakeSession = {
|
|
79
|
+
getName: (): string | undefined => undefined,
|
|
80
|
+
getSession: (): never => {
|
|
81
|
+
throw new Error('session not initialized (test fake)');
|
|
82
|
+
},
|
|
83
|
+
getFullHistory: (): never[] => [],
|
|
84
|
+
setName: vi.fn(),
|
|
85
|
+
shutdown: vi.fn(async () => {}),
|
|
86
|
+
sendAgentJob: vi.fn(async () => {}),
|
|
87
|
+
};
|
|
88
|
+
const fakeRegistry = {
|
|
89
|
+
getCommands: (): never[] => [],
|
|
90
|
+
getSubcommands: (): never[] => [],
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
sessionName: undefined,
|
|
94
|
+
stateManager: new TuiStateManager(),
|
|
95
|
+
onChange: null,
|
|
96
|
+
isShuttingDown: false,
|
|
97
|
+
permissionRequest: null,
|
|
98
|
+
start: vi.fn(async () => {}),
|
|
99
|
+
stop: vi.fn(async () => {}),
|
|
100
|
+
handleInput: vi.fn(async () => {}),
|
|
101
|
+
abort: vi.fn(),
|
|
102
|
+
cancelQueue: vi.fn(),
|
|
103
|
+
shutdown: vi.fn(async () => {}),
|
|
104
|
+
selectExecutionWorkspaceEntry: vi.fn(),
|
|
105
|
+
readExecutionWorkspaceDetail: vi.fn(async () => ({ lines: [], title: '' })),
|
|
106
|
+
getSession: () => fakeSession,
|
|
107
|
+
getRegistry: () => fakeRegistry,
|
|
108
|
+
getCommandEffectQueue: () => effectQueue,
|
|
109
|
+
effectQueue,
|
|
110
|
+
createdFor,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function asChannel(fake: IFakeChannel): TuiInteractionChannel {
|
|
115
|
+
return fake as unknown as TuiInteractionChannel;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createFakeStore(records: IInteractiveSessionRecord[]): IInteractiveSessionStore {
|
|
119
|
+
return {
|
|
120
|
+
save: () => undefined,
|
|
121
|
+
load: (id) => records.find((r) => r.id === id),
|
|
122
|
+
list: () => records,
|
|
123
|
+
delete: () => undefined,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sessionRecord(
|
|
128
|
+
id: string,
|
|
129
|
+
cwd: string,
|
|
130
|
+
updatedAt = '2026-06-13T00:00:00.000Z',
|
|
131
|
+
): IInteractiveSessionRecord {
|
|
132
|
+
return {
|
|
133
|
+
id,
|
|
134
|
+
cwd,
|
|
135
|
+
createdAt: '2026-06-13T00:00:00.000Z',
|
|
136
|
+
updatedAt,
|
|
137
|
+
messages: [
|
|
138
|
+
{ role: 'user', content: `hello from ${id}` },
|
|
139
|
+
{ role: 'assistant', content: `reply in ${id}` },
|
|
140
|
+
] as IInteractiveSessionRecord['messages'],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The picker lists sessions newest-first; bumping updatedAt puts a record on top. */
|
|
145
|
+
function touch(records: IInteractiveSessionRecord[], id: string, updatedAt: string): void {
|
|
146
|
+
const record = records.find((r) => r.id === id);
|
|
147
|
+
if (!record) throw new Error(`no record ${id}`);
|
|
148
|
+
record.updatedAt = updatedAt;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function createCliAdapter(settingsPath: string): ITuiCliAdapter {
|
|
152
|
+
return {
|
|
153
|
+
getUserSettingsPath: () => settingsPath,
|
|
154
|
+
readSettings: () => ({}),
|
|
155
|
+
writeSettings: vi.fn(),
|
|
156
|
+
deleteSettings: vi.fn().mockReturnValue(false),
|
|
157
|
+
applyStatusLineSettings: vi.fn(),
|
|
158
|
+
reloadPluginCommandSource: vi.fn(),
|
|
159
|
+
applyActiveModelChange: vi.fn().mockReturnValue({ applied: true }),
|
|
160
|
+
getGitBranch: vi.fn().mockReturnValue(undefined),
|
|
161
|
+
getProviderDisplayName: vi.fn((type: string) => type),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
describe('App session-switch channel ownership (CLI-B11)', () => {
|
|
166
|
+
let cwd: string;
|
|
167
|
+
let created: IFakeChannel[];
|
|
168
|
+
let createChannel: ReturnType<typeof vi.fn>;
|
|
169
|
+
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
cwd = mkdtempSync(join(tmpdir(), 'robota-b11-'));
|
|
172
|
+
created = [];
|
|
173
|
+
createChannel = vi.fn((resumeSessionId?: string) => {
|
|
174
|
+
const fake = createFakeChannel(resumeSessionId);
|
|
175
|
+
created.push(fake);
|
|
176
|
+
return asChannel(fake);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
afterEach(() => {
|
|
181
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
function renderApp(options?: { sessionIds?: string[] }) {
|
|
185
|
+
const ids = options?.sessionIds ?? ['session-aaaaaaaa', 'session-bbbbbbbb'];
|
|
186
|
+
const records = ids.map((id) => sessionRecord(id, cwd));
|
|
187
|
+
const store = createFakeStore(records);
|
|
188
|
+
const instance = render(
|
|
189
|
+
<App
|
|
190
|
+
cwd={cwd}
|
|
191
|
+
createChannel={createChannel}
|
|
192
|
+
sessionStore={store}
|
|
193
|
+
showSessionPickerOnStart
|
|
194
|
+
cliAdapter={createCliAdapter(join(cwd, 'settings.json'))}
|
|
195
|
+
/>,
|
|
196
|
+
);
|
|
197
|
+
return { ...instance, records };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
it('TC-01 (B11) / TC-01 (B12): the factory is the sole channel source — once at mount, once per switch with the selected sessionId', async () => {
|
|
201
|
+
const { stdin, lastFrame } = renderApp();
|
|
202
|
+
await tick();
|
|
203
|
+
expect(lastFrame()).toContain('Select a session to resume');
|
|
204
|
+
|
|
205
|
+
// CLI-B12 TC-01: initial channel from the useState initializer, exactly once.
|
|
206
|
+
expect(createChannel).toHaveBeenCalledTimes(1);
|
|
207
|
+
expect(createChannel).toHaveBeenNthCalledWith(1, undefined);
|
|
208
|
+
|
|
209
|
+
stdin.write('\r'); // select first item (newest first — equal timestamps keep list order)
|
|
210
|
+
await tick();
|
|
211
|
+
|
|
212
|
+
// CLI-B11 TC-A: the switch asks the factory for exactly one channel with the id.
|
|
213
|
+
expect(createChannel).toHaveBeenCalledTimes(2);
|
|
214
|
+
expect(createChannel).toHaveBeenNthCalledWith(2, 'session-aaaaaaaa');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('TC-03 (B11) / TC-02 (B12): the previous channel is stopped before the new one becomes active', async () => {
|
|
218
|
+
const { stdin } = renderApp();
|
|
219
|
+
await tick();
|
|
220
|
+
const initialChannel = created[0]!;
|
|
221
|
+
expect(initialChannel.start).toHaveBeenCalled();
|
|
222
|
+
|
|
223
|
+
stdin.write('\r');
|
|
224
|
+
await tick();
|
|
225
|
+
|
|
226
|
+
// Old channel released: stopped by the switch handler and by the unmounting
|
|
227
|
+
// AppInner's effect cleanup (stop() is idempotent by contract).
|
|
228
|
+
expect(initialChannel.stop).toHaveBeenCalled();
|
|
229
|
+
expect(created).toHaveLength(2);
|
|
230
|
+
const newChannel = created[1]!;
|
|
231
|
+
expect(newChannel.start).toHaveBeenCalled();
|
|
232
|
+
expect(newChannel.stop).not.toHaveBeenCalled();
|
|
233
|
+
|
|
234
|
+
// CLI-B12 TC-02 ordering: old stop() was invoked BEFORE the factory built
|
|
235
|
+
// the replacement channel (stop-before-active contract).
|
|
236
|
+
const stopOrder = initialChannel.stop.mock.invocationCallOrder[0]!;
|
|
237
|
+
const replacementOrder = createChannel.mock.invocationCallOrder[1]!;
|
|
238
|
+
expect(stopOrder).toBeLessThan(replacementOrder);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('TC-04 (B12): App renders from the factory alone — no channel prop exists', async () => {
|
|
242
|
+
// The old no-factory fallback (B11 TC-D) is deleted with CLI-B12: createChannel
|
|
243
|
+
// is required and `channel` is no longer a prop (enforced at the type level —
|
|
244
|
+
// passing one is a compile error). This pins the runtime half: a render with
|
|
245
|
+
// only the factory boots, starts the initial channel, and keeps rendering.
|
|
246
|
+
const { lastFrame } = renderApp();
|
|
247
|
+
await tick();
|
|
248
|
+
|
|
249
|
+
expect(lastFrame()).toBeTruthy();
|
|
250
|
+
expect(createChannel).toHaveBeenCalledTimes(1);
|
|
251
|
+
expect(created[0]!.start).toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('TC-05: consecutive switches A→B→C create one channel per switch and stop each prior channel', async () => {
|
|
255
|
+
// Selection always takes the top (newest) entry; arrow-key navigation itself
|
|
256
|
+
// is covered by ListPicker.test.tsx. updatedAt ordering decides the target.
|
|
257
|
+
const ids = ['aaaaaaaa-1111', 'bbbbbbbb-2222', 'cccccccc-3333'];
|
|
258
|
+
const { stdin, lastFrame, records } = renderApp({ sessionIds: ids });
|
|
259
|
+
touch(records, 'aaaaaaaa-1111', '2026-06-13T01:00:00.000Z'); // A on top
|
|
260
|
+
await waitForFrame(lastFrame, (f) => f.includes('Select a session to resume'));
|
|
261
|
+
|
|
262
|
+
// Mount creates the initial channel (factory call 1, undefined).
|
|
263
|
+
expect(createChannel).toHaveBeenNthCalledWith(1, undefined);
|
|
264
|
+
const channelInitial = created[0]!;
|
|
265
|
+
|
|
266
|
+
// Switch 1: pick A (top) from the startup picker.
|
|
267
|
+
stdin.write('\r');
|
|
268
|
+
await waitForFrame(lastFrame, () => createChannel.mock.calls.length === 2);
|
|
269
|
+
expect(createChannel).toHaveBeenNthCalledWith(2, 'aaaaaaaa-1111');
|
|
270
|
+
expect(channelInitial.stop).toHaveBeenCalled();
|
|
271
|
+
const channelA = created[1]!;
|
|
272
|
+
|
|
273
|
+
// Switch 2: reopen the picker via a queued session-picker-requested effect,
|
|
274
|
+
// drained by a submit on the active channel (real /resume drain path).
|
|
275
|
+
touch(records, 'bbbbbbbb-2222', '2026-06-13T02:00:00.000Z'); // B on top
|
|
276
|
+
channelA.effectQueue.enqueueEffects([{ type: 'session-picker-requested' }]);
|
|
277
|
+
stdin.write('x');
|
|
278
|
+
await tick();
|
|
279
|
+
stdin.write('\r'); // submit input → drains queue → picker opens
|
|
280
|
+
await waitForFrame(lastFrame, (f) => f.includes('> bbbbbbbb'));
|
|
281
|
+
await tick(); // settle: let the reopened picker's useInput subscription attach
|
|
282
|
+
stdin.write('\r');
|
|
283
|
+
await waitForFrame(lastFrame, () => createChannel.mock.calls.length === 3);
|
|
284
|
+
expect(createChannel).toHaveBeenNthCalledWith(3, 'bbbbbbbb-2222');
|
|
285
|
+
expect(channelA.stop).toHaveBeenCalled();
|
|
286
|
+
const channelB = created[2]!;
|
|
287
|
+
expect(channelB.start).toHaveBeenCalled();
|
|
288
|
+
|
|
289
|
+
// Switch 3: same drill from B to C.
|
|
290
|
+
touch(records, 'cccccccc-3333', '2026-06-13T03:00:00.000Z'); // C on top
|
|
291
|
+
channelB.effectQueue.enqueueEffects([{ type: 'session-picker-requested' }]);
|
|
292
|
+
stdin.write('x');
|
|
293
|
+
await tick();
|
|
294
|
+
stdin.write('\r');
|
|
295
|
+
await waitForFrame(lastFrame, (f) => f.includes('> cccccccc'));
|
|
296
|
+
await tick(); // settle: let the reopened picker's useInput subscription attach
|
|
297
|
+
stdin.write('\r');
|
|
298
|
+
await waitForFrame(lastFrame, () => createChannel.mock.calls.length === 4);
|
|
299
|
+
expect(createChannel).toHaveBeenNthCalledWith(4, 'cccccccc-3333');
|
|
300
|
+
expect(channelB.stop).toHaveBeenCalled();
|
|
301
|
+
|
|
302
|
+
const channelC = created[3]!;
|
|
303
|
+
expect(channelC.start).toHaveBeenCalled();
|
|
304
|
+
expect(channelC.stop).not.toHaveBeenCalled();
|
|
305
|
+
expect(createChannel).toHaveBeenCalledTimes(4);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
BundlePluginLoader,
|
|
10
10
|
PluginCommandSource,
|
|
11
11
|
} from '@robota-sdk/agent-framework';
|
|
12
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
ICommandInteraction,
|
|
14
|
+
IInteractiveSession,
|
|
15
|
+
} from '@robota-sdk/agent-interface-transport';
|
|
13
16
|
import { TuiStateManager } from '../tui-state-manager.js';
|
|
14
17
|
import { applySystemCommandResult } from '../hooks/useSlashRouting.js';
|
|
15
18
|
import { CommandEffectQueue } from '../hooks/command-effect-queue.js';
|
|
@@ -11,10 +11,10 @@ describe('formatStatusActivity', () => {
|
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
expect(activity.kind).toBe('tools');
|
|
14
|
-
expect(activity.label).toBe('Tools
|
|
14
|
+
expect(activity.label).toBe('Tools (2)');
|
|
15
15
|
expect(activity.color).toBe('cyan');
|
|
16
16
|
expect(activity.segments).toEqual(['queued']);
|
|
17
|
-
expect(activity.text).toBe('Tools
|
|
17
|
+
expect(activity.text).toBe('Tools (2) · queued');
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
it('shows thinking as the primary model waiting state', () => {
|
|
@@ -39,7 +39,7 @@ describe('formatStatusActivity', () => {
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
expect(activity.kind).toBe('background');
|
|
42
|
-
expect(activity.label).toBe('Background
|
|
42
|
+
expect(activity.label).toBe('Background (1)');
|
|
43
43
|
expect(activity.color).toBe('cyan');
|
|
44
44
|
});
|
|
45
45
|
|