@elench/testkit 0.1.110 → 0.1.112

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 (49) hide show
  1. package/README.md +1 -1
  2. package/lib/cli/args.mjs +1 -1
  3. package/lib/cli/assistant/actions.mjs +10 -7
  4. package/lib/cli/assistant/app.mjs +70 -20
  5. package/lib/cli/assistant/command-classifier.d.mts +6 -0
  6. package/lib/cli/assistant/command-classifier.d.mts.map +1 -0
  7. package/lib/cli/assistant/command-classifier.mjs +48 -0
  8. package/lib/cli/assistant/command-classifier.mjs.map +1 -0
  9. package/lib/cli/assistant/command-normalize.mjs +22 -0
  10. package/lib/cli/assistant/command-observer.mjs +69 -15
  11. package/lib/cli/assistant/command-results.mjs +12 -35
  12. package/lib/cli/assistant/context-pack.mjs +95 -57
  13. package/lib/cli/assistant/domain.d.mts +59 -0
  14. package/lib/cli/assistant/domain.d.mts.map +1 -0
  15. package/lib/cli/assistant/domain.mjs +2 -0
  16. package/lib/cli/assistant/domain.mjs.map +1 -0
  17. package/lib/cli/assistant/prompt-builder.mjs +21 -13
  18. package/lib/cli/assistant/providers/claude.mjs +77 -19
  19. package/lib/cli/assistant/providers/codex.mjs +8 -12
  20. package/lib/cli/assistant/providers/index.mjs +3 -2
  21. package/lib/cli/assistant/providers/shared.mjs +22 -3
  22. package/lib/cli/assistant/session-paths.d.mts +23 -0
  23. package/lib/cli/assistant/session-paths.d.mts.map +1 -0
  24. package/lib/cli/assistant/session-paths.mjs +31 -0
  25. package/lib/cli/assistant/session-paths.mjs.map +1 -0
  26. package/lib/cli/assistant/session.mjs +13 -3
  27. package/lib/cli/assistant/state.mjs +159 -3
  28. package/lib/cli/assistant/view-model.mjs +69 -9
  29. package/lib/cli/commands/assistant.mjs +3 -0
  30. package/lib/cli/commands/run.mjs +1 -1
  31. package/lib/cli/components/blocks/run-tree.mjs +2 -1
  32. package/lib/cli/entrypoint.mjs +1 -1
  33. package/lib/config/discovery.mjs +0 -10
  34. package/lib/discovery/index.mjs +1 -1
  35. package/lib/domain/test-types.mjs +5 -14
  36. package/lib/runner/maintenance.mjs +2 -2
  37. package/lib/runner/provenance.mjs +4 -1
  38. package/lib/runner/status-model.mjs +26 -9
  39. package/lib/runner/suite-selection.mjs +2 -3
  40. package/node_modules/@elench/next-analysis/package.json +1 -1
  41. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  42. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  43. package/node_modules/@elench/ts-analysis/package.json +1 -1
  44. package/package.json +10 -9
  45. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +0 -188
  46. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +0 -1
  47. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +0 -293
  48. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +0 -1
  49. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +0 -25
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `@elench/testkit` discovers `*.testkit.ts` files, infers suite ownership from the
4
4
  filesystem, starts local services, provisions Docker-managed local Postgres
5
- databases, and runs HTTP, DAL, and Playwright suites.
5
+ databases, and runs test suites.
6
6
 
7
7
  The package is now driven by `testkit.config.ts`, not `testkit.config.json`.
8
8
 
package/lib/cli/args.mjs CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  parseWorkersOption,
7
7
  } from "../runner/execution-config.mjs";
8
8
 
9
- export const POSITIONAL_TYPES = new Set(publicTestTypeList({ includeAll: true, includeLegacy: true }));
9
+ export const POSITIONAL_TYPES = new Set(publicTestTypeList({ includeAll: true }));
10
10
  export const LIFECYCLE = new Set(["status", "destroy", "cleanup"]);
11
11
  export const RESERVED = new Set([...POSITIONAL_TYPES, ...LIFECYCLE]);
12
12
 
