@elench/testkit 0.1.87 → 0.1.88

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
@@ -11,9 +11,17 @@ The package is now driven by `testkit.config.ts`, not `testkit.config.json`.
11
11
  ```bash
12
12
  cd my-product
13
13
 
14
- # Run every testkit-managed suite
14
+ # Launch the interactive assistant
15
15
  npx @elench/testkit
16
16
 
17
+ # Ask for one assistant turn non-interactively
18
+ npx @elench/testkit assistant --message "/status"
19
+ npx @elench/testkit assistant --message "/run e2e --service api"
20
+ npx @elench/testkit assistant --message "Why did the latest failure happen?"
21
+
22
+ # Run every testkit-managed suite in batch mode
23
+ npx @elench/testkit run
24
+
17
25
  # Inspect discovered tests without running them
18
26
  npx @elench/testkit discover
19
27
  npx @elench/testkit discover --output-mode verbose
@@ -55,12 +63,10 @@ npx @elench/testkit status
55
63
  npx @elench/testkit destroy
56
64
  npx @elench/testkit cleanup
57
65
 
58
- # Inspect the latest run artifact
59
- npx @elench/testkit inspect
60
- npx @elench/testkit inspect --file __testkit__/health/health.int.testkit.ts
61
- npx @elench/testkit inspect --pane artifacts --file __testkit__/health/health.int.testkit.ts
62
- npx @elench/testkit inspect --pane logs --file __testkit__/health/health.int.testkit.ts
63
- npx @elench/testkit inspect --live
66
+ # Inspect the latest run artifact through the assistant
67
+ npx @elench/testkit assistant --message '/inspect "__testkit__/health/health.int.testkit.ts"'
68
+ npx @elench/testkit assistant --pane artifacts --message '/inspect "__testkit__/health/health.int.testkit.ts"'
69
+ npx @elench/testkit assistant --pane logs --message '/inspect "__testkit__/health/health.int.testkit.ts"'
64
70
 
65
71
  # Automatic regression intelligence
66
72
  # Configure testkit.regressions.json and testkit classifies new vs known regressions automatically during runs
@@ -69,10 +75,12 @@ npx @elench/testkit inspect --live
69
75
  npx @elench/testkit db snapshot capture --service api --output scripts/testkit/schema-baseline.sql
70
76
  ```
71
77
 
72
- `testkit` now keeps the default terminal output intentionally short: one line
73
- per completed file, a concise failure block, and a final summary. Service logs,
74
- captured runtime output, emitted artifacts, and user-visible LLM responses are
75
- persisted under `.testkit/results/` and inspected on demand with `inspect`.
78
+ `testkit` is now assistant-first in an interactive TTY. The assistant keeps a
79
+ chat transcript and a live workbench around the current run artifact, selected
80
+ file, logs, setup operations, and emitted artifacts. Batch `run` output stays
81
+ intentionally short: one line per completed file, a concise failure block, and
82
+ a final summary. Service logs, captured runtime output, emitted artifacts, and
83
+ assistant-visible run state are persisted under `.testkit/results/`.
76
84
 
77
85
  `testkit discover` also maintains a small durable per-test history index at
78
86
  `.testkit/history/tests.json`. The index tracks first/last seen timestamps,
