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

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.
@@ -6,23 +6,32 @@ import { defaultCodemodeSettings, type ResolvedCodemodeSettings } from "../confi
6
6
  import type { EvalExecutionTracker } from "../extension/session-manager.ts";
7
7
  import { buildEvalPrompt } from "../prompt/eval-prompt.ts";
8
8
  import { TIMEOUT_PAUSE_OP, TIMEOUT_RESUME_OP } from "../timeouts/bridge-timeout.ts";
9
- import { IdleTimeout } from "../timeouts/idle-timeout.ts";
9
+ import { IdleTimeout, type IdleTimeoutOptions, type TimeoutPauseHandle } from "../timeouts/idle-timeout.ts";
10
10
  import { CellHandler, type CellState } from "./cell-handler.ts";
11
+ import { EvalDetachedCellManager, type EvalDetachedCellSnapshot } from "./detached-cell-manager.ts";
11
12
  import type { EvalImageResizer } from "./image.ts";
13
+ import { describeTimeoutState, interruptionStateNote } from "./interrupt-note.ts";
12
14
  import {
13
15
  createEvalInputSchema,
14
16
  type EnabledEvalLanguages,
17
+ type EvalCellResult,
18
+ type EvalControlInput,
15
19
  type EvalInputSchema,
16
20
  type EvalKernel,
17
21
  type EvalKernelManager,
18
22
  type EvalToolDetails,
19
23
  type EvalToolInput,
24
+ type EvalToolRequest,
20
25
  type ExecuteTool,
21
26
  enabledLanguageList,
22
27
  } from "./types.ts";
23
28
 
24
29
  export type { EnabledEvalLanguages, EvalKernel, EvalKernelManager } from "./types.ts";
25
30
 
31
+ export interface EvalTimeoutFactory {
32
+ create(options: IdleTimeoutOptions): TimeoutPauseHandle & { dispose(): void };
33
+ }
34
+
26
35
  export interface CreateEvalToolOptions {
27
36
  readonly enabledLanguages: EnabledEvalLanguages;
28
37
  readonly kernelManager: EvalKernelManager;
@@ -33,6 +42,8 @@ export interface CreateEvalToolOptions {
33
42
  readonly artifactsDir?: string;
34
43
  readonly imageResizer?: EvalImageResizer;
35
44
  readonly executionTracker?: EvalExecutionTracker;
45
+ readonly cellManager?: EvalDetachedCellManager;
46
+ readonly timeoutFactory?: EvalTimeoutFactory;
36
47
  readonly proxyExecutor?: (params: EvalToolInput, signal?: AbortSignal) => Promise<AgentToolResult<EvalToolDetails>>;
37
48
  readonly renderers?: Pick<ToolDefinition<EvalInputSchema, EvalToolDetails>, "renderCall" | "renderResult">;
38
49
  /** Whether the task-tool spawn helpers (agent()/output()/<dag>) are advertised in the prompt. */
@@ -56,18 +67,29 @@ interface EvalCellInvocation {
56
67
  interface CellExecutionOptions {
57
68
  readonly callerSignal: AbortSignal;
58
69
  readonly cellId: string;
59
- readonly onAbort: (error: Error) => void;
60
70
  readonly timeoutMs: number;
71
+ readonly timeoutFactory: EvalTimeoutFactory;
72
+ readonly onTimeout: (error: Error) => void;
73
+ readonly onAbort: (error: Error) => void;
61
74
  }
62
75
 
63
76
  const INTERRUPT_DELIVERY_GRACE_MS = 100;
77
+ const NON_INTERACTIVE_MODES = new Set(["print", "json"]);
78
+
79
+ const defaultTimeoutFactory: EvalTimeoutFactory = {
80
+ create(options): IdleTimeout {
81
+ return new IdleTimeout(options);
82
+ },
83
+ };
64
84
 
65
85
  class CellExecution {
66
86
  readonly #callerSignal: AbortSignal;
67
87
  readonly #onAbort: (error: Error) => void;
68
88
  readonly #abortPromise: Promise<never>;
69
- readonly #watchdog: IdleTimeout;
89
+ readonly #detachedPromise: Promise<void>;
90
+ readonly #watchdog: TimeoutPauseHandle & { dispose(): void };
70
91
  #rejectAbort: ((reason?: unknown) => void) | undefined;
92
+ #resolveDetached: (() => void) | undefined;
71
93
  #kernel: EvalKernel | undefined;
72
94
  #interruptDeadline: ReturnType<typeof setTimeout> | undefined;
73
95
  #active = true;
@@ -78,26 +100,44 @@ class CellExecution {
78
100
  this.#abortPromise = new Promise<never>((_resolve, reject) => {
79
101
  this.#rejectAbort = reject;
80
102
  });
81
- this.#watchdog = new IdleTimeout({
103
+ this.#detachedPromise = new Promise<void>((resolve) => {
104
+ this.#resolveDetached = resolve;
105
+ });
106
+ this.#watchdog = options.timeoutFactory.create({
82
107
  cellId: options.cellId,
83
108
  timeoutMs: options.timeoutMs,
84
- onTimeout: ({ error }) => this.#abort(error),
109
+ onTimeout: ({ error }) => options.onTimeout(error),
85
110
  });
