@elench/testkit 0.1.89 → 0.1.91

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.
Files changed (31) hide show
  1. package/README.md +14 -7
  2. package/lib/cli/agents/index.mjs +27 -19
  3. package/lib/cli/agents/providers/claude.mjs +3 -3
  4. package/lib/cli/agents/providers/codex.mjs +3 -3
  5. package/lib/cli/assistant/app.mjs +210 -0
  6. package/lib/cli/assistant/context-pack.mjs +191 -0
  7. package/lib/cli/assistant/interactive.mjs +53 -0
  8. package/lib/cli/assistant/prompt-builder.mjs +7 -9
  9. package/lib/cli/assistant/session.mjs +6 -1
  10. package/lib/cli/assistant/state.mjs +134 -46
  11. package/lib/cli/assistant/tool-registry.mjs +220 -230
  12. package/lib/cli/commands/assistant.mjs +50 -34
  13. package/lib/cli/{tui/detail-pane.mjs → context-resources.mjs} +81 -21
  14. package/lib/cli/entrypoint.mjs +12 -4
  15. package/lib/cli/presentation/tree-reporter.mjs +0 -101
  16. package/lib/cli/tui/inspect-app.mjs +7 -88
  17. package/lib/cli/tui/inspect-state.mjs +0 -117
  18. package/node_modules/@elench/next-analysis/package.json +1 -1
  19. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  20. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  21. package/node_modules/@elench/ts-analysis/package.json +1 -1
  22. package/package.json +5 -5
  23. package/lib/cli/agents/investigate.mjs +0 -75
  24. package/lib/cli/agents/investigation-context.mjs +0 -102
  25. package/lib/cli/agents/investigation-interpreter.mjs +0 -320
  26. package/lib/cli/agents/investigation-log.mjs +0 -37
  27. package/lib/cli/agents/prompt-builder.mjs +0 -25
  28. package/lib/cli/assistant/content.mjs +0 -60
  29. package/lib/cli/assistant/tool-run-reporter.mjs +0 -80
  30. package/lib/cli/tui/assistant-app.mjs +0 -82
  31. package/lib/cli/tui/assistant-render.mjs +0 -99
@@ -1,280 +1,284 @@
1
- import { discoverTests } from "../../discovery/index.mjs";
2
- import { runDoctor } from "../../app/doctor.mjs";
3
- import { resolveProductDir } from "../../config/index.mjs";
4
- import { resolveRequestedFiles } from "../args.mjs";
5
- import { buildDiscoveryReportLines } from "../presentation/discovery-reporter.mjs";
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { execaCommand } from "execa";
6
4
  import { loadCurrentRunArtifact, loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
7
5
  import {
8
- formatAssistantToolText,
9
- readAssistantContent,
10
- } from "./content.mjs";
11
- import { createAssistantRunReporter } from "./tool-run-reporter.mjs";
12
- import { buildRunRequest } from "../command-helpers.mjs";
13
- import * as runner from "../../runner/index.mjs";
6
+ readContextContent,
7
+ } from "../context-resources.mjs";
8
+
9
+ const COMMAND_OUTPUT_LIMIT = 14_000;
10
+ const COMMAND_LINE_LIMIT = 220;
11
+ const FILE_LINE_LIMIT = 160;
14
12
 
15
13
  export function listAssistantTools() {
16
14
  return [
17
- { name: "run_tests", description: "Run testkit-managed tests with optional type/service/file filters." },
18
- { name: "focus_file", description: "Focus a specific test file and summarize it." },
19
- { name: "focus_service", description: "Focus a specific service and summarize it." },
20
- { name: "inspect_focus", description: "Inspect the current focus inline in the conversation." },
21
- { name: "read_logs", description: "Read backend logs for the current focus or an explicit service." },
22
- { name: "read_artifacts", description: "Read persisted artifacts for the current focus or an explicit file." },
23
- { name: "read_setup", description: "Read setup operations for the current focus or an explicit service." },
24
- { name: "discover_tests", description: "Discover managed tests and summarize them." },
25
- { name: "show_status", description: "Show the current local testkit state for the product." },
26
- { name: "run_doctor", description: "Run built-in testkit doctor checks." },
15
+ {
16
+ name: "shell_exec",
17
+ description: "Execute a shell command inside the repository. Prefer real repo commands such as npm, npx, and testkit.",
18
+ },
19
+ {
20
+ name: "read_context",
21
+ description: "Read testkit-managed context such as focused detail, logs, artifacts, setup, or run summary.",
22
+ },
23
+ {
24
+ name: "read_file",
25
+ description: "Read a local file with optional start and end lines.",
26
+ },
27
+ {
28
+ name: "search_repo",
29
+ description: "Search the repository with ripgrep and return matching lines.",
30
+ },
27
31
  ];
28
32
  }
