fix(core): harden env loading, OpenAI compatibility, and runtime recovery
This commit is contained in:
@@ -49,6 +49,7 @@ function minimalTuiPrivates(value: MinimalTui): {
|
||||
};
|
||||
activePromptCancel: (() => void) | null;
|
||||
activeOperationCancel: (() => void) | null;
|
||||
commandInFlight: boolean;
|
||||
running: boolean;
|
||||
} {
|
||||
return value as unknown as {
|
||||
@@ -70,6 +71,7 @@ function minimalTuiPrivates(value: MinimalTui): {
|
||||
};
|
||||
activePromptCancel: (() => void) | null;
|
||||
activeOperationCancel: (() => void) | null;
|
||||
commandInFlight: boolean;
|
||||
running: boolean;
|
||||
};
|
||||
}
|
||||
@@ -469,6 +471,38 @@ describe('MinimalTui prompt cancellation', () => {
|
||||
expect(minimalTuiPrivates(tui).activePromptCancel).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty string when readline is already closed during question', async () => {
|
||||
const mockSession = {
|
||||
id: 'test',
|
||||
getHistory: () => [],
|
||||
addMessage: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
replaceHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const tui = new MinimalTui({
|
||||
session: asSession(mockSession),
|
||||
modelClient: asRouter({}),
|
||||
systemPrompt: 'test',
|
||||
});
|
||||
|
||||
const questionError = new Error('readline was closed');
|
||||
(questionError as Error & { code?: string }).code = 'ERR_USE_AFTER_CLOSE';
|
||||
|
||||
minimalTuiPrivates(tui).rl = {
|
||||
once: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
question: vi.fn(() => {
|
||||
throw questionError;
|
||||
}),
|
||||
write: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(minimalTuiPrivates(tui).prompt('Confirm? ')).resolves.toBe('');
|
||||
expect(minimalTuiPrivates(tui).activePromptCancel).toBeNull();
|
||||
});
|
||||
|
||||
it('uses Esc to cancel active running operation', () => {
|
||||
const mockSession = {
|
||||
id: 'test',
|
||||
@@ -530,4 +564,46 @@ describe('MinimalTui prompt cancellation', () => {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('exits immediately on Ctrl+C when a command is in flight', () => {
|
||||
const mockSession = {
|
||||
id: 'test',
|
||||
getHistory: () => [],
|
||||
addMessage: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
replaceHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const write = vi.fn();
|
||||
const prompt = vi.fn();
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const tui = new MinimalTui({
|
||||
session: asSession(mockSession),
|
||||
modelClient: asRouter({}),
|
||||
systemPrompt: 'test',
|
||||
});
|
||||
minimalTuiPrivates(tui).rl = {
|
||||
once: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
question: vi.fn(),
|
||||
write,
|
||||
prompt,
|
||||
};
|
||||
minimalTuiPrivates(tui).running = true;
|
||||
minimalTuiPrivates(tui).commandInFlight = true;
|
||||
const cancel = vi.fn();
|
||||
minimalTuiPrivates(tui).activeOperationCancel = cancel;
|
||||
|
||||
const shouldExit = minimalTuiPrivates(tui).handleCtrlCPress(1000);
|
||||
|
||||
expect(shouldExit).toBe(true);
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('Press Ctrl+C again to quit'));
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,7 @@ export class MinimalTui {
|
||||
private busyActive = false;
|
||||
private verbose = false;
|
||||
private lastCtrlCAtMs = 0;
|
||||
private commandInFlight = false;
|
||||
|
||||
constructor(private config: MinimalTuiConfig) {}
|
||||
|
||||
@@ -325,6 +326,13 @@ export class MinimalTui {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If a command is currently running (e.g. /council), exit immediately.
|
||||
// Some command paths are not cancellable mid-flight, so double-press UX can trap users.
|
||||
if (this.commandInFlight) {
|
||||
this.activeOperationCancel?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
const shouldExit = this.lastCtrlCAtMs > 0
|
||||
&& (nowMs - this.lastCtrlCAtMs) <= MinimalTui.CTRL_C_EXIT_WINDOW_MS;
|
||||
this.lastCtrlCAtMs = nowMs;
|
||||
@@ -358,7 +366,12 @@ export class MinimalTui {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.handleCommand(command);
|
||||
this.commandInFlight = true;
|
||||
try {
|
||||
await this.handleCommand(command);
|
||||
} finally {
|
||||
this.commandInFlight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,9 +434,14 @@ export class MinimalTui {
|
||||
}
|
||||
};
|
||||
|
||||
this.rl.question(promptText, (answer) => {
|
||||
finish(answer);
|
||||
});
|
||||
try {
|
||||
this.rl.question(promptText, (answer) => {
|
||||
finish(answer);
|
||||
});
|
||||
} catch {
|
||||
// readline can throw synchronously if it was closed between our guards.
|
||||
finish('');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -559,7 +577,7 @@ export class MinimalTui {
|
||||
|
||||
private async handleCouncilCommand(task: string): Promise<void> {
|
||||
if (!task.trim()) {
|
||||
console.log(`${colors.gray}Usage: /council <question or task>${colors.reset}\n`);
|
||||
console.log(`${colors.gray}Usage: /council <question or task> | /council preflight${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
if (!this.config.onCouncil) {
|
||||
|
||||
Reference in New Issue
Block a user