@elench/testkit 0.1.90 → 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.
package/README.md CHANGED
@@ -75,18 +75,16 @@ 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, but the interactive
79
- assistant is provider-native. `testkit` boots a real Codex CLI or Claude CLI
80
- session, writes local context files under `.testkit/assistant/`, places a
81
- repo-local `testkit` wrapper on the provider `PATH`, and then hands the
82
- terminal to the provider. That means the cursor, transcript, approvals, and
83
- conversation UX come from the provider itself, while `testkit` contributes
84
- repo context, durable run artifacts, focused detail/log/artifact/setup text
85
- files, and a stable local command surface.
86
-
87
- The non-interactive `assistant --message ...` mode remains available for one
88
- hosted turn at a time. It is useful in scripts and tests, but it is not the
89
- primary interactive UX.
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.
90
88
 
91
89
  Batch `run` output stays intentionally short: one line per completed file, a
92
90
  concise failure block, and a final summary. Service logs, captured runtime
@@ -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
 
@@ -71,33 +70,3 @@ export function startAgentSession({
71
70
  }
72
71
  return startCodexHostedSession({ command, cwd, prompt, onEvent, purpose });
73
72
  }
74
-
75
- export function startInteractiveProviderSession({
76
- provider = "auto",
77
- cwd,
78
- prompt,
79
- env = process.env,
80
- } = {}) {
81
- const resolvedProvider = resolvePreferredProvider(provider, env);
82
- const command = resolveProviderBinary(resolvedProvider, env);
83
- const args = buildInteractiveProviderArgs(resolvedProvider, prompt);
84
- const child = spawn(command, args, {
85
- cwd,
86
- env,
87
- stdio: "inherit",
88
- });
89
- return new Promise((resolve, reject) => {
90
- child.on("error", reject);
91
- child.on("close", (code) =>
92
- resolve({
93
- provider: resolvedProvider,
94
- exitCode: code ?? 0,
95
- })
96
- );
97
- });
98
- }
99
-
100
- export function buildInteractiveProviderArgs(provider, prompt = null) {
101
- if (!prompt) return [];
102
- return [prompt];
103
- }
@@ -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
+ }
@@ -1,8 +1,8 @@
1
- import path from "path";
2
- import { createInspectState } from "../tui/inspect-state.mjs";
1
+ import React, { createElement } from "react";
2
+ import { render } from "ink";
3
+ import { createAssistantState } from "./state.mjs";
4
+ import { AssistantApp } from "./app.mjs";
3
5
  import { loadLatestRunArtifact, resolveFileSubject } from "../viewer.mjs";
4
- import { startInteractiveProviderSession } from "../agents/index.mjs";
5
- import { prepareAssistantBootstrap } from "./bootstrap.mjs";
6
6
 
7
7
  export async function runInteractiveAssistant({
8
8
  productDir,
@@ -11,42 +11,43 @@ export async function runInteractiveAssistant({
11
11
  service = null,
12
12
  prompt = null,
13
13
  env = process.env,
14
+ configs = [],
15
+ stdout = process.stdout,
16
+ stderr = process.stderr,
14
17
  } = {}) {
15
- const inspectState = createInspectState({ dataSource: "artifact" });
16
- try {
17
- inspectState.hydrateFromArtifact(loadLatestRunArtifact(productDir));
18
- } catch {
19
- // No persisted artifact yet.
20
- }
18
+ const assistantState = createAssistantState({
19
+ productDir,
20
+ provider,
21
+ configs,
22
+ env,
23
+ });
21
24
 
25
+ await assistantState.loadLatestArtifact();
22
26
  if (file) {
23
27
  try {
24
28
  const artifact = loadLatestRunArtifact(productDir);
25
29
  const subject = resolveFileSubject(artifact, file, service || null);
26
- inspectState.revealFile(subject.service.name, subject.file.path);
30
+ assistantState.revealFile(subject.service.name, subject.file.path);
27
31
  } catch {
28
32
  // Ignore unresolved focus.
29
33
  }
30
34
  } else if (service) {
31
- inspectState.revealService(service);
35
+ assistantState.revealService(service);
32
36
  }
33
37
 
34
- const bootstrap = prepareAssistantBootstrap({
35
- productDir,
36
- inspectState,
37
- initialPrompt: prompt,
38
- });
39
-
40
- const sessionEnv = {
41
- ...env,
42
- ...bootstrap.env,
43
- PATH: [bootstrap.binDir, env.PATH || ""].filter(Boolean).join(path.delimiter),
44
- };
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
+ );
45
51
 
46
- return startInteractiveProviderSession({
47
- provider,
48
- cwd: productDir,
49
- prompt: bootstrap.prompt,
50
- env: sessionEnv,
51
- });
52
+ return app.waitUntilExit();
52
53
  }
@@ -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:",
@@ -11,6 +11,7 @@ export async function runAssistantConversationTurn({
11
11
  provider = "auto",
12
12
  env = process.env,
13
13
  configs,
14
+ commandLog,
14
15
  onStatus,
15
16
  onToolEvent,
16
17
  } = {}) {
@@ -19,6 +20,8 @@ export async function runAssistantConversationTurn({
19
20
  productDir,
20
21
  inspectState,
21
22
  configs,
23
+ env,
24
+ commandLog,
22
25
  onEvent: onToolEvent,
23
26
  };
24
27