29
33
 
30
34
  export async function executeAssistantTool(name, argumentsObject, context) {
31
35
  const args = argumentsObject && typeof argumentsObject === "object" ? argumentsObject : {};
32
36
  switch (name) {
33
- case "run_tests":
34
- return runTestsTool(args, context);
35
- case "focus_file":
36
- return focusFileTool(args, context);
37
- case "focus_service":
38
- return focusServiceTool(args, context);
39
- case "inspect_focus":
40
- return inspectFocusTool(args, context);
41
- case "read_logs":
42
- return readLogsTool(args, context);
43
- case "read_artifacts":
44
- return readArtifactsTool(args, context);
45
- case "read_setup":
46
- return readSetupTool(args, context);
47
- case "discover_tests":
48
- return discoverTestsTool(args, context);
49
- case "show_status":
50
- return showStatusTool(args, context);
51
- case "run_doctor":
52
- return runDoctorTool(args, context);
37
+ case "shell_exec":
38
+ return shellExecTool(args, context);
39
+ case "read_context":
40
+ return readContextTool(args, context);
41
+ case "read_file":
42
+ return readFileTool(args, context);
43
+ case "search_repo":
44
+ return searchRepoTool(args, context);
53
45
  default:
54
46
  throw new Error(`Unknown assistant tool "${name}"`);
55
47
  }
56
48
  }
57
49
 
