@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
package/README.md CHANGED
@@ -75,13 +75,20 @@ npx @elench/testkit assistant --message "/logs api"
75
75
  npx @elench/testkit db snapshot capture --service api --output scripts/testkit/schema-baseline.sql
76
76
  ```
77
77
 
78
- `testkit` is now assistant-first in an interactive TTY. The assistant is a
79
- single chat surface with a bottom composer. It keeps the current run artifact,
80
- selected file, logs, setup operations, and emitted artifacts as hidden context
81
- and pulls them into the conversation inline when needed. Batch `run` output
82
- stays intentionally short: one line per completed file, a concise failure
83
- block, and a final summary. Service logs, captured runtime output, emitted
84
- artifacts, and assistant-visible run state are persisted under
78
+ `testkit` is assistant-first in an interactive TTY. The interactive assistant
79
+ is a testkit-owned chat shell with a bottom composer, provider-backed
80
+ reasoning, repo context files under `.testkit/assistant/`, and inline tool
81
+ blocks for command execution. Natural-language turns still go through Codex or
82
+ Claude, but `testkit` owns the transcript, command execution surface, and
83
+ rendering around `testkit`, `npm`, and `npx` commands.
84
+
85
+ The non-interactive `assistant --message ...` mode uses the same provider/tool
86
+ engine for one hosted turn at a time. It is useful in scripts and tests, but
87
+ it is not the primary interactive UX.
88
+
89
+ Batch `run` output stays intentionally short: one line per completed file, a
90
+ concise failure block, and a final summary. Service logs, captured runtime
91
+ output, emitted artifacts, and assistant-visible run state are persisted under
85
92
  `.testkit/results/`.
86
93
 
87
94
  `testkit discover` also maintains a small durable per-test history index at
@@ -1,6 +1,5 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { spawn } from "child_process";
4
3
  import { startClaudeHostedSession } from "./providers/claude.mjs";
5
4
  import { startCodexHostedSession } from "./providers/codex.mjs";
6
5
 
@@ -20,7 +19,22 @@ export function resolvePreferredProvider(preferred = null, env = process.env) {
20
19
  throw new Error("Neither codex nor claude was found on PATH");
21
20
  }
22
21
 
22
+ export function resolveProviderBinary(provider, env = process.env) {
23
+ const override = env?.[provider === "codex" ? "TESTKIT_CODEX_BIN" : "TESTKIT_CLAUDE_BIN"];
24
+ if (override) return override;
25
+ return provider;
26
+ }
27
+
23
28
  export function isProviderInstalled(provider, env = process.env) {
29
+ const override = env?.[provider === "codex" ? "TESTKIT_CODEX_BIN" : "TESTKIT_CLAUDE_BIN"];
30
+ if (override) {
31
+ try {
32
+ fs.accessSync(override, fs.constants.X_OK);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
24
38
  const pathValue = env.PATH || "";
25
39
  const candidates = pathValue.split(path.delimiter).filter(Boolean);
26
40
  const fileNames =
@@ -41,24 +55,18 @@ export function isProviderInstalled(provider, env = process.env) {
41
55
  return false;
42
56
  }
43
57
 
44
- export function startAgentSession({ provider = "auto", cwd, prompt, onEvent, purpose = "investigate" } = {}) {
45
- const resolvedProvider = resolvePreferredProvider(provider);
58
+ export function startAgentSession({
59
+ provider = "auto",
60
+ cwd,
61
+ prompt,
62
+ onEvent,
63
+ purpose = "assistant",
64
+ env = process.env,
65
+ } = {}) {
66
+ const resolvedProvider = resolvePreferredProvider(provider, env);
67
+ const command = resolveProviderBinary(resolvedProvider, env);
46
68
  if (resolvedProvider === "claude") {
47
- return startClaudeHostedSession({ cwd, prompt, onEvent, purpose });
69
+ return startClaudeHostedSession({ command, cwd, prompt, onEvent, purpose });
48
70
  }
49
- return startCodexHostedSession({ cwd, prompt, onEvent, purpose });
50
- }
51
-
52
- export function startInteractiveAgentHandoff({ provider = "auto", cwd, prompt } = {}) {
53
- const resolvedProvider = resolvePreferredProvider(provider);
54
- const command = resolvedProvider;
55
- const args = prompt ? [prompt] : [];
56
- const child = spawn(command, args, {
57
- cwd,
58
- stdio: "inherit",
59
- });
60
- return new Promise((resolve, reject) => {
61
- child.on("error", reject);
62
- child.on("close", (code) => resolve({ provider: resolvedProvider, exitCode: code ?? 0 }));
63
- });
71
+ return startCodexHostedSession({ command, cwd, prompt, onEvent, purpose });
64
72
  }
@@ -7,7 +7,7 @@ import {
7
7
  extractTextFragments,
8
8
  } from "./shared.mjs";
9
9
 
10
- export function startClaudeHostedSession({ cwd, prompt, onEvent, purpose = "investigate" } = {}) {
10
+ export function startClaudeHostedSession({ command = "claude", cwd, prompt, onEvent, purpose = "assistant" } = {}) {
11
11
  const args = [
12
12
  "-p",
13
13
  "--output-format",
@@ -15,13 +15,13 @@ export function startClaudeHostedSession({ cwd, prompt, onEvent, purpose = "inve
15
15
  "--include-partial-messages",
16
16
  ];
17
17
 
18
- if (purpose === "investigate" || purpose === "assistant") {
18
+ if (purpose === "assistant") {
19
19
  args.push("--permission-mode", "plan");
20
20
  }
21
21
 
22
22
  args.push(prompt);
23
23
 
24
- const child = execa("claude", args, {
24
+ const child = execa(command, args, {
25
25
  cwd,
26
26
  stdout: "pipe",
27
27
  stderr: "pipe",
@@ -11,18 +11,18 @@ import {
11
11
  readTextFileIfPresent,
12
12
  } from "./shared.mjs";
13
13
 
14
- export function startCodexHostedSession({ cwd, prompt, onEvent, purpose = "investigate" } = {}) {
14
+ export function startCodexHostedSession({ command = "codex", cwd, prompt, onEvent, purpose = "assistant" } = {}) {
15
15
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-codex-"));
16
16
  const outputFile = path.join(tempDir, "final-message.txt");
17
17
  const args = ["exec", "--json", "-o", outputFile];
18
18
 
19
- if (purpose === "investigate" || purpose === "assistant") {
19
+ if (purpose === "assistant") {
20
20
  args.push("-s", "read-only");
21
21
  }
22
22
 
23
23
  args.push(prompt);
24
24
 
25
- const child = execa("codex", args, {
25
+ const child = execa(command, args, {
26
26
  cwd,
27
27
  stdout: "pipe",
28
28
  stderr: "pipe",
@@ -0,0 +1,210 @@
1
+ import React, { createElement, useEffect, useMemo, useState } from "react";
2
+ import { Box, Text, useApp, useInput } from "ink";
3
+ import { bold, dim, green, red, yellow } from "../presentation/colors.mjs";
4
+
5
+ const MAX_VISIBLE_MESSAGES = 22;
6
+
7
+ export function AssistantApp({
8
+ assistantState,
9
+ initialPrompt = null,
10
+ exitAfterInitialPrompt = false,
11
+ inputEnabled = true,
12
+ onRequestClose,
13
+ } = {}) {
14
+ const { exit } = useApp();
15
+ const [snapshot, setSnapshot] = useState(() => assistantState.getSnapshot());
16
+ const [initialPromptStarted, setInitialPromptStarted] = useState(false);
17
+ const [initialPromptFinished, setInitialPromptFinished] = useState(false);
18
+
19
+ useEffect(() => {
20
+ const unsubscribe = assistantState.subscribe(() => {
21
+ setSnapshot(assistantState.getSnapshot());
22
+ });
23
+ return unsubscribe;
24
+ }, [assistantState]);
25
+
26
+ useEffect(() => {
27
+ if (!initialPrompt || initialPromptStarted) return;
28
+ setInitialPromptStarted(true);
29
+ Promise.resolve(assistantState.submitInput(initialPrompt)).finally(() => {
30
+ setInitialPromptFinished(true);
31
+ });
32
+ }, [assistantState, exit, exitAfterInitialPrompt, initialPrompt, initialPromptStarted, onRequestClose]);
33
+
34
+ useEffect(() => {
35
+ if (!exitAfterInitialPrompt || !initialPromptFinished || snapshot.busy) return;
36
+ const timer = setTimeout(() => {
37
+ (onRequestClose || exit)();
38
+ }, 40);
39
+ return () => clearTimeout(timer);
40
+ }, [exit, exitAfterInitialPrompt, initialPromptFinished, onRequestClose, snapshot.busy]);
41
+
42
+ const visibleMessages = useMemo(
43
+ () => snapshot.messages.slice(-MAX_VISIBLE_MESSAGES),
44
+ [snapshot.messages]
45
+ );
46
+
47
+ return createElement(
48
+ Box,
49
+ { flexDirection: "column" },
50
+ inputEnabled
51
+ ? createElement(AssistantInputHandler, {
52
+ assistantState,
53
+ snapshot,
54
+ onRequestClose,
55
+ })
56
+ : null,
57
+ createElement(Text, null, dim(buildHeader(snapshot))),
58
+ snapshot.notice ? createElement(Text, null, yellow(snapshot.notice)) : null,
59
+ createElement(Text, null, ""),
60
+ createElement(
61
+ Box,
62
+ { flexDirection: "column" },
63
+ ...visibleMessages.flatMap((message) => renderMessage(message))
64
+ ),
65
+ createElement(Text, null, ""),
66
+ createElement(
67
+ Box,
68
+ {
69
+ borderStyle: "round",
70
+ flexDirection: "column",
71
+ paddingLeft: 1,
72
+ paddingRight: 1,
73
+ },
74
+ createElement(Text, null, dim("Message")),
75
+ renderComposer(snapshot)
76
+ ),
77
+ createElement(Text, null, ""),
78
+ createElement(Text, null, dim(buildFooter(snapshot, initialPromptFinished)))
79
+ );
80
+ }
81
+
82
+ function AssistantInputHandler({ assistantState, snapshot, onRequestClose }) {
83
+ const { exit } = useApp();
84
+
85
+ useInput((input, key) => {
86
+ if (key.ctrl && input === "c") {
87
+ (onRequestClose || exit)();
88
+ return;
89
+ }
90
+ if (input === "q" && !snapshot.busy && snapshot.composer.length === 0) {
91
+ (onRequestClose || exit)();
92
+ return;
93
+ }
94
+ if (key.return) {
95
+ if (!snapshot.busy) {
96
+ void assistantState.submitCurrentComposer();
97
+ }
98
+ return;
99
+ }
100
+ if (key.leftArrow) {
101
+ assistantState.moveComposerCursor(-1);
102
+ return;
103
+ }
104
+ if (key.rightArrow) {
105
+ assistantState.moveComposerCursor(1);
106
+ return;
107
+ }
108
+ if (key.home || (key.ctrl && input === "a")) {
109
+ assistantState.moveComposerCursorToStart();
110
+ return;
111
+ }
112
+ if (key.end || (key.ctrl && input === "e")) {
113
+ assistantState.moveComposerCursorToEnd();
114
+ return;
115
+ }
116
+ if (key.backspace || key.delete) {
117
+ assistantState.backspaceComposer();
118
+ return;
119
+ }
120
+ if (key.ctrl && input === "d") {
121
+ assistantState.deleteComposer();
122
+ return;
123
+ }
124
+ if (isPrintableInput(input, key)) {
125
+ assistantState.insertComposer(input);
126
+ }
127
+ });
128
+
129
+ return null;
130
+ }
131
+
132
+ function renderMessage(message) {
133
+ const prefix = rolePrefix(message);
134
+ const lines = String(message.text || "").split(/\r?\n/);
135
+ const rendered = [];
136
+ if (message.title) {
137
+ rendered.push(createElement(Text, { key: `${message.id}-title` }, `${prefix} ${bold(message.title)}`));
138
+ } else if (lines.length > 0) {
139
+ rendered.push(createElement(Text, { key: `${message.id}-first` }, `${prefix} ${colorForRole(message.role)(lines[0] || "")}`));
140
+ }
141
+
142
+ const remainingLines = message.title ? lines : lines.slice(1);
143
+ for (let index = 0; index < remainingLines.length; index += 1) {
144
+ rendered.push(
145
+ createElement(
146
+ Text,
147
+ { key: `${message.id}-line-${index}` },
148
+ `${message.title ? " " : " "}${remainingLines[index]}`
149
+ )
150
+ );
151
+ }
152
+ rendered.push(createElement(Text, { key: `${message.id}-gap` }, ""));
153
+ return rendered;
154
+ }
155
+
156
+ function renderComposer(snapshot) {
157
+ const composer = snapshot.composer || "";
158
+ const cursor = snapshot.composerCursor ?? composer.length;
159
+ const before = composer.slice(0, cursor);
160
+ const current = composer[cursor] || " ";
161
+ const after = composer.slice(cursor + (composer[cursor] ? 1 : 0));
162
+ const placeholder = composer.length === 0 ? dim("Ask testkit to run or inspect something...") : "";
163
+ if (composer.length === 0) {
164
+ return createElement(Text, null, placeholder);
165
+ }
166
+ return createElement(
167
+ Text,
168
+ null,
169
+ before,
170
+ createElement(Text, { inverse: true }, current),
171
+ after
172
+ );
173
+ }
174
+
175
+ function buildHeader(snapshot) {
176
+ const status = snapshot.busy ? snapshot.activeStatus || "working" : "ready";
177
+ const provider = snapshot.provider || "auto";
178
+ const context = snapshot.context?.selection?.filePath || snapshot.context?.selection?.serviceName || "no focus";
179
+ return `testkit assistant · ${provider} · ${status} · ${context}`;
180
+ }
181
+
182
+ function buildFooter(snapshot, promptFinished) {
183
+ if (promptFinished && snapshot.messages.length > 0) {
184
+ return "initial prompt complete";
185
+ }
186
+ if (snapshot.busy) {
187
+ return "Enter disabled while the provider is responding · Ctrl+C quit";
188
+ }
189
+ return "Enter send · Ctrl+A/Ctrl+E move cursor · Backspace delete · q quit";
190
+ }
191
+
192
+ function rolePrefix(message) {
193
+ if (message.role === "user") return green("you>");
194
+ if (message.role === "assistant") return bold("ai>");
195
+ if (message.role === "tool") return yellow("tool>");
196
+ return red("sys>");
197
+ }
198
+
199
+ function colorForRole(role) {
200
+ if (role === "user") return green;
201
+ if (role === "tool") return yellow;
202
+ if (role === "system") return red;
203
+ return (value) => value;
204
+ }
205
+
206
+ function isPrintableInput(input, key) {
207
+ if (!input) return false;
208
+ if (key.ctrl || key.meta || key.escape || key.tab) return false;
209
+ return input >= " ";
210
+ }
@@ -0,0 +1,191 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { readContextContent, buildContextSelection } from "../context-resources.mjs";
5
+
6
+ export function prepareAssistantContextPack({
7
+ productDir,
8
+ inspectState,
9
+ } = {}) {
10
+ const contextDir = path.join(productDir, ".testkit", "assistant");
11
+ const binDir = path.join(contextDir, "bin");
12
+ fs.mkdirSync(binDir, { recursive: true });
13
+
14
+ const commandLogPath = path.join(contextDir, "commands.jsonl");
15
+ const contextPath = path.join(contextDir, "context.md");
16
+ const summaryPath = path.join(contextDir, "latest-run-summary.json");
17
+ const selectionPath = path.join(contextDir, "current-selection.json");
18
+ const commandsPath = path.join(contextDir, "commands.md");
19
+ const focusedDetailPath = path.join(contextDir, "focused-detail.txt");
20
+ const focusedLogsPath = path.join(contextDir, "focused-logs.txt");
21
+ const focusedArtifactsPath = path.join(contextDir, "focused-artifacts.txt");
22
+ const focusedSetupPath = path.join(contextDir, "focused-setup.txt");
23
+ const wrapperPath = path.join(binDir, "testkit");
24
+
25
+ function refresh() {
26
+ const snapshot = inspectState?.getSnapshot?.() || {};
27
+ const detailContent = readContextContent({ productDir, snapshot, mode: "detail", logTail: 12 });
28
+ const logsContent = readContextContent({ productDir, snapshot, mode: "logs", logTail: 12 });
29
+ const artifactsContent = readContextContent({ productDir, snapshot, mode: "artifacts", logTail: 12 });
30
+ const setupContent = readContextContent({ productDir, snapshot, mode: "setup", logTail: 12 });
31
+
32
+ writeJson(summaryPath, {
33
+ summaryRows: snapshot.summaryData?.rows || [],
34
+ phase: snapshot.phase || null,
35
+ artifactPath: path.join(productDir, ".testkit", "results", "latest.json"),
36
+ });
37
+ writeJson(selectionPath, buildContextSelection(snapshot));
38
+ fs.writeFileSync(commandsPath, buildCommandsMarkdown(), "utf8");
39
+ fs.writeFileSync(focusedDetailPath, `${detailContent.lines.join("\n")}\n`, "utf8");
40
+ fs.writeFileSync(focusedLogsPath, `${logsContent.lines.join("\n")}\n`, "utf8");
41
+ fs.writeFileSync(focusedArtifactsPath, `${artifactsContent.lines.join("\n")}\n`, "utf8");
42
+ fs.writeFileSync(focusedSetupPath, `${setupContent.lines.join("\n")}\n`, "utf8");
43
+ fs.writeFileSync(
44
+ contextPath,
45
+ buildContextMarkdown(productDir, snapshot, {
46
+ contextPath,
47
+ summaryPath,
48
+ selectionPath,
49
+ commandsPath,
50
+ commandLogPath,
51
+ focusedDetailPath,
52
+ focusedLogsPath,
53
+ focusedArtifactsPath,
54
+ focusedSetupPath,
55
+ }),
56
+ "utf8"
57
+ );
58
+ fs.writeFileSync(wrapperPath, buildWrapperScript({ cliPath: resolveCliPath() }), {
59
+ encoding: "utf8",
60
+ mode: 0o755,
61
+ });
62
+ fs.chmodSync(wrapperPath, 0o755);
63
+ }
64
+
65
+ refresh();
66
+
67
+ return {
68
+ contextDir,
69
+ contextPath,
70
+ summaryPath,
71
+ selectionPath,
72
+ commandsPath,
73
+ commandLogPath,
74
+ focusedDetailPath,
75
+ focusedLogsPath,
76
+ focusedArtifactsPath,
77
+ focusedSetupPath,
78
+ binDir,
79
+ wrapperPath,
80
+ refresh,
81
+ appendCommandLog(event) {
82
+ if (!event || typeof event !== "object") return;
83
+ try {
84
+ fs.appendFileSync(
85
+ commandLogPath,
86
+ `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`,
87
+ "utf8"
88
+ );
89
+ } catch {
90
+ // Ignore assistant command log failures.
91
+ }
92
+ },
93
+ };
94
+ }
95
+
96
+ function resolveCliPath() {
97
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "bin", "testkit.mjs");
98
+ }
99
+
100
+ function buildWrapperScript({ cliPath } = {}) {
101
+ return `#!/usr/bin/env node
102
+ import { spawnSync } from "child_process";
103
+
104
+ const result = spawnSync(process.execPath, [${JSON.stringify(cliPath)}, ...process.argv.slice(2)], {
105
+ cwd: process.cwd(),
106
+ env: {
107
+ ...process.env,
108
+ TESTKIT_NO_ASSISTANT_DEFAULT: "1",
109
+ },
110
+ stdio: "inherit",
111
+ });
112
+
113
+ if (result.error) {
114
+ throw result.error;
115
+ }
116
+ process.exit(result.status ?? 0);
117
+ `;
118
+ }
119
+
120
+ function buildContextMarkdown(productDir, snapshot, paths) {
121
+ const rows = snapshot.summaryData?.rows || [];
122
+ const selection = snapshot.selectedEntry;
123
+ const lines = [
124
+ "# Testkit Assistant Context",
125
+ "",
126
+ `- Product directory: ${productDir}`,
127
+ `- Context file: ${paths.contextPath}`,
128
+ `- Run summary JSON: ${paths.summaryPath}`,
129
+ `- Current selection JSON: ${paths.selectionPath}`,
130
+ `- Command reference: ${paths.commandsPath}`,
131
+ `- Command log: ${paths.commandLogPath}`,
132
+ `- Focused detail: ${paths.focusedDetailPath}`,
133
+ `- Focused logs: ${paths.focusedLogsPath}`,
134
+ `- Focused artifacts: ${paths.focusedArtifactsPath}`,
135
+ `- Focused setup: ${paths.focusedSetupPath}`,
136
+ "",
137
+ "## Latest run summary",
138
+ ];
139
+
140
+ if (rows.length === 0) {
141
+ lines.push("- No persisted run artifact is currently loaded.");
142
+ } else {
143
+ for (const [label, value] of rows) {
144
+ lines.push(`- ${label}: ${value}`);
145
+ }
146
+ }
147
+
148
+ lines.push("", "## Current selection");
149
+ if (!selection) {
150
+ lines.push("- No focused file or service.");
151
+ } else {
152
+ lines.push(`- Kind: ${selection.kind}`);
153
+ if (selection.serviceName) lines.push(`- Service: ${selection.serviceName}`);
154
+ if (selection.type) lines.push(`- Type: ${selection.type}`);
155
+ if (selection.suiteName) lines.push(`- Suite: ${selection.suiteName}`);
156
+ if (selection.filePath) lines.push(`- File: ${selection.filePath}`);
157
+ if (selection.status) lines.push(`- Status: ${selection.status}`);
158
+ }
159
+
160
+ lines.push(
161
+ "",
162
+ "## Guidance",
163
+ "- Use shell commands like `npm run testkit`, `npx testkit`, or `testkit run --dir . --type <type>` when you need to execute tests.",
164
+ "- Use the command log and focused context files before rereading artifacts manually.",
165
+ "- Prefer repo-local commands over guessing project-specific wrappers.",
166
+ ""
167
+ );
168
+
169
+ return lines.join("\n");
170
+ }
171
+
172
+ function buildCommandsMarkdown() {
173
+ return [
174
+ "# Testkit Commands",
175
+ "",
176
+ "- `testkit run --dir . --type int`",
177
+ "- `testkit run --dir . --type e2e`",
178
+ "- `testkit run --dir . --file path/to/file.testkit.ts`",
179
+ "- `testkit discover --dir .`",
180
+ "- `testkit status --dir .`",
181
+ "- `testkit doctor --dir .`",
182
+ "- `testkit destroy --dir .`",
183
+ "- `npm run testkit`",
184
+ "- `npx testkit --dir . --type e2e`",
185
+ "",
186
+ ].join("\n");
187
+ }
188
+
189
+ function writeJson(filePath, value) {
190
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
191
+ }
@@ -0,0 +1,53 @@
1
+ import React, { createElement } from "react";
2
+ import { render } from "ink";
3
+ import { createAssistantState } from "./state.mjs";
4
+ import { AssistantApp } from "./app.mjs";
5
+ import { loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
6
+
7
+ export async function runInteractiveAssistant({
8
+ productDir,
9
+ provider = "auto",
10
+ file = null,
11
+ service = null,
12
+ prompt = null,
13
+ env = process.env,
14
+ configs = [],
15
+ stdout = process.stdout,
16
+ stderr = process.stderr,
17
+ } = {}) {
18
+ const assistantState = createAssistantState({
19
+ productDir,
20
+ provider,
21
+ configs,
22
+ env,
23
+ });
24
+
25
+ await assistantState.loadLatestArtifact();
26
+ if (file) {
27
+ try {
28
+ const artifact = loadLatestRunArtifact(productDir);
29
+ const subject = resolveFileSubject(artifact, file, service || null);
30
+ assistantState.revealFile(subject.service.name, subject.file.path);
31
+ } catch {
32
+ // Ignore unresolved focus.
33
+ }
34
+ } else if (service) {
35
+ assistantState.revealService(service);
36
+ }
37
+
38
+ const app = render(
39
+ createElement(AssistantApp, {
40
+ assistantState,
41
+ initialPrompt: prompt,
42
+ exitAfterInitialPrompt: Boolean(prompt),
43
+ inputEnabled: Boolean(process.stdin.isTTY),
44
+ }),
45
+ {
46
+ stdout,
47
+ stderr,
48
+ exitOnCtrlC: false,
49
+ }
50
+ );
51
+
52
+ return app.waitUntilExit();
53
+ }
@@ -1,4 +1,4 @@
1
- import { readAssistantContent } from "./content.mjs";
1
+ import { readContextContent } from "../context-resources.mjs";
2
2
  import { buildAssistantResponseContract } from "./protocol.mjs";
3
3
 
4
4
  export function buildAssistantPrompt({
@@ -14,8 +14,10 @@ export function buildAssistantPrompt({
14
14
 
15
15
  return [
16
16
  "You are Testkit Assistant.",
17
- "You help users run tests, inspect failures, read artifacts/logs, and explain the current local test state.",
18
- "Prefer using tools when the user is asking testkit to inspect or run something.",
17
+ "You help users run tests, inspect failures, read logs and artifacts, and navigate the current local test state.",
18
+ "All user natural-language requests must be handled through your own reasoning plus the available tools.",
19
+ "Prefer real repository commands through shell_exec when the user asks to run tests or inspect the working repo.",
20
+ "Use read_context before repeating artifact/log inspection work, and use read_file/search_repo when you need codebase context.",
19
21
  buildAssistantResponseContract({ tools }),
20
22
  "",
21
23
  "Current run summary:",
@@ -55,13 +57,9 @@ function buildSelectionSummary(snapshot) {
55
57
  function buildFocusPreview(productDir, snapshot) {
56
58
  if (!productDir || !snapshot) return [];
57
59
  try {
58
- const content = readAssistantContent({
60
+ const content = readContextContent({
59
61
  productDir,
60
- inspectState: {
61
- getSnapshot() {
62
- return snapshot;
63
- },
64
- },
62
+ snapshot,
65
63
  mode: "detail",
66
64
  logTail: 8,
67
65
  });
@@ -9,7 +9,9 @@ export async function runAssistantConversationTurn({
9
9
  transcript,
10
10
  userMessage,
11
11
  provider = "auto",
12
+ env = process.env,
12
13
  configs,
14
+ commandLog,
13
15
  onStatus,
14
16
  onToolEvent,
15
17
  } = {}) {
@@ -18,6 +20,8 @@ export async function runAssistantConversationTurn({
18
20
  productDir,
19
21
  inspectState,
20
22
  configs,
23
+ env,
24
+ commandLog,
21
25
  onEvent: onToolEvent,
22
26
  };
23
27
 
@@ -34,13 +38,14 @@ export async function runAssistantConversationTurn({
34
38
  userMessage,
35
39
  });
36
40
 
37
- onStatus?.(`Thinking with ${resolvePreferredProvider(provider)}...`);
41
+ onStatus?.(`Thinking with ${resolvePreferredProvider(provider, env)}...`);
38
42
  const events = [];
39
43
  const session = startAgentSession({
40
44
  provider,
41
45
  cwd: productDir,
42
46
  prompt,
43
47
  purpose: "assistant",
48
+ env,
44
49
  onEvent(event) {
45
50
  events.push(event);
46
51
  if (event.type === "status" || event.type === "tool") onStatus?.(formatProviderEvent(event));