@@ -60,13 +60,16 @@ function readContextAction(args, context) {
60
60
  function readFileAction(args, context) {
61
61
  const file = String(args.path || args.file || "").trim();
62
62
  if (!file) throw new Error("read_file requires a path");
63
- const resolved = path.resolve(context.productDir, file);
64
- if (!resolved.startsWith(path.resolve(context.productDir))) {
65
- throw new Error("read_file only supports paths inside the current repository");
66
- }
63
+ const root = fs.realpathSync(path.resolve(context.productDir));
64
+ const resolved = path.resolve(root, file);
67
65
  if (!fs.existsSync(resolved)) {
68
66
  throw new Error(`File not found: ${file}`);
69
67
  }
68
+ const realResolved = fs.realpathSync(resolved);
69
+ const relativeResolved = path.relative(root, realResolved);
70
+ if (relativeResolved.startsWith("..") || path.isAbsolute(relativeResolved)) {
71
+ throw new Error("read_file only supports paths inside the current repository");
72
+ }
70
73
  const startLine = Math.max(1, Number(args.startLine || args.start || 1) || 1);
71
74
  const requestedEnd = Number(args.endLine || args.end || startLine + FILE_LINE_LIMIT - 1) || startLine + FILE_LINE_LIMIT - 1;
72
75
  const endLine = Math.max(startLine, Math.min(requestedEnd, startLine + FILE_LINE_LIMIT - 1));
@@ -75,14 +78,14 @@ function readFileAction(args, context) {
75
78
  for (let lineNumber = startLine; lineNumber <= Math.min(endLine, lines.length); lineNumber += 1) {
76
79
  selected.push(`${lineNumber}: ${lines[lineNumber - 1]}`);
77
80
  }
78
- const title = `File ${path.relative(context.productDir, resolved) || path.basename(resolved)}`;
81
+ const title = `File ${relativeResolved || path.basename(realResolved)}`;
79
82
  return {
80
83
  ok: true,
81
84
  title,
82
85
  text: selected.join("\n"),
83
86
  data: {
84
- path: resolved,
85
- relativePath: path.relative(context.productDir, resolved),
87
+ path: realResolved,
88
+ relativePath: relativeResolved,
86
89
  startLine,
87
90
  endLine,
88
91
  lines: selected,
@@ -2,15 +2,16 @@ import React, { createElement, useEffect, useMemo, useRef, useState } from "reac
2
2
  import { Box, Text, useApp, useBoxMetrics, useCursor, useInput, useStdout } from "ink";
3
3
  import { bold, cyan, dim, green, red, yellow } from "../terminal/colors.mjs";
4
4
  import { RunTreeView } from "../components/blocks/run-tree.mjs";
5
- import { CodeBlock } from "./code-block.mjs";
5
+ import { colorCodeLine } from "./code-block.mjs";
6
6
  import { getComposerDisplayModel } from "./composer.mjs";
7
7
  import { MarkdownBlock } from "./markdown-block.mjs";
8
8
  import { QualitySignalStrip } from "./quality-signal-strip.mjs";
9
9
  import { buildAssistantViewModel } from "./view-model.mjs";
10
- import { truncateText, wrapText } from "../terminal/layout.mjs";
10
+ import { truncateText } from "../terminal/layout.mjs";
11
11
 
12
12
  const FALLBACK_COMMAND_BLOCK_WIDTH = 100;
13
13
  const COMMAND_BLOCK_CHROME_WIDTH = 4;
14
+ const COMMAND_BLOCK_BODY_ROWS = 8;
14
15
 
15
16
  export function AssistantApp({
16
17
  assistantState,
@@ -58,6 +59,10 @@ export function AssistantApp({
58
59
  const runSession = assistantState.getLiveRunSession?.() || assistantState.getLastRunSession?.() || null;
59
60
  const serializedRunSession = snapshot.liveRunSession || snapshot.lastRunSession || null;
60
61
  const runSessionProductDir = serializedRunSession?.productDir || runSession?.productDir || snapshot.productDir;
62
+ const visibleBlocks = useMemo(
63
+ () => runSession ? view.blocks.filter((block) => !isRunCommandBlock(block)) : view.blocks,
64
+ [runSession, view.blocks]
65
+ );
61
66
 
62
67
  return createElement(
63
68
  Box,
@@ -70,9 +75,9 @@ export function AssistantApp({
70
75
  })
71
76
  : null,
72
77
  createElement(HeaderChrome, { view }),
73
- view.blocks.length === 0
78
+ visibleBlocks.length === 0
74
79
  ? createElement(WelcomePanel, { view })
75
- : createElement(Transcript, { view }),
80
+ : createElement(Transcript, { view: { ...view, blocks: visibleBlocks } }),
76
81
  runSession
77
82
  ? createElement(
78
83
  Box,
@@ -105,6 +110,12 @@ export function AssistantApp({
105
110
  );
106
111
  }
107
112
 
113
+ function isRunCommandBlock(block) {
114
+ if (!block || block.kind !== "testkit-run") return false;
115
+ const command = String(block.command || "").trim();
116
+ return /^testkit\s+(run\s+)?(ui|e2e|scenario|int|dal|load|all|run)\b/.test(command);
117
+ }
118
+
108
119
  function HeaderChrome({ view }) {
109
120
  const provider = view.welcome.rows.find(([label]) => label === "Provider")?.[1] || "";
110
121
  return createElement(
@@ -257,12 +268,16 @@ function renderCommandBlock(block, view = {}) {
257
268
  : block.outputPreview?.omittedLineCount || block.omittedOutputLineCount || 0;
258
269
  const blockWidth = Math.max(1, Number(view.terminalWidth) || FALLBACK_COMMAND_BLOCK_WIDTH);
259
270
  const contentWidth = Math.max(1, blockWidth - COMMAND_BLOCK_CHROME_WIDTH);
260
- const commandLines = command ? wrapText(`${dim("$")} ${command}`, contentWidth) : [];
271
+ const commandLine = command ? truncateText(`${dim("$")} ${command}`, contentWidth) : null;
261
272
  const statusLine = status ? truncateText(status, contentWidth) : null;
262
- const previewLines = outputLines.map((line) => truncateText(line, contentWidth));
263
- const omittedLine = omitted > 0
264
- ? truncateText(`… ${omitted} more line${omitted === 1 ? "" : "s"} omitted`, contentWidth)
265
- : null;
273
+ const bodyRows = buildCommandBodyRows({
274
+ commandLine,
275
+ statusLine,
276
+ codeBlock,
277
+ outputLines,
278
+ omitted,
279
+ contentWidth,
280
+ });
266
281
 
267
282
  return [
268
283
  createElement(
@@ -277,21 +292,56 @@ function renderCommandBlock(block, view = {}) {
277
292
  width: blockWidth,
278
293
  },
279
294
  createElement(Text, { key: "title" }, `${marker} ${title}`),
280
- ...commandLines.map((line, index) => createElement(Text, { key: `command-${index}` }, line)),
281
- statusLine ? createElement(Text, { key: "status" }, colorCommandStatus(block, statusLine)) : null,
282
- codeBlock ? createElement(Text, { key: "code-gap" }, "") : null,
283
- ...(codeBlock ? CodeBlock({ lines: codeBlock.lines, language: codeBlock.language, width: contentWidth }) : []),
284
- ...previewLines.map((line, index) => (
285
- createElement(Text, { key: `output-${index}` }, dim(line))
286
- )),
287
- omittedLine ? createElement(Text, { key: "omitted" }, dim(omittedLine)) : null,
288
- block.text && !command && outputLines.length === 0
289
- ? createElement(Text, { key: "text" }, colorBlockText(block, truncateText(block.text, contentWidth)))
290
- : null
295
+ ...bodyRows.map((row, index) => renderCommandBodyRow(block, row, index)),
291
296
  ),
292
297
  ];
293
298
  }
294
299
 
300
+ function buildCommandBodyRows({ commandLine, statusLine, codeBlock, outputLines = [], omitted = 0, contentWidth }) {
301
+ const rows = [];
302
+ if (commandLine) rows.push({ tone: "command", text: commandLine });
303
+ if (statusLine) rows.push({ tone: "status", text: statusLine });
304
+
305
+ if (codeBlock) {
306
+ const available = Math.max(0, COMMAND_BLOCK_BODY_ROWS - rows.length - (omitted > 0 ? 1 : 0));
307
+ for (const line of (codeBlock.lines || []).slice(0, available)) {
308
+ rows.push({ tone: "code", language: codeBlock.language, text: truncateText(line, contentWidth) });
309
+ }
310
+ const omittedCount = Math.max(omitted, (codeBlock.lines || []).length - available);
311
+ if (omittedCount > 0 && rows.length < COMMAND_BLOCK_BODY_ROWS) {
312
+ rows.push({ tone: "muted", text: truncateText(`… ${omittedCount} more line${omittedCount === 1 ? "" : "s"} omitted`, contentWidth) });
313
+ }
314
+ } else {
315
+ const available = Math.max(0, COMMAND_BLOCK_BODY_ROWS - rows.length - (omitted > 0 ? 1 : 0));
316
+ for (const line of outputLines.slice(0, available)) {
317
+ rows.push({ tone: "muted", text: truncateText(line, contentWidth) });
318
+ }
319
+ const omittedCount = Math.max(omitted, outputLines.length - available);
320
+ if (omittedCount > 0 && rows.length < COMMAND_BLOCK_BODY_ROWS) {
321
+ rows.push({ tone: "muted", text: truncateText(`… ${omittedCount} more line${omittedCount === 1 ? "" : "s"} omitted`, contentWidth) });
322
+ }
323
+ }
324
+
325
+ while (rows.length < COMMAND_BLOCK_BODY_ROWS) rows.push({ tone: "blank", text: "" });
326
+ return rows.slice(0, COMMAND_BLOCK_BODY_ROWS);
327
+ }
328
+
329
+ function renderCommandBodyRow(block, row, index) {
330
+ if (row.tone === "status") {
331
+ return createElement(Text, { key: `body-${index}` }, colorCommandStatus(block, row.text));
332
+ }
333
+ if (row.tone === "code") {
334
+ return createElement(Text, { key: `body-${index}` }, colorCodeLine(row.text, row.language));
335
+ }
336
+ if (row.tone === "muted") {
337
+ return createElement(Text, { key: `body-${index}` }, dim(row.text));
338
+ }
339
+ if (row.tone === "blank") {
340
+ return createElement(Text, { key: `body-${index}` }, "");
341
+ }
342
+ return createElement(Text, { key: `body-${index}` }, row.text);
343
+ }
344
+
295
345
  function formatCommandLine(block) {
296
346
  if (!block.command) return null;
297
347
  if (typeof block.command === "string") return block.command;
@@ -0,0 +1,6 @@
1
+ export declare const ASSISTANT_RUN_SHORTCUTS: readonly string[];
2
+ export declare const ASSISTANT_COMMAND_VALUE_FLAGS: readonly string[];
3
+ export type AssistantObservedCommandKind = "run" | "discover" | "status" | "doctor" | "typecheck" | string;
4
+ export declare function classifyAssistantCommandKind(argv?: readonly string[]): AssistantObservedCommandKind;
5
+ export declare function isAssistantRunCommand(kind: string | null | undefined, argv?: readonly string[]): boolean;
6
+ //# sourceMappingURL=command-classifier.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command-classifier.d.mts","sourceRoot":"","sources":["../../../src/cli/assistant/command-classifier.mts"],"names":[],"mappings":"AAAA,eAAO,MAAM,uBAAuB,mBAQlC,CAAC;AAEH,eAAO,MAAM,6BAA6B,mBAUxC,CAAC;AAEH,MAAM,MAAM,4BAA4B,GACpC,KAAK,GACL,UAAU,GACV,QAAQ,GACR,QAAQ,GACR,WAAW,GACX,MAAM,CAAC;AAEX,wBAAgB,4BAA4B,CAAC,IAAI,GAAE,SAAS,MAAM,EAAO,GAAG,4BAA4B,CAIvG;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,IAAI,GAAE,SAAS,MAAM,EAAO,GAAG,OAAO,CAK5G"}
@@ -0,0 +1,48 @@
1
+ export const ASSISTANT_RUN_SHORTCUTS = Object.freeze([
2
+ "ui",
3
+ "e2e",
4
+ "scenario",
5
+ "int",
6
+ "dal",
7
+ "load",
8
+ "all",
9
+ ]);
10
+ export const ASSISTANT_COMMAND_VALUE_FLAGS = Object.freeze([
11
+ "--dir",
12
+ "--service",
13
+ "--type",
14
+ "--suite",
15
+ "--file",
16
+ "--workers",
17
+ "--file-timeout-seconds",
18
+ "--seed",
19
+ "--output-mode",
20
+ ]);
21
+ export function classifyAssistantCommandKind(argv = []) {
22
+ const first = findFirstPositional(argv, ASSISTANT_COMMAND_VALUE_FLAGS);
23
+ if (!first || ASSISTANT_RUN_SHORTCUTS.includes(first))
24
+ return "run";
25
+ return first;
26
+ }
27
+ export function isAssistantRunCommand(kind, argv = []) {
28
+ if (kind === "run")
29
+ return true;
30
+ if (kind && ASSISTANT_RUN_SHORTCUTS.includes(kind))
31
+ return true;
32
+ const first = argv[0] || null;
33
+ return first === "run" || Boolean(first && ASSISTANT_RUN_SHORTCUTS.includes(first));
34
+ }
35
+ function findFirstPositional(args, flagsWithValues) {
36
+ const valueFlags = new Set(flagsWithValues);
37
+ for (let index = 0; index < args.length; index += 1) {
38
+ const value = String(args[index] || "");
39
+ if (valueFlags.has(value)) {
40
+ index += 1;
41
+ continue;
42
+ }
43
+ if (!value.startsWith("-"))
44
+ return value;
45
+ }
46
+ return null;
47
+ }
48
+ //# sourceMappingURL=command-classifier.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command-classifier.mjs","sourceRoot":"","sources":["../../../src/cli/assistant/command-classifier.mts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAC,MAAM,CAAC;IACnD,IAAI;IACJ,KAAK;IACL,UAAU;IACV,KAAK;IACL,KAAK;IACL,MAAM;IACN,KAAK;CACN,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,6BAA6B,GAAG,MAAM,CAAC,MAAM,CAAC;IACzD,OAAO;IACP,WAAW;IACX,QAAQ;IACR,SAAS;IACT,QAAQ;IACR,WAAW;IACX,wBAAwB;IACxB,QAAQ;IACR,eAAe;CAChB,CAAC,CAAC;AAUH,MAAM,UAAU,4BAA4B,CAAC,OAA0B,EAAE;IACvE,MAAM,KAAK,GAAG,mBAAmB,CAAC,IAAI,EAAE,6BAA6B,CAAC,CAAC;IACvE,IAAI,CAAC,KAAK,IAAI,uBAAuB,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACpE,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,IAA+B,EAAE,OAA0B,EAAE;IACjG,IAAI,IAAI,KAAK,KAAK;QAAE,OAAO,IAAI,CAAC;IAChC,IAAI,IAAI,IAAI,uBAAuB,CAAC,QAAQ,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAChE,MAAM,KAAK,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;IAC9B,OAAO,KAAK,KAAK,KAAK,IAAI,OAAO,CAAC,KAAK,IAAI,uBAAuB,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;AACtF,CAAC;AAED,SAAS,mBAAmB,CAAC,IAAuB,EAAE,eAAkC;IACtF,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,eAAe,CAAC,CAAC;IAC5C,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QACpD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;QACxC,IAAI,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YAC1B,KAAK,IAAI,CAAC,CAAC;YACX,SAAS;QACX,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,OAAO,KAAK,CAAC;IAC3C,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
@@ -0,0 +1,22 @@
1
+ export function normalizeCommandLine(value) {
2
+ const text = unwrapShellCommand(value)
3
+ .replace(/\s+/g, " ")
4
+ .trim();
5
+ if (!text) return null;
6
+ return canonicalizeTestkitInvocation(text);
7
+ }
8
+
9
+ export function unwrapShellCommand(value) {
10
+ const text = String(value || "").trim();
11
+ const match = text.match(/(?:^|\s)(?:[^\s'"]*\/)?(?:bash|sh|zsh)\s+-lc\s+(['"])([\s\S]*)\1\s*$/);
12
+ if (!match) return text;
13
+ return match[2].replace(/\\(["'\\$`])/g, "$1").trim();
14
+ }
15
+
16
+ function canonicalizeTestkitInvocation(command) {
17
+ return command
18
+ .replace(/^npx\s+(?:--yes\s+|-y\s+)?testkit\b/, "testkit")
19
+ .replace(/^node(?:\s+--[^\s]+)*\s+(?:(?:\.?\.?\/)?[^\s]+\/)?(?:bin\/)?testkit(?:\.(?:mjs|js))?\b/, "testkit")
20
+ .replace(/^(?:(?:\.?\.?\/)?[^\s]+\/)?(?:bin\/)?testkit(?:\.(?:mjs|js))?\b/, "testkit")
21
+ .trim();
22
+ }
@@ -1,30 +1,32 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { publicTestTypeList } from "../../domain/test-types.mjs";
3
+ import { isAssistantRunCommand } from "./command-classifier.mjs";
4
4
 
5
5
  const POLL_INTERVAL_MS = 150;
6
6
  const OBSERVED_KINDS = new Set(["run", "discover", "status", "doctor", "typecheck"]);
7
- const RUN_KINDS = new Set(["run", ...publicTestTypeList({ includeAll: true, includeLegacy: true })]);
8
7
 
9
8
  export function createAssistantCommandObserver({
10
9
  productDir,
11
10
  commandLog,
11
+ turnId = null,
12
12
  runState,
13
13
  onEvent,
14
14
  intervalMs = POLL_INTERVAL_MS,
15
15
  } = {}) {
16
16
  const seenResultFiles = new Set();
17
- const seenCommandLogEvents = new Set();
18
17
  const observedRunCommandIds = new Set();
18
+ const hydratedArtifactKeys = new Set();
19
19
  let timer = null;
20
20
  let running = false;
21
21
  let lastArtifactSignatures = new Map();
22
+ let commandLogOffset = 0;
22
23
 
23
24
  function start() {
24
25
  if (running) return;
25
26
  running = true;
27
+ commandLogOffset = currentCommandLogSize();
28
+ markExistingResultFilesSeen();
26
29
  lastArtifactSignatures = readArtifactSignatures();
27
- scan();
28
30
  timer = setInterval(scan, intervalMs);
29
31
  }
30
32
 
@@ -44,18 +46,30 @@ export function createAssistantCommandObserver({
44
46
  function observeCommandLog() {
45
47
  const commandLogPath = commandLog?.commandLogPath;
46
48
  if (!commandLogPath || !fs.existsSync(commandLogPath)) return;
47
- const lines = fs.readFileSync(commandLogPath, "utf8").split(/\r?\n/).filter(Boolean);
48
- for (const [index, line] of lines.entries()) {
49
- const eventKey = `${index}:${line}`;
50
- if (seenCommandLogEvents.has(eventKey)) continue;
51
- seenCommandLogEvents.add(eventKey);
49
+ const stat = safeStat(commandLogPath);
50
+ if (!stat) return;
51
+ if (stat.size < commandLogOffset) commandLogOffset = 0;
52
+ if (stat.size === commandLogOffset) return;
53
+ const file = fs.openSync(commandLogPath, "r");
54
+ let chunk = "";
55
+ try {
56
+ const length = stat.size - commandLogOffset;
57
+ const buffer = Buffer.alloc(length);
58
+ fs.readSync(file, buffer, 0, length, commandLogOffset);
59
+ commandLogOffset = stat.size;
60
+ chunk = buffer.toString("utf8");
61
+ } finally {
62
+ fs.closeSync(file);
63
+ }
64
+ const lines = chunk.split(/\r?\n/).filter(Boolean);
65
+ for (const line of lines) {
52
66
  let event = null;
53
67
  try {
54
68
  event = JSON.parse(line);
55
69
  } catch {
56
70
  continue;
57
71
  }
58
- if (event.sessionId && commandLog.sessionId && event.sessionId !== commandLog.sessionId) continue;
72
+ if (!isCurrentObservation(event)) continue;
59
73
  if (event.type === "command_start" && isRunCommand(event)) {
60
74
  observedRunCommandIds.add(event.commandId);
61
75
  }
@@ -78,9 +92,12 @@ export function createAssistantCommandObserver({
78
92
  } catch {
79
93
  continue;
80
94
  }
81
- if (document.sessionId && commandLog.sessionId && document.sessionId !== commandLog.sessionId) continue;
82
- if (!OBSERVED_KINDS.has(document.kind)) continue;
95
+ if (!isCurrentObservation(document)) {
96
+ seenResultFiles.add(filePath);
97
+ continue;
98
+ }
83
99
  seenResultFiles.add(filePath);
100
+ if (!OBSERVED_KINDS.has(document.kind)) continue;
84
101
  if (document.kind === "run") {
85
102
  observedRunCommandIds.add(document.commandId);
86
103
  hydrateRunArtifact("command-result", document);
@@ -126,6 +143,9 @@ export function createAssistantCommandObserver({
126
143
  function hydrateRunArtifact(source, command = null) {
127
144
  const artifact = loadObservedRunArtifact(command);
128
145
  if (!artifact) return;
146
+ const key = artifactKey(source, command, artifact);
147
+ if (hydratedArtifactKeys.has(key)) return;
148
+ hydratedArtifactKeys.add(key);
129
149
  runState?.hydrateFromArtifact?.(artifact);
130
150
  onEvent?.({
131
151
  type: "observed-run-artifact",
@@ -146,15 +166,14 @@ export function createAssistantCommandObserver({
146
166
  const assistant = artifact?.provenance?.assistant || {};
147
167
  if (!assistant.sessionId || !assistant.commandId) return false;
148
168
  if (commandLog?.sessionId && assistant.sessionId !== commandLog.sessionId) return false;
169
+ if (turnId && assistant.turnId !== turnId) return false;
149
170
  return observedRunCommandIds.has(assistant.commandId);
150
171
  }
151
172
 
152
173
  function isRunCommand(event) {
153
174
  if (!event?.commandId) return false;
154
- if (event.kind === "run") return true;
155
- if (RUN_KINDS.has(event.kind)) return true;
156
175
  const argv = Array.isArray(event.argv) ? event.argv : [];
157
- return argv[0] === "run" || RUN_KINDS.has(argv[0]);
176
+ return isAssistantRunCommand(event.kind, argv);
158
177
  }
159
178
 
160
179
  function readJsonFile(filePath) {
@@ -165,6 +184,29 @@ export function createAssistantCommandObserver({
165
184
  }
166
185
  }
167
186
 
187
+ function isCurrentObservation(entry) {
188
+ if (entry?.sessionId && commandLog?.sessionId && entry.sessionId !== commandLog.sessionId) return false;
189
+ if (turnId && entry?.turnId !== turnId) return false;
190
+ return true;
191
+ }
192
+
193
+ function markExistingResultFilesSeen() {
194
+ const resultDir = commandLog?.resultDir;
195
+ if (!resultDir || !fs.existsSync(resultDir)) return;
196
+ for (const entry of fs.readdirSync(resultDir, { withFileTypes: true })) {
197
+ if (entry.isFile() && entry.name.endsWith(".json")) {
198
+ seenResultFiles.add(path.join(resultDir, entry.name));
199
+ }
200
+ }
201
+ }
202
+
203
+ function currentCommandLogSize() {
204
+ const commandLogPath = commandLog?.commandLogPath;
205
+ if (!commandLogPath) return 0;
206
+ const stat = safeStat(commandLogPath);
207
+ return stat?.size || 0;
208
+ }
209
+
168
210
  function safeStat(filePath) {
169
211
  try {
170
212
  return fs.statSync(filePath);
@@ -173,6 +215,18 @@ export function createAssistantCommandObserver({
173
215
  }
174
216
  }
175
217
 
218
+ function artifactKey(source, command, artifact) {
219
+ const assistant = artifact?.provenance?.assistant || {};
220
+ const runId = artifact?.run?.id || command?.result?.runArtifact?.run?.id || command?.artifact?.run?.id || "unknown-run";
221
+ return [
222
+ source,
223
+ assistant.sessionId || commandLog?.sessionId || "",
224
+ assistant.turnId || turnId || "",
225
+ assistant.commandId || command?.commandId || "",
226
+ runId,
227
+ ].join("|");
228
+ }
229
+
176
230
  return {
177
231
  start,
178
232
  stop,
@@ -1,26 +1,14 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
- import { publicTestTypeList } from "../../domain/test-types.mjs";
3
+ import { classifyAssistantCommandKind } from "./command-classifier.mjs";
4
4
 
5
5
  export const ASSISTANT_SESSION_ENV = "TESTKIT_ASSISTANT_SESSION_ID";
6
6
  export const ASSISTANT_RESULT_DIR_ENV = "TESTKIT_ASSISTANT_RESULT_DIR";
7
7
  export const ASSISTANT_COMMAND_LOG_ENV = "TESTKIT_ASSISTANT_COMMAND_LOG";
8
8
  export const ASSISTANT_COMMAND_ID_ENV = "TESTKIT_ASSISTANT_COMMAND_ID";
9
+ export const ASSISTANT_TURN_ENV = "TESTKIT_ASSISTANT_TURN_ID";
9
10
  export const ASSISTANT_WRAPPER_LOGGED_ENV = "TESTKIT_ASSISTANT_WRAPPER_LOGGED";
10
11
 
11
- const RUN_SHORTCUTS = new Set(publicTestTypeList({ includeAll: true, includeLegacy: true }));
12
- const FLAGS_WITH_VALUES = new Set([
13
- "--dir",
14
- "--service",
15
- "--type",
16
- "--suite",
17
- "--file",
18
- "--workers",
19
- "--file-timeout-seconds",
20
- "--seed",
21
- "--output-mode",
22
- ]);
23
-
24
12
  export function createAssistantCommandContext({
25
13
  kind,
26
14
  argv = process.argv.slice(2),
@@ -28,6 +16,7 @@ export function createAssistantCommandContext({
28
16
  env = process.env,
29
17
  } = {}) {
30
18
  const sessionId = env[ASSISTANT_SESSION_ENV] || null;
19
+ const turnId = env[ASSISTANT_TURN_ENV] || null;
31
20
  const resultDir = env[ASSISTANT_RESULT_DIR_ENV] || null;
32
21
  const commandLogPath = env[ASSISTANT_COMMAND_LOG_ENV] || null;
33
22
  if (!sessionId && !resultDir && !commandLogPath) return null;
@@ -36,10 +25,11 @@ export function createAssistantCommandContext({
36
25
  const startedAt = new Date().toISOString();
37
26
  return {
38
27
  sessionId,
28
+ turnId,
39
29
  resultDir,
40
30
  commandLogPath,
41
31
  commandId,
42
- kind: kind || inferCommandKind(argv),
32
+ kind: kind || classifyAssistantCommandKind(argv),
43
33
  argv: Array.isArray(argv) ? argv.map(String) : [],
44
34
  cwd,
45
35
  startedAt,
@@ -116,6 +106,7 @@ export function writeAssistantCommandResult(context, payload = {}) {
116
106
  schemaVersion: 1,
117
107
  source: "testkit-command-result",
118
108
  sessionId: context.sessionId,
109
+ turnId: context.turnId,
119
110
  commandId: context.commandId,
120
111
  kind: context.kind,
121
112
  argv: context.argv,
@@ -141,7 +132,12 @@ export function appendAssistantCommandLog(context, event) {
141
132
  fs.mkdirSync(path.dirname(commandLogPath), { recursive: true });
142
133
  fs.appendFileSync(
143
134
  commandLogPath,
144
- `${JSON.stringify({ timestamp: new Date().toISOString(), sessionId: context?.sessionId || null, ...event })}\n`,
135
+ `${JSON.stringify({
136
+ timestamp: new Date().toISOString(),
137
+ sessionId: context?.sessionId || null,
138
+ turnId: context?.turnId || null,
139
+ ...event,
140
+ })}\n`,
145
141
  "utf8"
146
142
  );
147
143
  } catch {
@@ -149,25 +145,6 @@ export function appendAssistantCommandLog(context, event) {
149
145
  }
150
146
  }
151
147
 
152
- function inferCommandKind(argv) {
153
- const first = findFirstPositional(argv);
154
- if (!first || RUN_SHORTCUTS.has(first)) return "run";
155
- return first;
156
- }
157
-
158
- function findFirstPositional(argv) {
159
- const args = Array.isArray(argv) ? argv : [];
160
- for (let index = 0; index < args.length; index += 1) {
161
- const arg = String(args[index]);
162
- if (FLAGS_WITH_VALUES.has(arg)) {
163
- index += 1;
164
- continue;
165
- }
166
- if (!arg.startsWith("-")) return arg;
167
- }
168
- return null;
169
- }
170
-
171
148
  function inferExitCode(result) {
172
149
  if (Number.isInteger(result?.exitCode)) return result.exitCode;
173
150
  if (result?.ok === false) return 1;