@code-yeongyu/senpi-codemode 2026.7.25-2 → 2026.7.28

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.
@@ -1,38 +1,51 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { join } from "node:path";
3
3
  import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext, ToolDefinition } from "@code-yeongyu/senpi";
4
+ import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
4
5
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
5
6
  import { defaultCodemodeSettings, type ResolvedCodemodeSettings } from "../config/settings.ts";
6
7
  import type { EvalExecutionTracker } from "../extension/session-manager.ts";
7
8
  import { buildEvalPrompt } from "../prompt/eval-prompt.ts";
8
9
  import { TIMEOUT_PAUSE_OP, TIMEOUT_RESUME_OP } from "../timeouts/bridge-timeout.ts";
9
- import { IdleTimeout } from "../timeouts/idle-timeout.ts";
10
+ import { IdleTimeout, type IdleTimeoutOptions, type TimeoutPauseHandle } from "../timeouts/idle-timeout.ts";
10
11
  import { CellHandler, type CellState } from "./cell-handler.ts";
12
+ import { EvalDetachedCellManager, type EvalDetachedCellSnapshot } from "./detached-cell-manager.ts";
11
13
  import type { EvalImageResizer } from "./image.ts";
14
+ import { describeTimeoutState, interruptionStateNote } from "./interrupt-note.ts";
12
15
  import {
13
16
  createEvalInputSchema,
14
17
  type EnabledEvalLanguages,
18
+ type EvalCellResult,
19
+ type EvalControlInput,
15
20
  type EvalInputSchema,
16
21
  type EvalKernel,
17
22
  type EvalKernelManager,
18
23
  type EvalToolDetails,
19
24
  type EvalToolInput,
25
+ type EvalToolRequest,
20
26
  type ExecuteTool,
21
27
  enabledLanguageList,
22
28
  } from "./types.ts";
23
29
 
24
30
  export type { EnabledEvalLanguages, EvalKernel, EvalKernelManager } from "./types.ts";
25
31
 
32
+ export interface EvalTimeoutFactory {
33
+ create(options: IdleTimeoutOptions): TimeoutPauseHandle & { dispose(): void };
34
+ }
35
+
26
36
  export interface CreateEvalToolOptions {
27
37
  readonly enabledLanguages: EnabledEvalLanguages;
28
38
  readonly kernelManager: EvalKernelManager;
29
39
  readonly cellTimeoutSeconds: number;
30
40
  readonly executeTool: ExecuteTool;
41
+ readonly listTools?: () => readonly EvalSchemaToolInfo[];
31
42
  readonly complete?: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
32
43
  readonly settings?: ResolvedCodemodeSettings;
33
44
  readonly artifactsDir?: string;
34
45
  readonly imageResizer?: EvalImageResizer;
35
46
  readonly executionTracker?: EvalExecutionTracker;
47
+ readonly cellManager?: EvalDetachedCellManager;
48
+ readonly timeoutFactory?: EvalTimeoutFactory;
36
49
  readonly proxyExecutor?: (params: EvalToolInput, signal?: AbortSignal) => Promise<AgentToolResult<EvalToolDetails>>;
37
50
  readonly renderers?: Pick<ToolDefinition<EvalInputSchema, EvalToolDetails>, "renderCall" | "renderResult">;
38
51
  /** Whether the task-tool spawn helpers (agent()/output()/<dag>) are advertised in the prompt. */
@@ -56,18 +69,29 @@ interface EvalCellInvocation {
56
69
  interface CellExecutionOptions {
57
70
  readonly callerSignal: AbortSignal;
58
71
  readonly cellId: string;
59
- readonly onAbort: (error: Error) => void;
60
72
  readonly timeoutMs: number;
73
+ readonly timeoutFactory: EvalTimeoutFactory;
74
+ readonly onTimeout: (error: Error) => void;
75
+ readonly onAbort: (error: Error) => void;
61
76
  }
62
77
 
63
78
  const INTERRUPT_DELIVERY_GRACE_MS = 100;
79
+ const NON_INTERACTIVE_MODES = new Set(["print", "json"]);
80
+
81
+ const defaultTimeoutFactory: EvalTimeoutFactory = {
82
+ create(options): IdleTimeout {
83
+ return new IdleTimeout(options);
84
+ },
85
+ };
64
86
 
65
87
  class CellExecution {
66
88
  readonly #callerSignal: AbortSignal;
67
89
  readonly #onAbort: (error: Error) => void;
68
90
  readonly #abortPromise: Promise<never>;
69
- readonly #watchdog: IdleTimeout;
91
+ readonly #detachedPromise: Promise<void>;
92
+ readonly #watchdog: TimeoutPauseHandle & { dispose(): void };
70
93
  #rejectAbort: ((reason?: unknown) => void) | undefined;
94
+ #resolveDetached: (() => void) | undefined;
71
95
  #kernel: EvalKernel | undefined;
72
96
  #interruptDeadline: ReturnType<typeof setTimeout> | undefined;
73
97
  #active = true;
@@ -78,26 +102,44 @@ class CellExecution {
78
102
  this.#abortPromise = new Promise<never>((_resolve, reject) => {
79
103
  this.#rejectAbort = reject;
80
104
  });
81
- this.#watchdog = new IdleTimeout({
105
+ this.#detachedPromise = new Promise<void>((resolve) => {
106
+ this.#resolveDetached = resolve;
107
+ });
108
+ this.#watchdog = options.timeoutFactory.create({
82
109
  cellId: options.cellId,
