Files
ChiGao a1b2d8596f feat(cli): align OpenTUI Vim, history and live output with Ink (#15067)
* fix(cli): restore bounded OpenTUI dialogs and add migration gates

* fix(cli): preserve OpenTUI input and attachment ownership

* feat(cli): align OpenTUI status lines with Ink

* fix(cli): align OpenTUI editor and cancellation transactions

* fix(tui): harden migration cleanup and evidence after review

* test(tui): fail acceptance on unhandled errors

* docs(cli): clarify consolidated OpenTUI delivery status

* test(cli): isolate system settings in TUI parity fixtures

* fix(cli): settle OpenTUI cancellation when replay resets a session

* feat(cli): align OpenTUI Vim, history and session output

Reuse the shared editing and history contracts, preserve cancellation ownership, and connect accepted interactive turns to the existing output bridge.

* style(cli): format merged OpenTUI test callbacks

* test(cli): preserve visible input across session rotation

* test(cli): cover shell recall provenance and streaming search selection

* test(cli): await cancellation before releasing held response

* fix(cli): preserve imported history ownership and active search

* docs(cli): synchronize consolidated OpenTUI validation status

* test(cli): await AppContainer configuration cleanup

* test(cli): mock Vim clipboard imports before module loading

* test(cli): share clipboard mocks across module export views

* test(cli): verify finalized MOD parts and shared JSON projection

* fix(cli): reuse Ink editing history for OpenTUI Unicode input

* test(cli): keep native Vim input callbacks void

* test(cli): preserve Escape boundaries in Unicode Vim fixtures

* test(cli): read the current composer across Vim remounts

* test(cli): align Unicode deletion cursor with Ink oracle

* fix(cli): preserve completion and escape precedence and validate status colors

* fix(cli): use boundary selection for the OpenTUI composer

* fix(cli): preserve attachment navigation with one completion

* test(cli): preserve Shift in the native redo key sequence

* fix(cli): display session titles in the OpenTUI composer border

* fix(cli): discard generated command work after cancellation

* test(cli): align Unicode unit cursor with Ink oracle

* test(cli): model native selection key delegation

* fix(cli): preserve Vim Escape ownership across listeners

* fix(cli): preserve shared Vim command messages in OpenTUI

* test(cli): observe rendered Vim mode during parity checks

* docs(cli): record integrated OpenTUI validation checkpoint

* fix(ci): account for dedicated TUI parity coverage

* fix(cli): preserve collapsed paste payloads across Vim undo
2026-10-22 14:52:40 +00:00

474 lines
12 KiB
TypeScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
createServer,
type IncomingMessage,
type IncomingHttpHeaders,
type Server,
type ServerResponse,
} from 'node:http';
import type { AddressInfo } from 'node:net';
import { randomUUID } from 'node:crypto';
type JsonObject = Record<string, unknown>;
export type FakeOpenAIToolCall = {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
};
export type FakeOpenAIResponse = {
model?: string;
content?: string;
contentChunks?: string[];
/**
* Reasoning text, emitted on the wire as `reasoning_content` — the field the
* OpenAI-compatible converter reads into a thought part. Streamed as one
* delta per entry of `reasoningChunks`, or as a single delta otherwise, and
* always before the content deltas of the same choice.
*/
reasoning?: string;
reasoningChunks?: string[];
errorContent?: string;
disconnectAfterContentChunks?: number;
/**
* Streaming-only: pause the response once this many content deltas have been
* written across the whole response, and resume when `holdUntil` resolves.
* Awaiting the handler only holds a turn before the first byte; a test that
* needs the CLI genuinely mid-stream has to stop the stream itself.
*/
holdAfterChunks?: number;
holdUntil?: Promise<void>;
toolCalls?: FakeOpenAIToolCall[];
finishReason?: 'stop' | 'tool_calls' | 'length';
choices?: FakeOpenAIChoice[];
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
prompt_tokens_details?: {
cached_tokens?: number;
};
};
};
export type FakeOpenAIChoice = {
index: number;
content?: string;
contentChunks?: string[];
reasoning?: string;
reasoningChunks?: string[];
/**
* Streaming-only mid-stream provider error: emits one chunk carrying this
* string as `delta.content` together with `finish_reason: 'error_finish'`,
* then ends the stream. Mirrors gateways that report upstream failures as
* stream content instead of an HTTP error status.
*/
errorContent?: string;
toolCalls?: FakeOpenAIToolCall[];
finishReason?: 'stop' | 'tool_calls' | 'length';
};
export type FakeOpenAIRequest = {
body: JsonObject;
headers: IncomingHttpHeaders;
transport: {
closedAt?: number;
writableEndedAtClose?: boolean;
};
};
export type FakeOpenAIServer = {
baseUrl: string;
requests: FakeOpenAIRequest[];
close: () => Promise<void>;
};
export type FakeOpenAIServerOptions = (
| { listenHost?: undefined; baseUrlHost?: undefined }
| { listenHost: string; baseUrlHost: string }
) & {
keepAlive?: boolean;
};
export type FakeOpenAIHandler = (ctx: {
body: JsonObject;
requestIndex: number;
}) => FakeOpenAIResponse | Promise<FakeOpenAIResponse>;
const MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024;
class RequestBodyTooLargeError extends Error {
constructor() {
super('fake OpenAI request body too large');
}
}
export function fakeToolCall(
name: string,
args: JsonObject,
id = `call_${randomUUID()}`,
): FakeOpenAIToolCall {
return {
id,
type: 'function',
function: {
name,
arguments: JSON.stringify(args),
},
};
}
export async function startFakeOpenAIServer(
handler: FakeOpenAIHandler,
options: FakeOpenAIServerOptions = {},
): Promise<FakeOpenAIServer> {
const requests: FakeOpenAIRequest[] = [];
const server = createServer(async (req, res) => {
if (req.method !== 'POST' || !req.url?.endsWith('/chat/completions')) {
res.writeHead(404);
res.end('not found');
return;
}
try {
const rawBody = await readRequestBody(req);
const body = parseJsonBody(rawBody);
if (!body) {
res.writeHead(400);
res.end('bad json');
return;
}
const requestIndex = requests.length;
const transport: FakeOpenAIRequest['transport'] = {};
res.once('close', () => {
transport.closedAt = performance.now();
transport.writableEndedAtClose = res.writableEnded;
});
requests.push({ body, headers: req.headers, transport });
const response = await handler({ body, requestIndex });
if (body['stream'] === true) {
await writeStreamed(
res,
getModel(body),
response,
options.keepAlive !== false,
);
} else {
writeNonStreamed(
res,
getModel(body),
response,
options.keepAlive !== false,
);
}
} catch (error) {
if (error instanceof RequestBodyTooLargeError) {
res.writeHead(413);
res.end('request body too large');
return;
}
if (res.headersSent) {
if (!res.writableEnded) {
res.destroy();
}
return;
}
res.writeHead(500, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
error: {
message: 'fake OpenAI server handler failed',
type: 'server_error',
},
}),
);
}
});
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
server.off('listening', onListening);
reject(error);
};
const onListening = () => {
server.off('error', onError);
resolve();
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(0, options.listenHost ?? '127.0.0.1');
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('failed to start fake OpenAI server');
}
let closePromise: Promise<void> | undefined;
return {
baseUrl: `http://${options.baseUrlHost ?? '127.0.0.1'}:${
(address as AddressInfo).port
}/v1`,
requests,
close: () => (closePromise ??= closeServer(server)),
};
}
function readRequestBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalLength = 0;
let tooLarge = false;
req.on('data', (chunk: Buffer) => {
if (tooLarge) return;
totalLength += chunk.length;
if (totalLength > MAX_REQUEST_BODY_BYTES) {
tooLarge = true;
reject(new RequestBodyTooLargeError());
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (!tooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
});
req.on('error', reject);
});
}
function parseJsonBody(rawBody: string): JsonObject | null {
try {
const parsed: unknown = JSON.parse(rawBody);
return isJsonObject(parsed) ? parsed : null;
} catch {
return null;
}
}
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function getModel(body: JsonObject): string {
return typeof body['model'] === 'string' ? body['model'] : 'fake-model';
}
function writeNonStreamed(
res: ServerResponse,
model: string,
message: FakeOpenAIResponse,
keepAlive: boolean,
): void {
res.writeHead(
200,
keepAlive
? { 'content-type': 'application/json' }
: { connection: 'close', 'content-type': 'application/json' },
);
res.end(
JSON.stringify({
id: chatCompletionId(),
object: 'chat.completion',
created: nowSeconds(),
model: message.model ?? model,
choices: responseChoices(message).map((choice) => ({
index: choice.index,
message: {
role: 'assistant',
content: choice.content ?? choice.contentChunks?.join('') ?? null,
...(choiceReasoning(choice)
? { reasoning_content: choiceReasoning(choice) }
: {}),
...(choice.toolCalls ? { tool_calls: choice.toolCalls } : {}),
},
finish_reason: finishReason(choice),
})),
usage: message.usage ?? DEFAULT_USAGE,
}),
);
}
async function writeStreamed(
res: ServerResponse,
model: string,
message: FakeOpenAIResponse,
keepAlive: boolean,
): Promise<void> {
res.writeHead(200, {
'cache-control': 'no-cache',
connection: keepAlive ? 'keep-alive' : 'close',
'content-type': 'text/event-stream',
});
const id = chatCompletionId();
const created = nowSeconds();
const responseModel = message.model ?? model;
const chunk = (
index: number,
delta: JsonObject,
finish_reason: string | null = null,
usage?: FakeOpenAIResponse['usage'],
) => ({
id,
object: 'chat.completion.chunk',
created,
model: responseModel,
choices: [{ index, delta, finish_reason }],
...(usage ? { usage } : {}),
});
const send = (payload: unknown, callback?: () => void) => {
res.write(`data: ${JSON.stringify(payload)}\n\n`, callback);
};
const holdAfterChunks = message.holdAfterChunks ?? -1;
let contentDeltas = 0;
const sendContent = async (choiceIndex: number, content: string) => {
send(chunk(choiceIndex, { content }));
contentDeltas += 1;
if (contentDeltas === holdAfterChunks) await message.holdUntil;
};
const choices = responseChoices(message);
for (const [choicePosition, choice] of choices.entries()) {
send(chunk(choice.index, { role: 'assistant' }));
if (choice.errorContent !== undefined) {
send(
chunk(choice.index, { content: choice.errorContent }, 'error_finish'),
);
continue;
}
for (const reasoning of choice.reasoningChunks ??
(choice.reasoning ? [choice.reasoning] : [])) {
send(chunk(choice.index, { reasoning_content: reasoning }));
}
for (const [contentIndex, content] of (
choice.contentChunks ?? []
).entries()) {
if (message.disconnectAfterContentChunks === contentIndex + 1) {
send(chunk(choice.index, { content }), () => res.destroy());
return;
}
await sendContent(choice.index, content);
}
if (!choice.contentChunks && choice.content) {
await sendContent(choice.index, choice.content);
}
for (const [toolIndex, toolCall] of (choice.toolCalls ?? []).entries()) {
send(
chunk(choice.index, {
tool_calls: [
{
index: toolIndex,
id: toolCall.id,
type: toolCall.type,
function: {
name: toolCall.function.name,
arguments: '',
},
},
],
}),
);
if (toolCall.function.arguments) {
send(
chunk(choice.index, {
tool_calls: [
{
index: toolIndex,
function: {
arguments: toolCall.function.arguments,
},
},
],
}),
);
}
}
send(
chunk(
choice.index,
{},
finishReason(choice),
choices.length === 1 && choicePosition === choices.length - 1
? (message.usage ?? DEFAULT_USAGE)
: undefined,
),
);
}
if (choices.length > 1) {
send({
id,
object: 'chat.completion.chunk',
created,
model: responseModel,
choices: [],
usage: message.usage ?? DEFAULT_USAGE,
});
}
res.write('data: [DONE]\n\n');
res.end();
}
function responseChoices(message: FakeOpenAIResponse): FakeOpenAIChoice[] {
return (
message.choices ?? [
{
index: 0,
content: message.content,
contentChunks: message.contentChunks,
reasoning: message.reasoning,
reasoningChunks: message.reasoningChunks,
errorContent: message.errorContent,
toolCalls: message.toolCalls,
finishReason: message.finishReason,
},
]
);
}
function choiceReasoning(
choice: Pick<FakeOpenAIChoice, 'reasoning' | 'reasoningChunks'>,
): string | undefined {
return choice.reasoningChunks?.join('') ?? choice.reasoning;
}
function finishReason(
message: Pick<FakeOpenAIChoice, 'finishReason' | 'toolCalls'>,
): string {
return message.finishReason ?? (message.toolCalls ? 'tool_calls' : 'stop');
}
const DEFAULT_USAGE: NonNullable<FakeOpenAIResponse['usage']> = {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
};
function chatCompletionId(): string {
return `chatcmpl-${randomUUID()}`;
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
server.closeAllConnections();
});
}