@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.
@@ -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) || result.stdout || null;
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 && !finalText) {
40
- const message = result.stderr || result.stdout || `${provider} exited with code ${result.exitCode ?? 1}`;
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 { getComposerRenderParts } from "./composer.mjs";
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 { before, current, after, empty } = getComposerRenderParts({
181
- text: view.composer.text,
182
- cursor: view.composer.cursor,
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
- promptText,
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: parts.slice(0, cursor).join(""),
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
- const toolResult = await executeAssistantTool(envelope.tool, envelope.arguments, toolContext);
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|default>",
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 = null;
180
- if (settings.model && getModelProviderMismatch(resolveInitialProvider(settings.provider, env), settings.model)) {
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/next-analysis",
3
- "version": "0.1.95",
3
+ "version": "0.1.97",
4
4
  "description": "SWC-backed Next.js source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-bridge",
3
- "version": "0.1.95",
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.95"
25
+ "@elench/testkit-protocol": "0.1.97"
26
26
  },
27
27
  "private": false
28
28
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit-protocol",
3
- "version": "0.1.95",
3
+ "version": "0.1.97",
4
4
  "description": "Shared browser protocol for testkit bridge and extension consumers",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/ts-analysis",
3
- "version": "0.1.95",
3
+ "version": "0.1.97",
4
4
  "description": "TypeScript compiler-backed source analysis primitives for Erench tools",
5
5
  "type": "module",
6
6
  "exports": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elench/testkit",
3
- "version": "0.1.95",
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.95",
87
- "@elench/testkit-bridge": "0.1.95",
88
- "@elench/testkit-protocol": "0.1.95",
89
- "@elench/ts-analysis": "0.1.95",
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",