@elench/testkit 0.1.95 → 0.1.97
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/lib/cli/agents/providers/claude.mjs +41 -3
- package/lib/cli/agents/providers/codex.mjs +3 -3
- package/lib/cli/agents/providers/shared.mjs +6 -2
- package/lib/cli/assistant/app.mjs +24 -11
- package/lib/cli/assistant/composer.mjs +19 -1
- package/lib/cli/assistant/model-discovery.mjs +243 -0
- package/lib/cli/assistant/session.mjs +26 -1
- package/lib/cli/assistant/slash-commands.mjs +8 -2
- package/lib/cli/assistant/state.mjs +83 -21
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +7 -6
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
buildStatusEvent,
|
|
5
5
|
buildToolEvent,
|
|
6
6
|
createHostedSessionRunner,
|
|
7
|
+
extractTextFragments,
|
|
7
8
|
} from "./shared.mjs";
|
|
8
9
|
|
|
9
10
|
export function startClaudeHostedSession({
|
|
@@ -50,8 +51,8 @@ export function startClaudeHostedSession({
|
|
|
50
51
|
child,
|
|
51
52
|
onEvent,
|
|
52
53
|
parsePayload: parseClaudePayload,
|
|
53
|
-
readFinalText() {
|
|
54
|
-
return null;
|
|
54
|
+
readFinalText(result) {
|
|
55
|
+
return readClaudeFinalText(result?.stdout || "") || null;
|
|
55
56
|
},
|
|
56
57
|
});
|
|
57
58
|
}
|
|
@@ -61,7 +62,7 @@ function normalizeProviderArgs(providerArgs) {
|
|
|
61
62
|
return providerArgs.flatMap((arg) => String(arg || "").split(/\s+/).filter(Boolean));
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
function parseClaudePayload(payload) {
|
|
65
|
+
export function parseClaudePayload(payload) {
|
|
65
66
|
const events = [];
|
|
66
67
|
if (!payload || typeof payload !== "object") return events;
|
|
67
68
|
|
|
@@ -102,6 +103,10 @@ function parseClaudePayload(payload) {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
if (type === "assistant") {
|
|
106
|
+
const fragments = [...new Set(extractTextFragments(payload.message?.content || payload.content || [], []))];
|
|
107
|
+
for (const fragment of fragments) {
|
|
108
|
+
events.push({ type: "delta", text: fragment });
|
|
109
|
+
}
|
|
105
110
|
return events;
|
|
106
111
|
}
|
|
107
112
|
|
|
@@ -113,7 +118,40 @@ function parseClaudePayload(payload) {
|
|
|
113
118
|
return events;
|
|
114
119
|
}
|
|
115
120
|
|
|
121
|
+
if (type === "system" || type === "rate_limit_event") {
|
|
122
|
+
return events;
|
|
123
|
+
}
|
|
124
|
+
|
|
116
125
|
const statusEvent = buildStatusEvent(type ? `Claude event: ${type}` : JSON.stringify(payload));
|
|
117
126
|
if (statusEvent) events.push(statusEvent);
|
|
118
127
|
return events;
|
|
119
128
|
}
|
|
129
|
+
|
|
130
|
+
export function readClaudeFinalText(stdout) {
|
|
131
|
+
const lines = String(stdout || "")
|
|
132
|
+
.split("\n")
|
|
133
|
+
.map((line) => line.trim())
|
|
134
|
+
.filter(Boolean);
|
|
135
|
+
|
|
136
|
+
let fallback = null;
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
let payload = null;
|
|
139
|
+
try {
|
|
140
|
+
payload = JSON.parse(line);
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!payload || typeof payload !== "object") continue;
|
|
145
|
+
|
|
146
|
+
if (payload.type === "result" && payload.subtype !== "error" && typeof payload.result === "string") {
|
|
147
|
+
return payload.result.trim() || null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (payload.type === "assistant") {
|
|
151
|
+
const fragments = [...new Set(extractTextFragments(payload.message?.content || payload.content || [], []))];
|
|
152
|
+
if (fragments.length > 0) fallback = fragments.join("");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return fallback;
|
|
157
|
+
}
|
|
@@ -48,7 +48,7 @@ export function startCodexHostedSession({
|
|
|
48
48
|
return String(message || "").trim() === "Reading additional input from stdin...";
|
|
49
49
|
},
|
|
50
50
|
readFinalText(result) {
|
|
51
|
-
return readTextFileIfPresent(outputFile) ||
|
|
51
|
+
return readTextFileIfPresent(outputFile) || null;
|
|
52
52
|
},
|
|
53
53
|
});
|
|
54
54
|
|
|
@@ -89,11 +89,11 @@ function normalizeProviderArgs(providerArgs) {
|
|
|
89
89
|
return providerArgs.flatMap((arg) => String(arg || "").split(/\s+/).filter(Boolean));
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function parseCodexPayload(payload) {
|
|
92
|
+
export function parseCodexPayload(payload) {
|
|
93
93
|
const events = [];
|
|
94
94
|
if (!payload || typeof payload !== "object") return events;
|
|
95
95
|
const type = payload.type || payload.event || payload.kind || null;
|
|
96
|
-
const errorMessage = payload.error?.message || payload.error || null;
|
|
96
|
+
const errorMessage = payload.error?.message || payload.error || (type === "error" ? payload.message : null) || null;
|
|
97
97
|
if (errorMessage) {
|
|
98
98
|
const event = buildErrorEvent(errorMessage);
|
|
99
99
|
if (event) events.push(event);
|
|
@@ -5,11 +5,15 @@ export function createHostedSessionRunner({ provider, child, onEvent, parsePaylo
|
|
|
5
5
|
let cancelled = false;
|
|
6
6
|
let settled = false;
|
|
7
7
|
let assistantText = "";
|
|
8
|
+
let lastErrorMessage = null;
|
|
8
9
|
|
|
9
10
|
const emit = (event) => {
|
|
10
11
|
if (event?.type === "delta" || event?.type === "final") {
|
|
11
12
|
assistantText += event.text || "";
|
|
12
13
|
}
|
|
14
|
+
if (event?.type === "error") {
|
|
15
|
+
lastErrorMessage = event.message || lastErrorMessage;
|
|
16
|
+
}
|
|
13
17
|
if (typeof onEvent === "function" && event) onEvent({ provider, ...event });
|
|
14
18
|
};
|
|
15
19
|
|
|
@@ -36,8 +40,8 @@ export function createHostedSessionRunner({ provider, child, onEvent, parsePaylo
|
|
|
36
40
|
const completion = (async () => {
|
|
37
41
|
const result = await child;
|
|
38
42
|
const finalText = (readFinalText ? readFinalText(result) : null) || assistantText.trim() || null;
|
|
39
|
-
if ((result.exitCode ?? 0) !== 0
|
|
40
|
-
const message =
|
|
43
|
+
if ((result.exitCode ?? 0) !== 0) {
|
|
44
|
+
const message = lastErrorMessage || result.stderr || `${provider} exited with code ${result.exitCode ?? 1}`;
|
|
41
45
|
emit({ type: "error", message });
|
|
42
46
|
throw new Error(message);
|
|
43
47
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import React, { createElement, useEffect, useMemo, useState } from "react";
|
|
2
|
-
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
1
|
+
import React, { createElement, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Box, Text, useApp, useBoxMetrics, useCursor, useInput, useStdout } from "ink";
|
|
3
3
|
import { bold, cyan, dim, green, red, yellow } from "../presentation/colors.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { getComposerDisplayModel } from "./composer.mjs";
|
|
5
5
|
import { buildAssistantViewModel } from "./view-model.mjs";
|
|
6
6
|
|
|
7
7
|
const MAX_BLOCK_LINES = 18;
|
|
@@ -177,15 +177,30 @@ function renderBlock(block) {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
function ComposerBar({ view, busy }) {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
180
|
+
const ref = useRef(null);
|
|
181
|
+
const metrics = useBoxMetrics(ref);
|
|
182
|
+
const { setCursorPosition } = useCursor();
|
|
183
|
+
const display = getComposerDisplayModel(
|
|
184
|
+
{
|
|
185
|
+
text: view.composer.text,
|
|
186
|
+
cursor: view.composer.cursor,
|
|
187
|
+
},
|
|
188
|
+
{ placeholder: view.composer.placeholder }
|
|
189
|
+
);
|
|
190
|
+
setCursorPosition(
|
|
191
|
+
metrics.hasMeasured
|
|
192
|
+
? {
|
|
193
|
+
x: 2 + display.cursorColumn,
|
|
194
|
+
y: metrics.top + 1,
|
|
195
|
+
}
|
|
196
|
+
: undefined
|
|
197
|
+
);
|
|
198
|
+
|
|
184
199
|
const prompt = cyan("❯");
|
|
185
|
-
const promptText = empty ? dim(`${view.composer.placeholder} `) : before;
|
|
186
200
|
return createElement(
|
|
187
201
|
Box,
|
|
188
202
|
{
|
|
203
|
+
ref,
|
|
189
204
|
borderStyle: "single",
|
|
190
205
|
borderLeft: false,
|
|
191
206
|
borderRight: false,
|
|
@@ -196,9 +211,7 @@ function ComposerBar({ view, busy }) {
|
|
|
196
211
|
Text,
|
|
197
212
|
null,
|
|
198
213
|
`${prompt} `,
|
|
199
|
-
|
|
200
|
-
createElement(Text, { inverse: true }, current),
|
|
201
|
-
after,
|
|
214
|
+
display.empty ? dim(display.placeholder) : display.text,
|
|
202
215
|
busy ? dim(" provider responding") : ""
|
|
203
216
|
)
|
|
204
217
|
);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { measureWidth } from "../presentation/terminal-layout.mjs";
|
|
2
|
+
|
|
1
3
|
const segmenter =
|
|
2
4
|
typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
|
|
3
5
|
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
@@ -81,14 +83,30 @@ export function moveComposerCursorToEnd(state) {
|
|
|
81
83
|
export function getComposerRenderParts(state) {
|
|
82
84
|
const parts = splitGraphemes(state?.text || "");
|
|
83
85
|
const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
|
|
86
|
+
const before = parts.slice(0, cursor).join("");
|
|
84
87
|
return {
|
|
85
|
-
before
|
|
88
|
+
before,
|
|
86
89
|
current: parts[cursor] || " ",
|
|
87
90
|
after: parts.slice(cursor + (parts[cursor] ? 1 : 0)).join(""),
|
|
88
91
|
empty: parts.length === 0,
|
|
89
92
|
};
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
export function getComposerDisplayModel(state, { placeholder = "" } = {}) {
|
|
96
|
+
const parts = splitGraphemes(state?.text || "");
|
|
97
|
+
const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
|
|
98
|
+
const before = parts.slice(0, cursor).join("");
|
|
99
|
+
const text = parts.join("");
|
|
100
|
+
return {
|
|
101
|
+
text,
|
|
102
|
+
before,
|
|
103
|
+
after: parts.slice(cursor).join(""),
|
|
104
|
+
cursorColumn: measureWidth(before),
|
|
105
|
+
placeholder: String(placeholder || ""),
|
|
106
|
+
empty: parts.length === 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
92
110
|
function normalizeComposerState(state) {
|
|
93
111
|
const text = String(state?.text || "");
|
|
94
112
|
const parts = splitGraphemes(text);
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { execaCommand } from "execa";
|
|
5
|
+
|
|
6
|
+
const CLAUDE_ALIASES = ["default", "best", "sonnet", "opus", "haiku", "opusplan", "sonnet[1m]", "opus[1m]"];
|
|
7
|
+
const CACHE_MAX_AGE_MS = 5 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
export async function discoverAssistantModels({
|
|
10
|
+
provider,
|
|
11
|
+
productDir = process.cwd(),
|
|
12
|
+
env = process.env,
|
|
13
|
+
} = {}) {
|
|
14
|
+
const resolvedProvider = String(provider || "").trim();
|
|
15
|
+
if (resolvedProvider === "codex") {
|
|
16
|
+
return discoverCodexModels({ productDir, env });
|
|
17
|
+
}
|
|
18
|
+
if (resolvedProvider === "claude") {
|
|
19
|
+
return discoverClaudeModels({ productDir, env });
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
provider: resolvedProvider || "unknown",
|
|
23
|
+
source: "none",
|
|
24
|
+
models: [providerDefaultModel()],
|
|
25
|
+
warnings: ["No provider is resolved yet."],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function discoverCodexModels({ productDir = process.cwd(), env = process.env } = {}) {
|
|
30
|
+
const cachePath = path.join(productDir, ".testkit", "assistant", "model-cache-codex.json");
|
|
31
|
+
const cached = readFreshCache(cachePath);
|
|
32
|
+
if (cached) return cached;
|
|
33
|
+
|
|
34
|
+
const command = env.TESTKIT_CODEX_BIN || "codex";
|
|
35
|
+
let catalog = null;
|
|
36
|
+
let warning = null;
|
|
37
|
+
try {
|
|
38
|
+
const result = await execaCommand(`${shellQuote(command)} debug models`, {
|
|
39
|
+
cwd: productDir,
|
|
40
|
+
reject: false,
|
|
41
|
+
shell: true,
|
|
42
|
+
env: { ...process.env, ...env },
|
|
43
|
+
});
|
|
44
|
+
if ((result.exitCode ?? 1) === 0) {
|
|
45
|
+
catalog = JSON.parse(result.stdout || "{}");
|
|
46
|
+
} else {
|
|
47
|
+
warning = (result.stderr || result.stdout || "codex debug models failed").trim();
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
warning = error instanceof Error ? error.message : String(error);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!catalog) {
|
|
54
|
+
catalog = readJson(path.join(os.homedir(), ".codex", "models_cache.json"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const models = normalizeCodexModels(catalog);
|
|
58
|
+
const discovery = {
|
|
59
|
+
provider: "codex",
|
|
60
|
+
source: catalog ? "codex debug models" : "fallback",
|
|
61
|
+
models: [providerDefaultModel(), ...models],
|
|
62
|
+
warnings: warning && models.length === 0 ? [warning] : [],
|
|
63
|
+
};
|
|
64
|
+
writeCache(cachePath, discovery);
|
|
65
|
+
return discovery;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function discoverClaudeModels({ productDir = process.cwd(), env = process.env } = {}) {
|
|
69
|
+
const configured = readClaudeAvailableModels({ productDir });
|
|
70
|
+
const apiModels = await fetchAnthropicModels({ env });
|
|
71
|
+
const dynamicModels = apiModels.map((model) => ({
|
|
72
|
+
id: model.id,
|
|
73
|
+
label: model.displayName || model.id,
|
|
74
|
+
description: model.description || "",
|
|
75
|
+
source: "anthropic api",
|
|
76
|
+
concrete: true,
|
|
77
|
+
}));
|
|
78
|
+
const aliasModels = CLAUDE_ALIASES.map((id) => ({
|
|
79
|
+
id: id === "default" ? null : id,
|
|
80
|
+
label: id === "default" ? "provider default" : id,
|
|
81
|
+
description: id === "default" ? "Use Claude Code's selected default model." : "Claude Code model alias.",
|
|
82
|
+
source: "claude alias",
|
|
83
|
+
concrete: false,
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
const merged = dedupeModels([...aliasModels, ...dynamicModels]);
|
|
87
|
+
const restricted = configured.length > 0
|
|
88
|
+
? merged.filter((model) => model.id == null || configured.includes(model.id))
|
|
89
|
+
: merged;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
provider: "claude",
|
|
93
|
+
source: apiModels.length > 0 ? "anthropic api" : "claude aliases",
|
|
94
|
+
models: restricted,
|
|
95
|
+
warnings: apiModels.length > 0
|
|
96
|
+
? []
|
|
97
|
+
: ["Claude Code does not expose a scriptable model catalog; showing stable aliases."],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function normalizeCodexModels(catalog) {
|
|
102
|
+
const rawModels = Array.isArray(catalog?.models) ? catalog.models : [];
|
|
103
|
+
return rawModels
|
|
104
|
+
.filter((model) => model?.slug && (model.visibility == null || model.visibility === "list"))
|
|
105
|
+
.sort((a, b) => Number(a.priority ?? 999) - Number(b.priority ?? 999))
|
|
106
|
+
.map((model) => ({
|
|
107
|
+
id: String(model.slug),
|
|
108
|
+
label: String(model.display_name || model.slug),
|
|
109
|
+
description: String(model.description || ""),
|
|
110
|
+
source: "codex catalog",
|
|
111
|
+
concrete: true,
|
|
112
|
+
defaultEffort: model.default_reasoning_level || null,
|
|
113
|
+
efforts: Array.isArray(model.supported_reasoning_levels)
|
|
114
|
+
? model.supported_reasoning_levels.map((entry) => entry.effort).filter(Boolean)
|
|
115
|
+
: [],
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function getModelProviderMismatch(provider, model) {
|
|
120
|
+
const normalizedModel = String(model || "").trim().toLowerCase();
|
|
121
|
+
if (!provider || !normalizedModel) return null;
|
|
122
|
+
|
|
123
|
+
const looksClaude = /\b(?:opus|sonnet|haiku|claude|opusplan|best)\b/.test(normalizedModel);
|
|
124
|
+
const looksCodex = /\b(?:gpt|codex|o[1-9]|chatgpt)\b/.test(normalizedModel);
|
|
125
|
+
|
|
126
|
+
if (provider === "codex" && looksClaude) {
|
|
127
|
+
return `Model "${model}" looks like a Claude model, but the assistant is using Codex. Run /provider claude or /model default.`;
|
|
128
|
+
}
|
|
129
|
+
if (provider === "claude" && looksCodex) {
|
|
130
|
+
return `Model "${model}" looks like a Codex/OpenAI model, but the assistant is using Claude. Run /provider codex or /model default.`;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function formatModelChoices(discovery, { currentModel = null } = {}) {
|
|
136
|
+
const current = currentModel || "provider default";
|
|
137
|
+
const lines = [
|
|
138
|
+
`Models for ${discovery.provider}`,
|
|
139
|
+
`Current: ${current}`,
|
|
140
|
+
"",
|
|
141
|
+
...discovery.models.map((model) => {
|
|
142
|
+
const command = model.id ? `/model ${model.id}` : "/model default";
|
|
143
|
+
const marker = (model.id || null) === (currentModel || null) ? "*" : " ";
|
|
144
|
+
return `${marker} ${model.label} ${command}`;
|
|
145
|
+
}),
|
|
146
|
+
" custom... /model custom <model>",
|
|
147
|
+
];
|
|
148
|
+
for (const warning of discovery.warnings || []) {
|
|
149
|
+
lines.push("", `Note: ${warning}`);
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function providerDefaultModel() {
|
|
155
|
+
return {
|
|
156
|
+
id: null,
|
|
157
|
+
label: "provider default",
|
|
158
|
+
description: "Use the provider CLI default model.",
|
|
159
|
+
source: "provider default",
|
|
160
|
+
concrete: false,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function readFreshCache(filePath) {
|
|
165
|
+
const value = readJson(filePath);
|
|
166
|
+
if (!value?.fetchedAt || !Array.isArray(value.models)) return null;
|
|
167
|
+
if (Date.now() - Date.parse(value.fetchedAt) > CACHE_MAX_AGE_MS) return null;
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function writeCache(filePath, discovery) {
|
|
172
|
+
try {
|
|
173
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
174
|
+
fs.writeFileSync(filePath, JSON.stringify({ ...discovery, fetchedAt: new Date().toISOString() }, null, 2), "utf8");
|
|
175
|
+
} catch {
|
|
176
|
+
// Model discovery is best-effort.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readJson(filePath) {
|
|
181
|
+
try {
|
|
182
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function readClaudeAvailableModels({ productDir }) {
|
|
189
|
+
const paths = [
|
|
190
|
+
path.join(os.homedir(), ".claude", "settings.json"),
|
|
191
|
+
path.join(productDir, ".claude", "settings.json"),
|
|
192
|
+
];
|
|
193
|
+
const models = [];
|
|
194
|
+
for (const filePath of paths) {
|
|
195
|
+
const settings = readJson(filePath);
|
|
196
|
+
const available = settings?.availableModels || settings?.model?.availableModels;
|
|
197
|
+
if (Array.isArray(available)) models.push(...available.map((entry) => String(entry).trim()).filter(Boolean));
|
|
198
|
+
}
|
|
199
|
+
return [...new Set(models)];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function fetchAnthropicModels({ env }) {
|
|
203
|
+
const apiKey = env.ANTHROPIC_API_KEY;
|
|
204
|
+
if (!apiKey || typeof fetch !== "function") return [];
|
|
205
|
+
try {
|
|
206
|
+
const response = await fetch("https://api.anthropic.com/v1/models", {
|
|
207
|
+
headers: {
|
|
208
|
+
"x-api-key": apiKey,
|
|
209
|
+
"anthropic-version": "2023-06-01",
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
if (!response.ok) return [];
|
|
213
|
+
const body = await response.json();
|
|
214
|
+
const data = Array.isArray(body?.data) ? body.data : [];
|
|
215
|
+
return data
|
|
216
|
+
.filter((entry) => entry?.id)
|
|
217
|
+
.map((entry) => ({
|
|
218
|
+
id: String(entry.id),
|
|
219
|
+
displayName: String(entry.display_name || entry.id),
|
|
220
|
+
description: entry.created_at ? `Created ${entry.created_at}` : "",
|
|
221
|
+
}));
|
|
222
|
+
} catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function dedupeModels(models) {
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
const result = [];
|
|
230
|
+
for (const model of models) {
|
|
231
|
+
const key = model.id || "__default__";
|
|
232
|
+
if (seen.has(key)) continue;
|
|
233
|
+
seen.add(key);
|
|
234
|
+
result.push(model);
|
|
235
|
+
}
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function shellQuote(value) {
|
|
240
|
+
const text = String(value);
|
|
241
|
+
if (/^[a-zA-Z0-9._:/-]+$/.test(text)) return text;
|
|
242
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
243
|
+
}
|
|
@@ -74,7 +74,24 @@ export async function runAssistantConversationTurn({
|
|
|
74
74
|
emitted.push({ role: "assistant", text: envelope.commentary });
|
|
75
75
|
currentTranscript.push({ role: "assistant", text: envelope.commentary });
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
let toolResult;
|
|
78
|
+
try {
|
|
79
|
+
toolResult = await executeAssistantTool(envelope.tool, envelope.arguments, toolContext);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const toolText = formatToolError(envelope.tool, error);
|
|
82
|
+
emitted.push({
|
|
83
|
+
role: "tool",
|
|
84
|
+
text: toolText,
|
|
85
|
+
toolName: envelope.tool,
|
|
86
|
+
title: `${envelope.tool} error`,
|
|
87
|
+
data: { ok: false, error: toolText },
|
|
88
|
+
});
|
|
89
|
+
currentTranscript.push({
|
|
90
|
+
role: "tool",
|
|
91
|
+
text: `${envelope.tool}: ${toolText}`,
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
78
95
|
const toolText = toolResult.text || `${envelope.tool} completed`;
|
|
79
96
|
emitted.push({
|
|
80
97
|
role: "tool",
|
|
@@ -105,6 +122,14 @@ export async function runAssistantConversationTurn({
|
|
|
105
122
|
return emitted;
|
|
106
123
|
}
|
|
107
124
|
|
|
125
|
+
export function formatToolError(tool, error) {
|
|
126
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
127
|
+
if (tool === "shell_exec" && /command string/.test(message)) {
|
|
128
|
+
return "The assistant requested shell_exec without a command. Retry with arguments.command set to the exact shell command.";
|
|
129
|
+
}
|
|
130
|
+
return `Tool failed: ${message}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
108
133
|
function formatProviderEvent(event) {
|
|
109
134
|
if (event.type === "tool") {
|
|
110
135
|
return `${event.provider}: ${event.name}${event.detail ? ` (${event.detail})` : ""}`;
|
|
@@ -23,8 +23,14 @@ export function parseSlashCommand(input) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
if (command === "model") {
|
|
26
|
+
if (tokens.length === 0) return { type: "model-list" };
|
|
27
|
+
if (tokens[0] === "list") return { type: "model-list" };
|
|
28
|
+
if (tokens[0] === "custom") {
|
|
29
|
+
const customModel = tokens.slice(1).join(" ").trim();
|
|
30
|
+
if (!customModel) throw new Error("/model custom expects a model name");
|
|
31
|
+
return { type: "model", model: customModel, custom: true };
|
|
32
|
+
}
|
|
26
33
|
const model = tokens.join(" ").trim();
|
|
27
|
-
if (!model) throw new Error("/model expects a model name or default");
|
|
28
34
|
return { type: "model", model: model === "default" ? null : model };
|
|
29
35
|
}
|
|
30
36
|
|
|
@@ -121,7 +127,7 @@ export function formatSlashHelpLines() {
|
|
|
121
127
|
"/status",
|
|
122
128
|
"/doctor",
|
|
123
129
|
"/provider <auto|claude|codex>",
|
|
124
|
-
"/model <model
|
|
130
|
+
"/model [list|default|custom <model>|model]",
|
|
125
131
|
"/effort <low|medium|high|xhigh|max|default>",
|
|
126
132
|
"/provider-arg add <arg>",
|
|
127
133
|
"/provider-arg list",
|
|
@@ -6,6 +6,11 @@ import { parseSlashCommand, formatSlashHelpLines } from "./slash-commands.mjs";
|
|
|
6
6
|
import { executeAssistantTool } from "./tool-registry.mjs";
|
|
7
7
|
import { runAssistantConversationTurn } from "./session.mjs";
|
|
8
8
|
import { prepareAssistantContextPack } from "./context-pack.mjs";
|
|
9
|
+
import {
|
|
10
|
+
discoverAssistantModels,
|
|
11
|
+
formatModelChoices,
|
|
12
|
+
getModelProviderMismatch,
|
|
13
|
+
} from "./model-discovery.mjs";
|
|
9
14
|
import {
|
|
10
15
|
DEFAULT_ASSISTANT_SETTINGS,
|
|
11
16
|
loadAssistantSettings,
|
|
@@ -57,7 +62,15 @@ export function createAssistantState({
|
|
|
57
62
|
}
|
|
58
63
|
);
|
|
59
64
|
let resolvedProviderName = resolveInitialProvider(settings.provider, env);
|
|
65
|
+
const sanitizedStartup = sanitizeSettingsForResolvedProvider({
|
|
66
|
+
productDir,
|
|
67
|
+
settings,
|
|
68
|
+
resolvedProvider: resolvedProviderName,
|
|
69
|
+
});
|
|
70
|
+
settings = sanitizedStartup.settings;
|
|
71
|
+
if (sanitizedStartup.notice) notice = sanitizedStartup.notice;
|
|
60
72
|
let activeStatus = null;
|
|
73
|
+
let startupNoticeEmitted = false;
|
|
61
74
|
let contextUsage = buildContextUsage({
|
|
62
75
|
provider: resolvedProviderName || settings.provider,
|
|
63
76
|
model: settings.model,
|
|
@@ -176,18 +189,18 @@ export function createAssistantState({
|
|
|
176
189
|
|
|
177
190
|
setProvider(nextProvider) {
|
|
178
191
|
settings = mergeAssistantSettings(settings, { provider: nextProvider || DEFAULT_ASSISTANT_SETTINGS.provider });
|
|
179
|
-
resolvedProviderName =
|
|
180
|
-
if (settings.model && getModelProviderMismatch(
|
|
192
|
+
resolvedProviderName = resolveInitialProvider(settings.provider, env);
|
|
193
|
+
if (settings.model && getModelProviderMismatch(resolvedProviderName, settings.model)) {
|
|
181
194
|
settings = mergeAssistantSettings(settings, { model: null });
|
|
182
195
|
}
|
|
183
196
|
saveAssistantSettings(productDir, settings);
|
|
184
197
|
notify();
|
|
185
198
|
},
|
|
186
199
|
|
|
187
|
-
setModel(nextModel) {
|
|
200
|
+
setModel(nextModel, { custom = false } = {}) {
|
|
188
201
|
const resolvedProvider = resolveInitialProvider(settings.provider, env);
|
|
189
202
|
const mismatch = getModelProviderMismatch(resolvedProvider, nextModel);
|
|
190
|
-
if (mismatch) {
|
|
203
|
+
if (mismatch && !custom) {
|
|
191
204
|
throw new Error(mismatch);
|
|
192
205
|
}
|
|
193
206
|
settings = mergeAssistantSettings(settings, { model: nextModel || null });
|
|
@@ -237,6 +250,10 @@ export function createAssistantState({
|
|
|
237
250
|
async submitInput(input) {
|
|
238
251
|
const trimmed = String(input || "").trim();
|
|
239
252
|
if (!trimmed) return;
|
|
253
|
+
if (notice && !startupNoticeEmitted) {
|
|
254
|
+
startupNoticeEmitted = true;
|
|
255
|
+
appendMessage({ role: "system", text: notice });
|
|
256
|
+
}
|
|
240
257
|
appendMessage({ role: "user", text: trimmed });
|
|
241
258
|
|
|
242
259
|
const slash = parseSlashCommandSafe(trimmed);
|
|
@@ -266,6 +283,29 @@ export function createAssistantState({
|
|
|
266
283
|
return;
|
|
267
284
|
}
|
|
268
285
|
|
|
286
|
+
const routedSlash = routeLocalIntent(trimmed);
|
|
287
|
+
if (routedSlash) {
|
|
288
|
+
try {
|
|
289
|
+
await executeSlashCommand({
|
|
290
|
+
slash: routedSlash,
|
|
291
|
+
state,
|
|
292
|
+
productDir,
|
|
293
|
+
settings,
|
|
294
|
+
configs,
|
|
295
|
+
env,
|
|
296
|
+
appendMessage,
|
|
297
|
+
});
|
|
298
|
+
} catch (error) {
|
|
299
|
+
appendMessage({
|
|
300
|
+
role: "system",
|
|
301
|
+
text: error instanceof Error ? error.message : String(error),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
refreshContextPack();
|
|
305
|
+
notify();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
269
309
|
try {
|
|
270
310
|
setBusy(true, `Thinking with ${settings.provider === "auto" ? "provider" : settings.provider}...`);
|
|
271
311
|
const emitted = await runAssistantConversationTurn({
|
|
@@ -353,22 +393,6 @@ function resolveInitialProvider(provider, env) {
|
|
|
353
393
|
return null;
|
|
354
394
|
}
|
|
355
395
|
|
|
356
|
-
function getModelProviderMismatch(provider, model) {
|
|
357
|
-
const normalizedModel = String(model || "").trim().toLowerCase();
|
|
358
|
-
if (!provider || !normalizedModel) return null;
|
|
359
|
-
|
|
360
|
-
const looksClaude = /\b(?:opus|sonnet|haiku|claude)\b/.test(normalizedModel);
|
|
361
|
-
const looksCodex = /\b(?:gpt|codex|o[1-9]|chatgpt)\b/.test(normalizedModel);
|
|
362
|
-
|
|
363
|
-
if (provider === "codex" && looksClaude) {
|
|
364
|
-
return `Model "${model}" looks like a Claude model, but the assistant is using Codex. Run /provider claude or /model default.`;
|
|
365
|
-
}
|
|
366
|
-
if (provider === "claude" && looksCodex) {
|
|
367
|
-
return `Model "${model}" looks like a Codex/OpenAI model, but the assistant is using Claude. Run /provider codex or /model default.`;
|
|
368
|
-
}
|
|
369
|
-
return null;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
396
|
async function executeSlashCommand({
|
|
373
397
|
slash,
|
|
374
398
|
state,
|
|
@@ -396,10 +420,17 @@ async function executeSlashCommand({
|
|
|
396
420
|
return;
|
|
397
421
|
}
|
|
398
422
|
if (slash.type === "model") {
|
|
399
|
-
state.setModel(slash.model);
|
|
423
|
+
state.setModel(slash.model, { custom: slash.custom });
|
|
400
424
|
appendMessage({ role: "assistant", text: slash.model ? `Model set to ${slash.model}.` : "Model reset to provider default." });
|
|
401
425
|
return;
|
|
402
426
|
}
|
|
427
|
+
if (slash.type === "model-list") {
|
|
428
|
+
const snapshot = state.getSnapshot();
|
|
429
|
+
const provider = snapshot.resolvedProvider || resolveInitialProvider(snapshot.provider, env) || snapshot.provider;
|
|
430
|
+
const discovery = await discoverAssistantModels({ provider, productDir, env });
|
|
431
|
+
appendMessage({ role: "assistant", text: formatModelChoices(discovery, { currentModel: snapshot.model }) });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
403
434
|
if (slash.type === "effort") {
|
|
404
435
|
state.setEffort(slash.effort);
|
|
405
436
|
appendMessage({ role: "assistant", text: slash.effort ? `Effort set to ${slash.effort}.` : "Effort reset to provider default." });
|
|
@@ -557,3 +588,34 @@ function parseSlashCommandSafe(input) {
|
|
|
557
588
|
};
|
|
558
589
|
}
|
|
559
590
|
}
|
|
591
|
+
|
|
592
|
+
function sanitizeSettingsForResolvedProvider({ productDir, settings, resolvedProvider }) {
|
|
593
|
+
const mismatch = getModelProviderMismatch(resolvedProvider, settings.model);
|
|
594
|
+
if (!mismatch) return { settings, notice: null };
|
|
595
|
+
const previousModel = settings.model;
|
|
596
|
+
const sanitized = mergeAssistantSettings(settings, { model: null });
|
|
597
|
+
saveAssistantSettings(productDir, sanitized);
|
|
598
|
+
return {
|
|
599
|
+
settings: sanitized,
|
|
600
|
+
notice: `Cleared incompatible saved model "${previousModel}" for ${resolvedProvider}.`,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function routeLocalIntent(input) {
|
|
605
|
+
const normalized = String(input || "").trim().toLowerCase();
|
|
606
|
+
const runMatch = normalized.match(/^run\s+(int|e2e|scenario|dal|load|pw|all)(?:\s+tests?)?$/);
|
|
607
|
+
if (runMatch) {
|
|
608
|
+
return {
|
|
609
|
+
type: "run",
|
|
610
|
+
options: {
|
|
611
|
+
type: [runMatch[1]],
|
|
612
|
+
suite: [],
|
|
613
|
+
file: [],
|
|
614
|
+
service: null,
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
if (/^(show\s+)?latest\s+summary$/.test(normalized)) return { type: "status" };
|
|
619
|
+
if (/^list\s+test\s+files$/.test(normalized)) return { type: "discover" };
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.97",
|
|
4
4
|
"description": "Browser bridge helpers for testkit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@elench/testkit-protocol": "0.1.
|
|
25
|
+
"@elench/testkit-protocol": "0.1.97"
|
|
26
26
|
},
|
|
27
27
|
"private": false
|
|
28
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elench/testkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.97",
|
|
4
4
|
"description": "Assistant-first CLI for running, inspecting, and debugging local testkit suites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
"test:audit": "node scripts/test-boundary-audit.mjs",
|
|
63
63
|
"test:unit": "npm run build:packages && npm run test:audit && vitest run --config vitest.unit.config.mjs",
|
|
64
64
|
"test:integration": "npm run build:packages && vitest run test/integration",
|
|
65
|
-
"test:system": "npm run build:packages && vitest run test/system --passWithNoTests"
|
|
65
|
+
"test:system": "npm run build:packages && vitest run test/system --passWithNoTests",
|
|
66
|
+
"test:live-providers": "npm run build:packages && vitest run --config vitest.live.config.mjs --passWithNoTests"
|
|
66
67
|
},
|
|
67
68
|
"files": [
|
|
68
69
|
"bin/",
|
|
@@ -83,10 +84,10 @@
|
|
|
83
84
|
},
|
|
84
85
|
"dependencies": {
|
|
85
86
|
"@babel/code-frame": "^7.29.0",
|
|
86
|
-
"@elench/next-analysis": "0.1.
|
|
87
|
-
"@elench/testkit-bridge": "0.1.
|
|
88
|
-
"@elench/testkit-protocol": "0.1.
|
|
89
|
-
"@elench/ts-analysis": "0.1.
|
|
87
|
+
"@elench/next-analysis": "0.1.97",
|
|
88
|
+
"@elench/testkit-bridge": "0.1.97",
|
|
89
|
+
"@elench/testkit-protocol": "0.1.97",
|
|
90
|
+
"@elench/ts-analysis": "0.1.97",
|
|
90
91
|
"@oclif/core": "^4.10.6",
|
|
91
92
|
"esbuild": "^0.25.11",
|
|
92
93
|
"execa": "^9.5.0",
|