@elench/testkit 0.1.82 → 0.1.84
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 +37 -7
- package/lib/cli/agents/index.mjs +64 -0
- package/lib/cli/agents/investigate.mjs +75 -0
- package/lib/cli/agents/investigation-context.mjs +102 -0
- package/lib/cli/agents/prompt-builder.mjs +25 -0
- package/lib/cli/agents/providers/claude.mjs +74 -0
- package/lib/cli/agents/providers/codex.mjs +83 -0
- package/lib/cli/agents/providers/shared.mjs +134 -0
- package/lib/cli/command-helpers.mjs +53 -25
- package/lib/cli/commands/investigate.mjs +87 -0
- package/lib/cli/entrypoint.mjs +3 -0
- package/lib/cli/presentation/colors.mjs +12 -0
- package/lib/cli/presentation/events-reporter.mjs +135 -0
- package/lib/cli/presentation/summary-box.mjs +11 -11
- package/lib/cli/presentation/tree-reporter.mjs +159 -0
- package/lib/cli/tui/run-app.mjs +1 -0
- package/lib/cli/tui/run-session-app.mjs +370 -0
- package/lib/cli/tui/run-session-state.mjs +481 -0
- package/lib/cli/tui/run-tree-state.mjs +1 -0
- package/lib/config-api/auth-fixtures.mjs +15 -10
- package/lib/discovery/index.mjs +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/runner/orchestrator.mjs +1 -0
- package/lib/runtime/index.d.ts +138 -5
- package/lib/runtime/index.mjs +68 -2
- package/lib/runtime-src/k6/http-assertions.js +31 -1
- package/lib/runtime-src/k6/http-checks.js +120 -0
- package/lib/runtime-src/k6/http-suite-runtime.js +5 -1
- package/lib/runtime-src/k6/http.js +213 -23
- package/lib/runtime-src/shared/error-body.mjs +42 -0
- package/lib/runtime-src/shared/http-parsing.mjs +68 -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 +7 -6
- package/lib/app/configs.test.mjs +0 -34
- package/lib/app/typecheck.test.mjs +0 -24
- package/lib/bundler/index.test.mjs +0 -164
- package/lib/cli/args.test.mjs +0 -110
- package/lib/cli/presentation/code-frames.test.mjs +0 -71
- package/lib/cli/presentation/run-reporter.test.mjs +0 -192
- package/lib/cli/presentation/summary-box.test.mjs +0 -43
- package/lib/cli/presentation/terminal-layout.test.mjs +0 -23
- package/lib/config/database.test.mjs +0 -29
- package/lib/config/discovery.test.mjs +0 -276
- package/lib/config/env.test.mjs +0 -40
- package/lib/config/index.test.mjs +0 -44
- package/lib/config/paths.test.mjs +0 -27
- package/lib/config/runtime.test.mjs +0 -82
- package/lib/config/skip-config.test.mjs +0 -63
- package/lib/config-api/index.test.mjs +0 -344
- package/lib/config-api/next-runtime-tsconfig.test.mjs +0 -58
- package/lib/coverage/backend-discovery.test.mjs +0 -61
- package/lib/coverage/evidence.test.mjs +0 -87
- package/lib/coverage/index.test.mjs +0 -715
- package/lib/coverage/routing.test.mjs +0 -36
- package/lib/coverage/shared.test.mjs +0 -72
- package/lib/database/fingerprint.test.mjs +0 -99
- package/lib/database/index.test.mjs +0 -95
- package/lib/database/naming.test.mjs +0 -39
- package/lib/database/state.test.mjs +0 -66
- package/lib/database/template-steps.test.mjs +0 -43
- package/lib/discovery/file-metadata.test.mjs +0 -51
- package/lib/discovery/index.test.mjs +0 -182
- package/lib/discovery/path-policy.test.mjs +0 -65
- package/lib/drizzle/index.test.mjs +0 -33
- package/lib/env/index.test.mjs +0 -82
- package/lib/history/index.test.mjs +0 -115
- package/lib/package.test.mjs +0 -59
- package/lib/playwright/index.test.mjs +0 -43
- package/lib/regressions/github.test.mjs +0 -324
- package/lib/regressions/index.test.mjs +0 -187
- package/lib/reporters/playwright.test.mjs +0 -167
- package/lib/runner/default-runtime-errors.test.mjs +0 -49
- package/lib/runner/execution-config.test.mjs +0 -67
- package/lib/runner/failure-details.test.mjs +0 -114
- package/lib/runner/formatting.test.mjs +0 -205
- package/lib/runner/metadata.test.mjs +0 -52
- package/lib/runner/planning.test.mjs +0 -371
- package/lib/runner/playwright-config.test.mjs +0 -78
- package/lib/runner/processes.test.mjs +0 -21
- package/lib/runner/regressions.test.mjs +0 -168
- package/lib/runner/reporting.test.mjs +0 -310
- package/lib/runner/results.test.mjs +0 -376
- package/lib/runner/runtime-manager.test.mjs +0 -252
- package/lib/runner/runtime-preparation.test.mjs +0 -141
- package/lib/runner/selection.test.mjs +0 -24
- package/lib/runner/setup-operations.test.mjs +0 -94
- package/lib/runner/state.test.mjs +0 -62
- package/lib/runner/suite-selection.test.mjs +0 -49
- package/lib/runner/template.test.mjs +0 -272
- package/lib/shared/build-config.test.mjs +0 -132
- package/lib/shared/configured-steps.test.mjs +0 -102
- package/lib/shared/execution-schema.test.mjs +0 -26
- package/lib/shared/file-timeout.test.mjs +0 -64
- package/lib/shared/test-context.test.mjs +0 -43
- package/lib/timing/index.test.mjs +0 -64
- package/lib/toolchains/index.test.mjs +0 -168
- package/lib/vitest/index.test.mjs +0 -20
package/README.md
CHANGED
|
@@ -365,9 +365,11 @@ HTTP suites:
|
|
|
365
365
|
|
|
366
366
|
```ts
|
|
367
367
|
import { defineHttpSuite } from "@elench/testkit";
|
|
368
|
+
import { expect } from "@elench/testkit/runtime";
|
|
368
369
|
|
|
369
370
|
const suite = defineHttpSuite(({ rawReq }) => {
|
|
370
|
-
rawReq("
|
|
371
|
+
const response = rawReq.get("/health");
|
|
372
|
+
expect.status(response, 200, "health returns 200");
|
|
371
373
|
});
|
|
372
374
|
|
|
373
375
|
export default suite;
|
|
@@ -410,9 +412,11 @@ export default defineConfig({
|
|
|
410
412
|
},
|
|
411
413
|
});
|
|
412
414
|
|
|
413
|
-
const suite = defineHttpSuite({ profile: "defaultAuth" }, ({ actor, req }) => {
|
|
415
|
+
const suite = defineHttpSuite({ profile: "defaultAuth" }, ({ actor, actors, req }) => {
|
|
414
416
|
req.get("/api/auth/session");
|
|
415
417
|
actor?.req.get("/api/auth/session");
|
|
418
|
+
req.as("outsider").get("/api/auth/session");
|
|
419
|
+
actors.get("reviewer").rawReq.get("/api/auth/session");
|
|
416
420
|
});
|
|
417
421
|
```
|
|
418
422
|
|
|
@@ -439,7 +443,7 @@ const suite = defineScenarioSuite(({ rawReq, scenario }) => {
|
|
|
439
443
|
includeHealthCheck: scenario.maybe("includeHealthCheck", 1),
|
|
440
444
|
});
|
|
441
445
|
|
|
442
|
-
const selected = scenario.resource("selected-endpoint", () => rawReq(
|
|
446
|
+
const selected = scenario.resource("selected-endpoint", () => rawReq.get(plan.endpoint));
|
|
443
447
|
|
|
444
448
|
scenario.step("fetch selected endpoint", () => {
|
|
445
449
|
selected.get();
|
|
@@ -449,21 +453,47 @@ const suite = defineScenarioSuite(({ rawReq, scenario }) => {
|
|
|
449
453
|
export default suite;
|
|
450
454
|
```
|
|
451
455
|
|
|
452
|
-
|
|
456
|
+
First-class runtime namespaces:
|
|
453
457
|
|
|
454
458
|
```ts
|
|
455
|
-
import {
|
|
459
|
+
import { checks, expect, parse, waitFor } from "@elench/testkit/runtime";
|
|
460
|
+
|
|
461
|
+
const suite = defineHttpSuite(({ actor, rawReq, req }) => {
|
|
462
|
+
const me = req.get("/api/v1/me");
|
|
463
|
+
expect.status(me, 200, "authenticated session loads");
|
|
464
|
+
expect.json(me, (body) => typeof body.data?.id === "string", "response includes user id");
|
|
465
|
+
|
|
466
|
+
checks.authGate(rawReq, "sessions", {
|
|
467
|
+
get: ["/api/v1/sessions"],
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
actor?.rawHeaders();
|
|
471
|
+
|
|
472
|
+
const upload = req.multipart.post("/api/v1/uploads", {
|
|
473
|
+
fields: { name: "fixture" },
|
|
474
|
+
files: [{ field: "file", data: "hello", filename: "fixture.txt", contentType: "text/plain" }],
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
expect.status(upload, 201, "upload is accepted");
|
|
478
|
+
parse.safeJson(upload);
|
|
479
|
+
});
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Low-level runtime primitives still exist when you genuinely need them:
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
import { check, group, http } from "@elench/testkit/runtime";
|
|
456
486
|
```
|
|
457
487
|
|
|
458
488
|
`waitFor()` consumes the file budget configured by `execution.fileTimeoutSeconds`.
|
|
459
489
|
Consumers should not set local timeout values in test files.
|
|
460
490
|
|
|
461
491
|
```ts
|
|
462
|
-
import { waitFor } from "@elench/testkit/runtime";
|
|
492
|
+
import { parse, waitFor } from "@elench/testkit/runtime";
|
|
463
493
|
|
|
464
494
|
const response = waitFor(
|
|
465
495
|
() => req.get("/api/v1/jobs/123"),
|
|
466
|
-
(res) =>
|
|
496
|
+
(res) => parse.json(res).data?.status === "completed",
|
|
467
497
|
{ description: "job 123 to complete" }
|
|
468
498
|
);
|
|
469
499
|
```
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { startClaudeHostedSession } from "./providers/claude.mjs";
|
|
5
|
+
import { startCodexHostedSession } from "./providers/codex.mjs";
|
|
6
|
+
|
|
7
|
+
const PROVIDERS = ["codex", "claude"];
|
|
8
|
+
|
|
9
|
+
export function resolvePreferredProvider(preferred = null, env = process.env) {
|
|
10
|
+
if (preferred && preferred !== "auto") {
|
|
11
|
+
if (!isProviderInstalled(preferred, env)) {
|
|
12
|
+
throw new Error(`${preferred} is not installed or not available on PATH`);
|
|
13
|
+
}
|
|
14
|
+
return preferred;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (const provider of PROVIDERS) {
|
|
18
|
+
if (isProviderInstalled(provider, env)) return provider;
|
|
19
|
+
}
|
|
20
|
+
throw new Error("Neither codex nor claude was found on PATH");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isProviderInstalled(provider, env = process.env) {
|
|
24
|
+
const pathValue = env.PATH || "";
|
|
25
|
+
const candidates = pathValue.split(path.delimiter).filter(Boolean);
|
|
26
|
+
const fileNames =
|
|
27
|
+
process.platform === "win32"
|
|
28
|
+
? [`${provider}.exe`, `${provider}.cmd`, `${provider}.bat`, provider]
|
|
29
|
+
: [provider];
|
|
30
|
+
for (const directory of candidates) {
|
|
31
|
+
for (const fileName of fileNames) {
|
|
32
|
+
const candidate = path.join(directory, fileName);
|
|
33
|
+
try {
|
|
34
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
// Continue searching PATH entries.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function startAgentSession({ provider = "auto", cwd, prompt, onEvent, purpose = "investigate" } = {}) {
|
|
45
|
+
const resolvedProvider = resolvePreferredProvider(provider);
|
|
46
|
+
if (resolvedProvider === "claude") {
|
|
47
|
+
return startClaudeHostedSession({ cwd, prompt, onEvent, purpose });
|
|
48
|
+
}
|
|
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
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { loadInvestigationContext } from "./investigation-context.mjs";
|
|
2
|
+
import { buildInvestigationPrompt } from "./prompt-builder.mjs";
|
|
3
|
+
import { startAgentSession, startInteractiveAgentHandoff } from "./index.mjs";
|
|
4
|
+
|
|
5
|
+
export function createInvestigationRequest({
|
|
6
|
+
productDir,
|
|
7
|
+
serviceName,
|
|
8
|
+
filePath,
|
|
9
|
+
provider = "auto",
|
|
10
|
+
userMessage,
|
|
11
|
+
} = {}) {
|
|
12
|
+
const context = loadInvestigationContext({
|
|
13
|
+
productDir,
|
|
14
|
+
serviceName,
|
|
15
|
+
filePath,
|
|
16
|
+
});
|
|
17
|
+
const prompt = buildInvestigationPrompt({ context, userMessage });
|
|
18
|
+
return {
|
|
19
|
+
provider,
|
|
20
|
+
context,
|
|
21
|
+
prompt,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function startHostedInvestigation({
|
|
26
|
+
productDir,
|
|
27
|
+
serviceName,
|
|
28
|
+
filePath,
|
|
29
|
+
provider = "auto",
|
|
30
|
+
userMessage,
|
|
31
|
+
onEvent,
|
|
32
|
+
} = {}) {
|
|
33
|
+
const request = createInvestigationRequest({
|
|
34
|
+
productDir,
|
|
35
|
+
serviceName,
|
|
36
|
+
filePath,
|
|
37
|
+
provider,
|
|
38
|
+
userMessage,
|
|
39
|
+
});
|
|
40
|
+
const session = startAgentSession({
|
|
41
|
+
provider: request.provider,
|
|
42
|
+
cwd: productDir,
|
|
43
|
+
prompt: request.prompt,
|
|
44
|
+
onEvent,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
...session,
|
|
48
|
+
request,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runInteractiveInvestigation({
|
|
53
|
+
productDir,
|
|
54
|
+
serviceName,
|
|
55
|
+
filePath,
|
|
56
|
+
provider = "auto",
|
|
57
|
+
userMessage,
|
|
58
|
+
} = {}) {
|
|
59
|
+
const request = createInvestigationRequest({
|
|
60
|
+
productDir,
|
|
61
|
+
serviceName,
|
|
62
|
+
filePath,
|
|
63
|
+
provider,
|
|
64
|
+
userMessage,
|
|
65
|
+
});
|
|
66
|
+
const result = await startInteractiveAgentHandoff({
|
|
67
|
+
provider: request.provider,
|
|
68
|
+
cwd: productDir,
|
|
69
|
+
prompt: request.prompt,
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
...result,
|
|
73
|
+
request,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import {
|
|
3
|
+
collectArtifactEntries,
|
|
4
|
+
formatArtifactPreview,
|
|
5
|
+
formatFileDetail,
|
|
6
|
+
getServiceLogRefs,
|
|
7
|
+
getSetupOperationsForService,
|
|
8
|
+
loadCurrentRunArtifact,
|
|
9
|
+
resolveFileSubject,
|
|
10
|
+
} from "../viewer.mjs";
|
|
11
|
+
import { readLogTail } from "../../runner/logs.mjs";
|
|
12
|
+
|
|
13
|
+
export function loadInvestigationContext({
|
|
14
|
+
productDir,
|
|
15
|
+
serviceName,
|
|
16
|
+
filePath,
|
|
17
|
+
logTail = 20,
|
|
18
|
+
failureLimit = 5,
|
|
19
|
+
previewLength = 6,
|
|
20
|
+
} = {}) {
|
|
21
|
+
if (!productDir) throw new Error("productDir is required");
|
|
22
|
+
if (!serviceName) throw new Error("serviceName is required");
|
|
23
|
+
if (!filePath) throw new Error("filePath is required");
|
|
24
|
+
|
|
25
|
+
const runArtifact = loadCurrentRunArtifact(productDir);
|
|
26
|
+
const subject = resolveFileSubject(runArtifact, filePath, serviceName);
|
|
27
|
+
const detailLines = formatFileDetail(productDir, runArtifact, subject, {
|
|
28
|
+
logTail,
|
|
29
|
+
failureLimit,
|
|
30
|
+
previewLength,
|
|
31
|
+
});
|
|
32
|
+
const artifacts = collectArtifactEntries(productDir, runArtifact, subject.file.path, subject.service.name).map((entry) => ({
|
|
33
|
+
name: entry.artifactRef.name,
|
|
34
|
+
kind: entry.artifactRef.kind || null,
|
|
35
|
+
summary: entry.artifactRef.summary || null,
|
|
36
|
+
path: entry.artifactRef.path,
|
|
37
|
+
previewLines: formatArtifactPreview(entry.payload, previewLength),
|
|
38
|
+
}));
|
|
39
|
+
const setupOperations = getSetupOperationsForService(runArtifact, subject.service.name).slice(0, 8).map((operation) => ({
|
|
40
|
+
stage: operation.stage,
|
|
41
|
+
status: operation.status,
|
|
42
|
+
summary: operation.summary || null,
|
|
43
|
+
durationMs: operation.durationMs ?? null,
|
|
44
|
+
logPath: operation.logRef?.path || null,
|
|
45
|
+
error: operation.error || null,
|
|
46
|
+
}));
|
|
47
|
+
const backendLogs = getServiceLogRefs(runArtifact, subject.service.name).map((logRef) => ({
|
|
48
|
+
runtimeLabel: logRef.runtimeLabel,
|
|
49
|
+
path: logRef.path,
|
|
50
|
+
lines: readLogTail(path.join(productDir, logRef.path), logTail),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
productDir,
|
|
55
|
+
runArtifact,
|
|
56
|
+
subject,
|
|
57
|
+
detailLines,
|
|
58
|
+
summary: {
|
|
59
|
+
service: subject.service.name,
|
|
60
|
+
suite: `${subject.suite.type}:${subject.suite.name}`,
|
|
61
|
+
file: subject.file.path,
|
|
62
|
+
status: subject.file.status,
|
|
63
|
+
durationMs: subject.file.durationMs || 0,
|
|
64
|
+
error: subject.file.error || null,
|
|
65
|
+
failureCount: Array.isArray(subject.file.failureDetails) ? subject.file.failureDetails.length : 0,
|
|
66
|
+
},
|
|
67
|
+
setupOperations,
|
|
68
|
+
artifacts,
|
|
69
|
+
backendLogs,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatInvestigationTranscriptContext(context) {
|
|
74
|
+
if (!context) return "";
|
|
75
|
+
const artifactSummary = context.artifacts.length === 0
|
|
76
|
+
? "none"
|
|
77
|
+
: context.artifacts
|
|
78
|
+
.map((entry) => `${entry.name}${entry.kind ? ` [${entry.kind}]` : ""}: ${entry.path}`)
|
|
79
|
+
.join("\n");
|
|
80
|
+
const logSummary = context.backendLogs.length === 0
|
|
81
|
+
? "none"
|
|
82
|
+
: context.backendLogs.map((entry) => `${entry.runtimeLabel}: ${entry.path}`).join("\n");
|
|
83
|
+
|
|
84
|
+
return [
|
|
85
|
+
`Service: ${context.summary.service}`,
|
|
86
|
+
`Suite: ${context.summary.suite}`,
|
|
87
|
+
`File: ${context.summary.file}`,
|
|
88
|
+
`Status: ${context.summary.status}`,
|
|
89
|
+
context.summary.error ? `Error: ${context.summary.error}` : null,
|
|
90
|
+
"",
|
|
91
|
+
"Artifacts:",
|
|
92
|
+
artifactSummary,
|
|
93
|
+
"",
|
|
94
|
+
"Backend Logs:",
|
|
95
|
+
logSummary,
|
|
96
|
+
"",
|
|
97
|
+
"Detailed Failure View:",
|
|
98
|
+
...context.detailLines,
|
|
99
|
+
]
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.join("\n");
|
|
102
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { formatInvestigationTranscriptContext } from "./investigation-context.mjs";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_INVESTIGATION_MESSAGE =
|
|
4
|
+
"Investigate the selected testkit failure. Explain the most likely root cause, cite concrete evidence from the provided context, and suggest the next debugging steps or code changes.";
|
|
5
|
+
|
|
6
|
+
export function defaultInvestigationMessage() {
|
|
7
|
+
return DEFAULT_INVESTIGATION_MESSAGE;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function buildInvestigationPrompt({ context, userMessage } = {}) {
|
|
11
|
+
const message = userMessage || DEFAULT_INVESTIGATION_MESSAGE;
|
|
12
|
+
const transcriptContext = formatInvestigationTranscriptContext(context);
|
|
13
|
+
|
|
14
|
+
return [
|
|
15
|
+
"You are investigating a failed testkit run.",
|
|
16
|
+
"Use the provided run context as the primary evidence.",
|
|
17
|
+
"If the evidence is incomplete, say what additional command or file you would inspect next.",
|
|
18
|
+
"Keep the response actionable and focused on root cause, evidence, and next steps.",
|
|
19
|
+
"",
|
|
20
|
+
`User request: ${message}`,
|
|
21
|
+
"",
|
|
22
|
+
"Run context:",
|
|
23
|
+
transcriptContext,
|
|
24
|
+
].join("\n");
|
|
25
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execa } from "execa";
|
|
2
|
+
import {
|
|
3
|
+
buildErrorEvent,
|
|
4
|
+
buildStatusEvent,
|
|
5
|
+
buildToolEvent,
|
|
6
|
+
createHostedSessionRunner,
|
|
7
|
+
extractTextFragments,
|
|
8
|
+
} from "./shared.mjs";
|
|
9
|
+
|
|
10
|
+
export function startClaudeHostedSession({ cwd, prompt, onEvent, purpose = "investigate" } = {}) {
|
|
11
|
+
const args = [
|
|
12
|
+
"-p",
|
|
13
|
+
"--output-format",
|
|
14
|
+
"stream-json",
|
|
15
|
+
"--include-partial-messages",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
if (purpose === "investigate") {
|
|
19
|
+
args.push("--permission-mode", "plan");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
args.push(prompt);
|
|
23
|
+
|
|
24
|
+
const child = execa("claude", args, {
|
|
25
|
+
cwd,
|
|
26
|
+
stdout: "pipe",
|
|
27
|
+
stderr: "pipe",
|
|
28
|
+
reject: false,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return createHostedSessionRunner({
|
|
32
|
+
provider: "claude",
|
|
33
|
+
child,
|
|
34
|
+
onEvent,
|
|
35
|
+
parsePayload: parseClaudePayload,
|
|
36
|
+
readFinalText() {
|
|
37
|
+
return null;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseClaudePayload(payload) {
|
|
43
|
+
const events = [];
|
|
44
|
+
if (!payload || typeof payload !== "object") return events;
|
|
45
|
+
|
|
46
|
+
const type = payload.type || payload.event || payload.kind || null;
|
|
47
|
+
const errorMessage = payload.error?.message || payload.error || null;
|
|
48
|
+
if (errorMessage) {
|
|
49
|
+
const event = buildErrorEvent(errorMessage);
|
|
50
|
+
if (event) events.push(event);
|
|
51
|
+
return events;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (type && /tool/i.test(type)) {
|
|
55
|
+
const event = buildToolEvent(
|
|
56
|
+
payload.name || payload.tool_name || payload.tool || type,
|
|
57
|
+
payload.detail || payload.summary || null
|
|
58
|
+
);
|
|
59
|
+
if (event) events.push(event);
|
|
60
|
+
return events;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fragments = [...new Set(extractTextFragments(payload, []))];
|
|
64
|
+
if (fragments.length > 0) {
|
|
65
|
+
for (const fragment of fragments) {
|
|
66
|
+
events.push({ type: "delta", text: fragment });
|
|
67
|
+
}
|
|
68
|
+
return events;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const statusEvent = buildStatusEvent(type ? `Claude event: ${type}` : JSON.stringify(payload));
|
|
72
|
+
if (statusEvent) events.push(statusEvent);
|
|
73
|
+
return events;
|
|
74
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
import {
|
|
6
|
+
buildErrorEvent,
|
|
7
|
+
buildStatusEvent,
|
|
8
|
+
buildToolEvent,
|
|
9
|
+
createHostedSessionRunner,
|
|
10
|
+
extractTextFragments,
|
|
11
|
+
readTextFileIfPresent,
|
|
12
|
+
} from "./shared.mjs";
|
|
13
|
+
|
|
14
|
+
export function startCodexHostedSession({ cwd, prompt, onEvent, purpose = "investigate" } = {}) {
|
|
15
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "testkit-codex-"));
|
|
16
|
+
const outputFile = path.join(tempDir, "final-message.txt");
|
|
17
|
+
const args = ["exec", "--json", "-o", outputFile];
|
|
18
|
+
|
|
19
|
+
if (purpose === "investigate") {
|
|
20
|
+
args.push("-s", "read-only");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
args.push(prompt);
|
|
24
|
+
|
|
25
|
+
const child = execa("codex", args, {
|
|
26
|
+
cwd,
|
|
27
|
+
stdout: "pipe",
|
|
28
|
+
stderr: "pipe",
|
|
29
|
+
reject: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const session = createHostedSessionRunner({
|
|
33
|
+
provider: "codex",
|
|
34
|
+
child,
|
|
35
|
+
onEvent,
|
|
36
|
+
parsePayload: parseCodexPayload,
|
|
37
|
+
readFinalText(result) {
|
|
38
|
+
return readTextFileIfPresent(outputFile) || result.stdout || null;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const completion = session.completion.finally(() => {
|
|
43
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
completion,
|
|
48
|
+
cancel: session.cancel,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseCodexPayload(payload) {
|
|
53
|
+
const events = [];
|
|
54
|
+
if (!payload || typeof payload !== "object") return events;
|
|
55
|
+
const type = payload.type || payload.event || payload.kind || null;
|
|
56
|
+
const errorMessage = payload.error?.message || payload.error || null;
|
|
57
|
+
if (errorMessage) {
|
|
58
|
+
const event = buildErrorEvent(errorMessage);
|
|
59
|
+
if (event) events.push(event);
|
|
60
|
+
return events;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (type && /(tool|command|patch|exec)/i.test(type)) {
|
|
64
|
+
const event = buildToolEvent(
|
|
65
|
+
payload.name || payload.tool_name || payload.command || type,
|
|
66
|
+
payload.detail || payload.summary || payload.status || null
|
|
67
|
+
);
|
|
68
|
+
if (event) events.push(event);
|
|
69
|
+
return events;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fragments = [...new Set(extractTextFragments(payload, []))];
|
|
73
|
+
if (fragments.length > 0) {
|
|
74
|
+
for (const fragment of fragments) {
|
|
75
|
+
events.push({ type: "delta", text: fragment });
|
|
76
|
+
}
|
|
77
|
+
return events;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const statusEvent = buildStatusEvent(type ? `Codex event: ${type}` : JSON.stringify(payload));
|
|
81
|
+
if (statusEvent) events.push(statusEvent);
|
|
82
|
+
return events;
|
|
83
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import readline from "readline";
|
|
3
|
+
|
|
4
|
+
export function createHostedSessionRunner({ provider, child, onEvent, parsePayload, readFinalText } = {}) {
|
|
5
|
+
let cancelled = false;
|
|
6
|
+
let settled = false;
|
|
7
|
+
let assistantText = "";
|
|
8
|
+
|
|
9
|
+
const emit = (event) => {
|
|
10
|
+
if (event?.type === "delta" || event?.type === "final") {
|
|
11
|
+
assistantText += event.text || "";
|
|
12
|
+
}
|
|
13
|
+
if (typeof onEvent === "function" && event) onEvent({ provider, ...event });
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
emit({ type: "start" });
|
|
17
|
+
|
|
18
|
+
const stdoutReader = readline.createInterface({ input: child.stdout });
|
|
19
|
+
stdoutReader.on("line", (line) => {
|
|
20
|
+
const parsed = tryParseJson(line);
|
|
21
|
+
if (parsed == null) {
|
|
22
|
+
emit({ type: "status", message: line });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const events = parsePayload ? parsePayload(parsed) : [];
|
|
26
|
+
if (!Array.isArray(events) || events.length === 0) return;
|
|
27
|
+
for (const event of events) emit(event);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const stderrReader = readline.createInterface({ input: child.stderr });
|
|
31
|
+
stderrReader.on("line", (line) => {
|
|
32
|
+
emit({ type: "status", message: line });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const completion = (async () => {
|
|
36
|
+
const result = await child;
|
|
37
|
+
const finalText = (readFinalText ? readFinalText(result) : null) || assistantText.trim() || null;
|
|
38
|
+
if (finalText) emit({ type: "final", text: finalText });
|
|
39
|
+
emit({ type: "exit", code: result.exitCode ?? 0 });
|
|
40
|
+
settled = true;
|
|
41
|
+
return {
|
|
42
|
+
provider,
|
|
43
|
+
exitCode: result.exitCode ?? 0,
|
|
44
|
+
stdout: result.stdout || "",
|
|
45
|
+
stderr: result.stderr || "",
|
|
46
|
+
finalText: finalText || result.stdout || "",
|
|
47
|
+
cancelled,
|
|
48
|
+
};
|
|
49
|
+
})();
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
completion,
|
|
53
|
+
cancel() {
|
|
54
|
+
cancelled = true;
|
|
55
|
+
if (settled) return;
|
|
56
|
+
try {
|
|
57
|
+
child.kill("SIGTERM");
|
|
58
|
+
} catch {
|
|
59
|
+
// Ignore cancellation races.
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function tryParseJson(line) {
|
|
66
|
+
const normalized = String(line || "").trim();
|
|
67
|
+
if (!normalized) return null;
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(normalized);
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildToolEvent(name, detail = null) {
|
|
76
|
+
if (!name) return null;
|
|
77
|
+
return {
|
|
78
|
+
type: "tool",
|
|
79
|
+
name: String(name),
|
|
80
|
+
...(detail ? { detail: String(detail) } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function buildStatusEvent(message) {
|
|
85
|
+
if (!message) return null;
|
|
86
|
+
return { type: "status", message: String(message) };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildErrorEvent(message) {
|
|
90
|
+
if (!message) return null;
|
|
91
|
+
return { type: "error", message: String(message) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function extractTextFragments(payload, fragments = [], depth = 0) {
|
|
95
|
+
if (payload == null || depth > 5 || fragments.length >= 12) return fragments;
|
|
96
|
+
if (typeof payload === "string") {
|
|
97
|
+
const normalized = payload.trim();
|
|
98
|
+
if (normalized) fragments.push(normalized);
|
|
99
|
+
return fragments;
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(payload)) {
|
|
102
|
+
for (const entry of payload) {
|
|
103
|
+
extractTextFragments(entry, fragments, depth + 1);
|
|
104
|
+
if (fragments.length >= 12) break;
|
|
105
|
+
}
|
|
106
|
+
return fragments;
|
|
107
|
+
}
|
|
108
|
+
if (typeof payload !== "object") return fragments;
|
|
109
|
+
|
|
110
|
+
const preferredKeys = ["delta", "text", "content", "message", "output_text", "summary"];
|
|
111
|
+
let matchedPreferredKey = false;
|
|
112
|
+
for (const key of preferredKeys) {
|
|
113
|
+
if (!(key in payload)) continue;
|
|
114
|
+
matchedPreferredKey = true;
|
|
115
|
+
extractTextFragments(payload[key], fragments, depth + 1);
|
|
116
|
+
if (fragments.length >= 12) return fragments;
|
|
117
|
+
}
|
|
118
|
+
if (matchedPreferredKey && fragments.length > 0) return fragments;
|
|
119
|
+
|
|
120
|
+
for (const value of Object.values(payload)) {
|
|
121
|
+
extractTextFragments(value, fragments, depth + 1);
|
|
122
|
+
if (fragments.length >= 12) break;
|
|
123
|
+
}
|
|
124
|
+
return fragments;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function readTextFileIfPresent(filePath) {
|
|
128
|
+
if (!filePath) return null;
|
|
129
|
+
try {
|
|
130
|
+
return fs.readFileSync(filePath, "utf8").trim() || null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|