@@ -15,7 +15,7 @@ export function startClaudeHostedSession({ cwd, prompt, onEvent, purpose = "inve
15
15
  "--include-partial-messages",
16
16
  ];
17
17
 
18
- if (purpose === "investigate") {
18
+ if (purpose === "investigate" || purpose === "assistant") {
19
19
  args.push("--permission-mode", "plan");
20
20
  }
21
21
 
@@ -16,7 +16,7 @@ export function startCodexHostedSession({ cwd, prompt, onEvent, purpose = "inves
16
16
  const outputFile = path.join(tempDir, "final-message.txt");
17
17
  const args = ["exec", "--json", "-o", outputFile];
18
18
 
19
- if (purpose === "investigate") {
19
+ if (purpose === "investigate" || purpose === "assistant") {
20
20
  args.push("-s", "read-only");
21
21
  }
22
22
 
@@ -0,0 +1,78 @@
1
+ import { buildInspectPaneContent } from "../tui/detail-pane.mjs";
2
+ import { buildAssistantResponseContract } from "./protocol.mjs";
3
+
4
+ export function buildAssistantPrompt({
5
+ productDir,
6
+ snapshot,
7
+ transcript = [],
8
+ tools = [],
9
+ userMessage,
10
+ } = {}) {
11
+ const selectionSummary = buildSelectionSummary(snapshot);
12
+ const panePreview = buildPanePreview(productDir, snapshot);
13
+ const summaryRows = snapshot?.summaryData?.rows || [];
14
+
15
+ return [
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.",
19
+ buildAssistantResponseContract({ tools }),
20
+ "",
21
+ "Current run summary:",
22
+ ...(summaryRows.length > 0 ? summaryRows.map(([label, value]) => `- ${label}: ${value}`) : ["- No run artifact is currently loaded."]),
23
+ "",
24
+ "Current selection:",
25
+ selectionSummary,
26
+ "",
27
+ "Current pane preview:",
28
+ ...(panePreview.length > 0 ? panePreview : ["(empty)"]),
29
+ "",
30
+ "Recent conversation:",
31
+ ...formatTranscript(transcript),
32
+ "",
33
+ `User message: ${String(userMessage || "").trim()}`,
34
+ ].join("\n");
35
+ }
36
+
37
+ function buildSelectionSummary(snapshot) {
38
+ const entry = snapshot?.selectedEntry;
39
+ if (!entry) return "No selection.";
40
+ if (entry.kind === "file") {
41
+ return `File ${entry.filePath} (${entry.status}) in ${entry.serviceName}/${entry.type}.`;
42
+ }
43
+ if (entry.kind === "suite") {
44
+ return `Suite ${entry.suiteName} (${entry.type}) in service ${entry.serviceName}.`;
45
+ }
46
+ if (entry.kind === "type") {
47
+ return `Type ${entry.type} in service ${entry.serviceName}.`;
48
+ }
49
+ if (entry.kind === "service") {
50
+ return `Service ${entry.serviceName}.`;
51
+ }
52
+ return entry.label || "Unknown selection.";
53
+ }
54
+
55
+ function buildPanePreview(productDir, snapshot) {
56
+ if (!productDir || !snapshot) return [];
57
+ try {
58
+ const pane = buildInspectPaneContent({
59
+ productDir,
60
+ snapshot,
61
+ paneMode: snapshot.paneMode || "detail",
62
+ logTail: 8,
63
+ });
64
+ return (pane.lines || []).slice(0, 24).map((line) => `- ${line}`);
65
+ } catch (error) {
66
+ return [`(pane unavailable: ${error instanceof Error ? error.message : String(error)})`];
67
+ }
68
+ }
69
+
70
+ function formatTranscript(transcript) {
71
+ const entries = Array.isArray(transcript) ? transcript.slice(-10) : [];
72
+ if (entries.length === 0) return ["- No prior turns."];
73
+ return entries.map((entry) => {
74
+ const role = entry.role || "system";
75
+ const text = String(entry.text || "").replace(/\s+/g, " ").trim();
76
+ return `- ${role}: ${text}`;
77
+ });
78
+ }
@@ -0,0 +1,67 @@
1
+ export function buildAssistantResponseContract({ tools = [] } = {}) {
2
+ return [
3
+ "Respond with exactly one JSON object and no surrounding commentary.",
4
+ 'Use {"type":"answer","message":"..."} when you can answer directly.',
5
+ 'Use {"type":"tool","tool":"<name>","arguments":{...},"commentary":"..."} when you need testkit to act before you answer.',
6
+ "Only request one tool at a time.",
7
+ `Available tools: ${tools.map((tool) => tool.name).join(", ") || "none"}.`,
8
+ ].join("\n");
9
+ }
10
+
11
+ export function parseAssistantEnvelope(text) {
12
+ const raw = String(text || "").trim();
13
+ if (!raw) {
14
+ return {
15
+ type: "answer",
16
+ message: "",
17
+ };
18
+ }
19
+
20
+ const candidate = extractJsonObject(raw);
21
+ if (!candidate) {
22
+ return {
23
+ type: "answer",
24
+ message: raw,
25
+ };
26
+ }
27
+
28
+ try {
29
+ const parsed = JSON.parse(candidate);
30
+ if (parsed?.type === "tool" && parsed.tool) {
31
+ return {
32
+ type: "tool",
33
+ tool: String(parsed.tool),
34
+ arguments: normalizePlainObject(parsed.arguments),
35
+ commentary: parsed.commentary ? String(parsed.commentary) : "",
36
+ };
37
+ }
38
+ if (parsed?.type === "answer") {
39
+ return {
40
+ type: "answer",
41
+ message: parsed.message ? String(parsed.message) : "",
42
+ };
43
+ }
44
+ } catch {
45
+ // Fall through to raw answer.
46
+ }
47
+
48
+ return {
49
+ type: "answer",
50
+ message: raw,
51
+ };
52
+ }
53
+
54
+ function extractJsonObject(text) {
55
+ const fencedMatch = text.match(/```json\s*([\s\S]*?)```/i);
56
+ if (fencedMatch?.[1]) return fencedMatch[1].trim();
57
+
58
+ const start = text.indexOf("{");
59
+ const end = text.lastIndexOf("}");
60
+ if (start < 0 || end <= start) return null;
61
+ return text.slice(start, end + 1);
62
+ }
63
+
64
+ function normalizePlainObject(value) {
65
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
66
+ return value;
67
+ }
@@ -0,0 +1,92 @@
1
+ import { startAgentSession, resolvePreferredProvider } from "../agents/index.mjs";
2
+ import { buildAssistantPrompt } from "./prompt-builder.mjs";
3
+ import { listAssistantTools, executeAssistantTool } from "./tool-registry.mjs";
4
+ import { parseAssistantEnvelope } from "./protocol.mjs";
5
+
6
+ export async function runAssistantConversationTurn({
7
+ productDir,
8
+ inspectState,
9
+ transcript,
10
+ userMessage,
11
+ provider = "auto",
12
+ configs,
13
+ onStatus,
14
+ onToolEvent,
15
+ } = {}) {
16
+ const tools = listAssistantTools();
17
+ const toolContext = {
18
+ productDir,
19
+ inspectState,
20
+ configs,
21
+ onEvent: onToolEvent,
22
+ };
23
+
24
+ let currentTranscript = [...(transcript || []), { role: "user", text: userMessage }];
25
+ const emitted = [];
26
+
27
+ for (let attempt = 0; attempt < 6; attempt += 1) {
28
+ const snapshot = inspectState.getSnapshot();
29
+ const prompt = buildAssistantPrompt({
30
+ productDir,
31
+ snapshot,
32
+ transcript: currentTranscript,
33
+ tools,
34
+ userMessage,
35
+ });
36
+
37
+ onStatus?.(`Thinking with ${resolvePreferredProvider(provider)}...`);
38
+ const events = [];
39
+ const session = startAgentSession({
40
+ provider,
41
+ cwd: productDir,
42
+ prompt,
43
+ purpose: "assistant",
44
+ onEvent(event) {
45
+ events.push(event);
46
+ if (event.type === "status" || event.type === "tool") onStatus?.(formatProviderEvent(event));
47
+ },
48
+ });
49
+ const result = await session.completion;
50
+ const envelope = parseAssistantEnvelope(result.finalText || "");
51
+
52
+ if (envelope.type === "tool") {
53
+ if (envelope.commentary) {
54
+ emitted.push({ role: "assistant", text: envelope.commentary });
55
+ currentTranscript.push({ role: "assistant", text: envelope.commentary });
56
+ }
57
+ const toolResult = await executeAssistantTool(envelope.tool, envelope.arguments, toolContext);
58
+ const toolText = toolResult.text || `${envelope.tool} completed`;
59
+ emitted.push({
60
+ role: "tool",
61
+ text: toolText,
62
+ toolName: envelope.tool,
63
+ data: toolResult.data || null,
64
+ });
65
+ currentTranscript.push({
66
+ role: "tool",
67
+ text: `${envelope.tool}: ${toolText}`,
68
+ });
69
+ onToolEvent?.({ type: "tool-result", tool: envelope.tool, text: toolText });
70
+ continue;
71
+ }
72
+
73
+ emitted.push({
74
+ role: "assistant",
75
+ text: envelope.message || result.finalText || "",
76
+ });
77
+ return emitted;
78
+ }
79
+
80
+ emitted.push({
81
+ role: "assistant",
82
+ text: "I hit the assistant tool-call limit before reaching a final answer.",
83
+ });
84
+ return emitted;
85
+ }
86
+
87
+ function formatProviderEvent(event) {
88
+ if (event.type === "tool") {
89
+ return `${event.provider}: ${event.name}${event.detail ? ` (${event.detail})` : ""}`;
90
+ }
91
+ return `${event.provider}: ${event.message}`;
92
+ }
@@ -0,0 +1,160 @@
1
+ const RUN_TYPES = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
2
+ const PANES = new Set(["detail", "artifacts", "logs", "setup"]);
3
+ const PROVIDERS = new Set(["auto", "claude", "codex"]);
4
+
5
+ export function parseSlashCommand(input) {
6
+ const trimmed = String(input || "").trim();
7
+ if (!trimmed.startsWith("/")) return null;
8
+ const tokens = tokenizeShellLike(trimmed.slice(1));
9
+ const command = tokens.shift() || "";
10
+
11
+ if (command === "help") return { type: "help" };
12
+ if (command === "clear") return { type: "clear" };
13
+ if (command === "quit" || command === "exit") return { type: "quit" };
14
+
15
+ if (command === "provider") {
16
+ const provider = tokens[0] || "auto";
17
+ if (!PROVIDERS.has(provider)) {
18
+ throw new Error(`/provider expects one of: ${[...PROVIDERS].join(", ")}`);
19
+ }
20
+ return { type: "provider", provider };
21
+ }
22
+
23
+ if (command === "pane") {
24
+ const pane = tokens[0] || "detail";
25
+ if (!PANES.has(pane)) {
26
+ throw new Error(`/pane expects one of: ${[...PANES].join(", ")}`);
27
+ }
28
+ return { type: "pane", pane };
29
+ }
30
+
31
+ if (command === "file" || command === "focus") {
32
+ if (!tokens[0]) throw new Error(`/${command} expects a file path`);
33
+ return { type: "file", file: tokens.join(" ") };
34
+ }
35
+
36
+ if (command === "service") {
37
+ if (!tokens[0]) throw new Error("/service expects a service name");
38
+ return { type: "service", service: tokens[0] };
39
+ }
40
+
41
+ if (command === "inspect") {
42
+ return {
43
+ type: "inspect",
44
+ file: tokens[0] || null,
45
+ };
46
+ }
47
+
48
+ if (command === "status") return { type: "status" };
49
+ if (command === "discover") return { type: "discover" };
50
+ if (command === "doctor") return { type: "doctor" };
51
+
52
+ if (command === "run") {
53
+ return {
54
+ type: "run",
55
+ options: parseRunCommandTokens(tokens),
56
+ };
57
+ }
58
+
59
+ throw new Error(`Unknown slash command "/${command}"`);
60
+ }
61
+
62
+ export function formatSlashHelpLines() {
63
+ return [
64
+ "/run [type] [--service name] [--file path] [--suite selector]",
65
+ "/file <path>",
66
+ "/service <name>",
67
+ "/pane <detail|artifacts|logs|setup>",
68
+ "/inspect [path]",
69
+ "/discover",
70
+ "/status",
71
+ "/doctor",
72
+ "/provider <auto|claude|codex>",
73
+ "/clear",
74
+ "/quit",
75
+ ];
76
+ }
77
+
78
+ function parseRunCommandTokens(tokens) {
79
+ const options = {
80
+ type: [],
81
+ suite: [],
82
+ file: [],
83
+ service: null,
84
+ };
85
+
86
+ for (let index = 0; index < tokens.length; index += 1) {
87
+ const token = tokens[index];
88
+ if (!token) continue;
89
+ if (RUN_TYPES.has(token)) {
90
+ options.type.push(token);
91
+ continue;
92
+ }
93
+ if (token === "--service") {
94
+ options.service = tokens[index + 1] || null;
95
+ index += 1;
96
+ continue;
97
+ }
98
+ if (token === "--file") {
99
+ if (tokens[index + 1]) options.file.push(tokens[index + 1]);
100
+ index += 1;
101
+ continue;
102
+ }
103
+ if (token === "--suite") {
104
+ if (tokens[index + 1]) options.suite.push(tokens[index + 1]);
105
+ index += 1;
106
+ continue;
107
+ }
108
+ if (token === "--type") {
109
+ if (tokens[index + 1]) {
110
+ options.type.push(...String(tokens[index + 1]).split(",").map((value) => value.trim()).filter(Boolean));
111
+ }
112
+ index += 1;
113
+ continue;
114
+ }
115
+ throw new Error(`Unsupported /run token "${token}"`);
116
+ }
117
+
118
+ return options;
119
+ }
120
+
121
+ function tokenizeShellLike(input) {
122
+ const tokens = [];
123
+ let current = "";
124
+ let quote = null;
125
+
126
+ for (let index = 0; index < input.length; index += 1) {
127
+ const char = input[index];
128
+ if (quote) {
129
+ if (char === quote) {
130
+ quote = null;
131
+ } else if (char === "\\" && index + 1 < input.length) {
132
+ current += input[index + 1];
133
+ index += 1;
134
+ } else {
135
+ current += char;
136
+ }
137
+ continue;
138
+ }
139
+ if (char === '"' || char === "'") {
140
+ quote = char;
141
+ continue;
142
+ }
143
+ if (/\s/.test(char)) {
144
+ if (current) {
145
+ tokens.push(current);
146
+ current = "";
147
+ }
148
+ continue;
149
+ }
150
+ if (char === "\\" && index + 1 < input.length) {
151
+ current += input[index + 1];
152
+ index += 1;
153
+ continue;
154
+ }
155
+ current += char;
156
+ }
157
+
158
+ if (current) tokens.push(current);
159
+ return tokens;
160
+ }