@elvatis_com/openclaw-cli-bridge-elvatis 0.2.0 → 0.2.2
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/.clawhubignore +7 -0
- package/SKILL.md +24 -0
- package/index.ts +115 -2
- package/openclaw.plugin.json +4 -2
- package/package.json +1 -1
- package/src/cli-runner.ts +105 -23
- package/test/cli-runner.test.ts +46 -0
package/.clawhubignore
ADDED
package/SKILL.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: openclaw-cli-bridge-elvatis
|
|
3
|
+
description: Bridge local Codex, Gemini, and Claude Code CLIs into OpenClaw (Codex OAuth auth bridge + Gemini/Claude OpenAI-compatible local proxy via vllm).
|
|
4
|
+
homepage: https://github.com/elvatis/openclaw-cli-bridge-elvatis
|
|
5
|
+
metadata:
|
|
6
|
+
{
|
|
7
|
+
"openclaw":
|
|
8
|
+
{
|
|
9
|
+
"emoji": "🌉",
|
|
10
|
+
"requires": { "bins": ["openclaw", "codex", "gemini", "claude"] }
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# OpenClaw CLI Bridge Elvatis
|
|
16
|
+
|
|
17
|
+
This project provides two layers:
|
|
18
|
+
|
|
19
|
+
1. **Codex auth bridge** for `openai-codex/*` by reading existing Codex CLI OAuth tokens from `~/.codex/auth.json`
|
|
20
|
+
2. **Local OpenAI-compatible proxy** (default `127.0.0.1:31337`) for Gemini/Claude CLI execution via OpenClaw `vllm` provider models:
|
|
21
|
+
- `vllm/cli-gemini/*`
|
|
22
|
+
- `vllm/cli-claude/*`
|
|
23
|
+
|
|
24
|
+
See `README.md` for setup and architecture.
|
package/index.ts
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
* and configures OpenClaw's vllm provider to route through it. Model calls
|
|
9
9
|
* are handled by the Gemini CLI and Claude Code CLI subprocesses.
|
|
10
10
|
*
|
|
11
|
+
* Phase 3 (slash commands): registers /cli-* commands for instant model switching.
|
|
12
|
+
* /cli-sonnet → vllm/cli-claude/claude-sonnet-4-6
|
|
13
|
+
* /cli-opus → vllm/cli-claude/claude-opus-4-6
|
|
14
|
+
* /cli-haiku → vllm/cli-claude/claude-haiku-4-5
|
|
15
|
+
* /cli-gemini → vllm/cli-gemini/gemini-2.5-pro
|
|
16
|
+
* /cli-gemini-flash → vllm/cli-gemini/gemini-2.5-flash
|
|
17
|
+
* /cli-gemini3 → vllm/cli-gemini/gemini-3-pro
|
|
18
|
+
*
|
|
11
19
|
* Provider / model naming:
|
|
12
20
|
* vllm/cli-gemini/gemini-2.5-pro → `gemini -m gemini-2.5-pro -p "<prompt>"`
|
|
13
21
|
* vllm/cli-claude/claude-opus-4-6 → `claude -p -m claude-opus-4-6 --output-format text "<prompt>"`
|
|
@@ -18,6 +26,12 @@ import type {
|
|
|
18
26
|
ProviderAuthContext,
|
|
19
27
|
ProviderAuthResult,
|
|
20
28
|
} from "openclaw/plugin-sdk";
|
|
29
|
+
|
|
30
|
+
// Types derived from the plugin SDK (PluginCommandContext / PluginCommandResult are
|
|
31
|
+
// not re-exported from the package, so we infer them from the registerCommand signature).
|
|
32
|
+
type RegisterCommandParam = Parameters<OpenClawPluginApi["registerCommand"]>[0];
|
|
33
|
+
type PluginCommandContext = Parameters<RegisterCommandParam["handler"]>[0];
|
|
34
|
+
type PluginCommandResult = Awaited<ReturnType<RegisterCommandParam["handler"]>>;
|
|
21
35
|
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk";
|
|
22
36
|
import {
|
|
23
37
|
DEFAULT_CODEX_AUTH_PATH,
|
|
@@ -44,16 +58,94 @@ interface CliPluginConfig {
|
|
|
44
58
|
const DEFAULT_PROXY_PORT = 31337;
|
|
45
59
|
const DEFAULT_PROXY_API_KEY = "cli-bridge";
|
|
46
60
|
|
|
61
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// Phase 3: slash-command model table
|
|
63
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** CLI bridge models available via /cli-* slash commands. */
|
|
66
|
+
const CLI_MODEL_COMMANDS = [
|
|
67
|
+
{
|
|
68
|
+
name: "cli-sonnet",
|
|
69
|
+
model: "vllm/cli-claude/claude-sonnet-4-6",
|
|
70
|
+
description: "Switch to Claude Sonnet 4.6 (CLI bridge)",
|
|
71
|
+
label: "Claude Sonnet 4.6 (CLI)",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "cli-opus",
|
|
75
|
+
model: "vllm/cli-claude/claude-opus-4-6",
|
|
76
|
+
description: "Switch to Claude Opus 4.6 (CLI bridge)",
|
|
77
|
+
label: "Claude Opus 4.6 (CLI)",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: "cli-haiku",
|
|
81
|
+
model: "vllm/cli-claude/claude-haiku-4-5",
|
|
82
|
+
description: "Switch to Claude Haiku 4.5 (CLI bridge)",
|
|
83
|
+
label: "Claude Haiku 4.5 (CLI)",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "cli-gemini",
|
|
87
|
+
model: "vllm/cli-gemini/gemini-2.5-pro",
|
|
88
|
+
description: "Switch to Gemini 2.5 Pro (CLI bridge)",
|
|
89
|
+
label: "Gemini 2.5 Pro (CLI)",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "cli-gemini-flash",
|
|
93
|
+
model: "vllm/cli-gemini/gemini-2.5-flash",
|
|
94
|
+
description: "Switch to Gemini 2.5 Flash (CLI bridge)",
|
|
95
|
+
label: "Gemini 2.5 Flash (CLI)",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "cli-gemini3",
|
|
99
|
+
model: "vllm/cli-gemini/gemini-3-pro",
|
|
100
|
+
description: "Switch to Gemini 3 Pro (CLI bridge)",
|
|
101
|
+
label: "Gemini 3 Pro (CLI)",
|
|
102
|
+
},
|
|
103
|
+
] as const;
|
|
104
|
+
|
|
105
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
// Helper: run `openclaw models set <model>` and return result text
|
|
107
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
async function switchModel(
|
|
110
|
+
api: OpenClawPluginApi,
|
|
111
|
+
model: string,
|
|
112
|
+
label: string,
|
|
113
|
+
_ctx: PluginCommandContext
|
|
114
|
+
): Promise<PluginCommandResult> {
|
|
115
|
+
try {
|
|
116
|
+
const result = await api.runtime.system.runCommandWithTimeout(
|
|
117
|
+
["openclaw", "models", "set", model],
|
|
118
|
+
{ timeoutMs: 8_000 }
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (result.code !== 0) {
|
|
122
|
+
const err = (result.stderr || result.stdout || "unknown error").trim();
|
|
123
|
+
api.logger.warn(`[cli-bridge] models set failed (code ${result.code}): ${err}`);
|
|
124
|
+
return { text: `❌ Failed to switch to ${label}: ${err}` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
api.logger.info(`[cli-bridge] switched model → ${model}`);
|
|
128
|
+
return {
|
|
129
|
+
text: `✅ Switched to ${label}\n\`${model}\``,
|
|
130
|
+
};
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const msg = (err as Error).message;
|
|
133
|
+
api.logger.warn(`[cli-bridge] models set error: ${msg}`);
|
|
134
|
+
return { text: `❌ Error switching model: ${msg}` };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
47
138
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
48
139
|
// Plugin definition
|
|
49
140
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
50
141
|
const plugin = {
|
|
51
142
|
id: "openclaw-cli-bridge-elvatis",
|
|
52
143
|
name: "OpenClaw CLI Bridge",
|
|
53
|
-
version: "0.2.
|
|
144
|
+
version: "0.2.1",
|
|
54
145
|
description:
|
|
55
146
|
"Phase 1: openai-codex auth bridge (reads ~/.codex/auth.json). " +
|
|
56
|
-
"Phase 2: HTTP proxy server routing model calls through gemini/claude CLIs."
|
|
147
|
+
"Phase 2: HTTP proxy server routing model calls through gemini/claude CLIs. " +
|
|
148
|
+
"Phase 3: /cli-* slash commands for instant model switching.",
|
|
57
149
|
|
|
58
150
|
register(api: OpenClawPluginApi) {
|
|
59
151
|
const cfg = (api.pluginConfig ?? {}) as CliPluginConfig;
|
|
@@ -154,6 +246,27 @@ const plugin = {
|
|
|
154
246
|
);
|
|
155
247
|
});
|
|
156
248
|
}
|
|
249
|
+
|
|
250
|
+
// ── Phase 3: /cli-* slash commands ────────────────────────────────────────
|
|
251
|
+
for (const entry of CLI_MODEL_COMMANDS) {
|
|
252
|
+
// Capture entry in closure (const iteration variable is stable in TS/ESM)
|
|
253
|
+
const { name, model, description, label } = entry;
|
|
254
|
+
|
|
255
|
+
api.registerCommand({
|
|
256
|
+
name,
|
|
257
|
+
description,
|
|
258
|
+
requireAuth: true, // only authorized senders
|
|
259
|
+
handler: async (ctx: PluginCommandContext): Promise<PluginCommandResult> => {
|
|
260
|
+
api.logger.info(`[cli-bridge] /${name} triggered by ${ctx.senderId ?? "unknown"} (authorized=${ctx.isAuthorizedSender})`);
|
|
261
|
+
return switchModel(api, model, label, ctx);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
api.logger.info(
|
|
267
|
+
`[cli-bridge] registered ${CLI_MODEL_COMMANDS.length} slash commands: ` +
|
|
268
|
+
CLI_MODEL_COMMANDS.map((c) => `/${c.name}`).join(", ")
|
|
269
|
+
);
|
|
157
270
|
},
|
|
158
271
|
};
|
|
159
272
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-cli-bridge-elvatis",
|
|
3
3
|
"name": "OpenClaw CLI Bridge",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.2",
|
|
5
5
|
"description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
|
|
6
|
-
"providers": [
|
|
6
|
+
"providers": [
|
|
7
|
+
"openai-codex"
|
|
8
|
+
],
|
|
7
9
|
"configSchema": {
|
|
8
10
|
"type": "object",
|
|
9
11
|
"additionalProperties": false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elvatis_com/openclaw-cli-bridge-elvatis",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
package/src/cli-runner.ts
CHANGED
|
@@ -2,10 +2,22 @@
|
|
|
2
2
|
* cli-runner.ts
|
|
3
3
|
*
|
|
4
4
|
* Spawns CLI subprocesses (gemini, claude) and captures their output.
|
|
5
|
-
* Input: OpenAI-format messages → formatted prompt string → CLI
|
|
5
|
+
* Input: OpenAI-format messages → formatted prompt string → CLI stdin.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: Prompt is always passed via stdin (not as a CLI argument) to
|
|
8
|
+
* avoid E2BIG ("Argument list too long") when conversation history is large.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import { spawn } from "node:child_process";
|
|
12
|
+
import { writeFileSync, unlinkSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
/** Max messages to include in the prompt sent to the CLI. */
|
|
18
|
+
const MAX_MESSAGES = 20;
|
|
19
|
+
/** Max characters per message content before truncation. */
|
|
20
|
+
const MAX_MSG_CHARS = 4000;
|
|
9
21
|
|
|
10
22
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
11
23
|
// Message formatting
|
|
@@ -18,31 +30,44 @@ export interface ChatMessage {
|
|
|
18
30
|
|
|
19
31
|
/**
|
|
20
32
|
* Convert OpenAI messages to a single flat prompt string.
|
|
21
|
-
*
|
|
33
|
+
* Truncates to MAX_MESSAGES (keeping the most recent) and MAX_MSG_CHARS per
|
|
34
|
+
* message to avoid E2BIG when conversation history is very large.
|
|
22
35
|
*/
|
|
23
36
|
export function formatPrompt(messages: ChatMessage[]): string {
|
|
24
37
|
if (messages.length === 0) return "";
|
|
25
38
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
39
|
+
// Keep system message (if any) + last N non-system messages
|
|
40
|
+
const system = messages.find((m) => m.role === "system");
|
|
41
|
+
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
42
|
+
const recent = nonSystem.slice(-MAX_MESSAGES);
|
|
43
|
+
const truncated = system ? [system, ...recent] : recent;
|
|
44
|
+
|
|
45
|
+
// If single user message with short content, send directly — no wrapping.
|
|
46
|
+
if (truncated.length === 1 && truncated[0].role === "user") {
|
|
47
|
+
return truncateContent(truncated[0].content);
|
|
29
48
|
}
|
|
30
49
|
|
|
31
|
-
return
|
|
50
|
+
return truncated
|
|
32
51
|
.map((m) => {
|
|
52
|
+
const content = truncateContent(m.content);
|
|
33
53
|
switch (m.role) {
|
|
34
54
|
case "system":
|
|
35
|
-
return `[System]\n${
|
|
55
|
+
return `[System]\n${content}`;
|
|
36
56
|
case "assistant":
|
|
37
|
-
return `[Assistant]\n${
|
|
57
|
+
return `[Assistant]\n${content}`;
|
|
38
58
|
case "user":
|
|
39
59
|
default:
|
|
40
|
-
return `[User]\n${
|
|
60
|
+
return `[User]\n${content}`;
|
|
41
61
|
}
|
|
42
62
|
})
|
|
43
63
|
.join("\n\n");
|
|
44
64
|
}
|
|
45
65
|
|
|
66
|
+
function truncateContent(s: string): string {
|
|
67
|
+
if (s.length <= MAX_MSG_CHARS) return s;
|
|
68
|
+
return s.slice(0, MAX_MSG_CHARS) + `\n...[truncated ${s.length - MAX_MSG_CHARS} chars]`;
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
47
72
|
// Core subprocess runner
|
|
48
73
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -53,23 +78,72 @@ export interface CliRunResult {
|
|
|
53
78
|
exitCode: number;
|
|
54
79
|
}
|
|
55
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Build a minimal, safe environment for spawning CLI subprocesses.
|
|
83
|
+
*
|
|
84
|
+
* WHY: The OpenClaw gateway may inject large values into process.env at
|
|
85
|
+
* runtime (system prompts, session data, OPENCLAW_* vars, etc.). Spreading
|
|
86
|
+
* the full process.env into spawn() can push the combined argv+envp over
|
|
87
|
+
* ARG_MAX (~2 MB on Linux), causing "spawn E2BIG". Using only the vars that
|
|
88
|
+
* the CLI tools actually need keeps us well under the limit regardless of
|
|
89
|
+
* what the parent process environment contains.
|
|
90
|
+
*/
|
|
91
|
+
function buildMinimalEnv(): Record<string, string> {
|
|
92
|
+
const pick = (key: string): string | undefined => process.env[key];
|
|
93
|
+
|
|
94
|
+
const env: Record<string, string> = {
|
|
95
|
+
NO_COLOR: "1",
|
|
96
|
+
TERM: "dumb",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Essential path/identity vars — always include when present.
|
|
100
|
+
for (const key of ["HOME", "PATH", "USER", "LOGNAME", "SHELL", "TMPDIR", "TMP", "TEMP"]) {
|
|
101
|
+
const v = pick(key);
|
|
102
|
+
if (v) env[key] = v;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Allow google-auth / claude auth paths to be inherited.
|
|
106
|
+
for (const key of [
|
|
107
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
108
|
+
"ANTHROPIC_API_KEY",
|
|
109
|
+
"CLAUDE_API_KEY",
|
|
110
|
+
"CODEX_API_KEY",
|
|
111
|
+
"OPENAI_API_KEY",
|
|
112
|
+
"XDG_CONFIG_HOME",
|
|
113
|
+
"XDG_DATA_HOME",
|
|
114
|
+
"XDG_CACHE_HOME",
|
|
115
|
+
]) {
|
|
116
|
+
const v = pick(key);
|
|
117
|
+
if (v) env[key] = v;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return env;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Spawn a CLI and deliver the prompt via stdin (not as an argument).
|
|
125
|
+
* This avoids E2BIG ("Argument list too long") for large conversation histories
|
|
126
|
+
* or when the parent process has a large runtime environment.
|
|
127
|
+
*/
|
|
56
128
|
export function runCli(
|
|
57
129
|
cmd: string,
|
|
58
130
|
args: string[],
|
|
131
|
+
prompt: string,
|
|
59
132
|
timeoutMs = 120_000
|
|
60
133
|
): Promise<CliRunResult> {
|
|
61
134
|
return new Promise((resolve, reject) => {
|
|
62
135
|
const proc = spawn(cmd, args, {
|
|
63
136
|
timeout: timeoutMs,
|
|
64
|
-
env:
|
|
137
|
+
env: buildMinimalEnv(),
|
|
65
138
|
});
|
|
66
139
|
|
|
67
140
|
let stdout = "";
|
|
68
141
|
let stderr = "";
|
|
69
142
|
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
143
|
+
// Write prompt to stdin then close — prevents the CLI from waiting for more input.
|
|
144
|
+
proc.stdin.write(prompt, "utf8", () => {
|
|
145
|
+
proc.stdin.end();
|
|
146
|
+
});
|
|
73
147
|
|
|
74
148
|
proc.stdout.on("data", (d: Buffer) => {
|
|
75
149
|
stdout += d.toString();
|
|
@@ -102,16 +176,24 @@ export async function runGemini(
|
|
|
102
176
|
timeoutMs: number
|
|
103
177
|
): Promise<string> {
|
|
104
178
|
const model = stripPrefix(modelId);
|
|
105
|
-
|
|
106
|
-
const
|
|
179
|
+
// Gemini CLI doesn't support stdin — write prompt to a temp file and read it via @file syntax
|
|
180
|
+
const tmpFile = join(tmpdir(), `cli-bridge-${randomBytes(6).toString("hex")}.txt`);
|
|
181
|
+
writeFileSync(tmpFile, prompt, "utf8");
|
|
182
|
+
try {
|
|
183
|
+
// Use @<file> to pass prompt from file (avoids ARG_MAX limit)
|
|
184
|
+
const args = ["-m", model, "-p", `@${tmpFile}`];
|
|
185
|
+
const result = await runCli("gemini", args, "", timeoutMs);
|
|
107
186
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
187
|
+
if (result.exitCode !== 0 && result.stdout.length === 0) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`gemini exited ${result.exitCode}: ${result.stderr || "(no output)"}`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
113
192
|
|
|
114
|
-
|
|
193
|
+
return result.stdout || result.stderr;
|
|
194
|
+
} finally {
|
|
195
|
+
try { unlinkSync(tmpFile); } catch { /* ignore */ }
|
|
196
|
+
}
|
|
115
197
|
}
|
|
116
198
|
|
|
117
199
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -128,6 +210,7 @@ export async function runClaude(
|
|
|
128
210
|
timeoutMs: number
|
|
129
211
|
): Promise<string> {
|
|
130
212
|
const model = stripPrefix(modelId);
|
|
213
|
+
// No prompt argument — deliver via stdin to avoid E2BIG
|
|
131
214
|
const args = [
|
|
132
215
|
"-p",
|
|
133
216
|
"--output-format",
|
|
@@ -138,9 +221,8 @@ export async function runClaude(
|
|
|
138
221
|
"",
|
|
139
222
|
"--model",
|
|
140
223
|
model,
|
|
141
|
-
prompt,
|
|
142
224
|
];
|
|
143
|
-
const result = await runCli("claude", args, timeoutMs);
|
|
225
|
+
const result = await runCli("claude", args, prompt, timeoutMs);
|
|
144
226
|
|
|
145
227
|
if (result.exitCode !== 0 && result.stdout.length === 0) {
|
|
146
228
|
throw new Error(
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { formatPrompt } from "../src/cli-runner.js";
|
|
3
|
+
|
|
4
|
+
describe("formatPrompt", () => {
|
|
5
|
+
it("returns empty string for empty messages", () => {
|
|
6
|
+
expect(formatPrompt([])).toBe("");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns bare user text for a single short user message", () => {
|
|
10
|
+
const result = formatPrompt([{ role: "user", content: "hello" }]);
|
|
11
|
+
expect(result).toBe("hello");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("truncates to MAX_MESSAGES (20) non-system messages", () => {
|
|
15
|
+
const messages = Array.from({ length: 30 }, (_, i) => ({
|
|
16
|
+
role: "user" as const,
|
|
17
|
+
content: `msg ${i}`,
|
|
18
|
+
}));
|
|
19
|
+
const result = formatPrompt(messages);
|
|
20
|
+
// Should contain last 20 messages, not first 10
|
|
21
|
+
expect(result).toContain("msg 29");
|
|
22
|
+
expect(result).not.toContain("msg 0\n");
|
|
23
|
+
// Single-turn mode doesn't apply when there are multiple messages
|
|
24
|
+
expect(result).toContain("[User]");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("keeps system message + last 20 non-system messages", () => {
|
|
28
|
+
const sys = { role: "system" as const, content: "You are helpful" };
|
|
29
|
+
const msgs = Array.from({ length: 25 }, (_, i) => ({
|
|
30
|
+
role: "user" as const,
|
|
31
|
+
content: `msg ${i}`,
|
|
32
|
+
}));
|
|
33
|
+
const result = formatPrompt([sys, ...msgs]);
|
|
34
|
+
expect(result).toContain("[System]");
|
|
35
|
+
expect(result).toContain("You are helpful");
|
|
36
|
+
expect(result).toContain("msg 24"); // last
|
|
37
|
+
expect(result).not.toContain("msg 0\n"); // first (truncated)
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("truncates individual message content at MAX_MSG_CHARS (4000)", () => {
|
|
41
|
+
const longContent = "x".repeat(5000);
|
|
42
|
+
const result = formatPrompt([{ role: "user", content: longContent }]);
|
|
43
|
+
expect(result.length).toBeLessThan(5000);
|
|
44
|
+
expect(result).toContain("truncated");
|
|
45
|
+
});
|
|
46
|
+
});
|