@dexto/tui 1.13.5 → 1.14.0
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/agent-backend.cjs +2 -0
- package/dist/agent-backend.d.ts +2 -0
- package/dist/agent-backend.d.ts.map +1 -1
- package/dist/agent-backend.js +2 -0
- package/dist/components/ApprovalPrompt.cjs +19 -17
- package/dist/components/ApprovalPrompt.d.ts +2 -1
- package/dist/components/ApprovalPrompt.d.ts.map +1 -1
- package/dist/components/ApprovalPrompt.js +19 -17
- package/dist/components/ApprovalPrompt.test.cjs +119 -0
- package/dist/components/ApprovalPrompt.test.d.ts +2 -0
- package/dist/components/ApprovalPrompt.test.d.ts.map +1 -0
- package/dist/components/ApprovalPrompt.test.js +120 -0
- package/dist/components/Footer.cjs +5 -1
- package/dist/components/Footer.d.ts.map +1 -1
- package/dist/components/Footer.js +5 -1
- package/dist/components/Footer.test.cjs +47 -0
- package/dist/components/Footer.test.d.ts +2 -0
- package/dist/components/Footer.test.d.ts.map +1 -0
- package/dist/components/Footer.test.js +46 -0
- package/dist/components/modes/AlternateBufferCLI.cjs +3 -1
- package/dist/components/modes/AlternateBufferCLI.d.ts.map +1 -1
- package/dist/components/modes/AlternateBufferCLI.js +3 -1
- package/dist/components/modes/StaticCLI.cjs +3 -1
- package/dist/components/modes/StaticCLI.d.ts.map +1 -1
- package/dist/components/modes/StaticCLI.js +3 -1
- package/dist/components/overlays/PermissionsSelector.cjs +139 -0
- package/dist/components/overlays/PermissionsSelector.d.ts +16 -0
- package/dist/components/overlays/PermissionsSelector.d.ts.map +1 -0
- package/dist/components/overlays/PermissionsSelector.js +115 -0
- package/dist/components/overlays/PermissionsSelector.test.cjs +105 -0
- package/dist/components/overlays/PermissionsSelector.test.d.ts +2 -0
- package/dist/components/overlays/PermissionsSelector.test.d.ts.map +1 -0
- package/dist/components/overlays/PermissionsSelector.test.js +104 -0
- package/dist/containers/InputContainer.cjs +108 -28
- package/dist/containers/InputContainer.d.ts +1 -0
- package/dist/containers/InputContainer.d.ts.map +1 -1
- package/dist/containers/InputContainer.js +108 -28
- package/dist/containers/OverlayContainer.cjs +15 -0
- package/dist/containers/OverlayContainer.d.ts.map +1 -1
- package/dist/containers/OverlayContainer.js +17 -0
- package/dist/hooks/useAgentEvents.cjs +6 -3
- package/dist/hooks/useAgentEvents.d.ts.map +1 -1
- package/dist/hooks/useAgentEvents.js +6 -3
- package/dist/hooks/useCLIState.cjs +28 -8
- package/dist/hooks/useCLIState.d.ts +1 -0
- package/dist/hooks/useCLIState.d.ts.map +1 -1
- package/dist/hooks/useCLIState.js +28 -8
- package/dist/hooks/useCLIState.test.cjs +583 -0
- package/dist/hooks/useCLIState.test.d.ts +2 -0
- package/dist/hooks/useCLIState.test.d.ts.map +1 -0
- package/dist/hooks/useCLIState.test.js +582 -0
- package/dist/index.d.cts +2 -0
- package/dist/interactive-commands/commands.test.cjs +6 -0
- package/dist/interactive-commands/commands.test.js +6 -0
- package/dist/interactive-commands/tool-commands.cjs +7 -0
- package/dist/interactive-commands/tool-commands.d.ts.map +1 -1
- package/dist/interactive-commands/tool-commands.js +7 -0
- package/dist/services/processStream.cjs +4 -2
- package/dist/services/processStream.d.ts.map +1 -1
- package/dist/services/processStream.js +4 -2
- package/dist/services/processStream.test.cjs +63 -0
- package/dist/services/processStream.test.js +63 -0
- package/dist/state/types.d.ts +1 -1
- package/dist/state/types.d.ts.map +1 -1
- package/dist/utils/approvalScope.cjs +35 -0
- package/dist/utils/approvalScope.d.ts +3 -0
- package/dist/utils/approvalScope.d.ts.map +1 -0
- package/dist/utils/approvalScope.js +11 -0
- package/dist/utils/commandOverlays.cjs +1 -0
- package/dist/utils/commandOverlays.d.ts.map +1 -1
- package/dist/utils/commandOverlays.js +1 -0
- package/package.json +5 -5
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { createElement, createRef, Fragment } from "react";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import { render, Static, Text } from "ink";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useCLIState } from "./useCLIState.js";
|
|
6
|
+
import { KeypressProvider } from "../contexts/KeypressContext.js";
|
|
7
|
+
import { AgentEventBus } from "@dexto/core";
|
|
8
|
+
import { createUserMessage } from "../utils/messageFormatting.js";
|
|
9
|
+
import { InputContainer } from "../containers/InputContainer.js";
|
|
10
|
+
vi.mock("../components/input/InputArea.js", () => ({ InputArea: () => null }));
|
|
11
|
+
const agent = {
|
|
12
|
+
getCurrentLLMConfig: () => ({ provider: "openai", model: "gpt-5" }),
|
|
13
|
+
getSessionHistory: async () => [],
|
|
14
|
+
on: () => {
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
function mountState(initialSessionId, backend = agent, initialPrompt, withInput = false) {
|
|
18
|
+
const inputRef = createRef();
|
|
19
|
+
const stdout = Object.assign(new PassThrough(), { columns: 120, rows: 40, isTTY: false });
|
|
20
|
+
let state;
|
|
21
|
+
let output = "";
|
|
22
|
+
stdout.on("data", (chunk) => {
|
|
23
|
+
output += chunk.toString();
|
|
24
|
+
});
|
|
25
|
+
function Probe() {
|
|
26
|
+
state = useCLIState({
|
|
27
|
+
agent: backend,
|
|
28
|
+
initialSessionId,
|
|
29
|
+
initialBypassPermissions: true,
|
|
30
|
+
startupInfo: {
|
|
31
|
+
connectedServers: { count: 0, names: [] },
|
|
32
|
+
failedConnections: [],
|
|
33
|
+
toolCount: 0,
|
|
34
|
+
logFile: null
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
const transcript = createElement(Static, {
|
|
38
|
+
items: state.visibleMessages,
|
|
39
|
+
children: (message) => createElement(Text, { key: message.id }, message.content)
|
|
40
|
+
});
|
|
41
|
+
return createElement(
|
|
42
|
+
Fragment,
|
|
43
|
+
null,
|
|
44
|
+
transcript,
|
|
45
|
+
withInput ? createElement(InputContainer, {
|
|
46
|
+
...state,
|
|
47
|
+
ref: inputRef,
|
|
48
|
+
initialPrompt,
|
|
49
|
+
configFilePath: null
|
|
50
|
+
}) : null
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const view = render(createElement(KeypressProvider, null, createElement(Probe)), {
|
|
54
|
+
stdout,
|
|
55
|
+
patchConsole: false,
|
|
56
|
+
exitOnCtrlC: false
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
getState() {
|
|
60
|
+
if (!state) throw new Error("Not mounted");
|
|
61
|
+
return state;
|
|
62
|
+
},
|
|
63
|
+
submit(text) {
|
|
64
|
+
if (!inputRef.current) throw new Error("Input not mounted");
|
|
65
|
+
return inputRef.current.submit(text);
|
|
66
|
+
},
|
|
67
|
+
output() {
|
|
68
|
+
return output;
|
|
69
|
+
},
|
|
70
|
+
close() {
|
|
71
|
+
view.unmount();
|
|
72
|
+
view.cleanup();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
describe("TUI session permission modes", () => {
|
|
77
|
+
it("keeps startup auto-approval when the first session is created, then resets on switch", async () => {
|
|
78
|
+
const screen = mountState(null);
|
|
79
|
+
try {
|
|
80
|
+
await vi.waitFor(() => expect(screen.getState().ui.bypassPermissions).toBe(true));
|
|
81
|
+
screen.getState().setSession((previous) => ({ ...previous, id: "a", hasActiveSession: true }));
|
|
82
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
|
|
83
|
+
expect(screen.getState().ui.bypassPermissions).toBe(true);
|
|
84
|
+
screen.getState().setSession((previous) => ({ ...previous, id: "b", hasActiveSession: true }));
|
|
85
|
+
await vi.waitFor(() => expect(screen.getState().ui.bypassPermissions).toBe(false));
|
|
86
|
+
} finally {
|
|
87
|
+
screen.close();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
it("resets accept-edits mode when a conversation is cleared", async () => {
|
|
91
|
+
const screen = mountState("a");
|
|
92
|
+
try {
|
|
93
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
|
|
94
|
+
screen.getState().setUi((previous) => ({
|
|
95
|
+
...previous,
|
|
96
|
+
autoApproveEdits: true,
|
|
97
|
+
bypassPermissions: false
|
|
98
|
+
}));
|
|
99
|
+
await vi.waitFor(() => expect(screen.getState().ui.autoApproveEdits).toBe(true));
|
|
100
|
+
screen.getState().setSession((previous) => ({ ...previous, id: null, hasActiveSession: false }));
|
|
101
|
+
await vi.waitFor(() => expect(screen.getState().ui.autoApproveEdits).toBe(false));
|
|
102
|
+
} finally {
|
|
103
|
+
screen.close();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe("TUI resumed history", () => {
|
|
108
|
+
it("waits for the actual history snapshot before persisting the startup prompt", async () => {
|
|
109
|
+
let releaseHistory = () => {
|
|
110
|
+
};
|
|
111
|
+
const ready = new Promise((resolve) => {
|
|
112
|
+
releaseHistory = resolve;
|
|
113
|
+
});
|
|
114
|
+
const persisted = [
|
|
115
|
+
{ role: "user", content: [{ type: "text", text: "earlier task" }] }
|
|
116
|
+
];
|
|
117
|
+
const stream = vi.fn(async function* (content) {
|
|
118
|
+
persisted.push({ role: "user", content: [{ type: "text", text: content }] });
|
|
119
|
+
yield { name: "run:complete", sessionId: "a", durationMs: 0, totalTokens: 0 };
|
|
120
|
+
});
|
|
121
|
+
const backend = {
|
|
122
|
+
...agent,
|
|
123
|
+
stream,
|
|
124
|
+
getSessionMetadata: async () => ({ messageCount: 1 }),
|
|
125
|
+
logger: { error: vi.fn() },
|
|
126
|
+
getSessionHistory: vi.fn(async () => {
|
|
127
|
+
await ready;
|
|
128
|
+
return [...persisted];
|
|
129
|
+
})
|
|
130
|
+
};
|
|
131
|
+
const screen = mountState("a", backend, "startup task", true);
|
|
132
|
+
try {
|
|
133
|
+
await vi.waitFor(() => expect(backend.getSessionHistory).toHaveBeenCalled());
|
|
134
|
+
expect(stream).not.toHaveBeenCalled();
|
|
135
|
+
screen.getState().buffer.setText("new draft");
|
|
136
|
+
await vi.waitFor(() => expect(screen.getState().input.value).toBe("new draft"));
|
|
137
|
+
releaseHistory();
|
|
138
|
+
await vi.waitFor(() => expect(stream).toHaveBeenCalledTimes(1));
|
|
139
|
+
expect(screen.getState().messages.map((message) => message.content)).toEqual([
|
|
140
|
+
"earlier task",
|
|
141
|
+
"startup task"
|
|
142
|
+
]);
|
|
143
|
+
expect(screen.getState().input.value).toBe("new draft");
|
|
144
|
+
expect(screen.output().match(/startup task/g)).toHaveLength(1);
|
|
145
|
+
} finally {
|
|
146
|
+
releaseHistory();
|
|
147
|
+
screen.close();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
it("cancels the startup prompt after switching away from its pending resumed session", async () => {
|
|
151
|
+
let releaseHistory = () => {
|
|
152
|
+
};
|
|
153
|
+
const ready = new Promise((resolve) => {
|
|
154
|
+
releaseHistory = resolve;
|
|
155
|
+
});
|
|
156
|
+
const stream = vi.fn(async function* (_content) {
|
|
157
|
+
yield { name: "run:complete", sessionId: "b", durationMs: 0, totalTokens: 0 };
|
|
158
|
+
});
|
|
159
|
+
const backend = {
|
|
160
|
+
...agent,
|
|
161
|
+
stream,
|
|
162
|
+
getSessionMetadata: async () => ({ messageCount: 1 }),
|
|
163
|
+
logger: { error: vi.fn() },
|
|
164
|
+
getSessionHistory: vi.fn(async () => {
|
|
165
|
+
await ready;
|
|
166
|
+
return [];
|
|
167
|
+
})
|
|
168
|
+
};
|
|
169
|
+
const screen = mountState("a", backend, "startup task for a", true);
|
|
170
|
+
try {
|
|
171
|
+
await vi.waitFor(() => expect(backend.getSessionHistory).toHaveBeenCalledWith("a"));
|
|
172
|
+
screen.getState().setSession((previous) => ({ ...previous, id: "b" }));
|
|
173
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("b"));
|
|
174
|
+
expect(stream).not.toHaveBeenCalled();
|
|
175
|
+
await screen.submit("manual task for b");
|
|
176
|
+
expect(stream).toHaveBeenCalledExactlyOnceWith("manual task for b", "b");
|
|
177
|
+
releaseHistory();
|
|
178
|
+
screen.getState().setUi((previous) => ({ ...previous, isThinking: true }));
|
|
179
|
+
await vi.waitFor(() => expect(screen.getState().ui.isThinking).toBe(true));
|
|
180
|
+
expect(stream).toHaveBeenCalledTimes(1);
|
|
181
|
+
expect(screen.getState().messages.map((message) => message.content)).toEqual([
|
|
182
|
+
"manual task for b"
|
|
183
|
+
]);
|
|
184
|
+
} finally {
|
|
185
|
+
releaseHistory();
|
|
186
|
+
screen.close();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
it("still creates a first session for an ordinary startup prompt", async () => {
|
|
190
|
+
const createSession = vi.fn(async () => ({ id: "created" }));
|
|
191
|
+
const stream = vi.fn(async function* (_content) {
|
|
192
|
+
yield { name: "run:complete", sessionId: "created", durationMs: 0, totalTokens: 0 };
|
|
193
|
+
});
|
|
194
|
+
const backend = {
|
|
195
|
+
...agent,
|
|
196
|
+
createSession,
|
|
197
|
+
stream,
|
|
198
|
+
getSessionMetadata: async () => ({ messageCount: 1 }),
|
|
199
|
+
logger: { error: vi.fn() }
|
|
200
|
+
};
|
|
201
|
+
const screen = mountState(null, backend, "new startup task", true);
|
|
202
|
+
try {
|
|
203
|
+
await vi.waitFor(
|
|
204
|
+
() => expect(stream).toHaveBeenCalledExactlyOnceWith("new startup task", "created")
|
|
205
|
+
);
|
|
206
|
+
expect(createSession).toHaveBeenCalledTimes(1);
|
|
207
|
+
expect(screen.getState().session.id).toBe("created");
|
|
208
|
+
} finally {
|
|
209
|
+
screen.close();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
it("preserves multiple submissions and a newer draft while resumed history is pending", async () => {
|
|
213
|
+
let releaseHistory = () => {
|
|
214
|
+
};
|
|
215
|
+
const ready = new Promise((resolve) => {
|
|
216
|
+
releaseHistory = resolve;
|
|
217
|
+
});
|
|
218
|
+
const stream = vi.fn(async function* (_content) {
|
|
219
|
+
yield { name: "run:complete", sessionId: "a", durationMs: 0, totalTokens: 0 };
|
|
220
|
+
});
|
|
221
|
+
const backend = {
|
|
222
|
+
...agent,
|
|
223
|
+
stream,
|
|
224
|
+
getSessionMetadata: async () => ({ messageCount: 1 }),
|
|
225
|
+
logger: { error: vi.fn() },
|
|
226
|
+
getSessionHistory: vi.fn(async () => {
|
|
227
|
+
await ready;
|
|
228
|
+
return [{ role: "user", content: [{ type: "text", text: "earlier task" }] }];
|
|
229
|
+
})
|
|
230
|
+
};
|
|
231
|
+
const screen = mountState("a", backend, "startup task", true);
|
|
232
|
+
try {
|
|
233
|
+
await vi.waitFor(() => expect(backend.getSessionHistory).toHaveBeenCalled());
|
|
234
|
+
const first = screen.submit("first task");
|
|
235
|
+
const second = screen.submit("second task");
|
|
236
|
+
const repeated = screen.submit("first task");
|
|
237
|
+
screen.getState().buffer.setText("newer draft");
|
|
238
|
+
await vi.waitFor(() => expect(screen.getState().input.value).toBe("newer draft"));
|
|
239
|
+
expect(stream).not.toHaveBeenCalled();
|
|
240
|
+
releaseHistory();
|
|
241
|
+
await Promise.all([first, second, repeated]);
|
|
242
|
+
await vi.waitFor(() => expect(stream).toHaveBeenCalledTimes(4));
|
|
243
|
+
expect(stream.mock.calls.map(([content]) => content)).toEqual([
|
|
244
|
+
"startup task",
|
|
245
|
+
"first task",
|
|
246
|
+
"second task",
|
|
247
|
+
"first task"
|
|
248
|
+
]);
|
|
249
|
+
expect(screen.getState().input.history).toEqual([
|
|
250
|
+
"earlier task",
|
|
251
|
+
"startup task",
|
|
252
|
+
"first task",
|
|
253
|
+
"second task",
|
|
254
|
+
"first task"
|
|
255
|
+
]);
|
|
256
|
+
expect(screen.getState().input.value).toBe("newer draft");
|
|
257
|
+
} finally {
|
|
258
|
+
releaseHistory();
|
|
259
|
+
screen.close();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
it("releases deferred submission after history failure without hydrating again on return", async () => {
|
|
263
|
+
let releaseHistory = () => {
|
|
264
|
+
};
|
|
265
|
+
const ready = new Promise((resolve) => {
|
|
266
|
+
releaseHistory = resolve;
|
|
267
|
+
});
|
|
268
|
+
const stream = vi.fn(async function* (_content) {
|
|
269
|
+
yield { name: "run:complete", sessionId: "a", durationMs: 0, totalTokens: 0 };
|
|
270
|
+
});
|
|
271
|
+
const backend = {
|
|
272
|
+
...agent,
|
|
273
|
+
stream,
|
|
274
|
+
getSessionMetadata: async () => ({ messageCount: 1 }),
|
|
275
|
+
logger: { error: vi.fn() },
|
|
276
|
+
getSessionHistory: vi.fn(async () => {
|
|
277
|
+
await ready;
|
|
278
|
+
throw new Error("history unavailable");
|
|
279
|
+
})
|
|
280
|
+
};
|
|
281
|
+
const screen = mountState("a", backend, void 0, true);
|
|
282
|
+
try {
|
|
283
|
+
await vi.waitFor(() => expect(backend.getSessionHistory).toHaveBeenCalled());
|
|
284
|
+
const pending = screen.submit("continue anyway");
|
|
285
|
+
expect(stream).not.toHaveBeenCalled();
|
|
286
|
+
releaseHistory();
|
|
287
|
+
await pending;
|
|
288
|
+
await vi.waitFor(() => expect(stream).toHaveBeenCalledTimes(1));
|
|
289
|
+
expect(screen.getState().messages.map((message) => message.content)).toEqual([
|
|
290
|
+
"Error: history unavailable",
|
|
291
|
+
"continue anyway"
|
|
292
|
+
]);
|
|
293
|
+
screen.getState().setSession((previous) => ({ ...previous, id: "b" }));
|
|
294
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("b"));
|
|
295
|
+
screen.getState().setSession((previous) => ({ ...previous, id: "a" }));
|
|
296
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
|
|
297
|
+
expect(backend.getSessionHistory).toHaveBeenCalledTimes(1);
|
|
298
|
+
} finally {
|
|
299
|
+
releaseHistory();
|
|
300
|
+
screen.close();
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
it("does not send deferred submissions into a different session", async () => {
|
|
304
|
+
let releaseHistory = () => {
|
|
305
|
+
};
|
|
306
|
+
const ready = new Promise((resolve) => {
|
|
307
|
+
releaseHistory = resolve;
|
|
308
|
+
});
|
|
309
|
+
const stream = vi.fn();
|
|
310
|
+
const backend = {
|
|
311
|
+
...agent,
|
|
312
|
+
stream,
|
|
313
|
+
getSessionHistory: vi.fn(async () => {
|
|
314
|
+
await ready;
|
|
315
|
+
return [];
|
|
316
|
+
})
|
|
317
|
+
};
|
|
318
|
+
const screen = mountState("a", backend, void 0, true);
|
|
319
|
+
try {
|
|
320
|
+
await vi.waitFor(() => expect(backend.getSessionHistory).toHaveBeenCalled());
|
|
321
|
+
const pending = screen.submit("session a task");
|
|
322
|
+
screen.getState().setSession((previous) => ({ ...previous, id: "b" }));
|
|
323
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("b"));
|
|
324
|
+
await pending;
|
|
325
|
+
releaseHistory();
|
|
326
|
+
expect(stream).not.toHaveBeenCalled();
|
|
327
|
+
expect(screen.getState().messages).toEqual([]);
|
|
328
|
+
} finally {
|
|
329
|
+
releaseHistory();
|
|
330
|
+
screen.close();
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
it("preserves resumed history and a prompt submitted while history is loading", async () => {
|
|
334
|
+
let finishHistory = () => {
|
|
335
|
+
throw new Error("History request not started");
|
|
336
|
+
};
|
|
337
|
+
const history = new Promise((resolve) => {
|
|
338
|
+
finishHistory = resolve;
|
|
339
|
+
});
|
|
340
|
+
const getSessionHistory = vi.fn(() => history);
|
|
341
|
+
const screen = mountState("a", { ...agent, getSessionHistory });
|
|
342
|
+
try {
|
|
343
|
+
await vi.waitFor(() => expect(getSessionHistory).toHaveBeenCalledWith("a"));
|
|
344
|
+
const prompt = createUserMessage("continue this task");
|
|
345
|
+
screen.getState().setMessages((previous) => [...previous, prompt]);
|
|
346
|
+
screen.getState().setInput((previous) => ({
|
|
347
|
+
...previous,
|
|
348
|
+
history: [...previous.history, prompt.content]
|
|
349
|
+
}));
|
|
350
|
+
await vi.waitFor(() => expect(screen.getState().messages).toEqual([prompt]));
|
|
351
|
+
expect(screen.output()).not.toContain("continue this task");
|
|
352
|
+
finishHistory([
|
|
353
|
+
{ role: "user", content: [{ type: "text", text: "earlier task" }] },
|
|
354
|
+
{
|
|
355
|
+
role: "assistant",
|
|
356
|
+
assistantOutput: { status: "complete" },
|
|
357
|
+
content: [{ type: "text", text: "earlier answer" }]
|
|
358
|
+
}
|
|
359
|
+
]);
|
|
360
|
+
await vi.waitFor(
|
|
361
|
+
() => expect(screen.getState().messages.map((message) => message.content)).toEqual([
|
|
362
|
+
"earlier task",
|
|
363
|
+
"earlier answer",
|
|
364
|
+
"continue this task"
|
|
365
|
+
])
|
|
366
|
+
);
|
|
367
|
+
expect(screen.getState().input.history).toEqual(["earlier task", "continue this task"]);
|
|
368
|
+
await vi.waitFor(() => expect(screen.output()).toContain("continue this task"));
|
|
369
|
+
expect(screen.output().indexOf("earlier task")).toBeLessThan(
|
|
370
|
+
screen.output().indexOf("continue this task")
|
|
371
|
+
);
|
|
372
|
+
expect(screen.output().indexOf("earlier answer")).toBeLessThan(
|
|
373
|
+
screen.output().indexOf("continue this task")
|
|
374
|
+
);
|
|
375
|
+
expect(screen.output().match(/continue this task/g)).toHaveLength(1);
|
|
376
|
+
expect(getSessionHistory).toHaveBeenCalledTimes(1);
|
|
377
|
+
} finally {
|
|
378
|
+
screen.close();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
it("discards pending resumed history after switching to another session", async () => {
|
|
382
|
+
let finishHistory = () => {
|
|
383
|
+
throw new Error("History request not started");
|
|
384
|
+
};
|
|
385
|
+
const history = new Promise((resolve) => {
|
|
386
|
+
finishHistory = resolve;
|
|
387
|
+
});
|
|
388
|
+
const getSessionHistory = vi.fn(() => history);
|
|
389
|
+
const screen = mountState("a", { ...agent, getSessionHistory });
|
|
390
|
+
try {
|
|
391
|
+
await vi.waitFor(() => expect(getSessionHistory).toHaveBeenCalledWith("a"));
|
|
392
|
+
screen.getState().setSession((previous) => ({
|
|
393
|
+
...previous,
|
|
394
|
+
id: "b",
|
|
395
|
+
hasActiveSession: true
|
|
396
|
+
}));
|
|
397
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("b"));
|
|
398
|
+
const prompt = createUserMessage("session b prompt");
|
|
399
|
+
screen.getState().setMessages([prompt]);
|
|
400
|
+
screen.getState().setInput((previous) => ({ ...previous, history: [prompt.content] }));
|
|
401
|
+
await vi.waitFor(() => expect(screen.output()).toContain("session b prompt"));
|
|
402
|
+
finishHistory([
|
|
403
|
+
{ role: "user", content: [{ type: "text", text: "session a history" }] }
|
|
404
|
+
]);
|
|
405
|
+
await history;
|
|
406
|
+
screen.getState().setUi((previous) => ({ ...previous, isThinking: true }));
|
|
407
|
+
await vi.waitFor(() => expect(screen.getState().ui.isThinking).toBe(true));
|
|
408
|
+
expect(screen.getState().messages).toEqual([prompt]);
|
|
409
|
+
expect(screen.getState().input.history).toEqual(["session b prompt"]);
|
|
410
|
+
expect(screen.output()).not.toContain("session a history");
|
|
411
|
+
expect(getSessionHistory).toHaveBeenCalledTimes(1);
|
|
412
|
+
} finally {
|
|
413
|
+
screen.close();
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
it("loads ordinary resumed history once without replacing a current input draft", async () => {
|
|
417
|
+
const getSessionHistory = vi.fn(
|
|
418
|
+
async () => [
|
|
419
|
+
{ role: "user", content: [{ type: "text", text: "earlier task" }] },
|
|
420
|
+
{
|
|
421
|
+
role: "assistant",
|
|
422
|
+
assistantOutput: { status: "complete" },
|
|
423
|
+
content: [{ type: "text", text: "earlier answer" }]
|
|
424
|
+
}
|
|
425
|
+
]
|
|
426
|
+
);
|
|
427
|
+
const screen = mountState("a", { ...agent, getSessionHistory });
|
|
428
|
+
try {
|
|
429
|
+
await vi.waitFor(() => expect(screen.getState().messages).toHaveLength(2));
|
|
430
|
+
screen.getState().buffer.setText("next task draft");
|
|
431
|
+
await vi.waitFor(() => expect(screen.getState().input.value).toBe("next task draft"));
|
|
432
|
+
expect(screen.getState().input.history).toEqual(["earlier task"]);
|
|
433
|
+
screen.getState().setSession((previous) => ({ ...previous, id: "b" }));
|
|
434
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("b"));
|
|
435
|
+
screen.getState().setSession((previous) => ({ ...previous, id: "a" }));
|
|
436
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
|
|
437
|
+
expect(screen.getState().messages.map((message) => message.content)).toEqual([
|
|
438
|
+
"earlier task",
|
|
439
|
+
"earlier answer"
|
|
440
|
+
]);
|
|
441
|
+
expect(getSessionHistory).toHaveBeenCalledTimes(1);
|
|
442
|
+
} finally {
|
|
443
|
+
screen.close();
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
it("releases submitted messages to static output when resumed history fails", async () => {
|
|
447
|
+
let releaseHistory = () => {
|
|
448
|
+
};
|
|
449
|
+
const ready = new Promise((resolve) => {
|
|
450
|
+
releaseHistory = resolve;
|
|
451
|
+
});
|
|
452
|
+
const getSessionHistory = vi.fn(async () => {
|
|
453
|
+
await ready;
|
|
454
|
+
throw new Error("history unavailable");
|
|
455
|
+
});
|
|
456
|
+
const screen = mountState("a", { ...agent, getSessionHistory });
|
|
457
|
+
try {
|
|
458
|
+
await vi.waitFor(() => expect(getSessionHistory).toHaveBeenCalledWith("a"));
|
|
459
|
+
const prompt = createUserMessage("continue without history");
|
|
460
|
+
screen.getState().setMessages([prompt]);
|
|
461
|
+
await vi.waitFor(() => expect(screen.getState().messages).toEqual([prompt]));
|
|
462
|
+
expect(screen.output()).not.toContain(prompt.content);
|
|
463
|
+
releaseHistory();
|
|
464
|
+
await vi.waitFor(() => expect(screen.output()).toContain("Error: history unavailable"));
|
|
465
|
+
expect(screen.output()).toContain(prompt.content);
|
|
466
|
+
expect(screen.output().match(/continue without history/g)).toHaveLength(1);
|
|
467
|
+
expect(screen.getState().messages.map((message) => message.content)).toEqual([
|
|
468
|
+
prompt.content,
|
|
469
|
+
"Error: history unavailable"
|
|
470
|
+
]);
|
|
471
|
+
} finally {
|
|
472
|
+
screen.close();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
describe("TUI session queue isolation", () => {
|
|
477
|
+
it("keeps a current-session queue snapshot while the composer changes", async () => {
|
|
478
|
+
const events = new AgentEventBus();
|
|
479
|
+
let release = () => {
|
|
480
|
+
};
|
|
481
|
+
const pending = new Promise((resolve) => {
|
|
482
|
+
release = resolve;
|
|
483
|
+
});
|
|
484
|
+
const messages = [
|
|
485
|
+
{ id: "queued", content: [{ type: "text", text: "queued task" }], queuedAt: 1 }
|
|
486
|
+
];
|
|
487
|
+
const backend = {
|
|
488
|
+
...agent,
|
|
489
|
+
on: events.on.bind(events),
|
|
490
|
+
getSteerMessages: vi.fn(async () => pending)
|
|
491
|
+
};
|
|
492
|
+
const screen = mountState("a", backend);
|
|
493
|
+
try {
|
|
494
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
|
|
495
|
+
events.emit("message:queued", {
|
|
496
|
+
sessionId: "a",
|
|
497
|
+
queue: "steer",
|
|
498
|
+
id: "queued",
|
|
499
|
+
position: 1
|
|
500
|
+
});
|
|
501
|
+
screen.getState().buffer.setText("a newer draft");
|
|
502
|
+
await vi.waitFor(() => expect(screen.getState().input.value).toBe("a newer draft"));
|
|
503
|
+
release(messages);
|
|
504
|
+
await vi.waitFor(() => expect(screen.getState().steerMessages).toEqual(messages));
|
|
505
|
+
expect(screen.getState().input.value).toBe("a newer draft");
|
|
506
|
+
} finally {
|
|
507
|
+
release([]);
|
|
508
|
+
screen.close();
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
it.each(["steer", "follow-up"])(
|
|
512
|
+
"ignores %s queue events from another session",
|
|
513
|
+
async (queue) => {
|
|
514
|
+
const events = new AgentEventBus();
|
|
515
|
+
const getMessages = vi.fn(async () => []);
|
|
516
|
+
const backend = {
|
|
517
|
+
...agent,
|
|
518
|
+
on: events.on.bind(events),
|
|
519
|
+
getSteerMessages: getMessages,
|
|
520
|
+
getFollowUpMessages: getMessages
|
|
521
|
+
};
|
|
522
|
+
const screen = mountState("a", backend);
|
|
523
|
+
try {
|
|
524
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
|
|
525
|
+
events.emit("message:queued", { sessionId: "b", queue, id: "other", position: 1 });
|
|
526
|
+
events.emit("message:removed", { sessionId: "b", queue, id: "other" });
|
|
527
|
+
expect(getMessages).not.toHaveBeenCalled();
|
|
528
|
+
} finally {
|
|
529
|
+
screen.close();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
);
|
|
533
|
+
it.each(["steer", "follow-up"])(
|
|
534
|
+
"discards a pending %s snapshot after switching sessions",
|
|
535
|
+
async (queue) => {
|
|
536
|
+
const events = new AgentEventBus();
|
|
537
|
+
let release = () => {
|
|
538
|
+
};
|
|
539
|
+
const pending = new Promise((resolve) => {
|
|
540
|
+
release = resolve;
|
|
541
|
+
});
|
|
542
|
+
const current = [
|
|
543
|
+
{ id: "current", content: [{ type: "text", text: "current task" }], queuedAt: 1 }
|
|
544
|
+
];
|
|
545
|
+
const getMessages = vi.fn(
|
|
546
|
+
async (sessionId) => sessionId === "a" ? pending : current
|
|
547
|
+
);
|
|
548
|
+
const backend = {
|
|
549
|
+
...agent,
|
|
550
|
+
on: events.on.bind(events),
|
|
551
|
+
getSteerMessages: getMessages,
|
|
552
|
+
getFollowUpMessages: getMessages
|
|
553
|
+
};
|
|
554
|
+
const screen = mountState("a", backend);
|
|
555
|
+
const visibleQueue = () => queue === "steer" ? screen.getState().steerMessages : screen.getState().queuedMessages;
|
|
556
|
+
try {
|
|
557
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("a"));
|
|
558
|
+
events.emit("message:queued", { sessionId: "a", queue, id: "old", position: 1 });
|
|
559
|
+
expect(getMessages).toHaveBeenCalledWith("a");
|
|
560
|
+
screen.getState().setSession((previous) => ({ ...previous, id: "b" }));
|
|
561
|
+
await vi.waitFor(() => expect(screen.getState().session.id).toBe("b"));
|
|
562
|
+
events.emit("message:queued", {
|
|
563
|
+
sessionId: "b",
|
|
564
|
+
queue,
|
|
565
|
+
id: "current",
|
|
566
|
+
position: 1
|
|
567
|
+
});
|
|
568
|
+
await vi.waitFor(() => expect(visibleQueue()).toEqual(current));
|
|
569
|
+
release([
|
|
570
|
+
{ id: "old", content: [{ type: "text", text: "old task" }], queuedAt: 0 }
|
|
571
|
+
]);
|
|
572
|
+
await pending;
|
|
573
|
+
screen.getState().setUi((previous) => ({ ...previous, isThinking: true }));
|
|
574
|
+
await vi.waitFor(() => expect(screen.getState().ui.isThinking).toBe(true));
|
|
575
|
+
expect(visibleQueue()).toEqual(current);
|
|
576
|
+
} finally {
|
|
577
|
+
release([]);
|
|
578
|
+
screen.close();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
);
|
|
582
|
+
});
|
package/dist/index.d.cts
CHANGED
|
@@ -229,6 +229,8 @@ interface TuiEffectiveConfig {
|
|
|
229
229
|
[key: string]: unknown;
|
|
230
230
|
}
|
|
231
231
|
interface TuiAgentBackend extends Pick<DextoAgent, 'stream' | 'stop' | 'run' | 'listSessions' | 'getSessionMetadata' | 'getSessionHistory' | 'getSessionTitle' | 'setSessionTitle' | 'generateSessionTitle' | 'forkSession' | 'getCurrentLLMConfig' | 'hasSessionLLMOverride' | 'switchLLM' | 'getSupportedProviders' | 'getSupportedModels' | 'getContextStats' | 'clearContext' | 'compactContext' | 'steer' | 'followUp' | 'getSteerMessages' | 'getFollowUpMessages' | 'removeSteerMessage' | 'removeFollowUpMessage' | 'clearSteerQueue' | 'clearFollowUpQueue' | 'cancel' | 'searchMessages' | 'listPrompts' | 'refreshPrompts' | 'resolvePrompt' | 'getSystemPrompt' | 'loadToolkits' | 'listResources' | 'setLogLevel' | 'getAllTools' | 'getEnabledTools' | 'getAllMcpTools' | 'setGlobalDisabledTools' | 'setSessionDisabledTools' | 'setSessionAutoApproveTools' | 'getSessionAutoApproveTools' | 'getMcpServersWithStatus' | 'addMcpServer' | 'enableMcpServer' | 'disableMcpServer' | 'removeMcpServer' | 'restartMcpServer' | 'getMcpClients' | 'getMcpFailedConnections'> {
|
|
232
|
+
getSessionApprovals?: DextoAgent['getSessionApprovals'];
|
|
233
|
+
revokeSessionApproval?: DextoAgent['revokeSessionApproval'];
|
|
232
234
|
createSession: (sessionId?: string) => Promise<{
|
|
233
235
|
id: string;
|
|
234
236
|
logger: Pick<RootLogger, 'getLevel' | 'getLogFilePath'>;
|
|
@@ -22,6 +22,12 @@ function createAgent(capabilities) {
|
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
(0, import_vitest.describe)("executeCommand", () => {
|
|
25
|
+
(0, import_vitest.it)("does not advertise local permission controls for an unsupported backend", async () => {
|
|
26
|
+
const agent = createAgent();
|
|
27
|
+
(0, import_vitest.expect)(await (0, import_commands.executeCommand)("permissions", [], agent)).toBe(
|
|
28
|
+
"\u26A0\uFE0F Command /permissions is not available for this chat target."
|
|
29
|
+
);
|
|
30
|
+
});
|
|
25
31
|
(0, import_vitest.it)("returns unknown-command guidance when prompts are unsupported and the command is not real", async () => {
|
|
26
32
|
const agent = createAgent({ prompts: false });
|
|
27
33
|
const result = await (0, import_commands.executeCommand)("typoed-command", [], agent);
|
|
@@ -21,6 +21,12 @@ function createAgent(capabilities) {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
describe("executeCommand", () => {
|
|
24
|
+
it("does not advertise local permission controls for an unsupported backend", async () => {
|
|
25
|
+
const agent = createAgent();
|
|
26
|
+
expect(await executeCommand("permissions", [], agent)).toBe(
|
|
27
|
+
"\u26A0\uFE0F Command /permissions is not available for this chat target."
|
|
28
|
+
);
|
|
29
|
+
});
|
|
24
30
|
it("returns unknown-command guidance when prompts are unsupported and the command is not real", async () => {
|
|
25
31
|
const agent = createAgent({ prompts: false });
|
|
26
32
|
const result = await executeCommand("typoed-command", [], agent);
|
|
@@ -22,6 +22,13 @@ __export(tool_commands_exports, {
|
|
|
22
22
|
});
|
|
23
23
|
module.exports = __toCommonJS(tool_commands_exports);
|
|
24
24
|
const toolCommands = [
|
|
25
|
+
{
|
|
26
|
+
name: "permissions",
|
|
27
|
+
description: "Choose approval mode and revoke remembered session permissions",
|
|
28
|
+
usage: "/permissions",
|
|
29
|
+
category: "Tool Management",
|
|
30
|
+
handler: async () => true
|
|
31
|
+
},
|
|
25
32
|
{
|
|
26
33
|
name: "tools",
|
|
27
34
|
description: "Browse available tools interactively",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-commands.d.ts","sourceRoot":"","sources":["../../src/interactive-commands/tool-commands.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAkB,MAAM,qBAAqB,CAAC;AAG7E;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,iBAAiB,
|
|
1
|
+
{"version":3,"file":"tool-commands.d.ts","sourceRoot":"","sources":["../../src/interactive-commands/tool-commands.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAkB,MAAM,qBAAqB,CAAC;AAG7E;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,iBAAiB,EAsB3C,CAAC"}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
const toolCommands = [
|
|
2
|
+
{
|
|
3
|
+
name: "permissions",
|
|
4
|
+
description: "Choose approval mode and revoke remembered session permissions",
|
|
5
|
+
usage: "/permissions",
|
|
6
|
+
category: "Tool Management",
|
|
7
|
+
handler: async () => true
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
name: "tools",
|
|
4
11
|
description: "Browse available tools interactively",
|
|
@@ -766,7 +766,7 @@ ${import_chalk.default.dim(runningDescription)}`;
|
|
|
766
766
|
const bypassPermissions = options.bypassPermissionsRef.current;
|
|
767
767
|
const autoApproveEdits = options.autoApproveEditsRef.current;
|
|
768
768
|
const { eventBus } = options;
|
|
769
|
-
if (bypassPermissions && (event.type === import_core.ApprovalType.TOOL_APPROVAL || event.type === import_core.ApprovalType.COMMAND_APPROVAL)) {
|
|
769
|
+
if (bypassPermissions && event.autoApproval !== "disallowed" && (event.type === import_core.ApprovalType.TOOL_APPROVAL || event.type === import_core.ApprovalType.COMMAND_APPROVAL)) {
|
|
770
770
|
if (event.type === import_core.ApprovalType.TOOL_APPROVAL) {
|
|
771
771
|
const { toolName } = event.metadata;
|
|
772
772
|
if (toolName === "plan_create" || toolName === "plan_review") {
|
|
@@ -786,7 +786,7 @@ ${import_chalk.default.dim(runningDescription)}`;
|
|
|
786
786
|
});
|
|
787
787
|
break;
|
|
788
788
|
}
|
|
789
|
-
if (autoApproveEdits && event.type === import_core.ApprovalType.TOOL_APPROVAL) {
|
|
789
|
+
if (autoApproveEdits && event.autoApproval !== "disallowed" && event.type === import_core.ApprovalType.TOOL_APPROVAL) {
|
|
790
790
|
const { toolName, approvalKey } = event.metadata;
|
|
791
791
|
if (approvalKey === void 0 && (0, import_toolUtils.isAutoApprovableInEditMode)(toolName)) {
|
|
792
792
|
eventBus.emit("approval:response", {
|
|
@@ -811,6 +811,8 @@ ${import_chalk.default.dim(runningDescription)}`;
|
|
|
811
811
|
timestamp: event.timestamp,
|
|
812
812
|
metadata: event.metadata
|
|
813
813
|
};
|
|
814
|
+
if (event.autoApproval !== void 0)
|
|
815
|
+
newApproval.autoApproval = event.autoApproval;
|
|
814
816
|
if (event.sessionId !== void 0) {
|
|
815
817
|
newApproval.sessionId = event.sessionId;
|
|
816
818
|
}
|