-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathinteractive-cli.ts
More file actions
165 lines (144 loc) · 4.8 KB
/
interactive-cli.ts
File metadata and controls
165 lines (144 loc) · 4.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
import * as pty from "node-pty";
import { IPty } from "node-pty";
import stripAnsi from "strip-ansi";
import { throwFailure } from "./logging.js";
export async function poll(predicate: () => boolean, timeout: number): Promise<boolean> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (predicate()) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
return false;
}
export interface RunInteractiveOptions {
cwd: string;
// The message to wait for until the user can type again
readyPrompt: string;
showOutput?: boolean;
env?: NodeJS.ProcessEnv;
}
export class InteractiveCLI {
// Output from the agent since the last text was inputted
private turnOutput = "";
private readonly ptyProcess: IPty;
private readonly timeout = 300_000;
private readonly exitTimeout = 5_000;
constructor(
command: string,
args: string[],
private readonly options: RunInteractiveOptions,
) {
this.ptyProcess = pty.spawn(command, args, {
name: "xterm-color",
cols: 80,
rows: 30,
cwd: options.cwd,
env: { ...process.env, ...options.env },
});
this.ptyProcess.onData((data) => {
this.turnOutput += data;
if (options.showOutput) {
process.stdout.write(data);
}
});
}
/**
* Should be called once at the beginning of tests
*/
async waitForReadyPrompt(): Promise<void> {
await this.waitForText(this.options.readyPrompt);
await this.waitForTurnComplete();
}
/**
* Simulates typing a string and waits for the turn to complete. It types one
* character at a time to avoid paste detection that the Gemini CLI has
*/
async type(text: string): Promise<void> {
for (const char of text) {
this.ptyProcess.write(char);
await new Promise((resolve) => setTimeout(resolve, 5));
}
// Clear the buffer so that text expectations only apply to this turn
this.turnOutput = "";
// Increases reliability. Sometimes the agent needs some time until it will
// accept "enter". My hunch is this is due to the autocomplete menu for
// slash commands needing time to appear / disappear
await new Promise((resolve) => setTimeout(resolve, 500));
// Simulate pressing enter
this.ptyProcess.write("\r");
await this.waitForTurnComplete();
}
/**
* Waits for a specific string or regex to appear in the agent's output.
* Throws an error if the text is not found within the timeout.
*/
private async waitForText(text: string | RegExp): Promise<void> {
const found = await poll(() => {
const cleanOutput = stripAnsi(this.turnOutput);
if (typeof text === "string") {
return cleanOutput.toLowerCase().includes(text.toLowerCase());
}
return text.test(cleanOutput);
}, this.timeout);
if (!found) {
throwFailure(`Did not find expected text: "${text}" in output within ${this.timeout}ms`);
}
}
/**
* Waits for the turn to complete.
* Throws an error if it doesn't complete within the timeout.
*/
private async waitForTurnComplete(timeout: number = this.timeout): Promise<void> {
// The Gemini CLI doesn't have a clear indicator that it's done with a turn
// other than it just stops writing output. We detect this
let lastOutput = "";
let counter = 0;
const repetitionsUntilComplete = 3;
const stoppedChanging = await poll(() => {
if (lastOutput === this.turnOutput) {
counter += 1;
return counter > repetitionsUntilComplete;
}
counter = 0;
lastOutput = this.turnOutput;
return false;
}, timeout);
if (!stoppedChanging) {
throwFailure(`CLI did not stop changing output within ${timeout}ms`);
}
}
/**
* Looks for a specific string or regex to in the agent's output since the
* last time the user typed and pressed enter.
* Throws an error if the text is not found within the timeout.
*/
async expectText(text: string | RegExp): Promise<void> {
let found = false;
const cleanOutput = stripAnsi(this.turnOutput);
if (typeof text === "string") {
found = cleanOutput.toLowerCase().includes(text.toLowerCase());
} else {
found = text.test(cleanOutput);
}
if (!found) {
throwFailure(`Did not find expected text: "${text}" in the latest output`);
} else {
console.log(` [FOUND] expectText: ${text}`);
}
}
/** Kills the underlying terminal process and waits for it to exit. */
async kill(): Promise<void> {
await new Promise((resolve) => {
const timer = setTimeout(() => resolve(1), this.exitTimeout);
this.ptyProcess.onExit(({ exitCode }) => {
clearTimeout(timer);
resolve(exitCode);
});
});
// Restore cursor visibility
process.stdout.write("\x1b[?25h");
this.ptyProcess.kill();
}
}