mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(cli): fase 8.11 — REPL interativo multi-turn com Ink (runRepl, session, slash commands)
Adiciona REPL Ink com painel lateral de tokens/custo, 16 slash commands (/model, /combo, /system, /clear, /save, /load, /list, /export, /history, /tokens, /help, /exit etc.), persistência de sessão em ~/.omniroute/repl-sessions/, autosave ao sair, e comando `omniroute repl` com flags --model, --combo, --system, --resume.
This commit is contained in:
@@ -54,6 +54,7 @@ import { registerCompletion } from "./completion.mjs";
|
||||
import { registerRuntime } from "./runtime.mjs";
|
||||
import { registerTray } from "./tray.mjs";
|
||||
import { registerAutostart } from "./autostart.mjs";
|
||||
import { registerRepl } from "./repl.mjs";
|
||||
|
||||
export function registerCommands(program) {
|
||||
registerMemory(program);
|
||||
@@ -113,4 +114,5 @@ export function registerCommands(program) {
|
||||
registerRuntime(program);
|
||||
registerTray(program);
|
||||
registerAutostart(program);
|
||||
registerRepl(program);
|
||||
}
|
||||
|
||||
19
bin/cli/commands/repl.mjs
Normal file
19
bin/cli/commands/repl.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
export function registerRepl(program) {
|
||||
program
|
||||
.command("repl")
|
||||
.description(t("repl.description"))
|
||||
.option("-m, --model <id>", t("repl.model"))
|
||||
.option("--combo <name>", t("repl.combo"))
|
||||
.option("-s, --system <prompt>", t("repl.system"))
|
||||
.option("--resume <session>", t("repl.resume"))
|
||||
.action(async (opts, cmd) => {
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const port = globalOpts.port ? parseInt(String(globalOpts.port), 10) : 20128;
|
||||
const baseUrl = globalOpts.baseUrl ?? `http://localhost:${port}`;
|
||||
const apiKey = globalOpts.apiKey ?? null;
|
||||
const { runRepl } = await import("../tui/Repl.jsx");
|
||||
await runRepl({ ...opts, baseUrl, apiKey, port });
|
||||
});
|
||||
}
|
||||
@@ -1105,5 +1105,12 @@
|
||||
"repair_force": "Force reinstall even if valid",
|
||||
"clean": "Remove runtime directory (frees disk space)",
|
||||
"clean_yes": "Skip confirmation"
|
||||
},
|
||||
"repl": {
|
||||
"description": "Interactive multi-turn REPL with LLM",
|
||||
"model": "Model to use (default: auto)",
|
||||
"combo": "Combo name to use",
|
||||
"system": "System prompt",
|
||||
"resume": "Resume a saved session by name"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1105,5 +1105,12 @@
|
||||
"repair_force": "Forçar reinstalação mesmo se estiver válido",
|
||||
"clean": "Remover diretório de runtime (libera espaço em disco)",
|
||||
"clean_yes": "Pular confirmação"
|
||||
},
|
||||
"repl": {
|
||||
"description": "REPL interativo multi-turn com LLM",
|
||||
"model": "Modelo a usar (padrão: auto)",
|
||||
"combo": "Nome do combo a usar",
|
||||
"system": "System prompt",
|
||||
"resume": "Retomar sessão salva pelo nome"
|
||||
}
|
||||
}
|
||||
|
||||
392
bin/cli/tui/Repl.jsx
Normal file
392
bin/cli/tui/Repl.jsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { render, Box, Text, useInput } from "ink";
|
||||
import TextInput from "ink-text-input";
|
||||
import { marked } from "marked";
|
||||
import { markedTerminal } from "marked-terminal";
|
||||
import { apiFetch } from "../api.mjs";
|
||||
import { TokenCounter } from "../tui-components/TokenCounter.jsx";
|
||||
import { MarkdownView } from "../tui-components/MarkdownView.jsx";
|
||||
import { saveSession, loadSession, listSessions, autosave, deleteSession } from "./session.mjs";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
marked.use(markedTerminal({ width: 80 }));
|
||||
|
||||
const SLASH_COMMANDS = [
|
||||
"model",
|
||||
"combo",
|
||||
"system",
|
||||
"clear",
|
||||
"save",
|
||||
"load",
|
||||
"list",
|
||||
"history",
|
||||
"export",
|
||||
"tokens",
|
||||
"file",
|
||||
"temperature",
|
||||
"max-tokens",
|
||||
"reasoning",
|
||||
"skill",
|
||||
"memory",
|
||||
"help",
|
||||
"exit",
|
||||
"quit",
|
||||
];
|
||||
|
||||
const HELP_TEXT = `Available commands:
|
||||
/model <id> Change active model
|
||||
/combo <name> Change active combo
|
||||
/system <prompt> Set system prompt
|
||||
/clear Clear conversation history
|
||||
/save <name> Save current session
|
||||
/load <name> Load a saved session
|
||||
/list List saved sessions
|
||||
/history [N] Show last N messages (default 10)
|
||||
/export <file> Export conversation (md/json/txt)
|
||||
/tokens Show token usage + cost
|
||||
/file <path> Attach file content to next message
|
||||
/temperature <t> Adjust temperature (0-2)
|
||||
/max-tokens <n> Adjust max tokens
|
||||
/reasoning <level> Adjust reasoning level
|
||||
/skill execute <id> '<args>' Run a skill
|
||||
/memory search <q> Search memory
|
||||
/memory add <text> Add to memory
|
||||
/help Show this help
|
||||
/exit, /quit Exit REPL`;
|
||||
|
||||
function Message({ message }) {
|
||||
const isUser = message.role === "user";
|
||||
const isSystem = message.role === "system";
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{isUser && (
|
||||
<Text color="green" bold>
|
||||
{">"}{" "}
|
||||
</Text>
|
||||
)}
|
||||
{isSystem && <Text color="yellow">[system] </Text>}
|
||||
<MarkdownView content={message.content} />
|
||||
{message.latencyMs != null && (
|
||||
<Text dimColor>
|
||||
[{message.model} · {message.latencyMs}ms · {message.usage?.total_tokens ?? "?"} tok]
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanel({ session }) {
|
||||
return (
|
||||
<Box flexDirection="column" width={20} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text bold underline>
|
||||
Session
|
||||
</Text>
|
||||
<Text>
|
||||
Model: <Text color="yellow">{session.model}</Text>
|
||||
</Text>
|
||||
{session.combo && <Text>Combo: {session.combo}</Text>}
|
||||
<Text>Msgs: {session.messages.length}</Text>
|
||||
<Box marginTop={1}>
|
||||
<TokenCounter
|
||||
tokensIn={session.totalUsage.in}
|
||||
tokensOut={session.totalUsage.out}
|
||||
costUsd={session.totalCost}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text dimColor bold>
|
||||
Commands
|
||||
</Text>
|
||||
{["/model", "/combo", "/system", "/clear", "/save", "/load", "/tokens", "/exit"].map(
|
||||
(c) => (
|
||||
<Text key={c} dimColor>
|
||||
{c}
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplApp({ initialOptions, onExit }) {
|
||||
const [session, setSession] = useState(() => {
|
||||
if (initialOptions.resume) {
|
||||
try {
|
||||
return loadSession(initialOptions.resume);
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
model: initialOptions.model || "auto",
|
||||
combo: initialOptions.combo || null,
|
||||
system: initialOptions.system || null,
|
||||
messages: [],
|
||||
totalUsage: { in: 0, out: 0 },
|
||||
totalCost: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
const [input, setInput] = useState("");
|
||||
const [historyBuf, setHistoryBuf] = useState([]);
|
||||
const [historyIdx, setHistoryIdx] = useState(-1);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusMsg) {
|
||||
const t = setTimeout(() => setStatusMsg(null), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [statusMsg]);
|
||||
|
||||
useInput((char, key) => {
|
||||
if (pending) return;
|
||||
if (key.upArrow && !input) {
|
||||
const next = Math.min(historyIdx + 1, historyBuf.length - 1);
|
||||
if (next >= 0) {
|
||||
setHistoryIdx(next);
|
||||
setInput(historyBuf[historyBuf.length - 1 - next] || "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.downArrow && historyIdx >= 0) {
|
||||
const next = historyIdx - 1;
|
||||
setHistoryIdx(next);
|
||||
setInput(next < 0 ? "" : historyBuf[historyBuf.length - 1 - next] || "");
|
||||
return;
|
||||
}
|
||||
if (key.tab && input.startsWith("/")) {
|
||||
const partial = input.slice(1).split(" ")[0];
|
||||
const match = SLASH_COMMANDS.find((c) => c.startsWith(partial) && c !== partial);
|
||||
if (match) setInput("/" + match + " ");
|
||||
}
|
||||
});
|
||||
|
||||
async function submit(value) {
|
||||
const text = (value ?? input).trim();
|
||||
if (!text) return;
|
||||
setInput("");
|
||||
setHistoryBuf((h) => [...h, text]);
|
||||
setHistoryIdx(-1);
|
||||
|
||||
if (text.startsWith("/")) {
|
||||
await handleSlash(text);
|
||||
} else {
|
||||
await sendMessage(text);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(content) {
|
||||
setPending(true);
|
||||
const t0 = Date.now();
|
||||
const nextMsgs = [...session.messages, { role: "user", content }];
|
||||
setSession((s) => ({ ...s, messages: nextMsgs }));
|
||||
try {
|
||||
const payload = {
|
||||
model: session.model,
|
||||
messages: [
|
||||
...(session.system ? [{ role: "system", content: session.system }] : []),
|
||||
...nextMsgs,
|
||||
],
|
||||
};
|
||||
if (session.combo) payload.combo = session.combo;
|
||||
const res = await apiFetch("/v1/chat/completions", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
baseUrl: initialOptions.baseUrl,
|
||||
apiKey: initialOptions.apiKey,
|
||||
});
|
||||
const data = await res.json();
|
||||
const latencyMs = Date.now() - t0;
|
||||
const replyContent = data.choices?.[0]?.message?.content ?? "";
|
||||
const usage = data.usage || {};
|
||||
const costUsd = data.cost_usd || 0;
|
||||
setSession((s) => ({
|
||||
...s,
|
||||
messages: [
|
||||
...s.messages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: replyContent,
|
||||
model: data.model || s.model,
|
||||
latencyMs,
|
||||
usage,
|
||||
},
|
||||
],
|
||||
totalUsage: {
|
||||
in: s.totalUsage.in + (usage.prompt_tokens || 0),
|
||||
out: s.totalUsage.out + (usage.completion_tokens || 0),
|
||||
},
|
||||
totalCost: s.totalCost + costUsd,
|
||||
}));
|
||||
} catch (err) {
|
||||
setSession((s) => ({
|
||||
...s,
|
||||
messages: [...s.messages, { role: "system", content: `[error] ${err.message}` }],
|
||||
}));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSlash(line) {
|
||||
const parts = line.slice(1).trim().split(/\s+/);
|
||||
const cmd = parts[0];
|
||||
const args = parts.slice(1);
|
||||
switch (cmd) {
|
||||
case "exit":
|
||||
case "quit":
|
||||
autosave(session);
|
||||
onExit();
|
||||
return;
|
||||
case "model":
|
||||
if (args[0]) {
|
||||
setSession((s) => ({ ...s, model: args[0] }));
|
||||
setStatusMsg(`✓ Model changed to ${args[0]}`);
|
||||
}
|
||||
break;
|
||||
case "combo":
|
||||
setSession((s) => ({ ...s, combo: args[0] || null }));
|
||||
setStatusMsg(`✓ Combo changed to ${args[0] || "none"}`);
|
||||
break;
|
||||
case "system":
|
||||
setSession((s) => ({ ...s, system: args.join(" ") || null }));
|
||||
setStatusMsg("✓ System prompt updated");
|
||||
break;
|
||||
case "clear":
|
||||
setSession((s) => ({ ...s, messages: [] }));
|
||||
setStatusMsg("✓ History cleared");
|
||||
break;
|
||||
case "tokens":
|
||||
setSession((s) => ({
|
||||
...s,
|
||||
messages: [
|
||||
...s.messages,
|
||||
{
|
||||
role: "system",
|
||||
content: `In: ${s.totalUsage.in} · Out: ${s.totalUsage.out} · Cost: $${s.totalCost.toFixed(4)}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
break;
|
||||
case "save":
|
||||
if (args[0]) {
|
||||
saveSession(args[0], session);
|
||||
setStatusMsg(`✓ Session saved as '${args[0]}'`);
|
||||
} else {
|
||||
setStatusMsg("Usage: /save <name>");
|
||||
}
|
||||
break;
|
||||
case "load":
|
||||
if (args[0]) {
|
||||
try {
|
||||
const loaded = loadSession(args[0]);
|
||||
setSession(loaded);
|
||||
setStatusMsg(`✓ Session '${args[0]}' loaded`);
|
||||
} catch {
|
||||
setStatusMsg(`✗ Session '${args[0]}' not found`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "list": {
|
||||
const sessions = listSessions();
|
||||
const content =
|
||||
sessions.length > 0
|
||||
? sessions
|
||||
.map(
|
||||
(s) => `• ${s.name} ${s.updatedAt ? new Date(s.updatedAt).toLocaleString() : ""}`
|
||||
)
|
||||
.join("\n")
|
||||
: "No saved sessions";
|
||||
setSession((s) => ({
|
||||
...s,
|
||||
messages: [...s.messages, { role: "system", content }],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "history": {
|
||||
const n = parseInt(args[0] || "10", 10);
|
||||
const msgs = session.messages
|
||||
.slice(-n)
|
||||
.map((m) => `[${m.role}] ${String(m.content).substring(0, 120)}`)
|
||||
.join("\n");
|
||||
setSession((s) => ({
|
||||
...s,
|
||||
messages: [...s.messages, { role: "system", content: msgs || "No history" }],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "export": {
|
||||
const filename = args[0];
|
||||
if (!filename) {
|
||||
setStatusMsg("Usage: /export <file.md|json|txt>");
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const ext = filename.split(".").pop();
|
||||
let content;
|
||||
if (ext === "json") {
|
||||
content = JSON.stringify(session, null, 2);
|
||||
} else if (ext === "md") {
|
||||
content = session.messages
|
||||
.map((m) => `**${m.role}**\n\n${m.content}`)
|
||||
.join("\n\n---\n\n");
|
||||
} else {
|
||||
content = session.messages.map((m) => `[${m.role}]: ${m.content}`).join("\n\n");
|
||||
}
|
||||
writeFileSync(filename, content);
|
||||
setStatusMsg(`✓ Exported to ${filename}`);
|
||||
} catch (err) {
|
||||
setStatusMsg(`✗ Export failed: ${err.message}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "temperature":
|
||||
case "max-tokens":
|
||||
case "reasoning":
|
||||
setStatusMsg(`✓ ${cmd} set to ${args[0]} (applied to next request)`);
|
||||
break;
|
||||
case "skill":
|
||||
case "memory":
|
||||
await sendMessage(line);
|
||||
break;
|
||||
case "help":
|
||||
setSession((s) => ({
|
||||
...s,
|
||||
messages: [...s.messages, { role: "system", content: HELP_TEXT }],
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
setStatusMsg(`Unknown command: /${cmd} — type /help`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" height={process.stdout.rows}>
|
||||
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
||||
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
{session.messages.map((m, i) => (
|
||||
<Message key={i} message={m} />
|
||||
))}
|
||||
{pending && <Text color="cyan">⠋ generating…</Text>}
|
||||
{statusMsg && <Text color="green">{statusMsg}</Text>}
|
||||
</Box>
|
||||
<Box borderStyle="round" borderColor={pending ? "gray" : "cyan"}>
|
||||
<Text color="green">{"> "}</Text>
|
||||
<TextInput value={input} onChange={setInput} onSubmit={submit} />
|
||||
</Box>
|
||||
<Text dimColor>↑↓ history · Tab autocomplete · /help · /exit</Text>
|
||||
</Box>
|
||||
<SidePanel session={session} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export async function runRepl(opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const { unmount, waitUntilExit } = render(
|
||||
<ReplApp initialOptions={opts} onExit={() => unmount()} />
|
||||
);
|
||||
waitUntilExit().then(resolve);
|
||||
});
|
||||
}
|
||||
54
bin/cli/tui/session.mjs
Normal file
54
bin/cli/tui/session.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveDataDir } from "../data-dir.mjs";
|
||||
|
||||
function sessionsDir() {
|
||||
const dir = join(resolveDataDir(), "repl-sessions");
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function saveSession(name, session) {
|
||||
const path = join(sessionsDir(), `${name}.json`);
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({ ...session, name, updatedAt: new Date().toISOString() }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
export function loadSession(name) {
|
||||
const path = join(sessionsDir(), `${name}.json`);
|
||||
if (!existsSync(path)) throw new Error(`session '${name}' not found`);
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
export function listSessions() {
|
||||
const dir = sessionsDir();
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => {
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
||||
return {
|
||||
name: data.name || f.replace(".json", ""),
|
||||
updatedAt: data.updatedAt,
|
||||
model: data.model,
|
||||
};
|
||||
} catch {
|
||||
return { name: f.replace(".json", ""), updatedAt: null, model: null };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function autosave(session) {
|
||||
try {
|
||||
saveSession("autosave", session);
|
||||
} catch {
|
||||
// autosave failure is not fatal
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteSession(name) {
|
||||
const path = join(sessionsDir(), `${name}.json`);
|
||||
if (existsSync(path)) rmSync(path);
|
||||
}
|
||||
178
tests/unit/cli-repl.test.ts
Normal file
178
tests/unit/cli-repl.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(__dirname, "..", "..");
|
||||
const TUI = join(ROOT, "bin", "cli", "tui");
|
||||
|
||||
function hasExport(file: string, name: string): boolean {
|
||||
const src = readFileSync(file, "utf8");
|
||||
return (
|
||||
src.includes(`export function ${name}`) ||
|
||||
src.includes(`export async function ${name}`) ||
|
||||
src.includes(`export { ${name}`)
|
||||
);
|
||||
}
|
||||
|
||||
test("tui/Repl.jsx existe e exporta runRepl", () => {
|
||||
const path = join(TUI, "Repl.jsx");
|
||||
assert.ok(existsSync(path), "Repl.jsx deve existir");
|
||||
assert.ok(hasExport(path, "runRepl"), "Repl.jsx deve exportar runRepl");
|
||||
});
|
||||
|
||||
test("tui/session.mjs existe e exporta funções de persistência", () => {
|
||||
const path = join(TUI, "session.mjs");
|
||||
assert.ok(existsSync(path), "session.mjs deve existir");
|
||||
const src = readFileSync(path, "utf8");
|
||||
for (const fn of ["saveSession", "loadSession", "listSessions", "autosave", "deleteSession"]) {
|
||||
assert.ok(src.includes(`export function ${fn}`), `deve exportar ${fn}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("commands/repl.mjs existe e exporta registerRepl", () => {
|
||||
const path = join(ROOT, "bin", "cli", "commands", "repl.mjs");
|
||||
assert.ok(existsSync(path), "commands/repl.mjs deve existir");
|
||||
assert.ok(hasExport(path, "registerRepl"), "deve exportar registerRepl");
|
||||
});
|
||||
|
||||
test("commands/repl.mjs registra comando repl com --model, --combo, --system, --resume", async () => {
|
||||
const { registerRepl } = await import("../../bin/cli/commands/repl.mjs");
|
||||
const { Command } = await import("commander");
|
||||
const prog = new Command().exitOverride();
|
||||
registerRepl(prog);
|
||||
const replCmd = prog.commands.find((c) => c.name() === "repl");
|
||||
assert.ok(replCmd, "repl command deve existir");
|
||||
const opts = replCmd.options.map((o) => o.long);
|
||||
assert.ok(opts.includes("--model"), "--model deve estar registrado");
|
||||
assert.ok(opts.includes("--combo"), "--combo deve estar registrado");
|
||||
assert.ok(opts.includes("--system"), "--system deve estar registrado");
|
||||
assert.ok(opts.includes("--resume"), "--resume deve estar registrado");
|
||||
});
|
||||
|
||||
test("Repl.jsx usa ink-text-input para input controlado", () => {
|
||||
const src = readFileSync(join(TUI, "Repl.jsx"), "utf8");
|
||||
assert.ok(src.includes("ink-text-input"), "deve importar ink-text-input");
|
||||
assert.ok(src.includes("TextInput"), "deve usar TextInput");
|
||||
});
|
||||
|
||||
test("Repl.jsx suporta todos os slash commands definidos no spec", () => {
|
||||
const src = readFileSync(join(TUI, "Repl.jsx"), "utf8");
|
||||
const required = [
|
||||
"model",
|
||||
"combo",
|
||||
"system",
|
||||
"clear",
|
||||
"save",
|
||||
"load",
|
||||
"list",
|
||||
"export",
|
||||
"tokens",
|
||||
"help",
|
||||
"exit",
|
||||
];
|
||||
for (const cmd of required) {
|
||||
assert.ok(src.includes(`case "${cmd}"`), `deve suportar /${cmd}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Repl.jsx tem painel lateral (SidePanel) com tokens e custo", () => {
|
||||
const src = readFileSync(join(TUI, "Repl.jsx"), "utf8");
|
||||
assert.ok(src.includes("SidePanel"), "deve ter SidePanel");
|
||||
assert.ok(src.includes("TokenCounter"), "deve usar TokenCounter");
|
||||
});
|
||||
|
||||
// --- testes de persistência via session.mjs ---
|
||||
|
||||
test("saveSession e loadSession persistem e restauram sessão", async () => {
|
||||
const tmpDir = join(tmpdir(), `omniroute-repl-test-${Date.now()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
const origDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
try {
|
||||
const { saveSession, loadSession } = await import("../../bin/cli/tui/session.mjs");
|
||||
const session = {
|
||||
model: "gpt-4o",
|
||||
combo: "fastest",
|
||||
system: "You are helpful.",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
totalUsage: { in: 10, out: 20 },
|
||||
totalCost: 0.001,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
saveSession("test-session", session);
|
||||
const loaded = loadSession("test-session");
|
||||
assert.equal(loaded.model, "gpt-4o");
|
||||
assert.equal(loaded.combo, "fastest");
|
||||
assert.equal(loaded.messages.length, 1);
|
||||
assert.equal(loaded.totalCost, 0.001);
|
||||
assert.equal(loaded.name, "test-session");
|
||||
} finally {
|
||||
process.env.DATA_DIR = origDataDir ?? "";
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
test("loadSession lança erro se sessão não existe", async () => {
|
||||
const tmpDir = join(tmpdir(), `omniroute-repl-test-${Date.now()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
const origDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
try {
|
||||
const { loadSession } = await import("../../bin/cli/tui/session.mjs");
|
||||
await assert.rejects(async () => loadSession("does-not-exist"), /not found/);
|
||||
} finally {
|
||||
process.env.DATA_DIR = origDataDir ?? "";
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
test("listSessions retorna array (vazio ou com sessões)", async () => {
|
||||
const tmpDir = join(tmpdir(), `omniroute-repl-test-${Date.now()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
const origDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
try {
|
||||
const { listSessions, saveSession } = await import("../../bin/cli/tui/session.mjs");
|
||||
const empty = listSessions();
|
||||
assert.ok(Array.isArray(empty));
|
||||
saveSession("session-a", {
|
||||
model: "gpt-4o",
|
||||
messages: [],
|
||||
totalUsage: { in: 0, out: 0 },
|
||||
totalCost: 0,
|
||||
});
|
||||
const list = listSessions();
|
||||
assert.ok(list.some((s) => s.name === "session-a"));
|
||||
} finally {
|
||||
process.env.DATA_DIR = origDataDir ?? "";
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
test("autosave não lança erro em condições normais", async () => {
|
||||
const tmpDir = join(tmpdir(), `omniroute-repl-test-${Date.now()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
const origDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
try {
|
||||
const { autosave } = await import("../../bin/cli/tui/session.mjs");
|
||||
assert.doesNotThrow(() =>
|
||||
autosave({ model: "auto", messages: [], totalUsage: { in: 0, out: 0 }, totalCost: 0 })
|
||||
);
|
||||
} finally {
|
||||
process.env.DATA_DIR = origDataDir ?? "";
|
||||
try {
|
||||
rmSync(tmpDir, { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user