@elench/testkit 0.1.100 → 0.1.102
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 +6 -3
- package/lib/cli/args.mjs +0 -19
- package/lib/cli/assistant/app.mjs +6 -0
- package/lib/cli/assistant/command-observer.mjs +75 -44
- package/lib/cli/assistant/command-results.mjs +29 -2
- package/lib/cli/assistant/context-pack.mjs +21 -1
- package/lib/cli/assistant/providers/claude.mjs +42 -7
- package/lib/cli/assistant/providers/codex.mjs +87 -9
- package/lib/cli/assistant/providers/events.mjs +71 -0
- package/lib/cli/assistant/providers/index.mjs +5 -4
- package/lib/cli/assistant/providers/shared.mjs +40 -21
- package/lib/cli/assistant/session.mjs +46 -8
- package/lib/cli/assistant/settings.mjs +29 -6
- package/lib/cli/assistant/state.mjs +181 -6
- package/lib/cli/assistant/transcript-text.mjs +35 -0
- package/lib/cli/assistant/view-model.mjs +11 -0
- package/lib/cli/command-flags.mjs +0 -3
- package/lib/cli/entrypoint.mjs +0 -2
- package/lib/cli/operations/run/operation.mjs +0 -3
- package/lib/runner/live-run.mjs +5 -1
- package/lib/runner/orchestrator.mjs +26 -26
- package/lib/runner/planning.mjs +0 -75
- package/lib/runner/provenance.mjs +20 -0
- package/lib/runner/reporting.mjs +14 -9
- package/lib/runner/run-finalization.mjs +5 -2
- package/lib/runner/run-guards.mjs +0 -1
- package/lib/runner/scheduler/estimates.mjs +61 -0
- package/lib/runner/scheduler/identity.mjs +31 -0
- package/lib/runner/scheduler/index.mjs +126 -0
- package/lib/runner/scheduler/observations.mjs +27 -0
- package/lib/runner/selection.mjs +1 -2
- package/lib/runner/worker-loop.mjs +3 -4
- package/lib/timing/index.mjs +33 -33
- 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 +14 -8
package/README.md
CHANGED
|
@@ -43,9 +43,6 @@ npx @elench/testkit --workers 8
|
|
|
43
43
|
# One file-level wall clock budget for every suite file
|
|
44
44
|
npx @elench/testkit --file-timeout-seconds 60
|
|
45
45
|
|
|
46
|
-
# Run a deterministic shard
|
|
47
|
-
npx @elench/testkit --shard 1/3
|
|
48
|
-
|
|
49
46
|
# Specific service / suite
|
|
50
47
|
npx @elench/testkit --service frontend --type pw -s navigation
|
|
51
48
|
npx @elench/testkit --service api --type int -s health
|
|
@@ -126,6 +123,12 @@ output, emitted artifacts, and assistant-visible run state are persisted under
|
|
|
126
123
|
run counts, pass/fail/skip counts, average duration, and last observed status,
|
|
127
124
|
and those summaries are exposed in compact, verbose, and JSON discovery output.
|
|
128
125
|
|
|
126
|
+
Test execution also maintains a scheduler cache at `.testkit/timings.json`.
|
|
127
|
+
Completed file-level task durations are used to rank future runs with a
|
|
128
|
+
longest-estimated-duration-first policy, so slow files start earlier when
|
|
129
|
+
workers are available. Run artifacts include compact scheduler metadata under
|
|
130
|
+
`planning` so ordering decisions are inspectable.
|
|
131
|
+
|
|
129
132
|
## Automatic Regression Diagnosis
|
|
130
133
|
|
|
131
134
|
If `regressions.file` is configured, every run automatically classifies observed
|
package/lib/cli/args.mjs
CHANGED
|
@@ -58,25 +58,6 @@ export {
|
|
|
58
58
|
parseWorkersOption,
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
-
export function parseShardOption(value) {
|
|
62
|
-
if (!value) return null;
|
|
63
|
-
|
|
64
|
-
const match = String(value).match(/^(\d+)\/(\d+)$/);
|
|
65
|
-
if (!match) {
|
|
66
|
-
throw new Error(
|
|
67
|
-
`Invalid --shard value "${value}". Expected the form "i/n", e.g. 1/3.`
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const index = Number.parseInt(match[1], 10);
|
|
72
|
-
const total = Number.parseInt(match[2], 10);
|
|
73
|
-
if (index <= 0 || total <= 0 || index > total) {
|
|
74
|
-
throw new Error(`Invalid --shard value "${value}". Expected 1 <= i <= n.`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return { index, total };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
61
|
export function resolveRequestedFiles(fileNames, productDir, invocationCwd = process.cwd()) {
|
|
81
62
|
const resolved = [];
|
|
82
63
|
const seen = new Set();
|
|
@@ -281,6 +281,9 @@ function colorWelcomeValue(label, value) {
|
|
|
281
281
|
function colorMarker(block) {
|
|
282
282
|
if (block.kind === "user") return cyan(block.marker);
|
|
283
283
|
if (block.kind === "system") return red(block.marker);
|
|
284
|
+
if (block.kind === "provider-error") return red(block.marker);
|
|
285
|
+
if (block.kind === "provider-activity") return dim(block.marker);
|
|
286
|
+
if (block.kind === "provider-tool") return yellow(block.marker);
|
|
284
287
|
if (block.kind === "tool-running") return yellow(block.marker);
|
|
285
288
|
if (block.kind === "testkit-run") return green(block.marker);
|
|
286
289
|
return block.marker;
|
|
@@ -289,6 +292,9 @@ function colorMarker(block) {
|
|
|
289
292
|
function colorBlockText(block, text) {
|
|
290
293
|
if (block.kind === "user") return text;
|
|
291
294
|
if (block.kind === "system") return red(text);
|
|
295
|
+
if (block.kind === "provider-error") return red(text);
|
|
296
|
+
if (block.kind === "provider-activity") return dim(text);
|
|
297
|
+
if (block.kind === "provider-tool") return yellow(text);
|
|
292
298
|
if (block.kind === "tool-running") return yellow(text);
|
|
293
299
|
return text;
|
|
294
300
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { loadCurrentRunArtifact, loadLatestRunArtifact } from "../../results/artifacts.mjs";
|
|
4
3
|
|
|
5
4
|
const POLL_INTERVAL_MS = 150;
|
|
6
5
|
const OBSERVED_KINDS = new Set(["run", "discover", "status", "doctor", "typecheck"]);
|
|
6
|
+
const RUN_KINDS = new Set(["run", "int", "e2e", "scenario", "dal", "load", "pw", "all"]);
|
|
7
7
|
|
|
8
8
|
export function createAssistantCommandObserver({
|
|
9
9
|
productDir,
|
|
@@ -13,15 +13,16 @@ export function createAssistantCommandObserver({
|
|
|
13
13
|
intervalMs = POLL_INTERVAL_MS,
|
|
14
14
|
} = {}) {
|
|
15
15
|
const seenResultFiles = new Set();
|
|
16
|
+
const seenCommandLogEvents = new Set();
|
|
17
|
+
const observedRunCommandIds = new Set();
|
|
16
18
|
let timer = null;
|
|
17
19
|
let running = false;
|
|
18
|
-
let
|
|
19
|
-
let preferLatestArtifact = false;
|
|
20
|
+
let lastArtifactSignatures = new Map();
|
|
20
21
|
|
|
21
22
|
function start() {
|
|
22
23
|
if (running) return;
|
|
23
24
|
running = true;
|
|
24
|
-
|
|
25
|
+
lastArtifactSignatures = readArtifactSignatures();
|
|
25
26
|
scan();
|
|
26
27
|
timer = setInterval(scan, intervalMs);
|
|
27
28
|
}
|
|
@@ -34,10 +35,32 @@ export function createAssistantCommandObserver({
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
function scan() {
|
|
38
|
+
observeCommandLog();
|
|
37
39
|
observeCommandResults();
|
|
38
40
|
observeRunArtifact();
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
function observeCommandLog() {
|
|
44
|
+
const commandLogPath = commandLog?.commandLogPath;
|
|
45
|
+
if (!commandLogPath || !fs.existsSync(commandLogPath)) return;
|
|
46
|
+
const lines = fs.readFileSync(commandLogPath, "utf8").split(/\r?\n/).filter(Boolean);
|
|
47
|
+
for (const [index, line] of lines.entries()) {
|
|
48
|
+
const eventKey = `${index}:${line}`;
|
|
49
|
+
if (seenCommandLogEvents.has(eventKey)) continue;
|
|
50
|
+
seenCommandLogEvents.add(eventKey);
|
|
51
|
+
let event = null;
|
|
52
|
+
try {
|
|
53
|
+
event = JSON.parse(line);
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (event.sessionId && commandLog.sessionId && event.sessionId !== commandLog.sessionId) continue;
|
|
58
|
+
if (event.type === "command_start" && isRunCommand(event)) {
|
|
59
|
+
observedRunCommandIds.add(event.commandId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
function observeCommandResults() {
|
|
42
65
|
const resultDir = commandLog?.resultDir;
|
|
43
66
|
if (!resultDir || !fs.existsSync(resultDir)) return;
|
|
@@ -54,10 +77,11 @@ export function createAssistantCommandObserver({
|
|
|
54
77
|
} catch {
|
|
55
78
|
continue;
|
|
56
79
|
}
|
|
80
|
+
if (document.sessionId && commandLog.sessionId && document.sessionId !== commandLog.sessionId) continue;
|
|
57
81
|
if (!OBSERVED_KINDS.has(document.kind)) continue;
|
|
58
82
|
seenResultFiles.add(filePath);
|
|
59
83
|
if (document.kind === "run") {
|
|
60
|
-
|
|
84
|
+
observedRunCommandIds.add(document.commandId);
|
|
61
85
|
hydrateRunArtifact("command-result", document);
|
|
62
86
|
}
|
|
63
87
|
onEvent?.({
|
|
@@ -68,29 +92,34 @@ export function createAssistantCommandObserver({
|
|
|
68
92
|
}
|
|
69
93
|
|
|
70
94
|
function observeRunArtifact() {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
const signatures = readArtifactSignatures();
|
|
96
|
+
const changed = [...signatures.entries()]
|
|
97
|
+
.filter(([artifactPath, signature]) => lastArtifactSignatures.get(artifactPath) !== signature)
|
|
98
|
+
.map(([artifactPath, signature]) => ({
|
|
99
|
+
artifactPath,
|
|
100
|
+
signature,
|
|
101
|
+
mtimeMs: Number(signature.split(":").at(-2) || 0),
|
|
102
|
+
}))
|
|
103
|
+
.sort((left, right) => left.mtimeMs - right.mtimeMs);
|
|
104
|
+
lastArtifactSignatures = signatures;
|
|
77
105
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
106
|
+
for (const entry of changed) {
|
|
107
|
+
const artifact = readJsonFile(entry.artifactPath);
|
|
108
|
+
if (!artifact || !shouldHydrateObservedArtifact(artifact)) continue;
|
|
109
|
+
hydrateRunArtifact("artifact", { artifactPath: entry.artifactPath, artifact });
|
|
110
|
+
}
|
|
83
111
|
}
|
|
84
112
|
|
|
85
|
-
function
|
|
113
|
+
function readArtifactSignatures() {
|
|
86
114
|
const livePath = path.join(productDir, ".testkit", "results", "live.json");
|
|
87
115
|
const latestPath = path.join(productDir, ".testkit", "results", "latest.json");
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
116
|
+
const signatures = new Map();
|
|
117
|
+
for (const artifactPath of [livePath, latestPath]) {
|
|
118
|
+
const stat = safeStat(artifactPath);
|
|
119
|
+
if (!stat) continue;
|
|
120
|
+
signatures.set(artifactPath, `${artifactPath}:${stat.mtimeMs}:${stat.size}`);
|
|
121
|
+
}
|
|
122
|
+
return signatures;
|
|
94
123
|
}
|
|
95
124
|
|
|
96
125
|
function hydrateRunArtifact(source, command = null) {
|
|
@@ -107,29 +136,31 @@ export function createAssistantCommandObserver({
|
|
|
107
136
|
|
|
108
137
|
function loadObservedRunArtifact(command = null) {
|
|
109
138
|
if (command?.result?.runArtifact) return command.result.runArtifact;
|
|
110
|
-
if (command?.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
139
|
+
if (command?.artifact) return command.artifact;
|
|
140
|
+
if (command?.artifactPath && fs.existsSync(command.artifactPath)) return readJsonFile(command.artifactPath);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function shouldHydrateObservedArtifact(artifact) {
|
|
145
|
+
const assistant = artifact?.provenance?.assistant || {};
|
|
146
|
+
if (!assistant.sessionId || !assistant.commandId) return false;
|
|
147
|
+
if (commandLog?.sessionId && assistant.sessionId !== commandLog.sessionId) return false;
|
|
148
|
+
return observedRunCommandIds.has(assistant.commandId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isRunCommand(event) {
|
|
152
|
+
if (!event?.commandId) return false;
|
|
153
|
+
if (event.kind === "run") return true;
|
|
154
|
+
if (RUN_KINDS.has(event.kind)) return true;
|
|
155
|
+
const argv = Array.isArray(event.argv) ? event.argv : [];
|
|
156
|
+
return argv[0] === "run" || RUN_KINDS.has(argv[0]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readJsonFile(filePath) {
|
|
125
160
|
try {
|
|
126
|
-
return
|
|
161
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
127
162
|
} catch {
|
|
128
|
-
|
|
129
|
-
return loadLatestRunArtifact(productDir);
|
|
130
|
-
} catch {
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
163
|
+
return null;
|
|
133
164
|
}
|
|
134
165
|
}
|
|
135
166
|
|
|
@@ -7,6 +7,19 @@ export const ASSISTANT_COMMAND_LOG_ENV = "TESTKIT_ASSISTANT_COMMAND_LOG";
|
|
|
7
7
|
export const ASSISTANT_COMMAND_ID_ENV = "TESTKIT_ASSISTANT_COMMAND_ID";
|
|
8
8
|
export const ASSISTANT_WRAPPER_LOGGED_ENV = "TESTKIT_ASSISTANT_WRAPPER_LOGGED";
|
|
9
9
|
|
|
10
|
+
const RUN_SHORTCUTS = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
|
|
11
|
+
const FLAGS_WITH_VALUES = new Set([
|
|
12
|
+
"--dir",
|
|
13
|
+
"--service",
|
|
14
|
+
"--type",
|
|
15
|
+
"--suite",
|
|
16
|
+
"--file",
|
|
17
|
+
"--workers",
|
|
18
|
+
"--file-timeout-seconds",
|
|
19
|
+
"--seed",
|
|
20
|
+
"--output-mode",
|
|
21
|
+
]);
|
|
22
|
+
|
|
10
23
|
export function createAssistantCommandContext({
|
|
11
24
|
kind,
|
|
12
25
|
argv = process.argv.slice(2),
|
|
@@ -136,8 +149,22 @@ export function appendAssistantCommandLog(context, event) {
|
|
|
136
149
|
}
|
|
137
150
|
|
|
138
151
|
function inferCommandKind(argv) {
|
|
139
|
-
const
|
|
140
|
-
|
|
152
|
+
const first = findFirstPositional(argv);
|
|
153
|
+
if (!first || RUN_SHORTCUTS.has(first)) return "run";
|
|
154
|
+
return first;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function findFirstPositional(argv) {
|
|
158
|
+
const args = Array.isArray(argv) ? argv : [];
|
|
159
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
160
|
+
const arg = String(args[index]);
|
|
161
|
+
if (FLAGS_WITH_VALUES.has(arg)) {
|
|
162
|
+
index += 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (!arg.startsWith("-")) return arg;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
141
168
|
}
|
|
142
169
|
|
|
143
170
|
function inferExitCode(result) {
|
|
@@ -178,7 +178,27 @@ function appendCommandLog(event) {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
function inferKind(args) {
|
|
181
|
-
|
|
181
|
+
const runShortcuts = new Set(["int", "e2e", "scenario", "dal", "load", "pw", "all"]);
|
|
182
|
+
const flagsWithValues = new Set([
|
|
183
|
+
"--dir",
|
|
184
|
+
"--service",
|
|
185
|
+
"--type",
|
|
186
|
+
"--suite",
|
|
187
|
+
"--file",
|
|
188
|
+
"--workers",
|
|
189
|
+
"--file-timeout-seconds",
|
|
190
|
+
"--seed",
|
|
191
|
+
"--output-mode",
|
|
192
|
+
]);
|
|
193
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
194
|
+
const arg = String(args[index]);
|
|
195
|
+
if (flagsWithValues.has(arg)) {
|
|
196
|
+
index += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!arg.startsWith("-")) return runShortcuts.has(arg) ? "run" : arg;
|
|
200
|
+
}
|
|
201
|
+
return "run";
|
|
182
202
|
}
|
|
183
203
|
`;
|
|
184
204
|
}
|
|
@@ -4,14 +4,15 @@ import {
|
|
|
4
4
|
buildStatusEvent,
|
|
5
5
|
buildToolEvent,
|
|
6
6
|
createHostedSessionRunner,
|
|
7
|
-
extractTextFragments,
|
|
8
7
|
} from "./shared.mjs";
|
|
8
|
+
import { providerAssistantDelta, providerAssistantFinal, providerToolEnd, providerToolStart } from "./events.mjs";
|
|
9
9
|
|
|
10
10
|
export function startClaudeHostedSession({
|
|
11
11
|
command = "claude",
|
|
12
12
|
cwd,
|
|
13
13
|
prompt,
|
|
14
14
|
onEvent,
|
|
15
|
+
onRawLine,
|
|
15
16
|
purpose = "assistant",
|
|
16
17
|
model = null,
|
|
17
18
|
effort = null,
|
|
@@ -49,6 +50,7 @@ export function startClaudeHostedSession({
|
|
|
49
50
|
provider: "claude",
|
|
50
51
|
child,
|
|
51
52
|
onEvent,
|
|
53
|
+
onRawLine,
|
|
52
54
|
parsePayload: parseClaudePayload,
|
|
53
55
|
readFinalText(result) {
|
|
54
56
|
return readClaudeFinalText(result?.stdout || "") || null;
|
|
@@ -77,7 +79,20 @@ export function parseClaudePayload(payload) {
|
|
|
77
79
|
const streamEvent = payload.event || {};
|
|
78
80
|
if (streamEvent.type === "content_block_delta" && streamEvent.delta?.type === "text_delta") {
|
|
79
81
|
const text = String(streamEvent.delta.text || "");
|
|
80
|
-
|
|
82
|
+
const event = providerAssistantDelta(text);
|
|
83
|
+
if (event) events.push(event);
|
|
84
|
+
return events;
|
|
85
|
+
}
|
|
86
|
+
if (streamEvent.type === "content_block_start" && streamEvent.content_block?.type === "tool_use") {
|
|
87
|
+
const tool = streamEvent.content_block;
|
|
88
|
+
const event = providerToolStart(
|
|
89
|
+
tool.name || tool.tool_name || "tool_use",
|
|
90
|
+
{
|
|
91
|
+
id: tool.id || streamEvent.index,
|
|
92
|
+
input: tool.input || null,
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
if (event) events.push(event);
|
|
81
96
|
return events;
|
|
82
97
|
}
|
|
83
98
|
if (streamEvent.type === "tool_use" || streamEvent.content_block?.type === "tool_use") {
|
|
@@ -102,10 +117,9 @@ export function parseClaudePayload(payload) {
|
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
if (type === "assistant") {
|
|
105
|
-
const fragments =
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
120
|
+
const fragments = extractClaudeTextFragments(payload.message?.content || payload.content || []);
|
|
121
|
+
const event = providerAssistantFinal(fragments.join(""));
|
|
122
|
+
if (event) events.push(event);
|
|
109
123
|
return events;
|
|
110
124
|
}
|
|
111
125
|
|
|
@@ -113,6 +127,10 @@ export function parseClaudePayload(payload) {
|
|
|
113
127
|
if (payload.is_error || payload.subtype === "error") {
|
|
114
128
|
const event = buildErrorEvent(payload.result || payload.error || "Claude command failed");
|
|
115
129
|
if (event) events.push(event);
|
|
130
|
+
} else if (typeof payload.result === "string") {
|
|
131
|
+
const event = providerAssistantFinal(payload.result);
|
|
132
|
+
if (event) events.push(event);
|
|
133
|
+
events.push(providerToolEnd("claude", { status: "ok", durationMs: payload.duration_ms || null }));
|
|
116
134
|
}
|
|
117
135
|
return events;
|
|
118
136
|
}
|
|
@@ -147,10 +165,27 @@ export function readClaudeFinalText(stdout) {
|
|
|
147
165
|
}
|
|
148
166
|
|
|
149
167
|
if (payload.type === "assistant") {
|
|
150
|
-
const fragments =
|
|
168
|
+
const fragments = extractClaudeTextFragments(payload.message?.content || payload.content || []);
|
|
151
169
|
if (fragments.length > 0) fallback = fragments.join("");
|
|
152
170
|
}
|
|
153
171
|
}
|
|
154
172
|
|
|
155
173
|
return fallback;
|
|
156
174
|
}
|
|
175
|
+
|
|
176
|
+
function extractClaudeTextFragments(content) {
|
|
177
|
+
const entries = Array.isArray(content) ? content : [content];
|
|
178
|
+
const fragments = [];
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
if (typeof entry === "string") {
|
|
181
|
+
const text = entry.trim();
|
|
182
|
+
if (text) fragments.push(text);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (!entry || typeof entry !== "object") continue;
|
|
186
|
+
if (entry.type !== "text") continue;
|
|
187
|
+
const text = String(entry.text || "").trim();
|
|
188
|
+
if (text) fragments.push(text);
|
|
189
|
+
}
|
|
190
|
+
return [...new Set(fragments)];
|
|
191
|
+
}
|
|
@@ -7,15 +7,16 @@ import {
|
|
|
7
7
|
buildStatusEvent,
|
|
8
8
|
buildToolEvent,
|
|
9
9
|
createHostedSessionRunner,
|
|
10
|
-
extractTextFragments,
|
|
11
10
|
readTextFileIfPresent,
|
|
12
11
|
} from "./shared.mjs";
|
|
12
|
+
import { providerAssistantDelta, providerAssistantFinal, providerToolEnd, providerToolStart, providerToolUpdate } from "./events.mjs";
|
|
13
13
|
|
|
14
14
|
export function startCodexHostedSession({
|
|
15
15
|
command = "codex",
|
|
16
16
|
cwd,
|
|
17
17
|
prompt,
|
|
18
18
|
onEvent,
|
|
19
|
+
onRawLine,
|
|
19
20
|
purpose = "assistant",
|
|
20
21
|
model = null,
|
|
21
22
|
providerArgs = [],
|
|
@@ -44,6 +45,7 @@ export function startCodexHostedSession({
|
|
|
44
45
|
provider: "codex",
|
|
45
46
|
child,
|
|
46
47
|
onEvent,
|
|
48
|
+
onRawLine,
|
|
47
49
|
parsePayload: parseCodexPayload,
|
|
48
50
|
shouldIgnoreStatus(message) {
|
|
49
51
|
return String(message || "").trim() === "Reading additional input from stdin...";
|
|
@@ -96,6 +98,45 @@ export function parseCodexPayload(payload) {
|
|
|
96
98
|
return events;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
if (type === "thread.started") {
|
|
102
|
+
const event = buildStatusEvent(payload.thread_id ? `Codex thread ${payload.thread_id} started` : "Codex thread started");
|
|
103
|
+
if (event) events.push(event);
|
|
104
|
+
return events;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (type === "turn.started") {
|
|
108
|
+
const event = buildStatusEvent("Codex turn started");
|
|
109
|
+
if (event) events.push(event);
|
|
110
|
+
return events;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (type === "turn.completed") {
|
|
114
|
+
const event = providerToolEnd("codex", { status: "ok", usage: payload.usage || null });
|
|
115
|
+
if (event) events.push(event);
|
|
116
|
+
return events;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (type === "item.started") {
|
|
120
|
+
const item = payload.item || {};
|
|
121
|
+
const event = codexItemStartedEvent(item);
|
|
122
|
+
if (event) events.push(event);
|
|
123
|
+
return events;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (type === "item.updated") {
|
|
127
|
+
const item = payload.item || {};
|
|
128
|
+
const event = codexItemUpdatedEvent(item);
|
|
129
|
+
if (event) events.push(event);
|
|
130
|
+
return events;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (type === "item.completed") {
|
|
134
|
+
const item = payload.item || {};
|
|
135
|
+
const event = codexItemCompletedEvent(item);
|
|
136
|
+
if (event) events.push(event);
|
|
137
|
+
return events;
|
|
138
|
+
}
|
|
139
|
+
|
|
99
140
|
if (type && /(tool|command|patch|exec)/i.test(type)) {
|
|
100
141
|
const event = buildToolEvent(
|
|
101
142
|
payload.name || payload.tool_name || payload.command || type,
|
|
@@ -105,15 +146,52 @@ export function parseCodexPayload(payload) {
|
|
|
105
146
|
return events;
|
|
106
147
|
}
|
|
107
148
|
|
|
108
|
-
const fragments = [...new Set(extractTextFragments(payload, []))];
|
|
109
|
-
if (fragments.length > 0) {
|
|
110
|
-
for (const fragment of fragments) {
|
|
111
|
-
events.push({ type: "delta", text: fragment });
|
|
112
|
-
}
|
|
113
|
-
return events;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
149
|
const statusEvent = buildStatusEvent(type ? `Codex event: ${type}` : JSON.stringify(payload));
|
|
117
150
|
if (statusEvent) events.push(statusEvent);
|
|
118
151
|
return events;
|
|
119
152
|
}
|
|
153
|
+
|
|
154
|
+
function codexItemStartedEvent(item) {
|
|
155
|
+
if (!item || typeof item !== "object") return null;
|
|
156
|
+
if (item.type === "command_execution" || item.type === "tool_call") {
|
|
157
|
+
return providerToolStart(item.command || item.name || item.type, {
|
|
158
|
+
id: item.id || null,
|
|
159
|
+
input: item.arguments || item.input || null,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (item.type === "agent_message") return buildStatusEvent("Codex started response");
|
|
163
|
+
if (item.type) return buildStatusEvent(`Codex started ${item.type}`);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function codexItemUpdatedEvent(item) {
|
|
168
|
+
if (!item || typeof item !== "object") return null;
|
|
169
|
+
if (item.type === "agent_message" && typeof item.text === "string") {
|
|
170
|
+
return providerAssistantDelta(item.text);
|
|
171
|
+
}
|
|
172
|
+
if (item.type === "command_execution" || item.type === "tool_call") {
|
|
173
|
+
return providerToolUpdate(item.command || item.name || item.type, {
|
|
174
|
+
id: item.id || null,
|
|
175
|
+
text: item.output || item.status || null,
|
|
176
|
+
data: item,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function codexItemCompletedEvent(item) {
|
|
183
|
+
if (!item || typeof item !== "object") return null;
|
|
184
|
+
if (item.type === "agent_message" && typeof item.text === "string") {
|
|
185
|
+
return providerAssistantFinal(item.text);
|
|
186
|
+
}
|
|
187
|
+
if (item.type === "command_execution" || item.type === "tool_call") {
|
|
188
|
+
return providerToolEnd(item.command || item.name || item.type, {
|
|
189
|
+
id: item.id || null,
|
|
190
|
+
status: item.status === "failed" ? "error" : "ok",
|
|
191
|
+
output: item.output || null,
|
|
192
|
+
data: item,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (item.type) return buildStatusEvent(`Codex completed ${item.type}`);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export const PROVIDER_EVENT_TYPES = new Set([
|
|
2
|
+
"session-start",
|
|
3
|
+
"status",
|
|
4
|
+
"assistant-delta",
|
|
5
|
+
"assistant-final",
|
|
6
|
+
"tool-start",
|
|
7
|
+
"tool-update",
|
|
8
|
+
"tool-end",
|
|
9
|
+
"error",
|
|
10
|
+
"session-end",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export function providerEvent(type, fields = {}) {
|
|
14
|
+
if (!PROVIDER_EVENT_TYPES.has(type)) {
|
|
15
|
+
throw new Error(`Unknown provider event type: ${type}`);
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
type,
|
|
19
|
+
...fields,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function providerStatus(text, fields = {}) {
|
|
24
|
+
const normalized = normalizeText(text);
|
|
25
|
+
if (!normalized) return null;
|
|
26
|
+
return providerEvent("status", { text: normalized, ...fields });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function providerAssistantDelta(text, fields = {}) {
|
|
30
|
+
if (!text) return null;
|
|
31
|
+
return providerEvent("assistant-delta", { text: String(text), ...fields });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function providerAssistantFinal(text, fields = {}) {
|
|
35
|
+
const normalized = normalizeText(text);
|
|
36
|
+
if (!normalized) return null;
|
|
37
|
+
return providerEvent("assistant-final", { text: normalized, ...fields });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function providerToolStart(name, fields = {}) {
|
|
41
|
+
const normalized = normalizeText(name);
|
|
42
|
+
if (!normalized) return null;
|
|
43
|
+
return providerEvent("tool-start", { name: normalized, ...fields });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function providerToolUpdate(name, fields = {}) {
|
|
47
|
+
const normalized = normalizeText(name);
|
|
48
|
+
if (!normalized && !fields.text && !fields.data) return null;
|
|
49
|
+
return providerEvent("tool-update", { ...(normalized ? { name: normalized } : {}), ...fields });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function providerToolEnd(name, fields = {}) {
|
|
53
|
+
const normalized = normalizeText(name);
|
|
54
|
+
if (!normalized) return null;
|
|
55
|
+
return providerEvent("tool-end", { name: normalized, status: fields.status || "ok", ...fields });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function providerError(text, fields = {}) {
|
|
59
|
+
const normalized = normalizeText(text);
|
|
60
|
+
if (!normalized) return null;
|
|
61
|
+
return providerEvent("error", { text: normalized, ...fields });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function providerSessionEnd(fields = {}) {
|
|
65
|
+
return providerEvent("session-end", fields);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function normalizeText(value) {
|
|
69
|
+
const text = String(value || "").trim();
|
|
70
|
+
return text || null;
|
|
71
|
+
}
|
|
@@ -3,7 +3,7 @@ import path from "path";
|
|
|
3
3
|
import { startClaudeHostedSession } from "./claude.mjs";
|
|
4
4
|
import { startCodexHostedSession } from "./codex.mjs";
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
export const HOSTED_ASSISTANT_PROVIDERS = Object.freeze(["codex", "claude"]);
|
|
7
7
|
|
|
8
8
|
export function resolvePreferredProvider(preferred = null, env = process.env) {
|
|
9
9
|
if (preferred && preferred !== "auto") {
|
|
@@ -13,7 +13,7 @@ export function resolvePreferredProvider(preferred = null, env = process.env) {
|
|
|
13
13
|
return preferred;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
for (const provider of
|
|
16
|
+
for (const provider of HOSTED_ASSISTANT_PROVIDERS) {
|
|
17
17
|
if (isProviderInstalled(provider, env)) return provider;
|
|
18
18
|
}
|
|
19
19
|
throw new Error("Neither codex nor claude was found on PATH");
|
|
@@ -63,13 +63,14 @@ export function startProviderSession({
|
|
|
63
63
|
cwd,
|
|
64
64
|
prompt,
|
|
65
65
|
onEvent,
|
|
66
|
+
onRawLine,
|
|
66
67
|
purpose = "assistant",
|
|
67
68
|
env = process.env,
|
|
68
69
|
} = {}) {
|
|
69
70
|
const resolvedProvider = resolvePreferredProvider(provider, env);
|
|
70
71
|
const command = resolveProviderBinary(resolvedProvider, env);
|
|
71
72
|
if (resolvedProvider === "claude") {
|
|
72
|
-
return startClaudeHostedSession({ command, cwd, prompt, onEvent, purpose, model, effort, providerArgs, env });
|
|
73
|
+
return startClaudeHostedSession({ command, cwd, prompt, onEvent, onRawLine, purpose, model, effort, providerArgs, env });
|
|
73
74
|
}
|
|
74
|
-
return startCodexHostedSession({ command, cwd, prompt, onEvent, purpose, model, providerArgs, env });
|
|
75
|
+
return startCodexHostedSession({ command, cwd, prompt, onEvent, onRawLine, purpose, model, providerArgs, env });
|
|
75
76
|
}
|