58
- async function runTestsTool(args, context) {
59
- const request = await buildRunRequest(
60
- {
61
- dir: context.productDir,
62
- service: args.service || null,
63
- type: normalizeArray(args.type),
64
- suite: normalizeArray(args.suite),
65
- file: normalizeArray(args.file),
66
- workers: args.workers == null ? null : String(args.workers),
67
- "file-timeout-seconds": args.fileTimeoutSeconds == null ? null : String(args.fileTimeoutSeconds),
68
- shard: args.shard || null,
69
- seed: args.seed || null,
70
- "write-status": Boolean(args.writeStatus),
71
- "allow-partial-status": Boolean(args.allowPartialStatus),
72
- "ignore-skip-rules": Boolean(args.ignoreSkipRules),
73
- },
74
- null,
75
- context.productDir,
76
- process.cwd()
77
- );
50
+ async function shellExecTool(args, context) {
51
+ const command = String(args.command || "").trim();
52
+ if (!command) throw new Error("shell_exec requires a command string");
78
53
 
79
- context.inspectState.resetForLive();
80
- const liveReporter = createAssistantRunReporter({
81
- inspectState: context.inspectState,
82
- onStatus(message) {
83
- context.onEvent?.({ type: "tool-status", tool: "run_tests", message });
84
- },
54
+ const shellCommand = classifyShellCommand(command);
55
+ const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
56
+ context.commandLog?.appendCommandLog({
57
+ type: "command_start",
58
+ command: shellCommand.command,
59
+ commandId,
60
+ cwd: context.productDir,
61
+ raw: command,
62
+ });
63
+ context.onEvent?.({
64
+ type: "tool-status",
65
+ tool: "shell_exec",
66
+ message: `Running ${shellCommand.display}`,
85
67
  });
86
68
 
87
- try {
88
- const result = await runner.runAll(
89
- request.configs,
90
- request.typeValues,
91
- request.suiteSelectors,
92
- {
93
- ...request.runOptions,
94
- reporter: liveReporter,
95
- },
96
- request.allConfigs
97
- );
98
- await liveReporter.finalize;
99
-
100
- return {
101
- ok: result.ok,
102
- title: "Run complete",
103
- text: summarizeRunResult(context.inspectState.getSnapshot()),
104
- data: result,
105
- };
106
- } catch (error) {
107
- liveReporter.close();
108
- await liveReporter.finalize.catch(() => {});
109
- throw error;
110
- }
111
- }
69
+ const result = await execaCommand(command, {
70
+ cwd: context.productDir,
71
+ reject: false,
72
+ shell: true,
73
+ env: {
74
+ ...process.env,
75
+ ...context.env,
76
+ PATH: [context.commandLog?.binDir, context.env?.PATH, process.env.PATH].filter(Boolean).join(path.delimiter),
77
+ TESTKIT_NO_ASSISTANT_DEFAULT: "1",
78
+ },
79
+ });
112
80
 
113
- function focusFileTool(args, context) {
114
- const file = args.file || args.path || null;
115
- if (!file) throw new Error("focus_file requires a file argument");
116
- ensureArtifactLoaded(context);
117
- const artifact = context.inspectState.getSnapshot().runArtifact;
118
- const subject = resolveFileSubject(artifact, file, args.service || null);
119
- context.inspectState.revealFile(subject.service.name, subject.file.path);
120
- return inspectFocusTool({}, context);
121
- }
81
+ context.commandLog?.appendCommandLog({
82
+ type: "command_exit",
83
+ command: shellCommand.command,
84
+ commandId,
85
+ cwd: context.productDir,
86
+ raw: command,
87
+ code: result.exitCode ?? 0,
88
+ signal: result.signal ?? null,
89
+ });
122
90
 
123
- function focusServiceTool(args, context) {
124
- const service = args.service || args.name || null;
125
- if (!service) throw new Error("focus_service requires a service argument");
126
- ensureArtifactLoaded(context);
127
- if (!context.inspectState.revealService(service)) {
128
- throw new Error(`Unknown service "${service}"`);
91
+ if (shellCommand.testkitRelated) {
92
+ refreshArtifactSelection(context);
129
93
  }
130
- return inspectFocusTool({}, context);
131
- }
94
+ context.commandLog?.refresh?.();
132
95
 
133
- function inspectFocusTool(_args, context) {
134
- const content = readAssistantContent({
135
- productDir: context.productDir,
136
- inspectState: context.inspectState,
137
- mode: "detail",
138
- logTail: 12,
139
- });
96
+ const lines = formatCommandResult(command, result, shellCommand);
140
97
  return {
141
- ok: true,
142
- title: content.title,
143
- text: formatAssistantToolText(content.title, content.lines),
98
+ ok: (result.exitCode ?? 0) === 0,
99
+ title: shellCommand.title,
100
+ text: lines.join("\n"),
144
101
  data: {
145
- title: content.title,
146
- lines: content.lines,
147
- selection: content.selection,
148
- mode: "detail",
102
+ command,
103
+ stdout: result.stdout || "",
104
+ stderr: result.stderr || "",
105
+ exitCode: result.exitCode ?? 0,
106
+ signal: result.signal ?? null,
107
+ testkitRelated: shellCommand.testkitRelated,
149
108
  },
150
109
  };
151
110
  }
152
111
 
153
- function readLogsTool(args, context) {
154
- if (args.service) {
112
+ function readContextTool(args, context) {
113
+ if (args.file || args.path) {
114
+ ensureArtifactLoaded(context);
115
+ const artifact = context.inspectState.getSnapshot().runArtifact;
116
+ const subject = resolveFileSubject(artifact, args.file || args.path, args.service || null);
117
+ context.inspectState.revealFile(subject.service.name, subject.file.path);
118
+ } else if (args.service) {
155
119
  ensureArtifactLoaded(context);
156
120
  if (!context.inspectState.revealService(args.service)) {
157
121
  throw new Error(`Unknown service "${args.service}"`);
158
122
  }
159
123
  }
160
- const content = readAssistantContent({
124
+
125
+ const content = readContextContent({
161
126
  productDir: context.productDir,
162
- inspectState: context.inspectState,
163
- mode: "logs",
127
+ snapshot: context.inspectState.getSnapshot(),
128
+ mode: normalizeContextMode(args.mode),
164
129
  logTail: args.logTail == null ? 12 : Number(args.logTail),
165
130
  });
131
+ context.commandLog?.refresh?.();
166
132
  return {
167
133
  ok: true,
168
134
  title: content.title,
169
- text: formatAssistantToolText(content.title, content.lines),
135
+ text: content.lines.join("\n"),
170
136
  data: {
171
137
  title: content.title,
172
138
  lines: content.lines,
173
139
  selection: content.selection,
174
- mode: "logs",
140
+ mode: content.mode,
175
141
  },
176
142
  };
177
143
  }
178
144
 
179
- function readArtifactsTool(args, context) {
180
- if (args.file || args.path) {
181
- ensureArtifactLoaded(context);
182
- const artifact = context.inspectState.getSnapshot().runArtifact;
183
- const subject = resolveFileSubject(artifact, args.file || args.path, args.service || null);
184
- context.inspectState.revealFile(subject.service.name, subject.file.path);
145
+ function readFileTool(args, context) {
146
+ const file = String(args.path || args.file || "").trim();
147
+ if (!file) throw new Error("read_file requires a path");
148
+ const resolved = resolveRepoPath(context.productDir, file);
149
+ if (!resolved.startsWith(path.resolve(context.productDir))) {
150
+ throw new Error("read_file only supports paths inside the current repository");
185
151
  }
186
- const content = readAssistantContent({
187
- productDir: context.productDir,
188
- inspectState: context.inspectState,
189
- mode: "artifacts",
190
- logTail: 12,
191
- });
152
+ if (!fs.existsSync(resolved)) {
153
+ throw new Error(`File not found: ${file}`);
154
+ }
155
+ const startLine = Math.max(1, Number(args.startLine || args.start || 1) || 1);
156
+ const requestedEnd = Number(args.endLine || args.end || startLine + FILE_LINE_LIMIT - 1) || startLine + FILE_LINE_LIMIT - 1;
157
+ const endLine = Math.max(startLine, Math.min(requestedEnd, startLine + FILE_LINE_LIMIT - 1));
158
+ const lines = fs.readFileSync(resolved, "utf8").split(/\r?\n/);
159
+ const selected = [];
160
+ for (let lineNumber = startLine; lineNumber <= Math.min(endLine, lines.length); lineNumber += 1) {
161
+ selected.push(`${lineNumber}: ${lines[lineNumber - 1]}`);
162
+ }
163
+ const title = `File ${path.relative(context.productDir, resolved) || path.basename(resolved)}`;
192
164
  return {
193
165
  ok: true,
194
- title: content.title,
195
- text: formatAssistantToolText(content.title, content.lines),
166
+ title,
167
+ text: selected.join("\n"),
196
168
  data: {
197
- title: content.title,
198
- lines: content.lines,
199
- selection: content.selection,
200
- mode: "artifacts",
169
+ path: resolved,
170
+ relativePath: path.relative(context.productDir, resolved),
171
+ startLine,
172
+ endLine,
173
+ lines: selected,
201
174
  },
202
175
  };
203
176
  }
204
177
 
205
- function readSetupTool(args, context) {
206
- if (args.service) {
207
- ensureArtifactLoaded(context);
208
- if (!context.inspectState.revealService(args.service)) {
209
- throw new Error(`Unknown service "${args.service}"`);
178
+ async function searchRepoTool(args, context) {
179
+ const query = String(args.query || args.pattern || "").trim();
180
+ if (!query) throw new Error("search_repo requires a query");
181
+ const result = await execaCommand(
182
+ `rg --line-number --smart-case --hidden --glob '!node_modules' --glob '!.git' ${shellQuote(query)} .`,
183
+ {
184
+ cwd: context.productDir,
185
+ reject: false,
186
+ shell: true,
210
187
  }
211
- }
212
- const content = readAssistantContent({
213
- productDir: context.productDir,
214
- inspectState: context.inspectState,
215
- mode: "setup",
216
- logTail: 12,
217
- });
188
+ );
189
+ const combined = truncateLines((result.stdout || "").split(/\r?\n/).filter(Boolean), COMMAND_LINE_LIMIT);
190
+ const lines =
191
+ combined.length > 0
192
+ ? combined
193
+ : [`No matches for ${query}`];
218
194
  return {
219
- ok: true,
220
- title: content.title,
221
- text: formatAssistantToolText(content.title, content.lines),
195
+ ok: (result.exitCode ?? 1) === 0,
196
+ title: `Search ${query}`,
197
+ text: lines.join("\n"),
222
198
  data: {
223
- title: content.title,
224
- lines: content.lines,
225
- selection: content.selection,
226
- mode: "setup",
199
+ query,
200
+ matches: combined,
201
+ exitCode: result.exitCode ?? 1,
227
202
  },
228
203
  };
229
204
  }
230
205
 
231
- async function discoverTestsTool(args, context) {
232
- const productDir = resolveProductDir(process.cwd(), context.productDir);
233
- const fileNames = resolveRequestedFiles(normalizeArray(args.file), productDir, process.cwd());
234
- const result = await discoverTests({
235
- dir: productDir,
236
- service: args.service || null,
237
- type: normalizeArray(args.type),
238
- suite: normalizeArray(args.suite),
239
- file: fileNames,
240
- runnableOnly: Boolean(args.runnableOnly),
241
- diagnostics: args.strict ? "error" : "report",
242
- });
243
- const lines = buildDiscoveryReportLines(result, { outputMode: args.outputMode || "compact" });
206
+ function classifyShellCommand(command) {
207
+ const normalized = command.trim();
208
+ if (/^(testkit)\b/.test(normalized)) {
209
+ return {
210
+ command: "testkit",
211
+ display: normalized,
212
+ title: "testkit command",
213
+ testkitRelated: true,
214
+ };
215
+ }
216
+ if (/^(npx)\s+testkit\b/.test(normalized)) {
217
+ return {
218
+ command: "npx testkit",
219
+ display: normalized,
220
+ title: "npx testkit",
221
+ testkitRelated: true,
222
+ };
223
+ }
224
+ if (/^(npm)\s+run\s+testkit\b/.test(normalized) || /^(npm)\s+run\s+testkit:/.test(normalized)) {
225
+ return {
226
+ command: "npm run testkit",
227
+ display: normalized,
228
+ title: "npm testkit script",
229
+ testkitRelated: true,
230
+ };
231
+ }
244
232
  return {
245
- ok: true,
246
- title: "Discovery",
247
- text: lines.join("\n"),
248
- data: result,
233
+ command: normalized.split(/\s+/)[0] || "command",
234
+ display: normalized,
235
+ title: "Shell command",
236
+ testkitRelated: false,
249
237
  };
250
238
  }
251
239
 
252
- function showStatusTool(_args, context) {
253
- const { output } = captureConsoleOutput(() => runner.showStatus(context.configs[0]));
254
- const lines = output.length > 0 ? output : ["No state"];
255
- return {
256
- ok: true,
257
- title: "Status",
258
- text: formatAssistantToolText("Status", lines),
259
- data: { lines },
260
- };
240
+ function formatCommandResult(command, result, shellCommand) {
241
+ const lines = [`$ ${command}`];
242
+ const stdout = (result.stdout || "").trim();
243
+ const stderr = (result.stderr || "").trim();
244
+ const merged = [];
245
+ if (stdout) merged.push(...stdout.split(/\r?\n/));
246
+ if (stderr) merged.push(...stderr.split(/\r?\n/).map((line) => `stderr: ${line}`));
247
+ if (merged.length === 0) {
248
+ merged.push(`exit ${result.exitCode ?? 0}`);
249
+ }
250
+ const trimmed = truncateLines(merged, COMMAND_LINE_LIMIT).map((line) => truncateText(line, COMMAND_OUTPUT_LIMIT));
251
+ lines.push(...trimmed);
252
+ if ((result.exitCode ?? 0) !== 0) {
253
+ lines.push(`exit code: ${result.exitCode ?? 0}`);
254
+ } else if (!shellCommand.testkitRelated) {
255
+ lines.push("exit code: 0");
256
+ }
257
+ return lines;
261
258
  }
262
259
 
263
- async function runDoctorTool(args, context) {
264
- const result = await runDoctor({
265
- dir: context.productDir,
266
- typecheck: args.typecheck ?? true,
267
- });
268
- const lines = [
269
- `testkit doctor ${result.ok ? "passed" : "failed"} for ${result.productDir}`,
270
- ...result.checks.map((check) => `${String(check.level || "").toUpperCase()} ${check.code} ${check.message}`),
271
- ];
272
- return {
273
- ok: result.ok,
274
- title: "Doctor",
275
- text: formatAssistantToolText("Doctor", lines),
276
- data: result,
277
- };
260
+ function truncateLines(lines, limit) {
261
+ if (lines.length <= limit) return lines;
262
+ return [...lines.slice(0, limit - 1), `… ${lines.length - limit + 1} more lines omitted`];
263
+ }
264
+
265
+ function truncateText(value, maxLength) {
266
+ const normalized = String(value || "");
267
+ if (normalized.length <= maxLength) return normalized;
268
+ return `${normalized.slice(0, maxLength - 1)}…`;
269
+ }
270
+
271
+ function resolveRepoPath(productDir, file) {
272
+ return path.resolve(productDir, file);
273
+ }
274
+
275
+ function shellQuote(value) {
276
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
277
+ }
278
+
279
+ function normalizeContextMode(mode) {
280
+ if (mode === "logs" || mode === "artifacts" || mode === "setup") return mode;
281
+ return "detail";
278
282
  }
279
283
 
280
284
  function ensureArtifactLoaded(context) {
@@ -288,28 +292,14 @@ function ensureArtifactLoaded(context) {
288
292
  return context.inspectState.getSnapshot().runArtifact;
289
293
  }
290
294
 
291
- function normalizeArray(value) {
292
- if (value == null) return [];
293
- if (Array.isArray(value)) return value.filter(Boolean);
294
- return [value].filter(Boolean);
295
- }
296
-
297
- function summarizeRunResult(snapshot) {
298
- const rows = snapshot?.summaryData?.rows || [];
299
- if (rows.length === 0) return "Run finished.";
300
- return rows.map(([label, value]) => `${label}: ${value}`).join("\n");
301
- }
302
-
303
- function captureConsoleOutput(fn) {
304
- const output = [];
305
- const originalLog = console.log;
306
- console.log = (...args) => {
307
- output.push(args.map((value) => String(value)).join(" "));
308
- };
295
+ function refreshArtifactSelection(context) {
309
296
  try {
310
- const result = fn();
311
- return { result, output };
312
- } finally {
313
- console.log = originalLog;
297
+ context.inspectState.hydrateFromArtifact(loadCurrentRunArtifact(context.productDir));
298
+ } catch {
299
+ try {
300
+ context.inspectState.hydrateFromArtifact(loadLatestRunArtifact(context.productDir));
301
+ } catch {
302
+ // Ignore missing artifacts.
303
+ }
314
304
  }
315
305
  }
@@ -1,12 +1,10 @@
1
- import React, { createElement } from "react";
2
1
  import { Command, Flags } from "@oclif/core";
3
- import { render } from "ink";
4
2
  import { sharedFlags, resolveConfigsForCommand } from "../command-helpers.mjs";
5
3
  import { createAssistantState } from "../assistant/state.mjs";
6
- import { loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
4
+ import { runInteractiveAssistant } from "../assistant/interactive.mjs";
7
5
 
8
6
  export default class AssistantCommand extends Command {
9
- static summary = "Launch the interactive testkit assistant";
7
+ static summary = "Open the testkit assistant shell";
10
8
 
11
9
  static enableJsonFlag = true;
12
10
 
@@ -20,6 +18,9 @@ export default class AssistantCommand extends Command {
20
18
  file: Flags.string({
21
19
  description: "Initial file selection",
22
20
  }),
21
+ prompt: Flags.string({
22
+ description: "Initial interactive prompt for the provider session",
23
+ }),
23
24
  message: Flags.string({
24
25
  description: "Run one assistant turn non-interactively",
25
26
  }),
@@ -27,51 +28,66 @@ export default class AssistantCommand extends Command {
27
28
 
28
29
  async run() {
29
30
  const { flags } = await this.parse(AssistantCommand);
31
+ if (flags.message && flags.prompt) {
32
+ this.error("Use either --message or --prompt, not both.");
33
+ }
30
34
  const { allConfigs } = await resolveConfigsForCommand(flags);
31
35
  const productDir = allConfigs[0]?.productDir || process.cwd();
32
- const assistantState = createAssistantState({
33
- productDir,
34
- provider: flags.provider,
35
- configs: allConfigs,
36
- });
37
-
38
- await assistantState.loadLatestArtifact();
39
- if (flags.file) {
40
- try {
41
- const artifact = loadLatestRunArtifact(productDir);
42
- const subject = resolveFileSubject(artifact, flags.file, flags.service || null);
43
- assistantState.revealFile(subject.service.name, subject.file.path);
44
- } catch {
45
- // Ignore missing initial selection.
46
- }
47
- } else if (flags.service) {
48
- assistantState.revealService(flags.service);
36
+ const interactive =
37
+ (process.stdout.isTTY || process.env.TESTKIT_FORCE_INTERACTIVE_ASSISTANT === "1") &&
38
+ !this.jsonEnabled() &&
39
+ !flags.message;
40
+ if (flags.prompt && !interactive) {
41
+ this.error("--prompt requires an interactive assistant session.");
49
42
  }
50
-
51
- const interactive = process.stdout.isTTY && !this.jsonEnabled() && !flags.message;
52
43
  if (!interactive) {
44
+ if (!flags.message) {
45
+ this.error("assistant requires an interactive tty; use --message for one non-interactive turn.");
46
+ }
47
+ const assistantState = createAssistantState({
48
+ productDir,
49
+ provider: flags.provider,
50
+ configs: allConfigs,
51
+ env: process.env,
52
+ });
53
+ await assistantState.loadLatestArtifact();
54
+ if (flags.file) {
55
+ try {
56
+ const { loadLatestRunArtifact, resolveFileSubject } = await import("../viewer.mjs");
57
+ const artifact = loadLatestRunArtifact(productDir);
58
+ const subject = resolveFileSubject(artifact, flags.file, flags.service || null);
59
+ assistantState.revealFile(subject.service.name, subject.file.path);
60
+ } catch {
61
+ // Ignore missing initial selection.
62
+ }
63
+ } else if (flags.service) {
64
+ assistantState.revealService(flags.service);
65
+ }
53
66
  if (flags.message) {
54
67
  await assistantState.submitInput(flags.message);
55
68
  }
56
69
  const snapshot = assistantState.getSnapshot();
57
70
  if (!this.jsonEnabled()) {
58
71
  for (const message of snapshot.messages) {
72
+ if (message.role === "tool" && message.title) {
73
+ this.log(`tool: ${message.title}`);
74
+ if (message.text) this.log(message.text);
75
+ continue;
76
+ }
59
77
  this.log(`${message.role}: ${message.text}`);
60
78
  }
61
79
  }
62
80
  return snapshot;
63
81
  }
64
82
 
65
- const { AssistantApp } = await import("../tui/assistant-app.mjs");
66
- const app = render(
67
- createElement(AssistantApp, {
68
- assistantState,
69
- stdout: process.stdout,
70
- }),
71
- { stdout: process.stdout, exitOnCtrlC: false }
72
- );
73
-
74
- await app.waitUntilExit();
75
- return assistantState.getSnapshot();
83
+ return runInteractiveAssistant({
84
+ productDir,
85
+ provider: flags.provider,
86
+ file: flags.file || null,
87
+ service: flags.service || null,
88
+ prompt: flags.prompt || null,
89
+ env: process.env,
90
+ configs: allConfigs,
91
+ });
76
92
  }
77
93
  }