@mintlify/cli 4.0.1654 → 4.0.1656
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/bin/cli.js +1 -7
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +9 -13
- package/src/cli.tsx +1 -12
- package/__test__/mintTestFilePreview.test.ts +0 -35
- package/__test__/mintTestOutput.test.ts +0 -256
- package/__test__/runHistory.test.ts +0 -240
- package/bin/agent-harness/agentPreflight.js +0 -24
- package/bin/agent-harness/buildTaskPrompt.js +0 -86
- package/bin/agent-harness/countReport.js +0 -10
- package/bin/agent-harness/discoverCodeBlocks.js +0 -101
- package/bin/agent-harness/executeCodeBlocks.js +0 -263
- package/bin/agent-harness/filterSelectedPages.js +0 -10
- package/bin/agent-harness/generateTestCode.js +0 -337
- package/bin/agent-harness/index.js +0 -95
- package/bin/agent-harness/loadAgentSdk.js +0 -46
- package/bin/agent-harness/manifest.js +0 -113
- package/bin/agent-harness/mapWithConcurrency.js +0 -26
- package/bin/agent-harness/runHistory.js +0 -256
- package/bin/agent-harness/runSave.js +0 -47
- package/bin/agent-harness/setupFolders.js +0 -73
- package/bin/agent-harness/tasks/checkTestability.js +0 -228
- package/bin/agent-harness/types.js +0 -66
- package/bin/mintTest.js +0 -122
- package/bin/mintTestFilePreview.js +0 -39
- package/bin/mintTestNav.js +0 -35
- package/bin/mintTestTelemetry.js +0 -63
- package/bin/mintTestText.js +0 -31
- package/bin/mintTestTree.js +0 -94
- package/bin/mintTestUi.js +0 -818
- package/src/agent-harness/MINT_TEST_SYSTEM_DESIGN.md +0 -64
- package/src/agent-harness/agentPreflight.ts +0 -13
- package/src/agent-harness/buildTaskPrompt.ts +0 -93
- package/src/agent-harness/countReport.ts +0 -17
- package/src/agent-harness/discoverCodeBlocks.ts +0 -90
- package/src/agent-harness/executeCodeBlocks.ts +0 -351
- package/src/agent-harness/filterSelectedPages.ts +0 -14
- package/src/agent-harness/generateTestCode.ts +0 -396
- package/src/agent-harness/index.ts +0 -127
- package/src/agent-harness/loadAgentSdk.ts +0 -35
- package/src/agent-harness/manifest.ts +0 -116
- package/src/agent-harness/mapWithConcurrency.ts +0 -21
- package/src/agent-harness/runHistory.ts +0 -255
- package/src/agent-harness/runSave.ts +0 -38
- package/src/agent-harness/setupFolders.ts +0 -87
- package/src/agent-harness/tasks/checkTestability.ts +0 -256
- package/src/agent-harness/types.ts +0 -239
- package/src/mintTest.tsx +0 -138
- package/src/mintTestFilePreview.ts +0 -32
- package/src/mintTestNav.ts +0 -31
- package/src/mintTestTelemetry.ts +0 -101
- package/src/mintTestText.ts +0 -35
- package/src/mintTestTree.ts +0 -134
- package/src/mintTestUi.tsx +0 -1378
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import fs from 'node:fs/promises';
|
|
3
|
-
import type { FileHandle } from 'node:fs/promises';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
-
import { z } from 'zod';
|
|
7
|
-
|
|
8
|
-
import { countReport } from './countReport.js';
|
|
9
|
-
import type { CodeTestReport, TestRunSummary, TestRunsIndex } from './types.js';
|
|
10
|
-
|
|
11
|
-
const RUN_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
12
|
-
const INDEX_LOCK_FILENAME = 'testRuns.lock';
|
|
13
|
-
const LOCK_RETRY_MS = 20;
|
|
14
|
-
const STALE_LOCK_MS = 30_000;
|
|
15
|
-
const LOCK_TIMEOUT_MS = STALE_LOCK_MS + 10_000;
|
|
16
|
-
const commandResultSchema = z
|
|
17
|
-
.object({
|
|
18
|
-
id: z.string(),
|
|
19
|
-
phase: z.enum(['setup', 'test']),
|
|
20
|
-
status: z.enum(['passed', 'failed', 'cancelled']),
|
|
21
|
-
executable: z.string(),
|
|
22
|
-
args: z.array(z.string()),
|
|
23
|
-
cwd: z.string(),
|
|
24
|
-
durationMs: z.number().nonnegative(),
|
|
25
|
-
exitCode: z.number().int().nullable(),
|
|
26
|
-
stdout: z.string(),
|
|
27
|
-
stderr: z.string(),
|
|
28
|
-
error: z.string().optional(),
|
|
29
|
-
})
|
|
30
|
-
.strict();
|
|
31
|
-
const taskResultSchema = z
|
|
32
|
-
.object({
|
|
33
|
-
id: z.string(),
|
|
34
|
-
agent: z.enum(['claude', 'codex']),
|
|
35
|
-
file: z.string(),
|
|
36
|
-
directory: z.string(),
|
|
37
|
-
generatedFiles: z.array(z.string()),
|
|
38
|
-
testable: z.boolean(),
|
|
39
|
-
attempts: z.number().int().nonnegative(),
|
|
40
|
-
tokens: z.number().nonnegative(),
|
|
41
|
-
status: z.enum(['passed', 'failed', 'agent_error', 'cancelled']),
|
|
42
|
-
error: z.string().optional(),
|
|
43
|
-
commands: z.array(commandResultSchema),
|
|
44
|
-
})
|
|
45
|
-
.strict();
|
|
46
|
-
const codeTestReportSchema = z
|
|
47
|
-
.object({
|
|
48
|
-
version: z.literal(1),
|
|
49
|
-
runId: z.string().regex(RUN_ID_PATTERN),
|
|
50
|
-
status: z.enum(['passed', 'completed', 'failed', 'cancelled']),
|
|
51
|
-
docsRoot: z.string(),
|
|
52
|
-
outputDirectory: z.string(),
|
|
53
|
-
reportPath: z.string(),
|
|
54
|
-
agents: z.array(z.enum(['claude', 'codex'])).min(1),
|
|
55
|
-
model: z.string().min(1),
|
|
56
|
-
selectedFiles: z.array(z.string()),
|
|
57
|
-
startedAt: z.string(),
|
|
58
|
-
completedAt: z.string(),
|
|
59
|
-
durationMs: z.number().nonnegative(),
|
|
60
|
-
tasks: z.array(taskResultSchema),
|
|
61
|
-
historyError: z.string().optional(),
|
|
62
|
-
})
|
|
63
|
-
.strict();
|
|
64
|
-
const countsSchema = z
|
|
65
|
-
.object({
|
|
66
|
-
passed: z.number().int().nonnegative(),
|
|
67
|
-
failed: z.number().int().nonnegative(),
|
|
68
|
-
cancelled: z.number().int().nonnegative(),
|
|
69
|
-
agentErrors: z.number().int().nonnegative(),
|
|
70
|
-
})
|
|
71
|
-
.strict();
|
|
72
|
-
const testRunSummarySchema = z
|
|
73
|
-
.object({
|
|
74
|
-
runId: z.string().regex(RUN_ID_PATTERN),
|
|
75
|
-
status: z.enum(['passed', 'completed', 'failed', 'cancelled']),
|
|
76
|
-
agents: z.array(z.enum(['claude', 'codex'])).min(1),
|
|
77
|
-
model: z.string().min(1),
|
|
78
|
-
selectedFiles: z.array(z.string()),
|
|
79
|
-
startedAt: z.string(),
|
|
80
|
-
completedAt: z.string(),
|
|
81
|
-
durationMs: z.number().nonnegative(),
|
|
82
|
-
reportPath: z.string(),
|
|
83
|
-
outputDirectory: z.string(),
|
|
84
|
-
counts: countsSchema,
|
|
85
|
-
})
|
|
86
|
-
.strict();
|
|
87
|
-
const testRunsIndexSchema = z
|
|
88
|
-
.object({ version: z.literal(1), runs: z.array(testRunSummarySchema) })
|
|
89
|
-
.strict();
|
|
90
|
-
|
|
91
|
-
function historyDirectory(docsRoot: string): string {
|
|
92
|
-
return path.join(docsRoot, '.mintlify', 'test');
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function runsDirectory(docsRoot: string): string {
|
|
96
|
-
return path.join(historyDirectory(docsRoot), 'runs');
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function testRunReportPath(docsRoot: string, runId: string): string {
|
|
100
|
-
if (!RUN_ID_PATTERN.test(runId)) throw new Error(`Invalid mint test run ID: ${runId}`);
|
|
101
|
-
return path.join(runsDirectory(docsRoot), `${runId}.json`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function testRunsIndexPath(docsRoot: string): string {
|
|
105
|
-
return path.join(historyDirectory(docsRoot), 'testRuns.json');
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function summarizeReport(report: CodeTestReport): TestRunSummary {
|
|
109
|
-
return {
|
|
110
|
-
runId: report.runId,
|
|
111
|
-
status: report.status,
|
|
112
|
-
agents: report.agents,
|
|
113
|
-
model: report.model,
|
|
114
|
-
selectedFiles: report.selectedFiles,
|
|
115
|
-
startedAt: report.startedAt,
|
|
116
|
-
completedAt: report.completedAt,
|
|
117
|
-
durationMs: report.durationMs,
|
|
118
|
-
reportPath: report.reportPath,
|
|
119
|
-
outputDirectory: report.outputDirectory,
|
|
120
|
-
counts: countReport(report.tasks),
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function errorCode(error: unknown): string | undefined {
|
|
125
|
-
return typeof error === 'object' && error !== null && 'code' in error
|
|
126
|
-
? String(error.code)
|
|
127
|
-
: undefined;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async function writeJsonAtomically(filePath: string, value: unknown): Promise<void> {
|
|
131
|
-
const temporaryPath = path.join(
|
|
132
|
-
path.dirname(filePath),
|
|
133
|
-
`.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
|
|
134
|
-
);
|
|
135
|
-
try {
|
|
136
|
-
await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
|
|
137
|
-
await fs.rename(temporaryPath, filePath);
|
|
138
|
-
} finally {
|
|
139
|
-
await fs.unlink(temporaryPath).catch(() => undefined);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function acquireIndexLock(docsRoot: string): Promise<() => Promise<void>> {
|
|
144
|
-
const lockPath = path.join(historyDirectory(docsRoot), INDEX_LOCK_FILENAME);
|
|
145
|
-
const token = `${process.pid}:${randomUUID()}`;
|
|
146
|
-
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
147
|
-
|
|
148
|
-
while (Date.now() < deadline) {
|
|
149
|
-
let lock: FileHandle;
|
|
150
|
-
try {
|
|
151
|
-
lock = await fs.open(lockPath, 'wx');
|
|
152
|
-
} catch (error) {
|
|
153
|
-
const code = errorCode(error);
|
|
154
|
-
// Windows can deny opening a lock while another writer's deletion is still pending.
|
|
155
|
-
if (code !== 'EEXIST' && !(process.platform === 'win32' && code === 'EPERM')) throw error;
|
|
156
|
-
|
|
157
|
-
const stats = await fs.stat(lockPath).catch(() => undefined);
|
|
158
|
-
if (stats && Date.now() - stats.mtimeMs > STALE_LOCK_MS) {
|
|
159
|
-
await fs.unlink(lockPath).catch(() => undefined);
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
await delay(LOCK_RETRY_MS);
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
try {
|
|
166
|
-
await lock.writeFile(token);
|
|
167
|
-
} finally {
|
|
168
|
-
await lock.close();
|
|
169
|
-
}
|
|
170
|
-
return async () => {
|
|
171
|
-
const owner = await fs.readFile(lockPath, 'utf8').catch(() => undefined);
|
|
172
|
-
if (owner === token) await fs.unlink(lockPath).catch(() => undefined);
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
throw new Error('Timed out updating mint test run history');
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async function readReport(filePath: string): Promise<CodeTestReport | null> {
|
|
179
|
-
try {
|
|
180
|
-
return codeTestReportSchema.parse(JSON.parse(await fs.readFile(filePath, 'utf8')));
|
|
181
|
-
} catch {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
async function rebuildIndex(docsRoot: string): Promise<TestRunsIndex> {
|
|
187
|
-
const entries = await fs
|
|
188
|
-
.readdir(runsDirectory(docsRoot), { withFileTypes: true })
|
|
189
|
-
.catch(() => []);
|
|
190
|
-
const reports = await Promise.all(
|
|
191
|
-
entries
|
|
192
|
-
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
193
|
-
.map((entry) => readReport(path.join(runsDirectory(docsRoot), entry.name)))
|
|
194
|
-
);
|
|
195
|
-
return {
|
|
196
|
-
version: 1,
|
|
197
|
-
runs: reports
|
|
198
|
-
.filter((report): report is CodeTestReport => report !== null)
|
|
199
|
-
.map(summarizeReport)
|
|
200
|
-
.sort((left, right) => right.completedAt.localeCompare(left.completedAt)),
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export async function loadTestRuns(docsRoot: string): Promise<TestRunsIndex> {
|
|
205
|
-
try {
|
|
206
|
-
return testRunsIndexSchema.parse(
|
|
207
|
-
JSON.parse(await fs.readFile(testRunsIndexPath(docsRoot), 'utf8'))
|
|
208
|
-
);
|
|
209
|
-
} catch {
|
|
210
|
-
return rebuildIndex(docsRoot);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export async function loadLatestTestRun(docsRoot: string): Promise<CodeTestReport | null> {
|
|
215
|
-
const index = await loadTestRuns(docsRoot);
|
|
216
|
-
for (const run of index.runs) {
|
|
217
|
-
const report = await readReport(testRunReportPath(docsRoot, run.runId));
|
|
218
|
-
if (report) return report;
|
|
219
|
-
}
|
|
220
|
-
return null;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export async function writeTestRunHistory(docsRoot: string, report: CodeTestReport): Promise<void> {
|
|
224
|
-
await fs.mkdir(runsDirectory(docsRoot), { recursive: true });
|
|
225
|
-
await writeJsonAtomically(report.reportPath, report);
|
|
226
|
-
const releaseLock = await acquireIndexLock(docsRoot);
|
|
227
|
-
try {
|
|
228
|
-
await writeJsonAtomically(testRunsIndexPath(docsRoot), await rebuildIndex(docsRoot));
|
|
229
|
-
} finally {
|
|
230
|
-
await releaseLock();
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Saves a finished run to history without letting a history failure fail the run.
|
|
236
|
-
* On failure the returned report points at `fallbackReportPath` (the report that was
|
|
237
|
-
* already written) and carries the problem in `historyError`.
|
|
238
|
-
*/
|
|
239
|
-
export async function recordTestRun(
|
|
240
|
-
docsRoot: string,
|
|
241
|
-
report: CodeTestReport,
|
|
242
|
-
fallbackReportPath: string
|
|
243
|
-
): Promise<CodeTestReport> {
|
|
244
|
-
try {
|
|
245
|
-
await writeTestRunHistory(docsRoot, report);
|
|
246
|
-
return report;
|
|
247
|
-
} catch (error) {
|
|
248
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
249
|
-
return {
|
|
250
|
-
...report,
|
|
251
|
-
reportPath: fallbackReportPath,
|
|
252
|
-
historyError: `run history could not be saved: ${message}`,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { z } from 'zod';
|
|
4
|
-
|
|
5
|
-
import type { RunSave } from './types.js';
|
|
6
|
-
|
|
7
|
-
const runSaveSchema = z
|
|
8
|
-
.object({
|
|
9
|
-
version: z.literal(1),
|
|
10
|
-
runId: z.string().regex(/^[A-Za-z0-9._-]+$/),
|
|
11
|
-
agents: z.array(z.enum(['claude', 'codex'])).min(1),
|
|
12
|
-
model: z.string().min(1),
|
|
13
|
-
selectedFiles: z.array(z.string().min(1)),
|
|
14
|
-
createdAt: z.string(),
|
|
15
|
-
})
|
|
16
|
-
.strict();
|
|
17
|
-
|
|
18
|
-
function savePath(outputDirectory: string): string {
|
|
19
|
-
return path.join(outputDirectory, 'save.json');
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export async function loadRunSave(outputDirectory: string): Promise<RunSave | null> {
|
|
23
|
-
try {
|
|
24
|
-
const content = await fs.readFile(savePath(outputDirectory), 'utf8');
|
|
25
|
-
return runSaveSchema.parse(JSON.parse(content));
|
|
26
|
-
} catch {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function writeRunSave(outputDirectory: string, save: RunSave): Promise<void> {
|
|
32
|
-
await fs.mkdir(outputDirectory, { recursive: true });
|
|
33
|
-
await fs.writeFile(savePath(outputDirectory), `${JSON.stringify(save, null, 2)}\n`);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function clearRunSave(outputDirectory: string): Promise<void> {
|
|
37
|
-
await fs.unlink(savePath(outputDirectory)).catch(() => undefined);
|
|
38
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import fs from 'node:fs/promises';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
import type { DocsPage, GeneratedTask, HarnessAgent } from './types.js';
|
|
6
|
-
|
|
7
|
-
const PACKAGE_JSON = `${JSON.stringify(
|
|
8
|
-
{
|
|
9
|
-
name: 'mint-test-task',
|
|
10
|
-
private: true,
|
|
11
|
-
type: 'module',
|
|
12
|
-
// No positional path: Node 21+ treats --test arguments as globs, so a bare
|
|
13
|
-
// directory such as `tests/` fails with MODULE_NOT_FOUND. The default patterns
|
|
14
|
-
// pick up tests/*.test.js on every supported Node version.
|
|
15
|
-
scripts: { test: 'node --test' },
|
|
16
|
-
},
|
|
17
|
-
null,
|
|
18
|
-
2
|
|
19
|
-
)}\n`;
|
|
20
|
-
|
|
21
|
-
function isExistingFile(error: unknown): boolean {
|
|
22
|
-
return (
|
|
23
|
-
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'EEXIST'
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function scaffoldTaskDirectory(directory: string): Promise<void> {
|
|
28
|
-
await fs.mkdir(path.join(directory, 'tests'), { recursive: true });
|
|
29
|
-
try {
|
|
30
|
-
await fs.writeFile(path.join(directory, 'package.json'), PACKAGE_JSON, { flag: 'wx' });
|
|
31
|
-
} catch (error) {
|
|
32
|
-
if (!isExistingFile(error)) throw error;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function scaffoldTaskDirectories(tasks: GeneratedTask[]): Promise<void> {
|
|
37
|
-
for (const task of tasks) await scaffoldTaskDirectory(task.directory);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export async function setupFolders({
|
|
41
|
-
pages,
|
|
42
|
-
agents,
|
|
43
|
-
docsRoot,
|
|
44
|
-
outputDirectory,
|
|
45
|
-
runId: savedRunId,
|
|
46
|
-
}: {
|
|
47
|
-
pages: DocsPage[];
|
|
48
|
-
agents: HarnessAgent[];
|
|
49
|
-
docsRoot: string;
|
|
50
|
-
outputDirectory: string;
|
|
51
|
-
runId: string | null;
|
|
52
|
-
}): Promise<{ runId: string; runDirectory: string; reportPath: string; tasks: GeneratedTask[] }> {
|
|
53
|
-
const runId =
|
|
54
|
-
savedRunId ??
|
|
55
|
-
`${new Date()
|
|
56
|
-
.toISOString()
|
|
57
|
-
.replace(/[-:TZ.]/g, '')
|
|
58
|
-
.slice(0, 14)}-${randomBytes(4).toString('hex')}`;
|
|
59
|
-
const runDirectory = path.join(path.resolve(outputDirectory), runId);
|
|
60
|
-
const reportPath = path.join(runDirectory, 'report.json');
|
|
61
|
-
await fs.mkdir(runDirectory, { recursive: true });
|
|
62
|
-
|
|
63
|
-
const tasks: GeneratedTask[] = [];
|
|
64
|
-
for (const agent of agents) {
|
|
65
|
-
for (const [pageIndex, page] of pages.entries()) {
|
|
66
|
-
const slug = page.file
|
|
67
|
-
.replace(/\.[^.]+$/, '')
|
|
68
|
-
.replace(/[^a-z0-9]+/gi, '-')
|
|
69
|
-
.replace(/^-|-$/g, '')
|
|
70
|
-
.slice(0, 56);
|
|
71
|
-
tasks.push({
|
|
72
|
-
id: `${agent}:${page.file}`,
|
|
73
|
-
agent,
|
|
74
|
-
docsRoot,
|
|
75
|
-
file: page.file,
|
|
76
|
-
directory: path.join(
|
|
77
|
-
runDirectory,
|
|
78
|
-
agent,
|
|
79
|
-
`${String(pageIndex + 1).padStart(3, '0')}-${slug || 'page'}`
|
|
80
|
-
),
|
|
81
|
-
attempts: 0,
|
|
82
|
-
tokens: 0,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return { runId, runDirectory, reportPath, tasks };
|
|
87
|
-
}
|
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import z from 'zod';
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
buildCheckPrompt,
|
|
7
|
-
CHECK_JSON_INSTRUCTION,
|
|
8
|
-
CHECK_SYSTEM_PROMPT,
|
|
9
|
-
CHECK_TOOL_INSTRUCTION,
|
|
10
|
-
} from '../buildTaskPrompt.js';
|
|
11
|
-
import { loadClaudeAgentSdk, loadCodexSdk } from '../loadAgentSdk.js';
|
|
12
|
-
import {
|
|
13
|
-
GeneratedTask,
|
|
14
|
-
HarnessAgent,
|
|
15
|
-
RunCodeTestsOptions,
|
|
16
|
-
TaskUpdate,
|
|
17
|
-
TestCheck,
|
|
18
|
-
testCheckSchema,
|
|
19
|
-
} from '../types.js';
|
|
20
|
-
|
|
21
|
-
const CHECK_FILENAME = 'mint-test-check.json';
|
|
22
|
-
|
|
23
|
-
const CHECK_MODEL = 'claude-haiku-4-5-20251001';
|
|
24
|
-
|
|
25
|
-
async function readValidCheck(directory: string): Promise<TestCheck | null> {
|
|
26
|
-
try {
|
|
27
|
-
const check = testCheckSchema.parse(
|
|
28
|
-
JSON.parse(await fs.readFile(path.join(directory, CHECK_FILENAME), 'utf8'))
|
|
29
|
-
);
|
|
30
|
-
return validateCheck(check).length === 0 ? check : null;
|
|
31
|
-
} catch {
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function validateCheck(check: TestCheck): string[] {
|
|
37
|
-
return !check.testable && !check.reason ? ['Not-testable page needs a reason'] : [];
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function parseJsonReply(reply: string): unknown {
|
|
41
|
-
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(reply);
|
|
42
|
-
const raw = fenced?.[1] ?? reply;
|
|
43
|
-
const start = raw.indexOf('{');
|
|
44
|
-
const end = raw.lastIndexOf('}');
|
|
45
|
-
if (start === -1 || end === -1) throw new Error('Reply contained no JSON object');
|
|
46
|
-
return JSON.parse(raw.slice(start, end + 1));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function runTestabilityCheck({
|
|
50
|
-
file,
|
|
51
|
-
content,
|
|
52
|
-
agent,
|
|
53
|
-
workingDirectory,
|
|
54
|
-
signal,
|
|
55
|
-
}: {
|
|
56
|
-
file: string;
|
|
57
|
-
content: string;
|
|
58
|
-
agent: HarnessAgent;
|
|
59
|
-
workingDirectory: string;
|
|
60
|
-
signal: AbortSignal;
|
|
61
|
-
}): Promise<{ tokens: number; check: TestCheck }> {
|
|
62
|
-
const prompt = buildCheckPrompt(file, content);
|
|
63
|
-
let tokens = 0;
|
|
64
|
-
let raw: unknown;
|
|
65
|
-
if (agent === 'claude') {
|
|
66
|
-
const reply = await promptClaudeCheck({ prompt, workingDirectory, signal });
|
|
67
|
-
tokens = reply.tokens;
|
|
68
|
-
if (reply.captured === null) throw new Error('agent never called report_testability');
|
|
69
|
-
raw = reply.captured;
|
|
70
|
-
} else {
|
|
71
|
-
const reply = await promptCodex({
|
|
72
|
-
prompt,
|
|
73
|
-
systemPrompt: CHECK_SYSTEM_PROMPT + CHECK_JSON_INSTRUCTION,
|
|
74
|
-
workingDirectory,
|
|
75
|
-
signal,
|
|
76
|
-
});
|
|
77
|
-
tokens = reply.tokens;
|
|
78
|
-
raw = parseJsonReply(reply.result);
|
|
79
|
-
}
|
|
80
|
-
const check = testCheckSchema.parse(raw);
|
|
81
|
-
const errors = validateCheck(check);
|
|
82
|
-
if (errors.length > 0) throw new Error(errors.join('\n'));
|
|
83
|
-
return { tokens, check };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export const checkFileTestability = async (
|
|
87
|
-
task: GeneratedTask,
|
|
88
|
-
{
|
|
89
|
-
onTaskUpdate,
|
|
90
|
-
signal,
|
|
91
|
-
runDirectory,
|
|
92
|
-
}: Pick<RunCodeTestsOptions, 'onTaskUpdate' | 'signal'> & { runDirectory: string }
|
|
93
|
-
): Promise<GeneratedTask> => {
|
|
94
|
-
const emit = (phase: TaskUpdate['phase'], tokens: number) =>
|
|
95
|
-
onTaskUpdate({
|
|
96
|
-
id: task.id,
|
|
97
|
-
agent: task.agent,
|
|
98
|
-
file: task.file,
|
|
99
|
-
directory: task.directory,
|
|
100
|
-
generatedFiles: [],
|
|
101
|
-
phase,
|
|
102
|
-
tokens,
|
|
103
|
-
});
|
|
104
|
-
let attempts = 0;
|
|
105
|
-
let tokens = 0;
|
|
106
|
-
let lastError = 'run cancelled';
|
|
107
|
-
|
|
108
|
-
let check = await readValidCheck(task.directory);
|
|
109
|
-
if (!check) {
|
|
110
|
-
emit('checking', tokens);
|
|
111
|
-
const content = await fs.readFile(path.join(task.docsRoot, task.file), 'utf8');
|
|
112
|
-
for (let attempt = 1; attempt <= 2 && !signal.aborted && !check; attempt++) {
|
|
113
|
-
attempts = attempt;
|
|
114
|
-
try {
|
|
115
|
-
const reply = await runTestabilityCheck({
|
|
116
|
-
file: task.file,
|
|
117
|
-
content,
|
|
118
|
-
agent: task.agent,
|
|
119
|
-
workingDirectory: runDirectory,
|
|
120
|
-
signal,
|
|
121
|
-
});
|
|
122
|
-
tokens += reply.tokens;
|
|
123
|
-
check = reply.check;
|
|
124
|
-
} catch (error) {
|
|
125
|
-
lastError = error instanceof Error ? error.message : String(error);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
if (!check) {
|
|
129
|
-
if (signal.aborted) {
|
|
130
|
-
emit('cancelled', tokens);
|
|
131
|
-
return { ...task, attempts, tokens, error: 'run cancelled' };
|
|
132
|
-
}
|
|
133
|
-
emit('agent_error', tokens);
|
|
134
|
-
return { ...task, attempts, tokens, error: lastError };
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (!check.testable) {
|
|
139
|
-
emit('not_testable', tokens);
|
|
140
|
-
return { ...task, attempts, tokens, testable: false };
|
|
141
|
-
}
|
|
142
|
-
await fs.mkdir(task.directory, { recursive: true });
|
|
143
|
-
await fs.writeFile(
|
|
144
|
-
path.join(task.directory, CHECK_FILENAME),
|
|
145
|
-
`${JSON.stringify(check, null, 2)}\n`
|
|
146
|
-
);
|
|
147
|
-
emit('checked', tokens);
|
|
148
|
-
return { ...task, attempts, tokens, testable: true };
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
async function promptClaudeCheck({
|
|
152
|
-
prompt,
|
|
153
|
-
workingDirectory,
|
|
154
|
-
signal,
|
|
155
|
-
}: {
|
|
156
|
-
prompt: string;
|
|
157
|
-
workingDirectory: string;
|
|
158
|
-
signal: AbortSignal;
|
|
159
|
-
}): Promise<{ tokens: number; captured: unknown }> {
|
|
160
|
-
const { query, tool, createSdkMcpServer } = await loadClaudeAgentSdk();
|
|
161
|
-
const abortController = new AbortController();
|
|
162
|
-
const abort = () => abortController.abort(signal.reason);
|
|
163
|
-
if (signal.aborted) abort();
|
|
164
|
-
else signal.addEventListener('abort', abort, { once: true });
|
|
165
|
-
|
|
166
|
-
let captured: unknown = null;
|
|
167
|
-
const server = createSdkMcpServer({
|
|
168
|
-
name: 'mint-test',
|
|
169
|
-
tools: [
|
|
170
|
-
tool(
|
|
171
|
-
'report_testability',
|
|
172
|
-
'Report whether the documentation page is testable. Call exactly once.',
|
|
173
|
-
{
|
|
174
|
-
version: z.literal(1),
|
|
175
|
-
testable: z.boolean(),
|
|
176
|
-
reason: z.string().optional(),
|
|
177
|
-
},
|
|
178
|
-
async (input) => {
|
|
179
|
-
captured = input;
|
|
180
|
-
return { content: [{ type: 'text', text: 'recorded' }] };
|
|
181
|
-
}
|
|
182
|
-
),
|
|
183
|
-
],
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
const session = query({
|
|
187
|
-
prompt,
|
|
188
|
-
options: {
|
|
189
|
-
abortController,
|
|
190
|
-
allowedTools: ['mcp__mint-test__report_testability'],
|
|
191
|
-
cwd: workingDirectory,
|
|
192
|
-
mcpServers: { 'mint-test': server },
|
|
193
|
-
model: CHECK_MODEL,
|
|
194
|
-
permissionMode: 'dontAsk',
|
|
195
|
-
persistSession: false,
|
|
196
|
-
settingSources: [],
|
|
197
|
-
strictMcpConfig: true,
|
|
198
|
-
systemPrompt: CHECK_SYSTEM_PROMPT + CHECK_TOOL_INSTRUCTION,
|
|
199
|
-
tools: [],
|
|
200
|
-
},
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
for await (const message of session) {
|
|
205
|
-
if (message.type !== 'result') continue;
|
|
206
|
-
if (message.subtype !== 'success' || message.is_error) {
|
|
207
|
-
const details = message.subtype === 'success' ? message.result : message.errors.join('; ');
|
|
208
|
-
throw new Error(details || `Claude stopped with ${message.subtype}`);
|
|
209
|
-
}
|
|
210
|
-
const usage = message.usage;
|
|
211
|
-
return {
|
|
212
|
-
tokens:
|
|
213
|
-
usage.input_tokens +
|
|
214
|
-
usage.output_tokens +
|
|
215
|
-
usage.cache_creation_input_tokens +
|
|
216
|
-
usage.cache_read_input_tokens,
|
|
217
|
-
captured,
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
throw new Error('Claude finished without returning a result');
|
|
221
|
-
} finally {
|
|
222
|
-
signal.removeEventListener('abort', abort);
|
|
223
|
-
session.close();
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async function promptCodex({
|
|
228
|
-
prompt,
|
|
229
|
-
systemPrompt,
|
|
230
|
-
workingDirectory,
|
|
231
|
-
signal,
|
|
232
|
-
}: {
|
|
233
|
-
prompt: string;
|
|
234
|
-
systemPrompt: string;
|
|
235
|
-
workingDirectory: string;
|
|
236
|
-
signal: AbortSignal;
|
|
237
|
-
}): Promise<{ tokens: number; result: string }> {
|
|
238
|
-
const { Codex } = await loadCodexSdk();
|
|
239
|
-
const thread = new Codex().startThread({
|
|
240
|
-
approvalPolicy: 'never',
|
|
241
|
-
networkAccessEnabled: true,
|
|
242
|
-
sandboxMode: 'workspace-write',
|
|
243
|
-
skipGitRepoCheck: true,
|
|
244
|
-
workingDirectory,
|
|
245
|
-
});
|
|
246
|
-
const result = await thread.run(`${systemPrompt}\n\n${prompt}`, { signal });
|
|
247
|
-
const usage = result.usage;
|
|
248
|
-
const tokens = usage
|
|
249
|
-
? usage.input_tokens +
|
|
250
|
-
usage.cached_input_tokens +
|
|
251
|
-
usage.cache_write_input_tokens +
|
|
252
|
-
usage.output_tokens +
|
|
253
|
-
usage.reasoning_output_tokens
|
|
254
|
-
: 0;
|
|
255
|
-
return { tokens, result: result.finalResponse };
|
|
256
|
-
}
|