@elench/testkit 0.1.109 → 0.1.111

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 (42) hide show
  1. package/lib/cli/assistant/actions.mjs +10 -7
  2. package/lib/cli/assistant/app.mjs +19 -5
  3. package/lib/cli/assistant/command-classifier.d.mts +6 -0
  4. package/lib/cli/assistant/command-classifier.d.mts.map +1 -0
  5. package/lib/cli/assistant/command-classifier.mjs +48 -0
  6. package/lib/cli/assistant/command-classifier.mjs.map +1 -0
  7. package/lib/cli/assistant/command-observer.mjs +20 -11
  8. package/lib/cli/assistant/command-results.mjs +2 -34
  9. package/lib/cli/assistant/context-pack.mjs +53 -45
  10. package/lib/cli/assistant/prompt-builder.mjs +21 -13
  11. package/lib/cli/assistant/providers/claude.mjs +77 -19
  12. package/lib/cli/assistant/providers/codex.mjs +8 -12
  13. package/lib/cli/assistant/providers/index.mjs +3 -2
  14. package/lib/cli/assistant/providers/shared.mjs +22 -3
  15. package/lib/cli/assistant/quality-signal-strip.mjs +103 -0
  16. package/lib/cli/assistant/session-paths.d.mts +23 -0
  17. package/lib/cli/assistant/session-paths.d.mts.map +1 -0
  18. package/lib/cli/assistant/session-paths.mjs +31 -0
  19. package/lib/cli/assistant/session-paths.mjs.map +1 -0
  20. package/lib/cli/assistant/session.mjs +10 -2
  21. package/lib/cli/assistant/state.mjs +51 -2
  22. package/lib/cli/assistant/transcript-text.mjs +2 -1
  23. package/lib/cli/assistant/view-model.mjs +79 -0
  24. package/lib/cli/commands/assistant.mjs +3 -0
  25. package/lib/runner/maintenance.mjs +1 -1
  26. package/lib/runner/status-model.mjs +11 -2
  27. package/node_modules/@elench/next-analysis/package.json +1 -1
  28. package/node_modules/@elench/testkit-bridge/package.json +2 -2
  29. package/node_modules/@elench/testkit-protocol/package.json +1 -1
  30. package/node_modules/@elench/ts-analysis/package.json +1 -1
  31. package/package.json +10 -9
  32. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts +188 -0
  33. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.d.ts.map +1 -0
  34. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js +293 -0
  35. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/dist/index.js.map +1 -0
  36. package/packages/testkit-bridge/node_modules/@elench/testkit-protocol/package.json +25 -0
  37. package/node_modules/es-toolkit/CHANGELOG.md +0 -801
  38. package/node_modules/es-toolkit/src/compat/_internal/Equals.d.ts +0 -1
  39. package/node_modules/es-toolkit/src/compat/_internal/IsWritable.d.ts +0 -3
  40. package/node_modules/es-toolkit/src/compat/_internal/MutableList.d.ts +0 -4
  41. package/node_modules/es-toolkit/src/compat/_internal/RejectReadonly.d.ts +0 -4
  42. package/node_modules/esprima/ChangeLog +0 -235
@@ -5,7 +5,12 @@ import {
5
5
  buildToolEvent,
6
6
  createHostedSessionRunner,
7
7
  } from "./shared.mjs";
8
- import { providerAssistantDelta, providerAssistantFinal, providerToolStart } from "./events.mjs";
8
+ import {
9
+ providerAssistantDelta,
10
+ providerAssistantFinal,
11
+ providerToolStart,
12
+ providerToolUpdate,
13
+ } from "./events.mjs";
9
14
 