83
110
  timeoutMs: options.timeoutMs,
84
- onTimeout: ({ error }) => this.#abort(error),
111
+ onTimeout: ({ error }) => options.onTimeout(error),
85
112
  });
86
113
  this.#callerSignal.addEventListener("abort", this.#handleCallerAbort, { once: true });
87
114
  }
88
115
 
116
+ get detached(): Promise<void> {
117
+ return this.#detachedPromise;
118
+ }
119
+
89
120
  pause(): void {
90
121
  this.#watchdog.pause();
91
122
  }
123
+
92
124
  resume(): void {
93
125
  this.#watchdog.resume();
94
126
  }
127
+
95
128
  setKernel(kernel: EvalKernel): void {
96
129
  this.#kernel = kernel;
97
130
  }
131
+
132
+ detach(): void {
133
+ if (!this.#active) return;
134
+ this.#watchdog.dispose();
135
+ this.#resolveDetached?.();
136
+ this.#resolveDetached = undefined;
137
+ }
138
+
98
139
  cancel(reason: unknown): void {
99
140
  this.#abort(reason);
100
141
  }
142
+
101
143
  finish(): void {
102
144
  this.#active = false;
103
145
  this.#cleanup();
@@ -115,6 +157,9 @@ class CellExecution {
115
157
  this.#abort(this.#callerSignal.reason);
116
158
  };
117
159
 
160
+ /** Outcome of the most recent interrupt, when a kernel was interrupted. */
161
+ interruptStateRetained: Promise<boolean> | undefined;
162
+
118
163
  #abort(reason: unknown): void {
119
164
  if (!this.#active) return;
120
165
  this.#active = false;
@@ -128,7 +173,12 @@ class CellExecution {
128
173
  }
129
174
  this.#interruptDeadline = setTimeout(() => this.#settleAbort(error), INTERRUPT_DELIVERY_GRACE_MS);
130
175
  void Promise.resolve()
131
- .then(() => kernel.interrupt(error.message))
176
+ .then(async () => {
177
+ const handle = await kernel.interrupt(error.message);
178
+ // Kernels predating the interrupt-outcome contract resolve void; leave
179
+ // the outcome undefined so callers report an honest unknown state.
180
+ this.interruptStateRetained = handle?.stateRetained;
181
+ })
132
182
  .then(
133
183
  () => this.#settleAbort(error),
134
184
  (interruptError: unknown) => this.#settleAbort(interruptError),
@@ -160,6 +210,7 @@ export function createEvalTool(options: CreateEvalToolOptions): ToolDefinition<E
160
210
  ...(options.hostLine === undefined ? {} : { hostLine: options.hostLine }),
161
211
  });
162
212
  const languages = enabledLanguageList(options.enabledLanguages);
213
+ const cellManager = options.cellManager ?? new EvalDetachedCellManager({ artifactsDir: options.artifactsDir });
163
214
  return {
164
215
  name: "eval",
165
216
  label: "Eval",
@@ -171,19 +222,23 @@ export function createEvalTool(options: CreateEvalToolOptions): ToolDefinition<E
171
222
  ...(options.renderers?.renderCall === undefined ? {} : { renderCall: options.renderers.renderCall }),
172
223
  ...(options.renderers?.renderResult === undefined ? {} : { renderResult: options.renderers.renderResult }),
173
224
  async execute(toolCallId, params, signal, onUpdate, ctx) {
174
- if (options.proxyExecutor) return await options.proxyExecutor(params, signal);
175
- if (!languages.includes(params.language))
225
+ const request = requestFrom(params);
226
+ if (isControlRequest(request)) return await executeControl(cellManager, request);
227
+ if (options.proxyExecutor) return await options.proxyExecutor(request, signal);
228
+ if (!languages.includes(request.language))
176
229
  throw new RangeError(
177
- `Unsupported eval language "${params.language}". Enabled languages: ${languages.join(", ")}`,
230
+ `Unsupported eval language "${request.language}". Enabled languages: ${languages.join(", ")}`,
178
231
  );
232
+ const busy = cellManager.busyFor(request.language);
233
+ if (busy !== undefined) throw kernelBusyError(busy);
179
234
  options.executionTracker?.assertEvalExecutionAllowed();
180
235
  const lifecycleController = new AbortController();
181
236
  const combinedSignal = signal
182
237
  ? AbortSignal.any([signal, lifecycleController.signal])
183
238
  : lifecycleController.signal;
184
- const execution = runEvalCell(options, {
239
+ const execution = runEvalCell(options, cellManager, {
185
240
  cellId: toolCallId,
186
- input: params,
241
+ input: request,
187
242
  signal: combinedSignal,
188
243
  onUpdate,
189
244
  ctx,
@@ -197,10 +252,12 @@ export function createEvalTool(options: CreateEvalToolOptions): ToolDefinition<E
197
252
 
198
253
  async function runEvalCell(
199
254
  options: CreateEvalToolOptions,
255
+ cellManager: EvalDetachedCellManager,
200
256
  invocation: EvalCellInvocation,
201
257
  ): Promise<AgentToolResult<EvalToolDetails>> {
202
258
  if (invocation.signal.aborted) throw abortError(invocation.signal.reason);
203
259
  const timeoutMs = Math.floor((invocation.input.timeout ?? options.cellTimeoutSeconds) * 1_000);
260
+ const timeoutBehavior = timeoutBehaviorFor(invocation.input, invocation.ctx);
204
261
  const bridgeAbortController = new AbortController();
205
262
  const cellSignal = AbortSignal.any([invocation.signal, bridgeAbortController.signal]);
206
263
  const bridgeContext: ExtensionContext = { ...invocation.ctx, signal: cellSignal };
@@ -217,20 +274,68 @@ async function runEvalCell(
217
274
  durationMs: 0,
218
275
  status: "pending",
219
276
  };
220
- const execution = new CellExecution({
277
+ const cell = cellManager.create(invocation.cellId, invocation.input);
278
+ let execution: CellExecution;
279
+ execution = new CellExecution({
221
280
  callerSignal: invocation.signal,
222
281
  cellId: invocation.cellId,
282
+ timeoutMs,
283
+ timeoutFactory: options.timeoutFactory ?? defaultTimeoutFactory,
284
+ onTimeout: (error) => {
285
+ if (timeoutBehavior === "detach" && cellManager.detach(cell)) {
286
+ execution.detach();
287
+ return;
288
+ }
289
+ execution.cancel(error);
290
+ },
223
291
  onAbort: (error) => {
224
292
  state.active = false;
225
293
  bridgeAbortController.abort(error);
226
294
  },
227
- timeoutMs,
228
295
  });
296
+ const running = executeCell(
297
+ options,
298
+ invocation,
299
+ cellManager,
300
+ cell,
301
+ state,
302
+ execution,
303
+ bridgeContext,
304
+ bridgeAbortController,
305
+ );
306
+ const finalized = running.then(
307
+ (result) => {
308
+ cellManager.complete(cell, result);
309
+ return result;
310
+ },
311
+ (error: unknown) => {
312
+ cellManager.fail(cell, error instanceof Error ? error : new Error(String(error)));
313
+ throw error;
314
+ },
315
+ );
316
+ const outcome = await Promise.race([
317
+ finalized.then((result) => ({ kind: "result" as const, result })),
318
+ execution.detached.then(() => ({ kind: "detached" as const })),
319
+ ]);
320
+ if (outcome.kind === "detached") return detachedResult(cellManager.peek(invocation.cellId), invocation.input);
321
+ return outcome.result;
322
+ }
323
+
324
+ async function executeCell(
325
+ options: CreateEvalToolOptions,
326
+ invocation: EvalCellInvocation,
327
+ cellManager: EvalDetachedCellManager,
328
+ cell: Parameters<EvalDetachedCellManager["markRunning"]>[0],
329
+ state: CellState,
330
+ execution: CellExecution,
331
+ bridgeContext: ExtensionContext,
332
+ bridgeAbortController: AbortController,
333
+ ): Promise<AgentToolResult<EvalToolDetails>> {
229
334
  let handler: CellHandler | undefined;
230
335
  try {
231
- const acquired = await execution.wait(
336
+ const kernel = await execution.wait(
232
337
  options.kernelManager.getKernel(invocation.input.language, (message) => {
233
- if (!state.active || !handler) return;
338
+ if (!state.active || handler === undefined) return;
234
339
  if (message.type === "status") {
235
340
  if (message.event.op === TIMEOUT_PAUSE_OP) {
236
341
  execution.pause();
@@ -245,10 +350,10 @@ async function runEvalCell(
245
350
  void pending.catch((error: unknown) => execution.cancel(error));
246
351
  }),
247
352
  );
248
- const kernel = acquired;
249
353
  execution.setKernel(kernel);
250
354
  handler = new CellHandler(kernel, state, {
251
355
  executeTool: options.executeTool,
356
+ ...(options.listTools === undefined ? {} : { listTools: options.listTools }),
252
357
  settings: options.settings ?? defaultCodemodeSettings,
253
358
  ...(options.complete === undefined ? {} : { complete: options.complete }),
254
359
  ctx: bridgeContext,
@@ -257,6 +362,7 @@ async function runEvalCell(
257
362
  : { artifactPath: join(options.artifactsDir, `eval-${randomUUID()}.log`) }),
258
363
  ...(options.imageResizer === undefined ? {} : { imageResizer: options.imageResizer }),
259
364
  });
365
+ cellManager.markRunning(cell, kernel, () => state.output);
260
366
  if ("setContext" in options.kernelManager && typeof options.kernelManager.setContext === "function") {
261
367
  options.kernelManager.setContext(bridgeContext);
262
368
  }
@@ -265,9 +371,9 @@ async function runEvalCell(
265
371
  if (result.ok && state.pendingBridgeCalls.length > 0) await execution.wait(Promise.all(state.pendingBridgeCalls));
266
372
  return await handler.finalize(result);
267
373
  } catch (error) {
268
- if (handler && error instanceof Error && error.name === "CodemodeSessionDisposedError") {
374
+ if (handler && error instanceof Error && error.name === "CodemodeSessionDisposedError")
269
375
  return await handler.finalizeCancellation(error);
270
- }
376
+ if (error instanceof Error && error.name === "TimeoutError") throw await describeTimeoutState(error, execution);
271
377
  throw error;
272
378
  } finally {
273
379
  state.active = false;
@@ -277,6 +383,138 @@ async function runEvalCell(
277
383
  }
278
384
  }
279
385
 
386
+ function requestFrom(params: unknown): EvalToolRequest {
387
+ if (typeof params !== "object" || params === null) throw new TypeError("eval parameters must be an object");
388
+ const value = params as Record<string, unknown>;
389
+ if (value.action === "peek" || value.action === "stop") {
390
+ if (typeof value.cell_id !== "string" || value.cell_id.length === 0)
391
+ throw new TypeError(`eval action "${value.action}" requires cell_id`);
392
+ return { action: value.action, cell_id: value.cell_id };
393
+ }
394
+ if (value.action !== undefined && value.action !== "run")
395
+ throw new TypeError(`Unknown eval action "${String(value.action)}"`);
396
+ if (!isEvalLanguage(value.language)) throw new TypeError("eval run requires language");
397
+ if (typeof value.code !== "string") throw new TypeError("eval run requires code");
398
+ if (value.on_timeout !== undefined && value.on_timeout !== "detach" && value.on_timeout !== "error")
399
+ throw new TypeError(`Unknown eval on_timeout value "${String(value.on_timeout)}"`);
400
+ return {
401
+ language: value.language,
402
+ code: value.code,
403
+ ...(value.action === "run" ? { action: "run" as const } : {}),
404
+ ...(typeof value.title === "string" ? { title: value.title } : {}),
405
+ ...(typeof value.timeout === "number" ? { timeout: value.timeout } : {}),
406
+ ...(value.on_timeout === "detach" || value.on_timeout === "error" ? { on_timeout: value.on_timeout } : {}),
407
+ ...(typeof value.reset === "boolean" ? { reset: value.reset } : {}),
408
+ };
409
+ }
410
+
411
+ function isControlRequest(request: EvalToolRequest): request is EvalControlInput {
412
+ return request.action === "peek" || request.action === "stop";
413
+ }
414
+
415
+ function isEvalLanguage(value: unknown): value is EvalToolInput["language"] {
416
+ return value === "py" || value === "js" || value === "rb" || value === "jl";
417
+ }
418
+
419
+ async function executeControl(
420
+ cellManager: EvalDetachedCellManager,
421
+ request: EvalControlInput,
422
+ ): Promise<AgentToolResult<EvalToolDetails>> {
423
+ const snapshot =
424
+ request.action === "stop" ? await cellManager.stop(request.cell_id) : cellManager.peek(request.cell_id);
425
+ return snapshotResult(snapshot);
426
+ }
427
+
428
+ function detachedResult(snapshot: EvalDetachedCellSnapshot, input: EvalToolInput): AgentToolResult<EvalToolDetails> {
429
+ return {
430
+ content: [
431
+ {
432
+ type: "text",
433
+ text: `Eval cell ${snapshot.cellId} detached and is still running in the ${input.language} kernel. Completion will arrive as a notification. Use eval({ action: "peek", cell_id: "${snapshot.cellId}" }) or eval({ action: "stop", cell_id: "${snapshot.cellId}" }).`,
434
+ },
435
+ ],
436
+ details: {
437
+ language: input.language,
438
+ languages: [input.language],
439
+ ...(input.title === undefined ? {} : { title: input.title }),
440
+ durationMs: 0,
441
+ toolCalls: [],
442
+ truncated: false,
443
+ statusEvents: [{ op: "detached", cellId: snapshot.cellId }],
444
+ cells: [
445
+ {
446
+ index: 0,
447
+ ...(input.title === undefined ? {} : { title: input.title }),
448
+ code: input.code,
449
+ language: input.language,
450
+ output: snapshot.outputTail,
451
+ status: "detached",
452
+ statusEvents: [{ op: "detached", cellId: snapshot.cellId }],
453
+ },
454
+ ],
455
+ },
456
+ };
457
+ }
458
+
459
+ function snapshotResult(snapshot: EvalDetachedCellSnapshot): AgentToolResult<EvalToolDetails> {
460
+ const terminationNote =
461
+ snapshot.state === "cancelled" ? interruptionStateNote(snapshot.language, snapshot.stateRetained) : undefined;
462
+ const text = [
463
+ `Eval cell ${snapshot.cellId} (${snapshot.language}) is ${snapshot.state}.`,
464
+ snapshot.outputTail.length === 0 ? "(no buffered output)" : snapshot.outputTail,
465
+ ...(terminationNote === undefined ? [] : [terminationNote]),
466
+ ].join("\n");
467
+ return {
468
+ content: [{ type: "text", text }],
469
+ details: {
470
+ language: snapshot.language,
471
+ languages: [snapshot.language],
472
+ durationMs: snapshot.result?.details.durationMs ?? 0,
473
+ toolCalls: snapshot.result?.details.toolCalls ?? [],
474
+ truncated: snapshot.result?.details.truncated ?? false,
475
+ ...(snapshot.state === "failed" ? { isError: true } : {}),
476
+ statusEvents: [{ op: snapshot.state, cellId: snapshot.cellId }],
477
+ cells: [
478
+ {
479
+ index: 0,
480
+ code: "",
481
+ language: snapshot.language,
482
+ output: snapshot.outputTail,
483
+ status: cellStatus(snapshot.state),
484
+ statusEvents: [{ op: snapshot.state, cellId: snapshot.cellId }],
485
+ },
486
+ ],
487
+ },
488
+ };
489
+ }
490
+
491
+ function cellStatus(state: EvalDetachedCellSnapshot["state"]): EvalCellResult["status"] {
492
+ switch (state) {
493
+ case "running":
494
+ return "running";
495
+ case "detached":
496
+ return "detached";
497
+ case "completed":
498
+ return "complete";
499
+ case "failed":
500
+ return "error";
501
+ case "cancelled":
502
+ return "cancelled";
503
+ }
504
+ }
505
+
506
+ function kernelBusyError(snapshot: EvalDetachedCellSnapshot): Error {
507
+ const tail = snapshot.outputTail.length === 0 ? "(no output yet)" : snapshot.outputTail;
508
+ return new Error(
509
+ `The ${snapshot.language} eval kernel is busy running detached cell ${snapshot.cellId}. Do not re-run it; use eval({ action: "peek", cell_id: "${snapshot.cellId}" }). Current output tail:\n${tail}`,
510
+ );
511
+ }
512
+
513
+ function timeoutBehaviorFor(input: EvalToolInput, ctx: ExtensionContext): "detach" | "error" {
514
+ if (input.on_timeout !== undefined) return input.on_timeout;
515
+ return NON_INTERACTIVE_MODES.has(ctx.mode) ? "error" : "detach";
516
+ }
517
+
280
518
  function abortError(reason: unknown): Error {
281
519
  if (reason instanceof Error && reason.name !== "AbortError") return reason;
282
520
  const error = new Error(typeof reason === "string" ? reason : "Eval interrupted", { cause: reason });
@@ -0,0 +1,58 @@
1
+ import type { EvalLanguage } from "./types.ts";
2
+
3
+ const TIMEOUT_STATE_GRACE_MS = 5_500;
4
+
5
+ function fallbackTimeoutMessage(base: string): string {
6
+ return `${base} Kernel state may have been lost; re-establish any variables the next cell needs.`;
7
+ }
8
+
9
+ /**
10
+ * Appends the kernel's actual post-timeout state to a TimeoutError, waiting a
11
+ * bounded window for the interrupt outcome so the model knows whether its
12
+ * variables survived. Falls back to an honest unknown when no outcome arrives.
13
+ */
14
+ export async function describeTimeoutState(
15
+ error: Error,
16
+ execution: { readonly interruptStateRetained: Promise<boolean> | undefined },
17
+ ): Promise<Error> {
18
+ const outcome = execution.interruptStateRetained;
19
+ if (outcome === undefined) {
20
+ error.message = fallbackTimeoutMessage(error.message);
21
+ return error;
22
+ }
23
+ let timer: ReturnType<typeof setTimeout> | undefined;
24
+ const retained = await Promise.race([
25
+ outcome,
26
+ new Promise<boolean | undefined>((resolve) => {
27
+ timer = setTimeout(() => resolve(undefined), TIMEOUT_STATE_GRACE_MS);
28
+ }),
29
+ ]).finally(() => {
30
+ if (timer !== undefined) clearTimeout(timer);
31
+ });
32
+ if (retained === undefined) error.message = fallbackTimeoutMessage(error.message);
33
+ else if (retained)
34
+ error.message = `${error.message} The kernel remains running; its existing variables are preserved.`;
35
+ else
36
+ error.message = `${error.message} The kernel was unresponsive and restarted; variables from earlier cells are lost.`;
37
+ return error;
38
+ }
39
+
40
+ const LANGUAGE_LABEL: Record<EvalLanguage, string> = {
41
+ py: "Python kernel",
42
+ js: "JavaScript worker",
43
+ rb: "Ruby kernel",
44
+ jl: "Julia kernel",
45
+ };
46
+
47
+ /**
48
+ * Composes the user-facing note for a cancelled eval cell from the interrupt
49
+ * outcome the kernel actually reported — never a per-language assumption.
50
+ *
51
+ * Returns undefined when there is nothing truthful to add (no interrupt ran).
52
+ */
53
+ export function interruptionStateNote(language: EvalLanguage, stateRetained: boolean | undefined): string | undefined {
54
+ if (stateRetained === undefined) return undefined;
55
+ const label = LANGUAGE_LABEL[language];
56
+ if (stateRetained) return `${label} was interrupted and remains running; its existing variables are preserved.`;
57
+ return `${label} was unresponsive to interrupt and was restarted; variables from earlier cells are lost.`;
58
+ }
@@ -26,6 +26,7 @@ import type {
26
26
  EvalStatusEvent,
27
27
  EvalToolDetails,
28
28
  EvalToolInput,
29
+ EvalToolRequest,
29
30
  } from "./types.ts";
30
31
 
31
32
  type EvalToolDefinition = ToolDefinition<EvalInputSchema, EvalToolDetails>;
@@ -202,7 +203,7 @@ type CellBadges = { readonly reset: boolean; readonly timeout: number | undefine
202
203
  type PrefixStyle = { readonly prefix: string; readonly continuation: string; readonly color: ThemeColor };
203
204
  type DetailedRenderContext = {
204
205
  readonly environment: RenderEnvironment;
205
- readonly args: EvalToolInput;
206
+ readonly args: EvalToolRequest;
206
207
  readonly showImageFallback: boolean;
207
208
  };
208
209
 
@@ -253,10 +254,14 @@ function cellPresentation(status: CellStatus, spinnerFrame: number | undefined):
253
254
  return { label: "pending", icon: "○", color: "muted" };
254
255
  case "running":
255
256
  return { label: "running", icon: spinner(spinnerFrame), color: "warning" };
257
+ case "detached":
258
+ return { label: "detached", icon: "↗", color: "warning" };
256
259
  case "complete":
257
260
  return { label: "done", icon: "✓", color: "success" };
258
261
  case "error":
259
262
  return { label: "error", icon: "✗", color: "error" };
263
+ case "cancelled":
264
+ return { label: "cancelled", icon: "×", color: "error" };
260
265
  default:
261
266
  return assertNever(status);
262
267
  }
@@ -404,6 +409,9 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme | undefined): st
404
409
  case "phase":
405
410
  parts.push(eventString(event.title) ?? "");
406
411
  break;
412
+ case "status-events-omitted":
413
+ parts.push(`${eventNumber(event.count)} earlier events omitted`);
414
+ break;
407
415
  default: {
408
416
  if (event.count !== undefined) parts.push(String(event.count));
409
417
  const path = eventString(event.path);
@@ -415,8 +423,13 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme | undefined): st
415
423
  }
416
424
 
417
425
  function renderStatusEvents(events: readonly EvalStatusEvent[], environment: RenderEnvironment): string[] {
418
- const retained = environment.expanded ? events : events.slice(-STATUS_PREVIEW_COUNT);
419
- const skipped = events.length - retained.length;
426
+ // A bounded history stores its exact omission count in a leading marker event; fold that
427
+ // count into the summary line so collapsing the preview can never understate omissions.
428
+ const first = events[0];
429
+ const omittedByBound = first?.op === "status-events-omitted" && typeof first.count === "number" ? first.count : 0;
430
+ const visible = omittedByBound > 0 ? events.slice(1) : events;
431
+ const retained = environment.expanded ? visible : visible.slice(-STATUS_PREVIEW_COUNT);
432
+ const skipped = visible.length - retained.length + omittedByBound;
420
433
  const lines: string[] = [];
421
434
  if (skipped > 0) lines.push(style(environment.theme, "dim", `├ … ${skipped} earlier status events`));
422
435
  for (const [index, event] of retained.entries()) {
@@ -613,9 +626,10 @@ function renderDetailedLines(
613
626
  const lines: string[] = [];
614
627
  const cells = details.cells ?? [];
615
628
  for (const [index, cell] of cells.entries()) {
629
+ const run = isEvalRunInput(context.args) ? context.args : undefined;
616
630
  const badges = {
617
- reset: index === 0 && context.args.reset === true,
618
- timeout: index === 0 ? context.args.timeout : undefined,
631
+ reset: index === 0 && run?.reset === true,
632
+ timeout: index === 0 ? run?.timeout : undefined,
619
633
  };
620
634
  appendLines(lines, renderCell(cell, context.environment, badges));
621
635
  if (index < cells.length - 1) lines.push("");
@@ -668,6 +682,10 @@ function textOutput(result: AgentToolResult<EvalToolDetails>, showImageFallback:
668
682
  return lines.join("\n");
669
683
  }
670
684
 
685
+ function isEvalRunInput(args: EvalToolRequest): args is EvalToolInput {
686
+ return args.action !== "peek" && args.action !== "stop";
687
+ }
688
+
671
689
  function toolCallRows(details: EvalToolDetails | undefined): ToolCallRow[] {
672
690
  if (!details?.toolCalls || details.toolCalls.length === 0) return [];
673
691
  return details.toolCalls.map((call) => {
@@ -720,7 +738,7 @@ function resultMetadata(
720
738
  }
721
739
 
722
740
  export function renderEvalCall(
723
- args: EvalToolInput,
741
+ args: EvalToolRequest,
724
742
  theme: Theme | undefined,
725
743
  context: RenderContext,
726
744
  ): EvalRenderComponent {
@@ -731,6 +749,10 @@ export function renderEvalCall(
731
749
  component.setBlocks([]);
732
750
  return component;
733
751
  }
752
+ if (!isEvalRunInput(args)) {
753
+ component.setBlocks([{ kind: "text", text: style(theme, "toolTitle", `eval ${args.action} ${args.cell_id}`) }]);
754
+ return component;
755
+ }
734
756
  if (theme === undefined && context.spinnerFrame === undefined) {
735
757
  const title = args.title === undefined ? "" : ` ${args.title}`;
736
758
  const reset = args.reset === true ? " reset" : "";
@@ -1,12 +1,32 @@
1
1
  import type { EvalStatusEvent } from "./types.ts";
2
2
 
3
+ export const STATUS_EVENT_HISTORY_LIMIT = 100;
4
+ const OMITTED_STATUS_EVENTS_OP = "status-events-omitted";
5
+
6
+ function trimStatusHistory(events: EvalStatusEvent[]): void {
7
+ if (events.length <= STATUS_EVENT_HISTORY_LIMIT) return;
8
+
9
+ const first = events[0];
10
+ if (first?.op === OMITTED_STATUS_EVENTS_OP && typeof first.count === "number") {
11
+ const removeCount = events.length - STATUS_EVENT_HISTORY_LIMIT;
12
+ events.splice(1, removeCount);
13
+ events[0] = { op: OMITTED_STATUS_EVENTS_OP, count: first.count + removeCount };
14
+ return;
15
+ }
16
+
17
+ const removeCount = events.length - STATUS_EVENT_HISTORY_LIMIT + 1;
18
+ events.splice(0, removeCount, { op: OMITTED_STATUS_EVENTS_OP, count: removeCount });
19
+ }
20
+
3
21
  export function upsertStatusEvent(events: EvalStatusEvent[], event: EvalStatusEvent): void {
4
22
  if (event.op === "agent" && typeof event.id === "string") {
5
23
  const index = events.findIndex((candidate) => candidate.op === "agent" && candidate.id === event.id);
6
24
  if (index >= 0) {
7
25
  events[index] = event;
26
+ trimStatusHistory(events);
8
27
  return;
9
28
  }
10
29
  }
11
30
  events.push(event);
31
+ trimStatusHistory(events);
12
32
  }