@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
@@ -13,6 +13,7 @@ export function createHostedSessionRunner({
13
13
  child,
14
14
  onEvent,
15
15
  onRawLine,
16
+ timeoutMs = null,
16
17
  parsePayload,
17
18
  readFinalText,
18
19
  shouldIgnoreStatus,
@@ -22,6 +23,7 @@ export function createHostedSessionRunner({
22
23
  let assistantText = "";
23
24
  let finalText = null;
24
25
  let lastErrorMessage = null;
26
+ let timedOut = false;
25
27
 
26
28
  const emit = (event) => {
27
29
  if (!event) return;
@@ -38,6 +40,18 @@ export function createHostedSessionRunner({
38
40
  };
39
41
 
40
42
  emit(providerEvent("session-start"));
43
+ const timeout = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
44
+ ? setTimeout(() => {
45
+ timedOut = true;
46
+ lastErrorMessage = `${provider} timed out after ${Math.floor(Number(timeoutMs))}ms`;
47
+ emit(providerError(lastErrorMessage));
48
+ try {
49
+ child.kill("SIGTERM");
50
+ } catch {
51
+ // Ignore timeout cancellation races.
52
+ }
53
+ }, Math.floor(Number(timeoutMs)))
54
+ : null;
41
55
 
42
56
  const stdoutReader = readline.createInterface({ input: child.stdout });
43
57
  const stdoutClosed = waitForReaderClose(stdoutReader);
@@ -62,11 +76,16 @@ export function createHostedSessionRunner({
62
76
  });
63
77
 
64
78
  const completion = (async () => {
65
- const result = await child;
66
- await Promise.all([stdoutClosed, stderrClosed]);
79
+ let result;
80
+ try {
81
+ result = await child;
82
+ await Promise.all([stdoutClosed, stderrClosed]);
83
+ } finally {
84
+ if (timeout) clearTimeout(timeout);
85
+ }
67
86
  const fileFinalText = readFinalText ? readFinalText(result) : null;
68
87
  const resolvedFinalText = fileFinalText || finalText || assistantText.trim() || null;
69
- if ((result.exitCode ?? 0) !== 0) {
88
+ if (timedOut || (result.exitCode ?? 0) !== 0) {
70
89
  const message = lastErrorMessage || result.stderr || `${provider} exited with code ${result.exitCode ?? 1}`;
71
90
  emit(providerError(message));
72
91
  throw new Error(message);
@@ -0,0 +1,23 @@
1
+ export interface AssistantSessionPaths {
2
+ assistantRoot: string;
3
+ sessionsDir: string;
4
+ contextDir: string;
5
+ contextPath: string;
6
+ currentPath: string;
7
+ summaryPath: string;
8
+ selectionPath: string;
9
+ commandsPath: string;
10
+ commandLogPath: string;
11
+ resultDir: string;
12
+ focusedDetailPath: string;
13
+ focusedLogsPath: string;
14
+ focusedArtifactsPath: string;
15
+ focusedSetupPath: string;
16
+ binDir: string;
17
+ wrapperPath: string;
18
+ providerEventsPath: string;
19
+ providerRawPath: string;
20
+ }
21
+ export declare function createAssistantSessionId(now?: number, random?: () => number): string;
22
+ export declare function assistantSessionPaths(productDir: string, sessionId: string): AssistantSessionPaths;
23
+ //# sourceMappingURL=session-paths.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-paths.d.mts","sourceRoot":"","sources":["../../../src/cli/assistant/session-paths.mts"],"names":[],"mappings":"AAEA,MAAM,WAAW,qBAAqB;IACpC,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,EAAE,MAAM,CAAC;IACxB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,gBAAgB,EAAE,MAAM,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,wBAAgB,wBAAwB,CAAC,GAAG,GAAE,MAAmB,EAAE,MAAM,GAAE,MAAM,MAAoB,GAAG,MAAM,CAE7G;AAED,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,qBAAqB,CAyBlG"}
@@ -0,0 +1,31 @@
1
+ import path from "node:path";
2
+ export function createAssistantSessionId(now = Date.now(), random = Math.random) {
3
+ return `session-${now}-${random().toString(36).slice(2, 10)}`;
4
+ }
5
+ export function assistantSessionPaths(productDir, sessionId) {
6
+ const assistantRoot = path.join(productDir, ".testkit", "assistant");
7
+ const sessionsDir = path.join(assistantRoot, "sessions");
8
+ const contextDir = path.join(sessionsDir, sessionId);
9
+ const binDir = path.join(contextDir, "bin");
10
+ return {
11
+ assistantRoot,
12
+ sessionsDir,
13
+ contextDir,
14
+ contextPath: path.join(contextDir, "context.md"),
15
+ currentPath: path.join(assistantRoot, "current.json"),
16
+ summaryPath: path.join(contextDir, "latest-run-summary.json"),
17
+ selectionPath: path.join(contextDir, "current-selection.json"),
18
+ commandsPath: path.join(contextDir, "commands.md"),
19
+ commandLogPath: path.join(contextDir, "commands.jsonl"),
20
+ resultDir: path.join(contextDir, "command-results"),
21
+ focusedDetailPath: path.join(contextDir, "focused-detail.txt"),
22
+ focusedLogsPath: path.join(contextDir, "focused-logs.txt"),
23
+ focusedArtifactsPath: path.join(contextDir, "focused-artifacts.txt"),
24
+ focusedSetupPath: path.join(contextDir, "focused-setup.txt"),
25
+ binDir,
26
+ wrapperPath: path.join(binDir, "testkit"),
27
+ providerEventsPath: path.join(contextDir, "provider-events.jsonl"),
28
+ providerRawPath: path.join(contextDir, "provider-raw.jsonl"),
29
+ };
30
+ }
31
+ //# sourceMappingURL=session-paths.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-paths.mjs","sourceRoot":"","sources":["../../../src/cli/assistant/session-paths.mts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAuB7B,MAAM,UAAU,wBAAwB,CAAC,MAAc,IAAI,CAAC,GAAG,EAAE,EAAE,SAAuB,IAAI,CAAC,MAAM;IACnG,OAAO,WAAW,GAAG,IAAI,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC;AAChE,CAAC;AAED,MAAM,UAAU,qBAAqB,CAAC,UAAkB,EAAE,SAAiB;IACzE,MAAM,aAAa,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,UAAU,EAAE,WAAW,CAAC,CAAC;IACrE,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,UAAU,CAAC,CAAC;IACzD,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,SAAS,CAAC,CAAC;IACrD,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IAC5C,OAAO;QACL,aAAa;QACb,WAAW;QACX,UAAU;QACV,WAAW,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,YAAY,CAAC;QAChD,WAAW,EAAE,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,cAAc,CAAC;QACrD,WAAW,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,yBAAyB,CAAC;QAC7D,aAAa,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,wBAAwB,CAAC;QAC9D,YAAY,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,aAAa,CAAC;QAClD,cAAc,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,gBAAgB,CAAC;QACvD,SAAS,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,iBAAiB,CAAC;QACnD,iBAAiB,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,oBAAoB,CAAC;QAC9D,eAAe,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,kBAAkB,CAAC;QAC1D,oBAAoB,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,uBAAuB,CAAC;QACpE,gBAAgB,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,mBAAmB,CAAC;QAC5D,MAAM;QACN,WAAW,EAAE,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,SAAS,CAAC;QACzC,kBAAkB,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,uBAAuB,CAAC;QAClE,eAAe,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,oBAAoB,CAAC;KAC7D,CAAC;AACJ,CAAC"}
@@ -11,6 +11,7 @@ export async function runAssistantConversationTurn({
11
11
  userMessage,
12
12
  provider = "auto",
13
13
  settings = null,
14
+ turnId = null,
14
15
  env = process.env,
15
16
  configs,
16
17
  commandLog,
@@ -24,6 +25,7 @@ export async function runAssistantConversationTurn({
24
25
  productDir,
25
26
  runState,
26
27
  commandLog,
28
+ turnId,
27
29
  onEvent: onToolEvent,
28
30
  });
29
31
 
@@ -38,12 +40,13 @@ export async function runAssistantConversationTurn({
38
40
 
39
41
  const runtimeSettings = settings || { provider };
40
42
  const resolvedProvider = resolvePreferredProvider(runtimeSettings.provider || provider, env);
41
- const providerEnv = commandLog?.providerEnv?.(env) || env;
43
+ const providerEnv = commandLog?.providerEnv?.(env, { turnId }) || env;
44
+ const timeoutMs = resolveProviderTimeoutMs(providerEnv);
42
45
  const tracePath = shouldTraceProviderEvents(env, providerEnv)
43
- ? path.join(commandLog?.contextDir || path.join(productDir, ".testkit", "assistant"), "provider-events.jsonl")
46
+ ? commandLog?.providerEventsPath || path.join(commandLog?.contextDir || path.join(productDir, ".testkit", "assistant"), "provider-events.jsonl")
44
47
  : null;
45
48
  const rawTracePath = shouldTraceProviderEvents(env, providerEnv)
46
- ? path.join(commandLog?.contextDir || path.join(productDir, ".testkit", "assistant"), "provider-raw.jsonl")
49
+ ? commandLog?.providerRawPath || path.join(commandLog?.contextDir || path.join(productDir, ".testkit", "assistant"), "provider-raw.jsonl")
47
50
  : null;
48
51
  onResolvedProvider?.(resolvedProvider);
49
52
  onPrompt?.({
@@ -67,6 +70,7 @@ export async function runAssistantConversationTurn({
67
70
  model: runtimeSettings.model || null,
68
71
  effort: runtimeSettings.effort || null,
69
72
  providerArgs: runtimeSettings.providerArgs || [],
73
+ timeoutMs,
70
74
  cwd: productDir,
71
75
  prompt,
72
76
  purpose: "assistant",
@@ -91,6 +95,12 @@ export async function runAssistantConversationTurn({
91
95
  }
92
96
  }
93
97
 
98
+ function resolveProviderTimeoutMs(env) {
99
+ const explicit = Number(env?.TESTKIT_ASSISTANT_PROVIDER_TIMEOUT_MS);
100
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
101
+ return 600_000;
102
+ }
103
+
94
104
  function formatProviderEvent(event) {
95
105
  if (event.type === "tool-start") {
96
106
  return `${event.provider}: ${event.name}${event.detail ? ` (${event.detail})` : ""}`;
@@ -17,6 +17,7 @@ import { parseSlashCommand, formatSlashHelpLines } from "./slash-commands.mjs";
17
17
  import { executeAssistantAction } from "./actions.mjs";
18
18
  import { runAssistantConversationTurn } from "./session.mjs";
19
19
  import { prepareAssistantContextPack } from "./context-pack.mjs";
20
+ import { normalizeCommandLine, unwrapShellCommand } from "./command-normalize.mjs";
20
21
  import {
21
22
  discoverAssistantModels,
22
23
  formatModelChoices,
@@ -41,6 +42,8 @@ import {
41
42
  } from "./composer.mjs";
42
43
  import { buildContextUsage } from "./context-window.mjs";
43
44
 
45
+ const SNAPSHOT_MESSAGE_TEXT_LIMIT = 20_000;
46
+
44
47
  export function createAssistantState({
45
48
  productDir,
46
49
  provider,
@@ -62,6 +65,7 @@ export function createAssistantState({
62
65
 
63
66
  const listeners = new Set();
64
67
  const messages = [];
68
+ const diagnostics = [];
65
69
  let composerState = createComposerState();
66
70
  let notice = null;
67
71
  let busy = false;
@@ -84,6 +88,8 @@ export function createAssistantState({
84
88
  if (sanitizedStartup.notice) notice = sanitizedStartup.notice;
85
89
  let activeStatus = null;
86
90
  let startupNoticeEmitted = false;
91
+ let lastTurnError = null;
92
+ let activeTurn = idleTurn();
87
93
  let contextUsage = buildContextUsage({
88
94
  provider: resolvedProviderName || settings.provider,
89
95
  model: settings.model,
@@ -105,6 +111,7 @@ export function createAssistantState({
105
111
  function appendMessage(message) {
106
112
  const entry = {
107
113
  id: `msg-${messages.length + 1}`,
114
+ ...(activeTurn?.id && !message.turnId ? { turnId: activeTurn.id } : {}),
108
115
  ...message,
109
116
  };
110
117
  messages.push(entry);
@@ -130,6 +137,14 @@ export function createAssistantState({
130
137
  notify();
131
138
  }
132
139
 
140
+ function appendDiagnostic(diagnostic) {
141
+ diagnostics.push({
142
+ timestamp: new Date().toISOString(),
143
+ ...diagnostic,
144
+ });
145
+ notify();
146
+ }
147
+
133
148
  function refreshContextPack() {
134
149
  commandLog.refresh();
135
150
  }
@@ -170,6 +185,7 @@ export function createAssistantState({
170
185
  commandLog,
171
186
  attachRunSession,
172
187
  completeRunSession,
188
+ updateMessage,
173
189
 
174
190
  async loadLatestArtifact() {
175
191
  try {
@@ -334,11 +350,26 @@ export function createAssistantState({
334
350
  async submitInput(input) {
335
351
  const trimmed = String(input || "").trim();
336
352
  if (!trimmed) return;
353
+ if (busy) {
354
+ const message = "Assistant is already handling a turn.";
355
+ lastTurnError = {
356
+ kind: "concurrency",
357
+ message,
358
+ };
359
+ appendDiagnostic({
360
+ level: "error",
361
+ code: "assistant_turn_already_active",
362
+ message,
363
+ });
364
+ throw new Error(message);
365
+ }
366
+ lastTurnError = null;
337
367
  if (notice && !startupNoticeEmitted) {
338
368
  startupNoticeEmitted = true;
339
369
  appendMessage({ role: "system", text: notice });
340
370
  }
341
- appendMessage({ role: "user", text: trimmed });
371
+ const turnId = createAssistantTurnId();
372
+ appendMessage({ role: "user", text: trimmed, turnId });
342
373
 
343
374
  const slash = parseSlashCommandSafe(trimmed);
344
375
  if (slash?.type === "__error__") {
@@ -347,6 +378,8 @@ export function createAssistantState({
347
378
  }
348
379
  if (slash) {
349
380
  try {
381
+ activeTurn = { id: turnId, state: "slash_running", input: trimmed, startedAt: new Date().toISOString() };
382
+ commandLog.setActiveTurnId?.(turnId);
350
383
  setBusy(true, `Running ${slash.type}...`);
351
384
  await executeSlashCommand({
352
385
  slash,
@@ -363,6 +396,14 @@ export function createAssistantState({
363
396
  text: error instanceof Error ? error.message : String(error),
364
397
  });
365
398
  } finally {
399
+ commandLog.setActiveTurnId?.(null);
400
+ activeTurn = {
401
+ id: turnId,
402
+ state: "idle",
403
+ input: trimmed,
404
+ startedAt: activeTurn?.startedAt || new Date().toISOString(),
405
+ finishedAt: new Date().toISOString(),
406
+ };
366
407
  setBusy(false, null);
367
408
  }
368
409
  refreshContextPack();
@@ -371,6 +412,8 @@ export function createAssistantState({
371
412
  }
372
413
 
373
414
  try {
415
+ activeTurn = { id: turnId, state: "provider_running", input: trimmed, startedAt: new Date().toISOString() };
416
+ commandLog.setActiveTurnId?.(turnId);
374
417
  setBusy(true, `Thinking with ${settings.provider === "auto" ? "provider" : settings.provider}...`);
375
418
  const providerTurn = createProviderTurnState();
376
419
  await runAssistantConversationTurn({
@@ -379,6 +422,7 @@ export function createAssistantState({
379
422
  transcript: buildConversationTranscript(messages),
380
423
  userMessage: trimmed,
381
424
  settings,
425
+ turnId,
382
426
  env,
383
427
  configs,
384
428
  commandLog,
@@ -413,12 +457,34 @@ export function createAssistantState({
413
457
  },
414
458
  });
415
459
  } catch (error) {
460
+ lastTurnError = {
461
+ kind: "provider",
462
+ message: error instanceof Error ? error.message : String(error),
463
+ };
416
464
  appendMessage({
417
465
  role: "system",
418
- text: error instanceof Error ? error.message : String(error),
466
+ text: lastTurnError.message,
419
467
  });
468
+ activeTurn = {
469
+ id: turnId,
470
+ state: "failed",
471
+ input: trimmed,
472
+ startedAt: activeTurn?.startedAt || new Date().toISOString(),
473
+ failedAt: new Date().toISOString(),
474
+ error: lastTurnError,
475
+ };
420
476
  } finally {
477
+ commandLog.setActiveTurnId?.(null);
421
478
  refreshContextPack();
479
+ if (activeTurn?.id === turnId && activeTurn.state !== "failed") {
480
+ activeTurn = {
481
+ id: turnId,
482
+ state: "idle",
483
+ input: trimmed,
484
+ startedAt: activeTurn?.startedAt || new Date().toISOString(),
485
+ finishedAt: new Date().toISOString(),
486
+ };
487
+ }
422
488
  setBusy(false, null);
423
489
  }
424
490
  },
@@ -429,11 +495,13 @@ export function createAssistantState({
429
495
  },
430
496
 
431
497
  getSnapshot() {
498
+ const snapshotMessages = messages.map(serializeMessageForSnapshot);
432
499
  return {
433
500
  context: buildContextSelection(runState.getSnapshot()),
434
501
  run: runState.getSnapshot(),
435
502
  productDir,
436
- messages: [...messages],
503
+ messages: snapshotMessages,
504
+ activities: buildSnapshotActivities(snapshotMessages),
437
505
  composer: composerState.text,
438
506
  composerCursor: composerState.cursor,
439
507
  notice,
@@ -445,15 +513,24 @@ export function createAssistantState({
445
513
  providerArgs: [...settings.providerArgs],
446
514
  cliConfig,
447
515
  activeStatus,
516
+ turn: activeTurn,
517
+ lastTurnError,
518
+ diagnostics: [...diagnostics],
448
519
  contextUsage,
449
520
  liveRunSession: serializeRunSession(liveRunSession),
450
521
  lastRunSession: serializeRunSession(lastRunSession),
451
522
  contextPaths: {
523
+ sessionId: commandLog.sessionId,
524
+ currentPath: commandLog.currentPath,
525
+ contextDir: commandLog.contextDir,
452
526
  contextPath: commandLog.contextPath,
453
527
  summaryPath: commandLog.summaryPath,
454
528
  selectionPath: commandLog.selectionPath,
455
529
  commandsPath: commandLog.commandsPath,
456
530
  commandLogPath: commandLog.commandLogPath,
531
+ resultDir: commandLog.resultDir,
532
+ providerEventsPath: commandLog.providerEventsPath,
533
+ providerRawPath: commandLog.providerRawPath,
457
534
  },
458
535
  };
459
536
  },
@@ -470,6 +547,17 @@ function resolveInitialProvider(provider, env) {
470
547
  return null;
471
548
  }
472
549
 
550
+ function idleTurn() {
551
+ return {
552
+ id: null,
553
+ state: "idle",
554
+ };
555
+ }
556
+
557
+ function createAssistantTurnId(now = Date.now(), random = Math.random) {
558
+ return `turn-${now}-${random().toString(36).slice(2, 10)}`;
559
+ }
560
+
473
561
  async function executeSlashCommand({
474
562
  slash,
475
563
  state,
@@ -626,6 +714,7 @@ function handleAssistantToolEvent(state, event, appendMessage) {
626
714
  return;
627
715
  }
628
716
  if (event.type === "observed-testkit-command") {
717
+ suppressMatchingProviderCommand(state, event.command);
629
718
  appendMessage({
630
719
  role: "tool",
631
720
  toolName: event.command?.kind || "testkit",
@@ -641,6 +730,28 @@ function handleAssistantToolEvent(state, event, appendMessage) {
641
730
  }
642
731
  }
643
732
 
733
+ function suppressMatchingProviderCommand(state, command) {
734
+ const observed = normalizeCommandLine(formatObservedCommandLine(command));
735
+ if (!observed) return;
736
+ const snapshot = state.getSnapshot();
737
+ for (const message of snapshot.messages || []) {
738
+ if (message.role !== "provider-tool") continue;
739
+ const providerCommand = normalizeCommandLine(providerToolCommandLine(message.data));
740
+ if (!providerCommand || providerCommand !== observed) continue;
741
+ state.updateMessage?.(message.id, (current) => ({
742
+ data: {
743
+ ...(current.data || {}),
744
+ supersededByTestkitCommand: command?.commandId || true,
745
+ },
746
+ }));
747
+ }
748
+ }
749
+
750
+ function providerToolCommandLine(event) {
751
+ if (!event) return null;
752
+ return event.input || event.data?.command || event.data?.input || null;
753
+ }
754
+
644
755
  function createProviderTurnState() {
645
756
  return {
646
757
  assistantMessageId: null,
@@ -891,6 +1002,51 @@ function serializeRunSession(session) {
891
1002
  };
892
1003
  }
893
1004
 
1005
+ function serializeMessageForSnapshot(message) {
1006
+ const text = String(message?.text || "");
1007
+ if (text.length <= SNAPSHOT_MESSAGE_TEXT_LIMIT) return { ...message };
1008
+ return {
1009
+ ...message,
1010
+ text: `${text.slice(0, SNAPSHOT_MESSAGE_TEXT_LIMIT)}\n... ${text.length - SNAPSHOT_MESSAGE_TEXT_LIMIT} characters omitted from snapshot`,
1011
+ fullTextOmitted: true,
1012
+ fullTextLength: text.length,
1013
+ };
1014
+ }
1015
+
1016
+ function buildSnapshotActivities(messages) {
1017
+ return (messages || []).map((message) => {
1018
+ const base = {
1019
+ id: message.id,
1020
+ turnId: message.turnId || null,
1021
+ title: message.title || null,
1022
+ text: message.text || "",
1023
+ status: message.status || null,
1024
+ data: message.data || null,
1025
+ };
1026
+ if (message.role === "user") return { ...base, kind: "user_message" };
1027
+ if (message.role === "assistant") return { ...base, kind: "assistant_message" };
1028
+ if (message.role === "provider-tool") {
1029
+ return {
1030
+ ...base,
1031
+ kind: "provider_command",
1032
+ command: providerToolCommandLine(message.data),
1033
+ supersededBy: message.data?.supersededByTestkitCommand || null,
1034
+ };
1035
+ }
1036
+ if (message.role === "provider-activity") return { ...base, kind: "provider_status" };
1037
+ if (message.role === "tool" && message.data?.testkitRelated) {
1038
+ const kind = message.data?.kind === "run" ? "testkit_run" : "testkit_command";
1039
+ return {
1040
+ ...base,
1041
+ kind,
1042
+ command: message.data?.command || null,
1043
+ commandId: message.data?.commandId || null,
1044
+ };
1045
+ }
1046
+ return { ...base, kind: "system_message" };
1047
+ });
1048
+ }
1049
+
894
1050
  function buildConversationTranscript(messages) {
895
1051
  return (messages || [])
896
1052
  .filter((entry) => !["provider-activity", "provider-tool", "provider-error"].includes(entry.role))
@@ -1,5 +1,6 @@
1
1
  import path from "path";
2
2
  import { formatContextRemaining } from "./context-window.mjs";
3
+ import { normalizeCommandLine, unwrapShellCommand } from "./command-normalize.mjs";
3
4
 
4
5
  const PROVIDER_COMMAND_OUTPUT_PREVIEW_LINES = 12;
5
6
  const PROVIDER_FILE_READ_OUTPUT_PREVIEW_LINES = 8;
@@ -13,7 +14,7 @@ export function buildAssistantViewModel(snapshot, { cwd = process.cwd(), termina
13
14
  title: `testkit · ${repoName}`,
14
15
  welcome: buildWelcomeModel(snapshot, { cwd, providerLabel }),
15
16
  qualitySignal: buildQualitySignal(snapshot),
16
- blocks: buildTranscriptBlocks(snapshot.messages || []),
17
+ blocks: snapshot.activities ? buildActivityBlocks(snapshot.activities) : buildTranscriptBlocks(snapshot.messages || []),
17
18
  composer: {
18
19
  text: snapshot.composer || "",
19
20
  cursor: snapshot.composerCursor ?? 0,
@@ -134,7 +135,8 @@ export function buildWelcomeModel(snapshot, { cwd = process.cwd(), providerLabel
134
135
  }
135
136
 
136
137
  export function buildTranscriptBlocks(messages) {
137
- return (messages || []).map((message) => {
138
+ const visibleMessages = filterSupersededProviderCommands(messages || []);
139
+ return visibleMessages.map((message) => {
138
140
  const role = message.role || "system";
139
141
  if (role === "tool") {
140
142
  const outputPreview = summarizeOutput(message.text || "", TESTKIT_COMMAND_OUTPUT_PREVIEW_LINES);
@@ -204,6 +206,71 @@ export function buildTranscriptBlocks(messages) {
204
206
  });
205
207
  }
206
208
 
209
+ export function buildActivityBlocks(activities) {
210
+ const messages = (activities || [])
211
+ .filter((activity) => !activity.supersededBy)
212
+ .map((activity) => {
213
+ if (activity.kind === "user_message") return { id: activity.id, role: "user", text: activity.text || "" };
214
+ if (activity.kind === "assistant_message") return { id: activity.id, role: "assistant", text: activity.text || "" };
215
+ if (activity.kind === "provider_command") {
216
+ return {
217
+ id: activity.id,
218
+ role: "provider-tool",
219
+ title: activity.title || "provider command",
220
+ text: activity.text || "",
221
+ status: activity.status || null,
222
+ data: {
223
+ ...(activity.data || {}),
224
+ input: activity.command || activity.data?.input || null,
225
+ },
226
+ };
227
+ }
228
+ if (activity.kind === "provider_status") {
229
+ return {
230
+ id: activity.id,
231
+ role: "provider-activity",
232
+ title: activity.title || null,
233
+ text: activity.text || "",
234
+ status: activity.status || null,
235
+ };
236
+ }
237
+ if (activity.kind === "testkit_command" || activity.kind === "testkit_run") {
238
+ return {
239
+ id: activity.id,
240
+ role: "tool",
241
+ title: activity.title || (activity.kind === "testkit_run" ? "testkit run" : "testkit command"),
242
+ text: activity.text || "",
243
+ status: activity.status || null,
244
+ data: {
245
+ ...(activity.data || {}),
246
+ testkitRelated: true,
247
+ kind: activity.kind === "testkit_run" ? "run" : activity.data?.kind,
248
+ command: activity.command || activity.data?.command || null,
249
+ commandId: activity.commandId || activity.data?.commandId || null,
250
+ },
251
+ };
252
+ }
253
+ return { id: activity.id, role: "system", title: activity.title || null, text: activity.text || "" };
254
+ });
255
+ return buildTranscriptBlocks(messages);
256
+ }
257
+
258
+ function filterSupersededProviderCommands(messages) {
259
+ const observedCommands = new Set(
260
+ (messages || [])
261
+ .filter((message) => message.role === "tool" && message.data?.testkitRelated)
262
+ .map((message) => normalizeCommandLine(message.data?.command))
263
+ .filter(Boolean)
264
+ );
265
+ return (messages || []).filter((message) => {
266
+ if (message.role !== "provider-tool") return true;
267
+ if (message.data?.supersededByTestkitCommand) return false;
268
+ const providerCommand = normalizeCommandLine(message.data?.input || message.data?.data?.command || message.data?.data?.input);
269
+ if (!providerCommand) return true;
270
+ return !observedCommands.has(providerCommand);
271
+ });
272
+ }
273
+
207
274
  export function buildStatusLine(snapshot, { cwd = process.cwd(), providerLabel = null } = {}) {
208
275
  const context = formatContextRemaining(snapshot.contextUsage);
209
276
  const provider = providerLabel || buildProviderLabel(snapshot);
@@ -339,13 +406,6 @@ function isDiffProducingShellCommand(command) {
339
406
  return /^((git\s+diff|git\s+show|diff|apply_patch)\b|.*\bapply_patch\b)/.test(normalized);
340
407
  }
341
408
 
342
- function unwrapShellCommand(command) {
343
- const text = String(command || "").trim();
344
- const match = text.match(/(?:^|\s)(?:[^\s'"]*\/)?(?:bash|sh|zsh)\s+-lc\s+(['"])([\s\S]*)\1\s*$/);
345
- if (!match) return text;
346
- return match[2].replace(/\\(["'\\$`])/g, "$1").trim();
347
- }
348
-
349
409
  function stringifyMaybe(value) {
350
410
  if (value == null) return "";
351
411
  if (typeof value === "string") return value;
@@ -95,6 +95,9 @@ export default class AssistantCommand extends Command {
95
95
  this.log(`${message.role}: ${message.text}`);
96
96
  }
97
97
  }
98
+ if (snapshot.lastTurnError?.kind === "provider") {
99
+ process.exitCode = 1;
100
+ }
98
101
  return snapshot;
99
102
  }
100
103
 
@@ -14,7 +14,7 @@ export default class RunCommand extends Command {
14
14
  type: Args.string({
15
15
  description: `Optional suite type shortcut: ${publicTestTypeListText({ includeAll: true })}`,
16
16
  required: false,
17
- options: publicTestTypeList({ includeAll: true, includeLegacy: true }),
17
+ options: publicTestTypeList({ includeAll: true }),
18
18
  }),
19
19
  };
20
20
 
@@ -25,8 +25,9 @@ export function RunTreeView({
25
25
  interactive = true,
26
26
  } = {}) {
27
27
  const { exit } = useApp();
28
+ const controlledSnapshot = Boolean(snapshotOverride);
28
29
  const [snapshot, setSnapshot] = useState(() => snapshotOverride || runState.getSnapshot());
29
- const { frame } = useAnimation({ interval: 80, isActive: !snapshot.finished });
30
+ const { frame } = useAnimation({ interval: 80, isActive: !controlledSnapshot && !snapshot.finished });
30
31
  const spinnerFrame = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
31
32
 
32
33
  useEffect(() => {
@@ -16,7 +16,7 @@ export function normalizeCliArgs(argv) {
16
16
  "browser",
17
17
  "db",
18
18
  ]);
19
- const runTypeShortcuts = new Set(publicTestTypeList({ includeAll: true, includeLegacy: true }));
19
+ const runTypeShortcuts = new Set(publicTestTypeList({ includeAll: true }));
20
20
  const valueFlags = new Set([
21
21
  "--dir",
22
22
  "--service",
@@ -16,7 +16,6 @@ const DISCOVERY_RULES = [
16
16
  { suffix: ".dal.testkit.ts", type: "dal", framework: "k6" },
17
17
  { suffix: ".load.testkit.ts", type: "load", framework: "k6" },
18
18
  { suffix: ".ui.testkit.ts", type: "ui", framework: "playwright" },
19
- { suffix: ".pw.testkit.ts", type: "ui", framework: "playwright", legacySuffix: true },
20
19
  ];
21
20
 
22
21
  export function discoverProject(productDir, explicitServices = {}, options = {}) {
@@ -31,15 +30,6 @@ export function discoverProject(productDir, explicitServices = {}, options = {})
31
30
  for (const filePath of suiteFiles) {
32
31
  const rule = inferRule(filePath);
33
32
  if (!rule) continue;
34
- if (rule.legacySuffix) {
35
- diagnostics.push({
36
- code: "legacy_ui_suffix",
37
- severity: "warning",
38
- message: `Legacy UI test suffix ".pw.testkit.ts" is deprecated. Rename to ".ui.testkit.ts": ${filePath}`,
39
- path: filePath,
40
- });
41
- }
42
-
43
33
  const owners = inferOwners(filePath, explicitServices, repoDiscovery);
44
34
  if (owners === null) continue;
45
35
  if (owners.length === 0) {
@@ -489,7 +489,7 @@ function normalizePath(filePath) {
489
489
  export function fileDisplayName(filePath) {
490
490
  const base = path.posix
491
491
  .basename(filePath)
492
- .replace(/(\.int|\.e2e|\.scenario|\.dal|\.load|\.ui|\.pw)\.testkit\.ts$/, "");
492
+ .replace(/(\.int|\.e2e|\.scenario|\.dal|\.load|\.ui)\.testkit\.ts$/, "");
493
493
  return formatDisplayName(base);
494
494
  }
495
495