@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.
Files changed (100) hide show
  1. package/README.md +37 -7
  2. package/lib/cli/agents/index.mjs +64 -0
  3. package/lib/cli/agents/investigate.mjs +75 -0
  4. package/lib/cli/agents/investigation-context.mjs +102 -0
  5. package/lib/cli/agents/prompt-builder.mjs +25 -0
  6. package/lib/cli/agents/providers/claude.mjs +74 -0
  7. package/lib/cli/agents/providers/codex.mjs +83 -0
  8. package/lib/cli/agents/providers/shared.mjs +134 -0
  9. package/lib/cli/command-helpers.mjs +53 -25
  10. package/lib/cli/commands/investigate.mjs +87 -0
  11. package/lib/cli/entrypoint.mjs +3 -0
  12. package/lib/cli/presentation/colors.mjs +12 -0
  13. package/lib/cli/presentation/events-reporter.mjs +135 -0
  14. package/lib/cli/presentation/summary-box.mjs +11 -11
  15. package/lib/cli/presentation/tree-reporter.mjs +159 -0
  16. package/lib/cli/tui/run-app.mjs +1 -0
  17. package/lib/cli/tui/run-session-app.mjs +370 -0
  18. package/lib/cli/tui/run-session-state.mjs +481 -0
  19. package/lib/cli/tui/run-tree-state.mjs +1 -0
  20. package/lib/config-api/auth-fixtures.mjs +15 -10
  21. package/lib/discovery/index.mjs +1 -1
  22. package/lib/index.d.ts +5 -1
  23. package/lib/runner/orchestrator.mjs +1 -0
  24. package/lib/runtime/index.d.ts +138 -5
  25. package/lib/runtime/index.mjs +68 -2
  26. package/lib/runtime-src/k6/http-assertions.js +31 -1
  27. package/lib/runtime-src/k6/http-checks.js +120 -0
  28. package/lib/runtime-src/k6/http-suite-runtime.js +5 -1
  29. package/lib/runtime-src/k6/http.js +213 -23
  30. package/lib/runtime-src/shared/error-body.mjs +42 -0
  31. package/lib/runtime-src/shared/http-parsing.mjs +68 -0
  32. package/node_modules/@elench/next-analysis/package.json +1 -1
  33. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  34. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  35. package/node_modules/@elench/ts-analysis/package.json +1 -1
  36. package/package.json +7 -6
  37. package/lib/app/configs.test.mjs +0 -34
  38. package/lib/app/typecheck.test.mjs +0 -24
  39. package/lib/bundler/index.test.mjs +0 -164
  40. package/lib/cli/args.test.mjs +0 -110
  41. package/lib/cli/presentation/code-frames.test.mjs +0 -71
  42. package/lib/cli/presentation/run-reporter.test.mjs +0 -192
  43. package/lib/cli/presentation/summary-box.test.mjs +0 -43
  44. package/lib/cli/presentation/terminal-layout.test.mjs +0 -23
  45. package/lib/config/database.test.mjs +0 -29
  46. package/lib/config/discovery.test.mjs +0 -276
  47. package/lib/config/env.test.mjs +0 -40
  48. package/lib/config/index.test.mjs +0 -44
  49. package/lib/config/paths.test.mjs +0 -27
  50. package/lib/config/runtime.test.mjs +0 -82
  51. package/lib/config/skip-config.test.mjs +0 -63
  52. package/lib/config-api/index.test.mjs +0 -344
  53. package/lib/config-api/next-runtime-tsconfig.test.mjs +0 -58
  54. package/lib/coverage/backend-discovery.test.mjs +0 -61
  55. package/lib/coverage/evidence.test.mjs +0 -87
  56. package/lib/coverage/index.test.mjs +0 -715
  57. package/lib/coverage/routing.test.mjs +0 -36
  58. package/lib/coverage/shared.test.mjs +0 -72
  59. package/lib/database/fingerprint.test.mjs +0 -99
  60. package/lib/database/index.test.mjs +0 -95
  61. package/lib/database/naming.test.mjs +0 -39
  62. package/lib/database/state.test.mjs +0 -66
  63. package/lib/database/template-steps.test.mjs +0 -43
  64. package/lib/discovery/file-metadata.test.mjs +0 -51
  65. package/lib/discovery/index.test.mjs +0 -182
  66. package/lib/discovery/path-policy.test.mjs +0 -65
  67. package/lib/drizzle/index.test.mjs +0 -33
  68. package/lib/env/index.test.mjs +0 -82
  69. package/lib/history/index.test.mjs +0 -115
  70. package/lib/package.test.mjs +0 -59
  71. package/lib/playwright/index.test.mjs +0 -43
  72. package/lib/regressions/github.test.mjs +0 -324
  73. package/lib/regressions/index.test.mjs +0 -187
  74. package/lib/reporters/playwright.test.mjs +0 -167
  75. package/lib/runner/default-runtime-errors.test.mjs +0 -49
  76. package/lib/runner/execution-config.test.mjs +0 -67
  77. package/lib/runner/failure-details.test.mjs +0 -114
  78. package/lib/runner/formatting.test.mjs +0 -205
  79. package/lib/runner/metadata.test.mjs +0 -52
  80. package/lib/runner/planning.test.mjs +0 -371
  81. package/lib/runner/playwright-config.test.mjs +0 -78
  82. package/lib/runner/processes.test.mjs +0 -21
  83. package/lib/runner/regressions.test.mjs +0 -168
  84. package/lib/runner/reporting.test.mjs +0 -310
  85. package/lib/runner/results.test.mjs +0 -376
  86. package/lib/runner/runtime-manager.test.mjs +0 -252
  87. package/lib/runner/runtime-preparation.test.mjs +0 -141
  88. package/lib/runner/selection.test.mjs +0 -24
  89. package/lib/runner/setup-operations.test.mjs +0 -94
  90. package/lib/runner/state.test.mjs +0 -62
  91. package/lib/runner/suite-selection.test.mjs +0 -49
  92. package/lib/runner/template.test.mjs +0 -272
  93. package/lib/shared/build-config.test.mjs +0 -132
  94. package/lib/shared/configured-steps.test.mjs +0 -102
  95. package/lib/shared/execution-schema.test.mjs +0 -26
  96. package/lib/shared/file-timeout.test.mjs +0 -64
  97. package/lib/shared/test-context.test.mjs +0 -43
  98. package/lib/timing/index.test.mjs +0 -64
  99. package/lib/toolchains/index.test.mjs +0 -168
  100. 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("GET", "/health");
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("GET", plan.endpoint));
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
- Low-level runtime primitives remain available:
456
+ First-class runtime namespaces:
453
457
 
454
458
  ```ts
455
- import { check, group, http, waitFor } from "@elench/testkit/runtime";
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) => JSON.parse(res.body).data?.status === "completed",
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
+ }