10
15
  export function startClaudeHostedSession({
11
16
  command = "claude",
@@ -13,6 +18,7 @@ export function startClaudeHostedSession({
13
18
  prompt,
14
19
  onEvent,
15
20
  onRawLine,
21
+ timeoutMs = null,
16
22
  purpose = "assistant",
17
23
  model = null,
18
24
  effort = null,
@@ -46,12 +52,16 @@ export function startClaudeHostedSession({
46
52
  env,
47
53
  });
48
54
 
55
+ const parserState = createClaudeParserState();
49
56
  return createHostedSessionRunner({
50
57
  provider: "claude",
51
58
  child,
52
59
  onEvent,
53
60
  onRawLine,
54
- parsePayload: parseClaudePayload,
61
+ timeoutMs,
62
+ parsePayload(payload) {
63
+ return parseClaudePayload(payload, parserState);
64
+ },
55
65
  readFinalText(result) {
56
66
  return readClaudeFinalText(result?.stdout || "") || null;
57
67
  },
@@ -60,10 +70,17 @@ export function startClaudeHostedSession({
60
70
 
61
71
  function normalizeProviderArgs(providerArgs) {
62
72
  if (!Array.isArray(providerArgs)) return [];
63
- return providerArgs.flatMap((arg) => String(arg || "").split(/\s+/).filter(Boolean));
73
+ return providerArgs.map((arg) => String(arg || "").trim()).filter(Boolean);
64
74
  }
65
75
 
66
- export function parseClaudePayload(payload) {
76
+ export function createClaudeParserState() {
77
+ return {
78
+ currentMessageId: null,
79
+ contentBlocksByIndex: new Map(),
80
+ };
81
+ }
82
+
83
+ export function parseClaudePayload(payload, state = null) {
67
84
  const events = [];
68
85
  if (!payload || typeof payload !== "object") return events;
69
86
 
@@ -77,24 +94,54 @@ export function parseClaudePayload(payload) {
77
94
 
78
95
  if (type === "stream_event") {
79
96
  const streamEvent = payload.event || {};
97
+ if (streamEvent.type === "message_start") {
98
+ if (state) state.currentMessageId = streamEvent.message?.id || null;
99
+ return events;
100
+ }
80
101
  if (streamEvent.type === "content_block_delta" && streamEvent.delta?.type === "text_delta") {
81
102
  const text = String(streamEvent.delta.text || "");
82
- const event = providerAssistantDelta(text);
103
+ const event = providerAssistantDelta(text, messageIdFields(state?.currentMessageId));
83
104
  if (event) events.push(event);
84
105
  return events;
85
106
  }
86
107
  if (streamEvent.type === "content_block_start" && streamEvent.content_block?.type === "tool_use") {
87
108
  const tool = streamEvent.content_block;
109
+ const block = {
110
+ id: tool.id || streamEvent.index,
111
+ name: tool.name || tool.tool_name || "tool_use",
112
+ input: tool.input || null,
113
+ inputJson: "",
114
+ };
115
+ state?.contentBlocksByIndex?.set(streamEvent.index, block);
88
116
  const event = providerToolStart(
89
- tool.name || tool.tool_name || "tool_use",
117
+ block.name,
90
118
  {
91
- id: tool.id || streamEvent.index,
92
- input: tool.input || null,
119
+ id: block.id,
120
+ input: block.input,
93
121
  }
94
122
  );
95
123
  if (event) events.push(event);
96
124
  return events;
97
125
  }
126
+ if (streamEvent.type === "content_block_delta" && streamEvent.delta?.type === "input_json_delta") {
127
+ const block = state?.contentBlocksByIndex?.get(streamEvent.index);
128
+ if (block) block.inputJson += String(streamEvent.delta.partial_json || "");
129
+ return events;
130
+ }
131
+ if (streamEvent.type === "content_block_stop") {
132
+ const block = state?.contentBlocksByIndex?.get(streamEvent.index);
133
+ if (!block) return events;
134
+ state.contentBlocksByIndex.delete(streamEvent.index);
135
+ const parsedInput = parseClaudeToolInput(block.inputJson) ?? block.input;
136
+ if (parsedInput != null) {
137
+ const event = providerToolUpdate(block.name, {
138
+ id: block.id,
139
+ input: parsedInput,
140
+ });
141
+ if (event) events.push(event);
142
+ }
143
+ return events;
144
+ }
98
145
  if (streamEvent.type === "tool_use" || streamEvent.content_block?.type === "tool_use") {
99
146
  const tool = streamEvent.content_block || streamEvent;
100
147
  const event = buildToolEvent(
@@ -107,18 +154,10 @@ export function parseClaudePayload(payload) {
107
154
  return events;
108
155
  }
109
156
 
110
- if (type && /tool/i.test(type)) {
111
- const event = buildToolEvent(
112
- payload.name || payload.tool_name || payload.tool || type,
113
- payload.detail || payload.summary || null
114
- );
115
- if (event) events.push(event);
116
- return events;
117
- }
118
-
119
157
  if (type === "assistant") {
120
158
  const fragments = extractClaudeTextFragments(payload.message?.content || payload.content || []);
121
- const event = providerAssistantFinal(fragments.join(""));
159
+ const id = payload.message?.id || state?.currentMessageId || null;
160
+ const event = providerAssistantFinal(fragments.join(""), messageIdFields(id));
122
161
  if (event) events.push(event);
123
162
  return events;
124
163
  }
@@ -138,11 +177,30 @@ export function parseClaudePayload(payload) {
138
177
  return events;
139
178
  }
140
179
 
141
- const statusEvent = buildStatusEvent(type ? `Claude event: ${type}` : JSON.stringify(payload));
180
+ const statusEvent = buildStatusEvent(type ? `Claude event: ${type}` : "Claude emitted an unknown event");
181
+ if (statusEvent) {
182
+ statusEvent.transient = true;
183
+ statusEvent.display = false;
184
+ statusEvent.data = payload;
185
+ }
142
186
  if (statusEvent) events.push(statusEvent);
143
187
  return events;
144
188
  }
145
189
 
190
+ function messageIdFields(id) {
191
+ return id ? { id: String(id) } : {};
192
+ }
193
+
194
+ function parseClaudeToolInput(value) {
195
+ const text = String(value || "").trim();
196
+ if (!text) return null;
197
+ try {
198
+ return JSON.parse(text);
199
+ } catch {
200
+ return text;
201
+ }
202
+ }
203
+
146
204
  export function readClaudeFinalText(stdout) {
147
205
  const lines = String(stdout || "")
148
206
  .split("\n")
@@ -4,7 +4,6 @@ import path from "path";
4
4
  import { execa } from "execa";
5
5
  import {
6
6
  buildErrorEvent,
7
- buildToolEvent,
8
7
  createHostedSessionRunner,
9
8
  readTextFileIfPresent,
10
9
  } from "./shared.mjs";
@@ -23,6 +22,7 @@ export function startCodexHostedSession({
23
22
  prompt,
24
23
  onEvent,
25
24
  onRawLine,
25
+ timeoutMs = null,
26
26
  purpose = "assistant",
27
27
  model = null,
28
28
  providerArgs = [],
@@ -52,6 +52,7 @@ export function startCodexHostedSession({
52
52
  child,
53
53
  onEvent,
54
54
  onRawLine,
55
+ timeoutMs,
55
56
  parsePayload: parseCodexPayload,
56
57
  shouldIgnoreStatus(message) {
57
58
  return String(message || "").trim() === "Reading additional input from stdin...";
@@ -90,7 +91,7 @@ export function buildCodexArgs({
90
91
 
91
92
  function normalizeProviderArgs(providerArgs) {
92
93
  if (!Array.isArray(providerArgs)) return [];
93
- return providerArgs.flatMap((arg) => String(arg || "").split(/\s+/).filter(Boolean));
94
+ return providerArgs.map((arg) => String(arg || "").trim()).filter(Boolean);
94
95
  }
95
96
 
96
97
  export function parseCodexPayload(payload) {
@@ -146,16 +147,11 @@ export function parseCodexPayload(payload) {
146
147
  return events;
147
148
  }
148
149
 
149
- if (type && /(tool|command|patch|exec)/i.test(type)) {
150
- const event = buildToolEvent(
151
- payload.name || payload.tool_name || payload.command || type,
152
- payload.detail || payload.summary || payload.status || null
153
- );
154
- if (event) events.push(event);
155
- return events;
156
- }
157
-
158
- const statusEvent = providerStatus(type ? `Codex event: ${type}` : JSON.stringify(payload));
150
+ const statusEvent = providerStatus(type ? `Codex event: ${type}` : "Codex emitted an unknown event", {
151
+ transient: true,
152
+ display: false,
153
+ data: payload,
154
+ });
159
155
  if (statusEvent) events.push(statusEvent);
160
156
  return events;
161
157
  }
@@ -64,13 +64,14 @@ export function startProviderSession({
64
64
  prompt,
65
65
  onEvent,
66
66
  onRawLine,
67
+ timeoutMs,
67
68
  purpose = "assistant",
68
69
  env = process.env,
69
70
  } = {}) {
70
71
  const resolvedProvider = resolvePreferredProvider(provider, env);
71
72
  const command = resolveProviderBinary(resolvedProvider, env);
72
73
  if (resolvedProvider === "claude") {
73
- return startClaudeHostedSession({ command, cwd, prompt, onEvent, onRawLine, purpose, model, effort, providerArgs, env });
74
+ return startClaudeHostedSession({ command, cwd, prompt, onEvent, onRawLine, timeoutMs, purpose, model, effort, providerArgs, env });
74
75
  }
75
- return startCodexHostedSession({ command, cwd, prompt, onEvent, onRawLine, purpose, model, providerArgs, env });
76
+ return startCodexHostedSession({ command, cwd, prompt, onEvent, onRawLine, timeoutMs, purpose, model, providerArgs, env });
76
77
  }
@@ -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,103 @@
1
+ import React, { createElement } from "react";
2
+ import { Box, Text } from "ink";
3
+ import { cyan, dim, green, red, yellow } from "../terminal/colors.mjs";
4
+ import { measureWidth, truncateText } from "../terminal/layout.mjs";
5
+
6
+ const LABEL_ITEM_SEPARATOR = " ";
7
+ const ITEM_SEPARATOR = " · ";
8
+ const MIN_TRUNCATED_ITEM_WIDTH = 4;
9
+
10
+ export function fitQualitySignal(signal, { width = 100 } = {}) {
11
+ const availableWidth = Math.max(0, Number(width) || 0);
12
+ const label = normalizeText(signal?.label) || "Quality signal";
13
+ const items = normalizeItems(signal?.items);
14
+ if (availableWidth <= 0) return { label: "", items: [] };
15
+ if (items.length === 0) {
16
+ return {
17
+ label: truncateText(label, availableWidth),
18
+ items: [],
19
+ };
20
+ }
21
+
22
+ for (let count = items.length; count >= 1; count -= 1) {
23
+ const candidate = {
24
+ label,
25
+ items: items.slice(0, count),
26
+ };
27
+ if (measureWidth(formatQualitySignalText(candidate)) <= availableWidth) return candidate;
28
+ }
29
+
30
+ const firstItemWidth = availableWidth - measureWidth(label) - measureWidth(LABEL_ITEM_SEPARATOR);
31
+ if (firstItemWidth >= MIN_TRUNCATED_ITEM_WIDTH) {
32
+ return {
33
+ label,
34
+ items: [
35
+ {
36
+ ...items[0],
37
+ text: truncateText(items[0].text, firstItemWidth),
38
+ },
39
+ ],
40
+ };
41
+ }
42
+
43
+ return {
44
+ label: truncateText(label, availableWidth),
45
+ items: [],
46
+ };
47
+ }
48
+
49
+ export function formatQualitySignalText(signal) {
50
+ const label = normalizeText(signal?.label) || "Quality signal";
51
+ const items = normalizeItems(signal?.items);
52
+ if (items.length === 0) return label;
53
+ return `${label}${LABEL_ITEM_SEPARATOR}${items.map((item) => item.text).join(ITEM_SEPARATOR)}`;
54
+ }
55
+
56
+ export function QualitySignalStrip({ signal, width = 100 } = {}) {
57
+ const fitted = fitQualitySignal(signal, { width });
58
+ if (!fitted.label) return null;
59
+ return createElement(
60
+ Box,
61
+ {
62
+ height: 1,
63
+ flexDirection: "row",
64
+ },
65
+ createElement(
66
+ Text,
67
+ null,
68
+ dim(fitted.label),
69
+ fitted.items.length > 0 ? LABEL_ITEM_SEPARATOR : "",
70
+ ...fitted.items.flatMap((item, index) => [
71
+ index > 0 ? dim(ITEM_SEPARATOR) : "",
72
+ colorQualitySignalItem(item),
73
+ ])
74
+ )
75
+ );
76
+ }
77
+
78
+ function normalizeItems(items) {
79
+ return (Array.isArray(items) ? items : [])
80
+ .map((item) => ({
81
+ id: normalizeText(item?.id),
82
+ text: normalizeText(item?.text),
83
+ tone: normalizeTone(item?.tone),
84
+ }))
85
+ .filter((item) => item.text);
86
+ }
87
+
88
+ function normalizeText(value) {
89
+ return String(value ?? "").replace(/\s+/g, " ").trim();
90
+ }
91
+
92
+ function normalizeTone(value) {
93
+ if (value === "good" || value === "warning" || value === "danger" || value === "progress") return value;
94
+ return "neutral";
95
+ }
96
+
97
+ function colorQualitySignalItem(item) {
98
+ if (item.tone === "good") return green(item.text);
99
+ if (item.tone === "warning") return yellow(item.text);
100
+ if (item.tone === "danger") return red(item.text);
101
+ if (item.tone === "progress") return cyan(item.text);
102
+ return item.text;
103
+ }
@@ -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"}
@@ -39,11 +39,12 @@ export async function runAssistantConversationTurn({
39
39
  const runtimeSettings = settings || { provider };
40
40
  const resolvedProvider = resolvePreferredProvider(runtimeSettings.provider || provider, env);
41
41
  const providerEnv = commandLog?.providerEnv?.(env) || env;
42
+ const timeoutMs = resolveProviderTimeoutMs(providerEnv);
42
43
  const tracePath = shouldTraceProviderEvents(env, providerEnv)
43
- ? path.join(commandLog?.contextDir || path.join(productDir, ".testkit", "assistant"), "provider-events.jsonl")
44
+ ? commandLog?.providerEventsPath || path.join(commandLog?.contextDir || path.join(productDir, ".testkit", "assistant"), "provider-events.jsonl")
44
45
  : null;
45
46
  const rawTracePath = shouldTraceProviderEvents(env, providerEnv)
46
- ? path.join(commandLog?.contextDir || path.join(productDir, ".testkit", "assistant"), "provider-raw.jsonl")
47
+ ? commandLog?.providerRawPath || path.join(commandLog?.contextDir || path.join(productDir, ".testkit", "assistant"), "provider-raw.jsonl")
47
48
  : null;
48
49
  onResolvedProvider?.(resolvedProvider);
49
50
  onPrompt?.({
@@ -67,6 +68,7 @@ export async function runAssistantConversationTurn({
67
68
  model: runtimeSettings.model || null,
68
69
  effort: runtimeSettings.effort || null,
69
70
  providerArgs: runtimeSettings.providerArgs || [],
71
+ timeoutMs,
70
72
  cwd: productDir,
71
73
  prompt,
72
74
  purpose: "assistant",
@@ -91,6 +93,12 @@ export async function runAssistantConversationTurn({
91
93
  }
92
94
  }
93
95
 
96
+ function resolveProviderTimeoutMs(env) {
97
+ const explicit = Number(env?.TESTKIT_ASSISTANT_PROVIDER_TIMEOUT_MS);
98
+ if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit);
99
+ return 600_000;
100
+ }
101
+
94
102
  function formatProviderEvent(event) {
95
103
  if (event.type === "tool-start") {
96
104
  return `${event.provider}: ${event.name}${event.detail ? ` (${event.detail})` : ""}`;
@@ -41,6 +41,8 @@ import {
41
41
  } from "./composer.mjs";
42
42
  import { buildContextUsage } from "./context-window.mjs";
43
43
 
44
+ const SNAPSHOT_MESSAGE_TEXT_LIMIT = 20_000;
45
+
44
46
  export function createAssistantState({
45
47
  productDir,
46
48
  provider,
@@ -62,6 +64,7 @@ export function createAssistantState({
62
64
 
63
65
  const listeners = new Set();
64
66
  const messages = [];
67
+ const diagnostics = [];
65
68
  let composerState = createComposerState();
66
69
  let notice = null;
67
70
  let busy = false;
@@ -84,6 +87,7 @@ export function createAssistantState({
84
87
  if (sanitizedStartup.notice) notice = sanitizedStartup.notice;
85
88
  let activeStatus = null;
86
89
  let startupNoticeEmitted = false;
90
+ let lastTurnError = null;
87
91
  let contextUsage = buildContextUsage({
88
92
  provider: resolvedProviderName || settings.provider,
89
93
  model: settings.model,
@@ -130,6 +134,14 @@ export function createAssistantState({
130
134
  notify();
131
135
  }
132
136
 
137
+ function appendDiagnostic(diagnostic) {
138
+ diagnostics.push({
139
+ timestamp: new Date().toISOString(),
140
+ ...diagnostic,
141
+ });
142
+ notify();
143
+ }
144
+
133
145
  function refreshContextPack() {
134
146
  commandLog.refresh();
135
147
  }
@@ -334,6 +346,20 @@ export function createAssistantState({
334
346
  async submitInput(input) {
335
347
  const trimmed = String(input || "").trim();
336
348
  if (!trimmed) return;
349
+ if (busy) {
350
+ const message = "Assistant is already handling a turn.";
351
+ lastTurnError = {
352
+ kind: "concurrency",
353
+ message,
354
+ };
355
+ appendDiagnostic({
356
+ level: "error",
357
+ code: "assistant_turn_already_active",
358
+ message,
359
+ });
360
+ throw new Error(message);
361
+ }
362
+ lastTurnError = null;
337
363
  if (notice && !startupNoticeEmitted) {
338
364
  startupNoticeEmitted = true;
339
365
  appendMessage({ role: "system", text: notice });
@@ -413,9 +439,13 @@ export function createAssistantState({
413
439
  },
414
440
  });
415
441
  } catch (error) {
442
+ lastTurnError = {
443
+ kind: "provider",
444
+ message: error instanceof Error ? error.message : String(error),
445
+ };
416
446
  appendMessage({
417
447
  role: "system",
418
- text: error instanceof Error ? error.message : String(error),
448
+ text: lastTurnError.message,
419
449
  });
420
450
  } finally {
421
451
  refreshContextPack();
@@ -433,7 +463,7 @@ export function createAssistantState({
433
463
  context: buildContextSelection(runState.getSnapshot()),
434
464
  run: runState.getSnapshot(),
435
465
  productDir,
436
- messages: [...messages],
466
+ messages: messages.map(serializeMessageForSnapshot),
437
467
  composer: composerState.text,
438
468
  composerCursor: composerState.cursor,
439
469
  notice,
@@ -445,15 +475,23 @@ export function createAssistantState({
445
475
  providerArgs: [...settings.providerArgs],
446
476
  cliConfig,
447
477
  activeStatus,
478
+ lastTurnError,
479
+ diagnostics: [...diagnostics],
448
480
  contextUsage,
449
481
  liveRunSession: serializeRunSession(liveRunSession),
450
482
  lastRunSession: serializeRunSession(lastRunSession),
451
483
  contextPaths: {
484
+ sessionId: commandLog.sessionId,
485
+ currentPath: commandLog.currentPath,
486
+ contextDir: commandLog.contextDir,
452
487
  contextPath: commandLog.contextPath,
453
488
  summaryPath: commandLog.summaryPath,
454
489
  selectionPath: commandLog.selectionPath,
455
490
  commandsPath: commandLog.commandsPath,
456
491
  commandLogPath: commandLog.commandLogPath,
492
+ resultDir: commandLog.resultDir,
493
+ providerEventsPath: commandLog.providerEventsPath,
494
+ providerRawPath: commandLog.providerRawPath,
457
495
  },
458
496
  };
459
497
  },
@@ -891,6 +929,17 @@ function serializeRunSession(session) {
891
929
  };
892
930
  }
893
931
 
932
+ function serializeMessageForSnapshot(message) {
933
+ const text = String(message?.text || "");
934
+ if (text.length <= SNAPSHOT_MESSAGE_TEXT_LIMIT) return { ...message };
935
+ return {
936
+ ...message,
937
+ text: `${text.slice(0, SNAPSHOT_MESSAGE_TEXT_LIMIT)}\n... ${text.length - SNAPSHOT_MESSAGE_TEXT_LIMIT} characters omitted from snapshot`,
938
+ fullTextOmitted: true,
939
+ fullTextLength: text.length,
940
+ };
941
+ }
942
+
894
943
  function buildConversationTranscript(messages) {
895
944
  return (messages || [])
896
945
  .filter((entry) => !["provider-activity", "provider-tool", "provider-error"].includes(entry.role))
@@ -3,10 +3,11 @@ import { truncateText, wrapText } from "../terminal/layout.mjs";
3
3
  import { buildAssistantViewModel } from "./view-model.mjs";
4
4
  import { renderCodeBlockText } from "./code-block.mjs";
5
5
  import { renderMarkdownToAnsi } from "./markdown-block.mjs";
6
+ import { formatQualitySignalText, fitQualitySignal } from "./quality-signal-strip.mjs";
6
7
 
7
8
  export function renderAssistantSnapshotText(snapshot, { cwd = process.cwd(), ansi = false, width = 100 } = {}) {
8
9
  const view = buildAssistantViewModel(snapshot || {}, { cwd, terminalWidth: width });
9
- const lines = [view.title, view.welcome.rows.find(([label]) => label === "Provider")?.[1] || ""]
10
+ const lines = [view.title, view.welcome.rows.find(([label]) => label === "Provider")?.[1] || "", formatQualitySignalText(fitQualitySignal(view.qualitySignal, { width }))]
10
11
  .filter(Boolean);
11
12
  for (const block of view.blocks || []) {
12
13
  lines.push("");