@elench/testkit 0.1.90 → 0.1.92

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,25 +4,63 @@ import { buildContextSelection } from "../context-resources.mjs";
4
4
  import { parseSlashCommand, formatSlashHelpLines } from "./slash-commands.mjs";
5
5
  import { executeAssistantTool } from "./tool-registry.mjs";
6
6
  import { runAssistantConversationTurn } from "./session.mjs";
7
+ import { prepareAssistantContextPack } from "./context-pack.mjs";
8
+ import {
9
+ DEFAULT_ASSISTANT_SETTINGS,
10
+ loadAssistantSettings,
11
+ mergeAssistantSettings,
12
+ resetAssistantSettings,
13
+ saveAssistantSettings,
14
+ } from "./settings.mjs";
15
+ import {
16
+ backspaceComposerText,
17
+ createComposerState,
18
+ deleteComposerText,
19
+ insertComposerText,
20
+ moveComposerCursor as moveComposerCursorState,
21
+ moveComposerCursorToEnd as moveComposerCursorStateToEnd,
22
+ moveComposerCursorToStart as moveComposerCursorStateToStart,
23
+ setComposerText,
24
+ } from "./composer.mjs";
7
25
 
8
26
  export function createAssistantState({
9
27
  productDir,
10
- provider = "auto",
28
+ provider,
29
+ model,
30
+ effort,
31
+ providerArgs,
32
+ resetSettings = false,
11
33
  dataSource = "artifact",
12
34
  configs = [],
13
35
  env = process.env,
14
36
  } = {}) {
15
37
  const inspectState = createInspectState({ dataSource });
38
+ const commandLog = prepareAssistantContextPack({
39
+ productDir,
40
+ inspectState,
41
+ });
16
42
 
17
43
  const listeners = new Set();
18
44
  const messages = [];
19
- let composer = "";
45
+ let composerState = createComposerState();
20
46
  let notice = null;
21
47
  let busy = false;
22
- let providerName = provider;
48
+ let settings = mergeAssistantSettings(
49
+ resetSettings ? resetAssistantSettings(productDir) : loadAssistantSettings(productDir),
50
+ {
51
+ provider,
52
+ model,
53
+ effort,
54
+ providerArgs,
55
+ }
56
+ );
57
+ let resolvedProviderName = null;
23
58
  let activeStatus = null;
24
59
 
25
- inspectState.subscribe(() => notify());
60
+ inspectState.subscribe(() => {
61
+ commandLog.refresh();
62
+ notify();
63
+ });
26
64
 
27
65
  function notify() {
28
66
  for (const callback of listeners) callback();
@@ -42,8 +80,13 @@ export function createAssistantState({
42
80
  notify();
43
81
  }
44
82
 
45
- return {
83
+ function refreshContextPack() {
84
+ commandLog.refresh();
85
+ }
86
+
87
+ const state = {
46
88
  inspectState,
89
+ commandLog,
47
90
 
48
91
  async loadLatestArtifact() {
49
92
  try {
@@ -51,6 +94,7 @@ export function createAssistantState({
51
94
  } catch {
52
95
  // No artifact yet.
53
96
  }
97
+ refreshContextPack();
54
98
  },
55
99
 
56
100
  async loadCurrentArtifact() {
@@ -59,28 +103,57 @@ export function createAssistantState({
59
103
  } catch {
60
104
  // No artifact yet.
61
105
  }
106
+ refreshContextPack();
62
107
  },
63
108
 
64
109
  revealFile(serviceName, filePath) {
65
- return inspectState.revealFile(serviceName, filePath);
110
+ const revealed = inspectState.revealFile(serviceName, filePath);
111
+ refreshContextPack();
112
+ return revealed;
66
113
  },
67
114
 
68
115
  revealService(serviceName) {
69
- return inspectState.revealService(serviceName);
116
+ const revealed = inspectState.revealService(serviceName);
117
+ refreshContextPack();
118
+ return revealed;
70
119
  },
71
120
 
72
121
  setComposer(value) {
73
- composer = String(value || "");
122
+ composerState = setComposerText(composerState, value);
74
123
  notify();
75
124
  },
76
125
 
77
- appendComposer(text) {
78
- composer += String(text || "");
126
+ insertComposer(text) {
127
+ composerState = insertComposerText(composerState, text);
79
128
  notify();
80
129
  },
81
130
 
131
+ appendComposer(text) {
132
+ state.insertComposer(text);
133
+ },
134
+
82
135
  backspaceComposer() {
83
- composer = composer.slice(0, -1);
136
+ composerState = backspaceComposerText(composerState);
137
+ notify();
138
+ },
139
+
140
+ deleteComposer() {
141
+ composerState = deleteComposerText(composerState);
142
+ notify();
143
+ },
144
+
145
+ moveComposerCursor(delta) {
146
+ composerState = moveComposerCursorState(composerState, delta);
147
+ notify();
148
+ },
149
+
150
+ moveComposerCursorToStart() {
151
+ composerState = moveComposerCursorStateToStart(composerState);
152
+ notify();
153
+ },
154
+
155
+ moveComposerCursorToEnd() {
156
+ composerState = moveComposerCursorStateToEnd(composerState);
84
157
  notify();
85
158
  },
86
159
 
@@ -95,7 +168,41 @@ export function createAssistantState({
95
168
  },
96
169
 
97
170
  setProvider(nextProvider) {
98
- providerName = nextProvider || "auto";
171
+ settings = mergeAssistantSettings(settings, { provider: nextProvider || DEFAULT_ASSISTANT_SETTINGS.provider });
172
+ resolvedProviderName = null;
173
+ saveAssistantSettings(productDir, settings);
174
+ notify();
175
+ },
176
+
177
+ setModel(nextModel) {
178
+ settings = mergeAssistantSettings(settings, { model: nextModel || null });
179
+ saveAssistantSettings(productDir, settings);
180
+ notify();
181
+ },
182
+
183
+ setEffort(nextEffort) {
184
+ settings = mergeAssistantSettings(settings, { effort: nextEffort || null });
185
+ saveAssistantSettings(productDir, settings);
186
+ notify();
187
+ },
188
+
189
+ addProviderArg(value) {
190
+ const arg = String(value || "").trim();
191
+ if (!arg) return;
192
+ settings = mergeAssistantSettings(settings, { providerArgs: [...settings.providerArgs, arg] });
193
+ saveAssistantSettings(productDir, settings);
194
+ notify();
195
+ },
196
+
197
+ clearProviderArgs() {
198
+ settings = mergeAssistantSettings(settings, { providerArgs: [] });
199
+ saveAssistantSettings(productDir, settings);
200
+ notify();
201
+ },
202
+
203
+ resetSettings() {
204
+ settings = resetAssistantSettings(productDir);
205
+ resolvedProviderName = null;
99
206
  notify();
100
207
  },
101
208
 
@@ -105,11 +212,11 @@ export function createAssistantState({
105
212
  },
106
213
 
107
214
  async submitCurrentComposer() {
108
- const value = composer.trim();
109
- composer = "";
215
+ const value = composerState.text.trim();
216
+ composerState = createComposerState();
110
217
  notify();
111
218
  if (!value) return;
112
- await this.submitInput(value);
219
+ await state.submitInput(value);
113
220
  },
114
221
 
115
222
  async submitInput(input) {
@@ -119,29 +226,19 @@ export function createAssistantState({
119
226
 
120
227
  const slash = parseSlashCommandSafe(trimmed);
121
228
  if (slash?.type === "__error__") {
122
- appendMessage({
123
- role: "system",
124
- text: slash.error,
125
- });
229
+ appendMessage({ role: "system", text: slash.error });
126
230
  return;
127
231
  }
128
232
  if (slash) {
129
233
  try {
130
234
  await executeSlashCommand({
131
235
  slash,
236
+ state,
132
237
  productDir,
133
- inspectState,
238
+ settings,
134
239
  configs,
135
- setProvider: (value) => {
136
- providerName = value;
137
- },
240
+ env,
138
241
  appendMessage,
139
- setNotice: (value) => {
140
- notice = value;
141
- },
142
- clearMessages: () => {
143
- messages.length = 0;
144
- },
145
242
  });
146
243
  } catch (error) {
147
244
  appendMessage({
@@ -149,24 +246,30 @@ export function createAssistantState({
149
246
  text: error instanceof Error ? error.message : String(error),
150
247
  });
151
248
  }
249
+ refreshContextPack();
152
250
  notify();
153
251
  return;
154
252
  }
155
253
 
156
254
  try {
157
- setBusy(true, "Waiting for assistant...");
255
+ setBusy(true, `Thinking with ${settings.provider === "auto" ? "provider" : settings.provider}...`);
158
256
  const emitted = await runAssistantConversationTurn({
159
257
  productDir,
160
258
  inspectState,
161
259
  transcript: messages.map((entry) => ({ role: entry.role, text: entry.text })),
162
260
  userMessage: trimmed,
163
- provider: providerName,
261
+ settings,
164
262
  env,
165
263
  configs,
264
+ commandLog,
166
265
  onStatus(status) {
167
266
  activeStatus = status;
168
267
  notify();
169
268
  },
269
+ onResolvedProvider(provider) {
270
+ resolvedProviderName = provider;
271
+ notify();
272
+ },
170
273
  });
171
274
  for (const message of emitted) appendMessage(message);
172
275
  } catch (error) {
@@ -175,6 +278,7 @@ export function createAssistantState({
175
278
  text: error instanceof Error ? error.message : String(error),
176
279
  });
177
280
  } finally {
281
+ refreshContextPack();
178
282
  setBusy(false, null);
179
283
  }
180
284
  },
@@ -188,48 +292,104 @@ export function createAssistantState({
188
292
  return {
189
293
  context: buildContextSelection(inspectState.getSnapshot()),
190
294
  messages: [...messages],
191
- composer,
295
+ composer: composerState.text,
296
+ composerCursor: composerState.cursor,
192
297
  notice,
193
298
  busy,
194
- provider: providerName,
299
+ provider: settings.provider,
300
+ resolvedProvider: resolvedProviderName,
301
+ model: settings.model,
302
+ effort: settings.effort,
303
+ providerArgs: [...settings.providerArgs],
195
304
  activeStatus,
305
+ contextPaths: {
306
+ contextPath: commandLog.contextPath,
307
+ summaryPath: commandLog.summaryPath,
308
+ selectionPath: commandLog.selectionPath,
309
+ commandsPath: commandLog.commandsPath,
310
+ commandLogPath: commandLog.commandLogPath,
311
+ },
196
312
  };
197
313
  },
198
314
  };
315
+
316
+ refreshContextPack();
317
+ return state;
199
318
  }
200
319
 
201
320
  async function executeSlashCommand({
202
321
  slash,
322
+ state,
203
323
  productDir,
204
- inspectState,
324
+ settings,
205
325
  configs,
206
- setProvider,
326
+ env,
207
327
  appendMessage,
208
- setNotice,
209
- clearMessages,
210
328
  } = {}) {
211
329
  if (slash.type === "help") {
212
330
  appendMessage({ role: "assistant", text: formatSlashHelpLines().join("\n") });
213
331
  return;
214
332
  }
215
333
  if (slash.type === "clear") {
216
- clearMessages?.();
334
+ state.clearMessages();
217
335
  return;
218
336
  }
219
337
  if (slash.type === "quit") {
220
- setNotice?.("Use q or Ctrl+C to quit the interactive assistant.");
338
+ state.setNotice("Use q or Ctrl+C to quit the interactive assistant.");
221
339
  return;
222
340
  }
223
341
  if (slash.type === "provider") {
224
- setProvider(slash.provider);
342
+ state.setProvider(slash.provider);
225
343
  appendMessage({ role: "assistant", text: `Provider set to ${slash.provider}.` });
226
344
  return;
227
345
  }
346
+ if (slash.type === "model") {
347
+ state.setModel(slash.model);
348
+ appendMessage({ role: "assistant", text: slash.model ? `Model set to ${slash.model}.` : "Model reset to provider default." });
349
+ return;
350
+ }
351
+ if (slash.type === "effort") {
352
+ state.setEffort(slash.effort);
353
+ appendMessage({ role: "assistant", text: slash.effort ? `Effort set to ${slash.effort}.` : "Effort reset to provider default." });
354
+ return;
355
+ }
356
+ if (slash.type === "provider-args-add") {
357
+ state.addProviderArg(slash.value);
358
+ appendMessage({ role: "assistant", text: `Provider arg added: ${slash.value}` });
359
+ return;
360
+ }
361
+ if (slash.type === "provider-args-clear") {
362
+ state.clearProviderArgs();
363
+ appendMessage({ role: "assistant", text: "Provider args cleared." });
364
+ return;
365
+ }
366
+ if (slash.type === "provider-args-list") {
367
+ const args = state.getSnapshot().providerArgs;
368
+ appendMessage({ role: "assistant", text: args.length ? args.map((arg) => `- ${arg}`).join("\n") : "No provider args configured." });
369
+ return;
370
+ }
371
+ if (slash.type === "settings-show") {
372
+ appendMessage({ role: "assistant", text: formatSettings(state.getSnapshot()) });
373
+ return;
374
+ }
375
+ if (slash.type === "settings-reset") {
376
+ state.resetSettings();
377
+ appendMessage({ role: "assistant", text: "Assistant settings reset." });
378
+ return;
379
+ }
228
380
 
229
381
  const result = await executeSlashTool(slash, {
230
382
  productDir,
231
- inspectState,
383
+ inspectState: state.inspectState,
232
384
  configs,
385
+ env,
386
+ commandLog: state.commandLog,
387
+ onEvent(event) {
388
+ if (event.type === "tool-status") {
389
+ state.setNotice(event.message);
390
+ }
391
+ },
392
+ provider: settings.provider,
233
393
  });
234
394
  appendMessage({
235
395
  role: "tool",
@@ -240,35 +400,69 @@ async function executeSlashCommand({
240
400
  });
241
401
  }
242
402
 
403
+ function formatSettings(snapshot) {
404
+ const rows = [
405
+ ["Provider", snapshot.provider || "auto"],
406
+ ["Resolved", snapshot.resolvedProvider || "not resolved yet"],
407
+ ["Model", snapshot.model || "provider default"],
408
+ ["Effort", snapshot.effort || "provider default"],
409
+ ["Provider args", snapshot.providerArgs?.length ? snapshot.providerArgs.join(" ") : "none"],
410
+ ];
411
+ return rows.map(([label, value]) => `${label}: ${value}`).join("\n");
412
+ }
413
+
243
414
  async function executeSlashTool(slash, context) {
244
415
  switch (slash.type) {
245
- case "file":
246
- return executeAssistantTool("focus_file", { file: slash.file }, context);
247
416
  case "inspect":
248
- return slash.file
249
- ? executeAssistantTool("focus_file", { file: slash.file }, context)
250
- : executeAssistantTool("inspect_focus", {}, context);
417
+ return executeAssistantTool(
418
+ "read_context",
419
+ { file: slash.file || null, mode: "detail" },
420
+ context
421
+ );
251
422
  case "logs":
252
- return executeAssistantTool("read_logs", { service: slash.service || null }, context);
423
+ return executeAssistantTool("read_context", { service: slash.service || null, mode: "logs" }, context);
253
424
  case "artifacts":
254
- return executeAssistantTool("read_artifacts", { file: slash.file || null }, context);
425
+ return executeAssistantTool("read_context", { file: slash.file || null, mode: "artifacts" }, context);
255
426
  case "setup":
256
- return executeAssistantTool("read_setup", { service: slash.service || null }, context);
427
+ return executeAssistantTool("read_context", { service: slash.service || null, mode: "setup" }, context);
428
+ case "file":
429
+ return executeAssistantTool("read_file", { path: slash.file }, context);
257
430
  case "service":
258
- return executeAssistantTool("focus_service", { service: slash.service }, context);
431
+ return executeAssistantTool("read_context", { service: slash.service, mode: "detail" }, context);
259
432
  case "status":
260
- return executeAssistantTool("show_status", {}, context);
433
+ return executeAssistantTool("shell_exec", { command: "testkit status --dir ." }, context);
261
434
  case "discover":
262
- return executeAssistantTool("discover_tests", {}, context);
435
+ return executeAssistantTool("shell_exec", { command: "testkit discover --dir ." }, context);
263
436
  case "doctor":
264
- return executeAssistantTool("run_doctor", {}, context);
437
+ return executeAssistantTool("shell_exec", { command: "testkit doctor --dir ." }, context);
265
438
  case "run":
266
- return executeAssistantTool("run_tests", slash.options, context);
439
+ return executeAssistantTool("shell_exec", { command: buildRunSlashCommand(slash.options) }, context);
267
440
  default:
268
441
  throw new Error(`Unsupported slash command "${slash.type}"`);
269
442
  }
270
443
  }
271
444
 
445
+ function buildRunSlashCommand(options = {}) {
446
+ const parts = ["testkit", "run", "--dir", "."];
447
+ for (const type of options.type || []) {
448
+ parts.push("--type", type);
449
+ }
450
+ for (const suite of options.suite || []) {
451
+ parts.push("--suite", suite);
452
+ }
453
+ for (const file of options.file || []) {
454
+ parts.push("--file", file);
455
+ }
456
+ if (options.service) parts.push("--service", options.service);
457
+ return parts.map(shellEscapeArg).join(" ");
458
+ }
459
+
460
+ function shellEscapeArg(value) {
461
+ const stringValue = String(value);
462
+ if (/^[a-zA-Z0-9._:/-]+$/.test(stringValue)) return stringValue;
463
+ return `'${stringValue.replace(/'/g, `'\\''`)}'`;
464
+ }
465
+
272
466
  function parseSlashCommandSafe(input) {
273
467
  try {
274
468
  return parseSlashCommand(input);