86
111
  this.#callerSignal.addEventListener("abort", this.#handleCallerAbort, { once: true });
87
112
  }
88
113
 
114
+ get detached(): Promise<void> {
115
+ return this.#detachedPromise;
116
+ }
117
+
89
118
  pause(): void {
90
119
  this.#watchdog.pause();
91
120
  }
121
+
92
122
  resume(): void {
93
123
  this.#watchdog.resume();
94
124
  }
125
+
95
126
  setKernel(kernel: EvalKernel): void {
96
127
  this.#kernel = kernel;
97
128
  }
129
+
130
+ detach(): void {
131
+ if (!this.#active) return;
132
+ this.#watchdog.dispose();
133
+ this.#resolveDetached?.();
134
+ this.#resolveDetached = undefined;
135
+ }
136
+
98
137
  cancel(reason: unknown): void {
99
138
  this.#abort(reason);
100
139
  }
140
+
101
141
  finish(): void {
102
142
  this.#active = false;
103
143
  this.#cleanup();
@@ -115,6 +155,9 @@ class CellExecution {
115
155
  this.#abort(this.#callerSignal.reason);
116
156
  };
117
157
 
158
+ /** Outcome of the most recent interrupt, when a kernel was interrupted. */
159
+ interruptStateRetained: Promise<boolean> | undefined;
160
+
118
161
  #abort(reason: unknown): void {
119
162
  if (!this.#active) return;
120
163
  this.#active = false;
@@ -128,7 +171,12 @@ class CellExecution {
128
171
  }
129
172
  this.#interruptDeadline = setTimeout(() => this.#settleAbort(error), INTERRUPT_DELIVERY_GRACE_MS);
130
173
  void Promise.resolve()
131
- .then(() => kernel.interrupt(error.message))
174
+ .then(async () => {
175
+ const handle = await kernel.interrupt(error.message);
176
+ // Kernels predating the interrupt-outcome contract resolve void; leave
177
+ // the outcome undefined so callers report an honest unknown state.
178
+ this.interruptStateRetained = handle?.stateRetained;
179
+ })
132
180
  .then(
133
181
  () => this.#settleAbort(error),
134
182
  (interruptError: unknown) => this.#settleAbort(interruptError),
@@ -160,6 +208,7 @@ export function createEvalTool(options: CreateEvalToolOptions): ToolDefinition<E
160
208
  ...(options.hostLine === undefined ? {} : { hostLine: options.hostLine }),
161
209
  });
162
210
  const languages = enabledLanguageList(options.enabledLanguages);
211
+ const cellManager = options.cellManager ?? new EvalDetachedCellManager({ artifactsDir: options.artifactsDir });
163
212
  return {
164
213
  name: "eval",
165
214
  label: "Eval",
@@ -171,19 +220,23 @@ export function createEvalTool(options: CreateEvalToolOptions): ToolDefinition<E
171
220
  ...(options.renderers?.renderCall === undefined ? {} : { renderCall: options.renderers.renderCall }),
172
221
  ...(options.renderers?.renderResult === undefined ? {} : { renderResult: options.renderers.renderResult }),
173
222
  async execute(toolCallId, params, signal, onUpdate, ctx) {
174
- if (options.proxyExecutor) return await options.proxyExecutor(params, signal);
175
- if (!languages.includes(params.language))
223
+ const request = requestFrom(params);
224
+ if (isControlRequest(request)) return await executeControl(cellManager, request);
225
+ if (options.proxyExecutor) return await options.proxyExecutor(request, signal);
226
+ if (!languages.includes(request.language))
176
227
  throw new RangeError(
177
- `Unsupported eval language "${params.language}". Enabled languages: ${languages.join(", ")}`,
228
+ `Unsupported eval language "${request.language}". Enabled languages: ${languages.join(", ")}`,
178
229
  );
230
+ const busy = cellManager.busyFor(request.language);
231
+ if (busy !== undefined) throw kernelBusyError(busy);
179
232
  options.executionTracker?.assertEvalExecutionAllowed();
180
233
  const lifecycleController = new AbortController();
181
234
  const combinedSignal = signal
182
235
  ? AbortSignal.any([signal, lifecycleController.signal])
183
236
  : lifecycleController.signal;
184
- const execution = runEvalCell(options, {
237
+ const execution = runEvalCell(options, cellManager, {
185
238
  cellId: toolCallId,
186
- input: params,
239
+ input: request,
187
240
  signal: combinedSignal,
188
241
  onUpdate,
189
242
  ctx,
@@ -197,10 +250,12 @@ export function createEvalTool(options: CreateEvalToolOptions): ToolDefinition<E
197
250
 
198
251
  async function runEvalCell(
199
252
  options: CreateEvalToolOptions,
253
+ cellManager: EvalDetachedCellManager,
200
254
  invocation: EvalCellInvocation,
201
255
  ): Promise<AgentToolResult<EvalToolDetails>> {
202
256
  if (invocation.signal.aborted) throw abortError(invocation.signal.reason);
203
257
  const timeoutMs = Math.floor((invocation.input.timeout ?? options.cellTimeoutSeconds) * 1_000);
258
+ const timeoutBehavior = timeoutBehaviorFor(invocation.input, invocation.ctx);
204
259
  const bridgeAbortController = new AbortController();
205
260
  const cellSignal = AbortSignal.any([invocation.signal, bridgeAbortController.signal]);
206
261
  const bridgeContext: ExtensionContext = { ...invocation.ctx, signal: cellSignal };
@@ -217,20 +272,68 @@ async function runEvalCell(
217
272
  durationMs: 0,
218
273
  status: "pending",
219
274
  };
220
- const execution = new CellExecution({
275
+ const cell = cellManager.create(invocation.cellId, invocation.input);
276
+ let execution: CellExecution;
277
+ execution = new CellExecution({
221
278
  callerSignal: invocation.signal,
222
279
  cellId: invocation.cellId,
280
+ timeoutMs,
281
+ timeoutFactory: options.timeoutFactory ?? defaultTimeoutFactory,
282
+ onTimeout: (error) => {
283
+ if (timeoutBehavior === "detach" && cellManager.detach(cell)) {
284
+ execution.detach();
285
+ return;
286
+ }
287
+ execution.cancel(error);
288
+ },
223
289
  onAbort: (error) => {
224
290
  state.active = false;
225
291
  bridgeAbortController.abort(error);
226
292
  },
227
- timeoutMs,
228
293
  });
294
+ const running = executeCell(
295
+ options,
296
+ invocation,
297
+ cellManager,
298
+ cell,
299
+ state,
300
+ execution,
301
+ bridgeContext,
302
+ bridgeAbortController,
303
+ );
304
+ const finalized = running.then(
305
+ (result) => {
306
+ cellManager.complete(cell, result);
307
+ return result;
308
+ },
309
+ (error: unknown) => {
310
+ cellManager.fail(cell, error instanceof Error ? error : new Error(String(error)));
311
+ throw error;
312
+ },
313
+ );
314
+ const outcome = await Promise.race([
315
+ finalized.then((result) => ({ kind: "result" as const, result })),
316
+ execution.detached.then(() => ({ kind: "detached" as const })),
317
+ ]);
318
+ if (outcome.kind === "detached") return detachedResult(cellManager.peek(invocation.cellId), invocation.input);
319
+ return outcome.result;
320
+ }
321
+
322
+ async function executeCell(
323
+ options: CreateEvalToolOptions,
324
+ invocation: EvalCellInvocation,
325
+ cellManager: EvalDetachedCellManager,
326
+ cell: Parameters<EvalDetachedCellManager["markRunning"]>[0],
327
+ state: CellState,
328
+ execution: CellExecution,
329
+ bridgeContext: ExtensionContext,
330
+ bridgeAbortController: AbortController,
331
+ ): Promise<AgentToolResult<EvalToolDetails>> {
229
332
  let handler: CellHandler | undefined;
230
333
  try {
231
- const acquired = await execution.wait(
334
+ const kernel = await execution.wait(
232
335
  options.kernelManager.getKernel(invocation.input.language, (message) => {
233
- if (!state.active || !handler) return;
336
+ if (!state.active || handler === undefined) return;
234
337
  if (message.type === "status") {
235
338
  if (message.event.op === TIMEOUT_PAUSE_OP) {
236
339
  execution.pause();
@@ -245,7 +348,6 @@ async function runEvalCell(
245
348
  void pending.catch((error: unknown) => execution.cancel(error));
246
349
  }),
247
350
  );
248
- const kernel = acquired;
249
351
  execution.setKernel(kernel);
250
352
  handler = new CellHandler(kernel, state, {
251
353
  executeTool: options.executeTool,
@@ -257,6 +359,7 @@ async function runEvalCell(
257
359
  : { artifactPath: join(options.artifactsDir, `eval-${randomUUID()}.log`) }),
258
360
  ...(options.imageResizer === undefined ? {} : { imageResizer: options.imageResizer }),
259
361
  });
362
+ cellManager.markRunning(cell, kernel, () => state.output);
260
363
  if ("setContext" in options.kernelManager && typeof options.kernelManager.setContext === "function") {
261
364
  options.kernelManager.setContext(bridgeContext);
262
365
  }
@@ -265,9 +368,9 @@ async function runEvalCell(
265
368
  if (result.ok && state.pendingBridgeCalls.length > 0) await execution.wait(Promise.all(state.pendingBridgeCalls));
266
369
  return await handler.finalize(result);
267
370
  } catch (error) {
268
- if (handler && error instanceof Error && error.name === "CodemodeSessionDisposedError") {
371
+ if (handler && error instanceof Error && error.name === "CodemodeSessionDisposedError")
269
372
  return await handler.finalizeCancellation(error);
270
- }
373
+ if (error instanceof Error && error.name === "TimeoutError") throw await describeTimeoutState(error, execution);
271
374
  throw error;
272
375
  } finally {
273
376
  state.active = false;
@@ -277,6 +380,138 @@ async function runEvalCell(
277
380
  }
278
381
  }
279
382
 
383
+ function requestFrom(params: unknown): EvalToolRequest {
384
+ if (typeof params !== "object" || params === null) throw new TypeError("eval parameters must be an object");
385
+ const value = params as Record<string, unknown>;
386
+ if (value.action === "peek" || value.action === "stop") {
387
+ if (typeof value.cell_id !== "string" || value.cell_id.length === 0)
388
+ throw new TypeError(`eval action "${value.action}" requires cell_id`);
389
+ return { action: value.action, cell_id: value.cell_id };
390
+ }
391
+ if (value.action !== undefined && value.action !== "run")
392
+ throw new TypeError(`Unknown eval action "${String(value.action)}"`);
393
+ if (!isEvalLanguage(value.language)) throw new TypeError("eval run requires language");
394
+ if (typeof value.code !== "string") throw new TypeError("eval run requires code");
395
+ if (value.on_timeout !== undefined && value.on_timeout !== "detach" && value.on_timeout !== "error")
396
+ throw new TypeError(`Unknown eval on_timeout value "${String(value.on_timeout)}"`);
397
+ return {
398
+ language: value.language,
399
+ code: value.code,
400
+ ...(value.action === "run" ? { action: "run" as const } : {}),
401
+ ...(typeof value.title === "string" ? { title: value.title } : {}),
402
+ ...(typeof value.timeout === "number" ? { timeout: value.timeout } : {}),
403
+ ...(value.on_timeout === "detach" || value.on_timeout === "error" ? { on_timeout: value.on_timeout } : {}),
404
+ ...(typeof value.reset === "boolean" ? { reset: value.reset } : {}),
405
+ };
406
+ }
407
+
408
+ function isControlRequest(request: EvalToolRequest): request is EvalControlInput {
409
+ return request.action === "peek" || request.action === "stop";
410
+ }
411
+
412
+ function isEvalLanguage(value: unknown): value is EvalToolInput["language"] {
413
+ return value === "py" || value === "js" || value === "rb" || value === "jl";
414
+ }
415
+
416
+ async function executeControl(
417
+ cellManager: EvalDetachedCellManager,
418
+ request: EvalControlInput,
419
+ ): Promise<AgentToolResult<EvalToolDetails>> {
420
+ const snapshot =
421
+ request.action === "stop" ? await cellManager.stop(request.cell_id) : cellManager.peek(request.cell_id);
422
+ return snapshotResult(snapshot);
423
+ }
424
+
425
+ function detachedResult(snapshot: EvalDetachedCellSnapshot, input: EvalToolInput): AgentToolResult<EvalToolDetails> {
426
+ return {
427
+ content: [
428
+ {
429
+ type: "text",
430
+ 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}" }).`,
431
+ },
432
+ ],
433
+ details: {
434
+ language: input.language,
435
+ languages: [input.language],
436
+ ...(input.title === undefined ? {} : { title: input.title }),
437
+ durationMs: 0,
438
+ toolCalls: [],
439
+ truncated: false,
440
+ statusEvents: [{ op: "detached", cellId: snapshot.cellId }],
441
+ cells: [
442
+ {
443
+ index: 0,
444
+ ...(input.title === undefined ? {} : { title: input.title }),
445
+ code: input.code,
446
+ language: input.language,
447
+ output: snapshot.outputTail,
448
+ status: "detached",
449
+ statusEvents: [{ op: "detached", cellId: snapshot.cellId }],
450
+ },
451
+ ],
452
+ },
453
+ };
454
+ }
455
+
456
+ function snapshotResult(snapshot: EvalDetachedCellSnapshot): AgentToolResult<EvalToolDetails> {
457
+ const terminationNote =
458
+ snapshot.state === "cancelled" ? interruptionStateNote(snapshot.language, snapshot.stateRetained) : undefined;
459
+ const text = [
460
+ `Eval cell ${snapshot.cellId} (${snapshot.language}) is ${snapshot.state}.`,
461
+ snapshot.outputTail.length === 0 ? "(no buffered output)" : snapshot.outputTail,
462
+ ...(terminationNote === undefined ? [] : [terminationNote]),
463
+ ].join("\n");
464
+ return {
465
+ content: [{ type: "text", text }],
466
+ details: {
467
+ language: snapshot.language,
468
+ languages: [snapshot.language],
469
+ durationMs: snapshot.result?.details.durationMs ?? 0,
470
+ toolCalls: snapshot.result?.details.toolCalls ?? [],
471
+ truncated: snapshot.result?.details.truncated ?? false,
472
+ ...(snapshot.state === "failed" ? { isError: true } : {}),
473
+ statusEvents: [{ op: snapshot.state, cellId: snapshot.cellId }],
474
+ cells: [
475
+ {
476
+ index: 0,
477
+ code: "",
478
+ language: snapshot.language,
479
+ output: snapshot.outputTail,
480
+ status: cellStatus(snapshot.state),
481
+ statusEvents: [{ op: snapshot.state, cellId: snapshot.cellId }],
482
+ },
483
+ ],
484
+ },
485
+ };
486
+ }
487
+
488
+ function cellStatus(state: EvalDetachedCellSnapshot["state"]): EvalCellResult["status"] {
489
+ switch (state) {
490
+ case "running":
491
+ return "running";
492
+ case "detached":
493
+ return "detached";
494
+ case "completed":
495
+ return "complete";
496
+ case "failed":
497
+ return "error";
498
+ case "cancelled":
499
+ return "cancelled";
500
+ }
501
+ }
502
+
503
+ function kernelBusyError(snapshot: EvalDetachedCellSnapshot): Error {
504
+ const tail = snapshot.outputTail.length === 0 ? "(no output yet)" : snapshot.outputTail;
505
+ return new Error(
506
+ `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}`,
507
+ );
508
+ }
509
+
510
+ function timeoutBehaviorFor(input: EvalToolInput, ctx: ExtensionContext): "detach" | "error" {
511
+ if (input.on_timeout !== undefined) return input.on_timeout;
512
+ return NON_INTERACTIVE_MODES.has(ctx.mode) ? "error" : "detach";
513
+ }
514
+
280
515
  function abortError(reason: unknown): Error {
281
516
  if (reason instanceof Error && reason.name !== "AbortError") return reason;
282
517
  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
  }