@elench/testkit 0.1.90 → 0.1.92
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 +20 -12
- package/lib/cli/agents/index.mjs +5 -33
- package/lib/cli/agents/providers/claude.mjs +22 -1
- package/lib/cli/agents/providers/codex.mjs +18 -1
- package/lib/cli/assistant/app.mjs +209 -0
- package/lib/cli/assistant/composer.mjs +112 -0
- package/lib/cli/assistant/context-pack.mjs +191 -0
- package/lib/cli/assistant/interactive.mjs +39 -30
- package/lib/cli/assistant/prompt-builder.mjs +4 -2
- package/lib/cli/assistant/session.mjs +13 -2
- package/lib/cli/assistant/settings.mjs +98 -0
- package/lib/cli/assistant/slash-commands.mjs +45 -1
- package/lib/cli/assistant/state.mjs +248 -54
- package/lib/cli/assistant/tool-registry.mjs +216 -226
- package/lib/cli/commands/assistant.mjs +29 -2
- package/lib/cli/entrypoint.mjs +3 -0
- 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/assistant/bootstrap.mjs +0 -248
- package/lib/cli/assistant/tool-run-reporter.mjs +0 -80
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ cd my-product
|
|
|
13
13
|
|
|
14
14
|
# Launch the interactive assistant
|
|
15
15
|
npx @elench/testkit
|
|
16
|
+
npx @elench/testkit assistant --provider codex --model gpt-5.4
|
|
17
|
+
npx @elench/testkit assistant --provider claude --model sonnet --effort high
|
|
16
18
|
|
|
17
19
|
# Ask for one assistant turn non-interactively
|
|
18
20
|
npx @elench/testkit assistant --message "/status"
|
|
@@ -75,18 +77,24 @@ npx @elench/testkit assistant --message "/logs api"
|
|
|
75
77
|
npx @elench/testkit db snapshot capture --service api --output scripts/testkit/schema-baseline.sql
|
|
76
78
|
```
|
|
77
79
|
|
|
78
|
-
`testkit` is
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
80
|
+
`testkit` is assistant-first in an interactive TTY. The interactive assistant
|
|
81
|
+
is a testkit-owned chat shell with a bottom composer, provider-backed
|
|
82
|
+
reasoning, repo context files under `.testkit/assistant/`, and inline tool
|
|
83
|
+
blocks for command execution. Natural-language turns still go through Codex or
|
|
84
|
+
Claude, but `testkit` owns the transcript, command execution surface, and
|
|
85
|
+
rendering around `testkit`, `npm`, and `npx` commands.
|
|
86
|
+
|
|
87
|
+
Assistant runtime settings are repo-local. Use `/provider`, `/model`,
|
|
88
|
+
`/effort`, and `/settings` inside the assistant to inspect or change the active
|
|
89
|
+
provider runtime; changes are persisted to `.testkit/assistant/settings.json`.
|
|
90
|
+
CLI flags such as `--provider`, `--model`, `--effort`, and repeatable
|
|
91
|
+
`--provider-arg` override those settings for the current launch. The composer
|
|
92
|
+
has an always-visible cursor and supports arrow keys, Home/End, Ctrl+A/Ctrl+E,
|
|
93
|
+
Backspace, Delete, and Ctrl+D.
|
|
94
|
+
|
|
95
|
+
The non-interactive `assistant --message ...` mode uses the same provider/tool
|
|
96
|
+
engine for one hosted turn at a time. It is useful in scripts and tests, but
|
|
97
|
+
it is not the primary interactive UX.
|
|
90
98
|
|
|
91
99
|
Batch `run` output stays intentionally short: one line per completed file, a
|
|
92
100
|
concise failure block, and a final summary. Service logs, captured runtime
|
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
|
|
|
@@ -58,6 +57,9 @@ export function isProviderInstalled(provider, env = process.env) {
|
|
|
58
57
|
|
|
59
58
|
export function startAgentSession({
|
|
60
59
|
provider = "auto",
|
|
60
|
+
model = null,
|
|
61
|
+
effort = null,
|
|
62
|
+
providerArgs = [],
|
|
61
63
|
cwd,
|
|
62
64
|
prompt,
|
|
63
65
|
onEvent,
|
|
@@ -67,37 +69,7 @@ export function startAgentSession({
|
|
|
67
69
|
const resolvedProvider = resolvePreferredProvider(provider, env);
|
|
68
70
|
const command = resolveProviderBinary(resolvedProvider, env);
|
|
69
71
|
if (resolvedProvider === "claude") {
|
|
70
|
-
return startClaudeHostedSession({ command, cwd, prompt, onEvent, purpose });
|
|
72
|
+
return startClaudeHostedSession({ command, cwd, prompt, onEvent, purpose, model, effort, providerArgs });
|
|
71
73
|
}
|
|
72
|
-
return startCodexHostedSession({ command, cwd, prompt, onEvent, purpose });
|
|
73
|
-
}
|
|
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];
|
|
74
|
+
return startCodexHostedSession({ command, cwd, prompt, onEvent, purpose, model, providerArgs });
|
|
103
75
|
}
|
|
@@ -7,7 +7,16 @@ import {
|
|
|
7
7
|
extractTextFragments,
|
|
8
8
|
} from "./shared.mjs";
|
|
9
9
|
|
|
10
|
-
export function startClaudeHostedSession({
|
|
10
|
+
export function startClaudeHostedSession({
|
|
11
|
+
command = "claude",
|
|
12
|
+
cwd,
|
|
13
|
+
prompt,
|
|
14
|
+
onEvent,
|
|
15
|
+
purpose = "assistant",
|
|
16
|
+
model = null,
|
|
17
|
+
effort = null,
|
|
18
|
+
providerArgs = [],
|
|
19
|
+
} = {}) {
|
|
11
20
|
const args = [
|
|
12
21
|
"-p",
|
|
13
22
|
"--output-format",
|
|
@@ -18,6 +27,13 @@ export function startClaudeHostedSession({ command = "claude", cwd, prompt, onEv
|
|
|
18
27
|
if (purpose === "assistant") {
|
|
19
28
|
args.push("--permission-mode", "plan");
|
|
20
29
|
}
|
|
30
|
+
if (model) {
|
|
31
|
+
args.push("--model", String(model));
|
|
32
|
+
}
|
|
33
|
+
if (effort) {
|
|
34
|
+
args.push("--effort", String(effort));
|
|
35
|
+
}
|
|
36
|
+
args.push(...normalizeProviderArgs(providerArgs));
|
|
21
37
|
|
|
22
38
|
args.push(prompt);
|
|
23
39
|
|
|
@@ -39,6 +55,11 @@ export function startClaudeHostedSession({ command = "claude", cwd, prompt, onEv
|
|
|
39
55
|
});
|
|
40
56
|
}
|
|
41
57
|
|
|
58
|
+
function normalizeProviderArgs(providerArgs) {
|
|
59
|
+
if (!Array.isArray(providerArgs)) return [];
|
|
60
|
+
return providerArgs.flatMap((arg) => String(arg || "").split(/\s+/).filter(Boolean));
|
|
61
|
+
}
|
|
62
|
+
|
|
42
63
|
function parseClaudePayload(payload) {
|
|
43
64
|
const events = [];
|
|
44
65
|
if (!payload || typeof payload !== "object") return events;
|
|
@@ -11,7 +11,15 @@ import {
|
|
|
11
11
|
readTextFileIfPresent,
|
|
12
12
|
} from "./shared.mjs";
|
|
13
13
|
|
|
14
|
-
export function startCodexHostedSession({
|
|
14
|
+
export function startCodexHostedSession({
|
|
15
|
+
command = "codex",
|
|
16
|
+
cwd,
|
|
17
|
+
prompt,
|
|
18
|
+
onEvent,
|
|
19
|
+
purpose = "assistant",
|
|
20
|
+
model = null,
|
|
21
|
+
providerArgs = [],
|
|
22
|
+
} = {}) {
|
|
15
23
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-codex-"));
|
|
16
24
|
const outputFile = path.join(tempDir, "final-message.txt");
|
|
17
25
|
const args = ["exec", "--json", "-o", outputFile];
|
|
@@ -19,6 +27,10 @@ export function startCodexHostedSession({ command = "codex", cwd, prompt, onEven
|
|
|
19
27
|
if (purpose === "assistant") {
|
|
20
28
|
args.push("-s", "read-only");
|
|
21
29
|
}
|
|
30
|
+
if (model) {
|
|
31
|
+
args.push("--model", String(model));
|
|
32
|
+
}
|
|
33
|
+
args.push(...normalizeProviderArgs(providerArgs));
|
|
22
34
|
|
|
23
35
|
args.push(prompt);
|
|
24
36
|
|
|
@@ -49,6 +61,11 @@ export function startCodexHostedSession({ command = "codex", cwd, prompt, onEven
|
|
|
49
61
|
};
|
|
50
62
|
}
|
|
51
63
|
|
|
64
|
+
function normalizeProviderArgs(providerArgs) {
|
|
65
|
+
if (!Array.isArray(providerArgs)) return [];
|
|
66
|
+
return providerArgs.flatMap((arg) => String(arg || "").split(/\s+/).filter(Boolean));
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
function parseCodexPayload(payload) {
|
|
53
70
|
const events = [];
|
|
54
71
|
if (!payload || typeof payload !== "object") return events;
|
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
import { getComposerRenderParts } from "./composer.mjs";
|
|
5
|
+
|
|
6
|
+
const MAX_VISIBLE_MESSAGES = 22;
|
|
7
|
+
|
|
8
|
+
export function AssistantApp({
|
|
9
|
+
assistantState,
|
|
10
|
+
initialPrompt = null,
|
|
11
|
+
exitAfterInitialPrompt = false,
|
|
12
|
+
inputEnabled = true,
|
|
13
|
+
onRequestClose,
|
|
14
|
+
} = {}) {
|
|
15
|
+
const { exit } = useApp();
|
|
16
|
+
const [snapshot, setSnapshot] = useState(() => assistantState.getSnapshot());
|
|
17
|
+
const [initialPromptStarted, setInitialPromptStarted] = useState(false);
|
|
18
|
+
const [initialPromptFinished, setInitialPromptFinished] = useState(false);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const unsubscribe = assistantState.subscribe(() => {
|
|
22
|
+
setSnapshot(assistantState.getSnapshot());
|
|
23
|
+
});
|
|
24
|
+
return unsubscribe;
|
|
25
|
+
}, [assistantState]);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!initialPrompt || initialPromptStarted) return;
|
|
29
|
+
setInitialPromptStarted(true);
|
|
30
|
+
Promise.resolve(assistantState.submitInput(initialPrompt)).finally(() => {
|
|
31
|
+
setInitialPromptFinished(true);
|
|
32
|
+
});
|
|
33
|
+
}, [assistantState, exit, exitAfterInitialPrompt, initialPrompt, initialPromptStarted, onRequestClose]);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!exitAfterInitialPrompt || !initialPromptFinished || snapshot.busy) return;
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
(onRequestClose || exit)();
|
|
39
|
+
}, 40);
|
|
40
|
+
return () => clearTimeout(timer);
|
|
41
|
+
}, [exit, exitAfterInitialPrompt, initialPromptFinished, onRequestClose, snapshot.busy]);
|
|
42
|
+
|
|
43
|
+
const visibleMessages = useMemo(
|
|
44
|
+
() => snapshot.messages.slice(-MAX_VISIBLE_MESSAGES),
|
|
45
|
+
[snapshot.messages]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return createElement(
|
|
49
|
+
Box,
|
|
50
|
+
{ flexDirection: "column" },
|
|
51
|
+
inputEnabled
|
|
52
|
+
? createElement(AssistantInputHandler, {
|
|
53
|
+
assistantState,
|
|
54
|
+
snapshot,
|
|
55
|
+
onRequestClose,
|
|
56
|
+
})
|
|
57
|
+
: null,
|
|
58
|
+
createElement(Text, null, dim(buildHeader(snapshot))),
|
|
59
|
+
snapshot.notice ? createElement(Text, null, yellow(snapshot.notice)) : null,
|
|
60
|
+
createElement(Text, null, ""),
|
|
61
|
+
createElement(
|
|
62
|
+
Box,
|
|
63
|
+
{ flexDirection: "column" },
|
|
64
|
+
...visibleMessages.flatMap((message) => renderMessage(message))
|
|
65
|
+
),
|
|
66
|
+
createElement(Text, null, ""),
|
|
67
|
+
createElement(
|
|
68
|
+
Box,
|
|
69
|
+
{
|
|
70
|
+
borderStyle: "round",
|
|
71
|
+
flexDirection: "column",
|
|
72
|
+
paddingLeft: 1,
|
|
73
|
+
paddingRight: 1,
|
|
74
|
+
},
|
|
75
|
+
createElement(Text, null, dim("Message")),
|
|
76
|
+
renderComposer(snapshot)
|
|
77
|
+
),
|
|
78
|
+
createElement(Text, null, ""),
|
|
79
|
+
createElement(Text, null, dim(buildFooter(snapshot, initialPromptFinished)))
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function AssistantInputHandler({ assistantState, snapshot, onRequestClose }) {
|
|
84
|
+
const { exit } = useApp();
|
|
85
|
+
|
|
86
|
+
useInput((input, key) => {
|
|
87
|
+
if (key.ctrl && input === "c") {
|
|
88
|
+
(onRequestClose || exit)();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (input === "q" && !snapshot.busy && snapshot.composer.length === 0) {
|
|
92
|
+
(onRequestClose || exit)();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (key.return) {
|
|
96
|
+
if (!snapshot.busy) {
|
|
97
|
+
void assistantState.submitCurrentComposer();
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (key.leftArrow) {
|
|
102
|
+
assistantState.moveComposerCursor(-1);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (key.rightArrow) {
|
|
106
|
+
assistantState.moveComposerCursor(1);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (key.home || (key.ctrl && input === "a")) {
|
|
110
|
+
assistantState.moveComposerCursorToStart();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (key.end || (key.ctrl && input === "e")) {
|
|
114
|
+
assistantState.moveComposerCursorToEnd();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (key.backspace) {
|
|
118
|
+
assistantState.backspaceComposer();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (key.delete || (key.ctrl && input === "d")) {
|
|
122
|
+
assistantState.deleteComposer();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (isPrintableInput(input, key)) {
|
|
126
|
+
assistantState.insertComposer(input);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function renderMessage(message) {
|
|
134
|
+
const prefix = rolePrefix(message);
|
|
135
|
+
const lines = String(message.text || "").split(/\r?\n/);
|
|
136
|
+
const rendered = [];
|
|
137
|
+
if (message.title) {
|
|
138
|
+
rendered.push(createElement(Text, { key: `${message.id}-title` }, `${prefix} ${bold(message.title)}`));
|
|
139
|
+
} else if (lines.length > 0) {
|
|
140
|
+
rendered.push(createElement(Text, { key: `${message.id}-first` }, `${prefix} ${colorForRole(message.role)(lines[0] || "")}`));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const remainingLines = message.title ? lines : lines.slice(1);
|
|
144
|
+
for (let index = 0; index < remainingLines.length; index += 1) {
|
|
145
|
+
rendered.push(
|
|
146
|
+
createElement(
|
|
147
|
+
Text,
|
|
148
|
+
{ key: `${message.id}-line-${index}` },
|
|
149
|
+
`${message.title ? " " : " "}${remainingLines[index]}`
|
|
150
|
+
)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
rendered.push(createElement(Text, { key: `${message.id}-gap` }, ""));
|
|
154
|
+
return rendered;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderComposer(snapshot) {
|
|
158
|
+
const { before, current, after, empty } = getComposerRenderParts({
|
|
159
|
+
text: snapshot.composer || "",
|
|
160
|
+
cursor: snapshot.composerCursor ?? 0,
|
|
161
|
+
});
|
|
162
|
+
return createElement(
|
|
163
|
+
Text,
|
|
164
|
+
null,
|
|
165
|
+
empty ? dim("Ask testkit to run or inspect something... ") : before,
|
|
166
|
+
createElement(Text, { inverse: true }, current),
|
|
167
|
+
after
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function buildHeader(snapshot) {
|
|
172
|
+
const status = snapshot.busy ? snapshot.activeStatus || "working" : "ready";
|
|
173
|
+
const provider = snapshot.provider || "auto";
|
|
174
|
+
const resolvedProvider = snapshot.resolvedProvider && snapshot.resolvedProvider !== provider ? `→${snapshot.resolvedProvider}` : "";
|
|
175
|
+
const model = snapshot.model ? ` · ${snapshot.model}` : "";
|
|
176
|
+
const effort = snapshot.effort ? ` · ${snapshot.effort}` : "";
|
|
177
|
+
const context = snapshot.context?.selection?.filePath || snapshot.context?.selection?.serviceName || "no focus";
|
|
178
|
+
return `testkit assistant · ${provider}${resolvedProvider}${model}${effort} · ${status} · ${context}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildFooter(snapshot, promptFinished) {
|
|
182
|
+
if (promptFinished && snapshot.messages.length > 0) {
|
|
183
|
+
return "initial prompt complete";
|
|
184
|
+
}
|
|
185
|
+
if (snapshot.busy) {
|
|
186
|
+
return "Enter disabled while the provider is responding · Ctrl+C quit";
|
|
187
|
+
}
|
|
188
|
+
return "Enter send · arrows/Home/End move cursor · Backspace/Delete edit · /settings · q quit";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function rolePrefix(message) {
|
|
192
|
+
if (message.role === "user") return green("you>");
|
|
193
|
+
if (message.role === "assistant") return bold("ai>");
|
|
194
|
+
if (message.role === "tool") return yellow("tool>");
|
|
195
|
+
return red("sys>");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function colorForRole(role) {
|
|
199
|
+
if (role === "user") return green;
|
|
200
|
+
if (role === "tool") return yellow;
|
|
201
|
+
if (role === "system") return red;
|
|
202
|
+
return (value) => value;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isPrintableInput(input, key) {
|
|
206
|
+
if (!input) return false;
|
|
207
|
+
if (key.ctrl || key.meta || key.escape || key.tab) return false;
|
|
208
|
+
return input >= " ";
|
|
209
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const segmenter =
|
|
2
|
+
typeof Intl !== "undefined" && typeof Intl.Segmenter === "function"
|
|
3
|
+
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
4
|
+
: null;
|
|
5
|
+
|
|
6
|
+
export function createComposerState(value = "") {
|
|
7
|
+
const text = String(value || "");
|
|
8
|
+
return {
|
|
9
|
+
text,
|
|
10
|
+
cursor: splitGraphemes(text).length,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function setComposerText(state, value) {
|
|
15
|
+
const text = String(value || "");
|
|
16
|
+
const length = splitGraphemes(text).length;
|
|
17
|
+
return {
|
|
18
|
+
text,
|
|
19
|
+
cursor: clampCursor(state?.cursor ?? length, length),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function insertComposerText(state, value) {
|
|
24
|
+
const insertText = String(value || "");
|
|
25
|
+
if (!insertText) return normalizeComposerState(state);
|
|
26
|
+
const parts = splitGraphemes(state?.text || "");
|
|
27
|
+
const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
|
|
28
|
+
const insertParts = splitGraphemes(insertText);
|
|
29
|
+
const nextParts = [...parts.slice(0, cursor), ...insertParts, ...parts.slice(cursor)];
|
|
30
|
+
return {
|
|
31
|
+
text: nextParts.join(""),
|
|
32
|
+
cursor: cursor + insertParts.length,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function backspaceComposerText(state) {
|
|
37
|
+
const parts = splitGraphemes(state?.text || "");
|
|
38
|
+
const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
|
|
39
|
+
if (cursor === 0) return { text: parts.join(""), cursor };
|
|
40
|
+
const nextParts = [...parts.slice(0, cursor - 1), ...parts.slice(cursor)];
|
|
41
|
+
return {
|
|
42
|
+
text: nextParts.join(""),
|
|
43
|
+
cursor: cursor - 1,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function deleteComposerText(state) {
|
|
48
|
+
const parts = splitGraphemes(state?.text || "");
|
|
49
|
+
const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
|
|
50
|
+
if (cursor >= parts.length) return { text: parts.join(""), cursor };
|
|
51
|
+
const nextParts = [...parts.slice(0, cursor), ...parts.slice(cursor + 1)];
|
|
52
|
+
return {
|
|
53
|
+
text: nextParts.join(""),
|
|
54
|
+
cursor,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function moveComposerCursor(state, delta) {
|
|
59
|
+
const parts = splitGraphemes(state?.text || "");
|
|
60
|
+
return {
|
|
61
|
+
text: parts.join(""),
|
|
62
|
+
cursor: clampCursor((state?.cursor ?? parts.length) + Number(delta || 0), parts.length),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function moveComposerCursorToStart(state) {
|
|
67
|
+
return {
|
|
68
|
+
text: String(state?.text || ""),
|
|
69
|
+
cursor: 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function moveComposerCursorToEnd(state) {
|
|
74
|
+
const text = String(state?.text || "");
|
|
75
|
+
return {
|
|
76
|
+
text,
|
|
77
|
+
cursor: splitGraphemes(text).length,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getComposerRenderParts(state) {
|
|
82
|
+
const parts = splitGraphemes(state?.text || "");
|
|
83
|
+
const cursor = clampCursor(state?.cursor ?? parts.length, parts.length);
|
|
84
|
+
return {
|
|
85
|
+
before: parts.slice(0, cursor).join(""),
|
|
86
|
+
current: parts[cursor] || " ",
|
|
87
|
+
after: parts.slice(cursor + (parts[cursor] ? 1 : 0)).join(""),
|
|
88
|
+
empty: parts.length === 0,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeComposerState(state) {
|
|
93
|
+
const text = String(state?.text || "");
|
|
94
|
+
const parts = splitGraphemes(text);
|
|
95
|
+
return {
|
|
96
|
+
text,
|
|
97
|
+
cursor: clampCursor(state?.cursor ?? parts.length, parts.length),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function splitGraphemes(value) {
|
|
102
|
+
const text = String(value || "");
|
|
103
|
+
if (!text) return [];
|
|
104
|
+
if (segmenter) {
|
|
105
|
+
return [...segmenter.segment(text)].map((entry) => entry.segment);
|
|
106
|
+
}
|
|
107
|
+
return [...text];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function clampCursor(value, length) {
|
|
111
|
+
return Math.max(0, Math.min(Number(value || 0), length));
|
|
112
|
+
}
|
|
@@ -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
|
+
}
|