@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.
- package/README.md +14 -7
- package/lib/cli/agents/index.mjs +27 -19
- package/lib/cli/agents/providers/claude.mjs +3 -3
- package/lib/cli/agents/providers/codex.mjs +3 -3
- package/lib/cli/assistant/app.mjs +210 -0
- package/lib/cli/assistant/context-pack.mjs +191 -0
- package/lib/cli/assistant/interactive.mjs +53 -0
- package/lib/cli/assistant/prompt-builder.mjs +7 -9
- package/lib/cli/assistant/session.mjs +6 -1
- package/lib/cli/assistant/state.mjs +134 -46
- package/lib/cli/assistant/tool-registry.mjs +220 -230
- package/lib/cli/commands/assistant.mjs +50 -34
- package/lib/cli/{tui/detail-pane.mjs → context-resources.mjs} +81 -21
- package/lib/cli/entrypoint.mjs +12 -4
- package/lib/cli/presentation/tree-reporter.mjs +0 -101
- package/lib/cli/tui/inspect-app.mjs +7 -88
- package/lib/cli/tui/inspect-state.mjs +0 -117
- package/node_modules/@elench/next-analysis/package.json +1 -1
- package/node_modules/@elench/testkit-bridge/package.json +2 -2
- package/node_modules/@elench/testkit-protocol/package.json +1 -1
- package/node_modules/@elench/ts-analysis/package.json +1 -1
- package/package.json +5 -5
- package/lib/cli/agents/investigate.mjs +0 -75
- package/lib/cli/agents/investigation-context.mjs +0 -102
- package/lib/cli/agents/investigation-interpreter.mjs +0 -320
- package/lib/cli/agents/investigation-log.mjs +0 -37
- package/lib/cli/agents/prompt-builder.mjs +0 -25
- package/lib/cli/assistant/content.mjs +0 -60
- package/lib/cli/assistant/tool-run-reporter.mjs +0 -80
- package/lib/cli/tui/assistant-app.mjs +0 -82
- 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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
package/lib/cli/agents/index.mjs
CHANGED
|
@@ -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({
|
|
45
|
-
|
|
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 = "
|
|
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 === "
|
|
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(
|
|
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 = "
|
|
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 === "
|
|
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(
|
|
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 {
|
|
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
|
|
18
|
-
"
|
|
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 =
|
|
60
|
+
const content = readContextContent({
|
|
59
61
|
productDir,
|
|
60
|
-
|
|
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));
|