@code-yeongyu/senpi-codemode 2026.8.13 → 2026.8.16

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.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,44 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.8.16] - 2026-08-16
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - Published one versioned `senpi.eval.execution` event per settled eval cell: the in-process bus receives bounded rich call details, while the external RPC projection exposes only byte-capped timing/count metadata for safe OMO analytics; total wall time, kernel runtime, pending calls, exact aggregate totals, and overflow accounting are reported separately ([#897](https://github.com/code-yeongyu/senpi/pull/897)).
22
+
23
+ ### Changed
24
+
25
+ ### Fixed
26
+
27
+ ### Removed
28
+
29
+ ## [2026.8.14] - 2026-08-14
30
+
31
+ ### Breaking Changes
32
+
33
+ ### Added
34
+
35
+ ### Changed
36
+
37
+ ### Fixed
38
+
39
+ ### Removed
40
+
41
+ ## [2026.8.13-2] - 2026-08-13
42
+
43
+ ### Breaking Changes
44
+
45
+ ### Added
46
+
47
+ ### Changed
48
+
49
+ ### Fixed
50
+
51
+ ### Removed
52
+
15
53
  ## [2026.8.13] - 2026-08-13
16
54
 
17
55
  ### Breaking Changes
package/README.md CHANGED
@@ -20,6 +20,11 @@ task-tool names are known.
20
20
  - Loopback, bearer-authenticated kernel bridge with bounded JSONL frames.
21
21
  - Structured status events for file operations, environment access, phases,
22
22
  bridge activity, and delegated task progress.
23
+ - One versioned `senpi.eval.execution` event at terminal cell settlement. The
24
+ in-process event bus receives bounded per-call arguments and result previews
25
+ for extension-owned consumers; external RPC clients receive a 32 KiB-capped
26
+ metadata-only projection with wall time, kernel time, exact call counts,
27
+ pending-call counts, and bounded per-tool aggregates.
23
28
  - Bounded streaming output with head/tail previews, column clamping, and
24
29
  session-adjacent spill files for large streams.
25
30
  - TUI and HTML-export rendering for syntax-highlighted cells, status rows,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.8.13",
3
+ "version": "2026.8.16",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -30,14 +30,14 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@babel/parser": "8.0.4",
33
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.8.13",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.8.16",
34
34
  "typebox": "1.3.8"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "2026.8.13"
37
+ "@code-yeongyu/senpi": "2026.8.16"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.8.13"
40
+ "@code-yeongyu/senpi": "2026.8.16"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
package/src/index.ts CHANGED
@@ -17,6 +17,11 @@ import type { CodemodeSessionManager, CreateCodemodeSessionManagerOptions } from
17
17
  import { SessionManagerProxy } from "./extension/session-manager-proxy.ts";
18
18
  import { WAKE_SOURCE_STATE_EVENT, type WakeSourceState } from "./extension/wake-source-state.ts";
19
19
  import { EvalDetachedCellManager, type EvalDetachedCellStatusEntry } from "./tool/detached-cell-manager.ts";
20
+ import {
21
+ EVAL_EXECUTION_EVENT,
22
+ type EvalExecutionEventPayload,
23
+ toEvalExecutionRpcPayload,
24
+ } from "./tool/eval-execution-event.ts";
20
25
  import { createEvalTool } from "./tool/eval-tool.ts";
21
26
  import { renderEvalCall, renderEvalResult } from "./tool/render.ts";
22
27
 
@@ -39,8 +44,10 @@ export interface CodemodeExtensionAPI {
39
44
  getActiveTools(): string[];
40
45
  getAllTools(): readonly EvalSchemaToolInfo[];
41
46
  sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
42
- /** Optional host event bus; a host without one turns wake-source emission into a harmless no-op. */
47
+ /** Optional host event bus; a host without one turns extension event emission into a harmless no-op. */
43
48
  events?: { emit(name: string, data: unknown): void };
49
+ /** Optional host RPC surface for forwarding extension-owned events to connected clients. */
50
+ rpc?: { emit(name: string, data: unknown): void };
44
51
  }
45
52
 
46
53
  export interface SenpiCodemodeOptions {
@@ -90,6 +97,11 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
90
97
  modelId: string | undefined,
91
98
  cellManager: EvalDetachedCellManager,
92
99
  ): void => {
100
+ const onCellSettled = (payload: EvalExecutionEventPayload): void => {
101
+ if (activeCells !== cellManager) return;
102
+ pi.rpc?.emit(EVAL_EXECUTION_EVENT, toEvalExecutionRpcPayload(payload));
103
+ pi.events?.emit(EVAL_EXECUTION_EVENT, payload);
104
+ };
93
105
  pi.registerTool(
94
106
  createEvalTool({
95
107
  enabledLanguages: runtime.enabledLanguages,
@@ -102,6 +114,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
102
114
  artifactsDir: runtime.artifactsDir,
103
115
  cellManager,
104
116
  executionTracker: manager,
117
+ onCellSettled,
105
118
  renderers,
106
119
  spawns: runtime.spawns,
107
120
  spawnDefaultAgent: runtime.settings.taskTools.task,
@@ -1,6 +1,11 @@
1
1
  import { type AgentToolResult, sanitizeTerminalLabel } from "@code-yeongyu/senpi";
2
+ import type { EvalToolCallSummary } from "./types.ts";
2
3
 
3
4
  export const MAX_ENRICHED_TOOL_CALLS = 30;
5
+ export const MAX_AGGREGATED_TOOL_NAMES = 64;
6
+ export const MAX_CAPTURED_IDENTIFIER_CODE_POINTS = 128;
7
+ export const MAX_CAPTURED_TOOL_NAME_CODE_POINTS = 128;
8
+ export const MAX_RPC_EVENT_BYTES = 32 * 1024;
4
9
 
5
10
  const MAX_ARGUMENT_STRING_CODE_POINTS = 512;
6
11
  const MAX_ARGUMENT_ENTRIES = 32;
@@ -13,6 +18,22 @@ type BoundedValue = {
13
18
  readonly truncated: boolean;
14
19
  };
15
20
 
21
+ export interface EvalToolCallMetric {
22
+ readonly name: string;
23
+ readonly startedAt: number;
24
+ ok: boolean | undefined;
25
+ durationMs: number | undefined;
26
+ }
27
+
28
+ export interface ToolCallCapture {
29
+ readonly callId: string;
30
+ readonly args: unknown;
31
+ readonly startedAt: number;
32
+ readonly metric: EvalToolCallMetric;
33
+ readonly includeDetails: boolean;
34
+ readonly argsTruncated?: true;
35
+ }
36
+
16
37
  export function capCodePoints(text: string, max: number): string {
17
38
  let end = 0;
18
39
  let count = 0;
@@ -83,6 +104,49 @@ export function boundToolCallArgs(args: unknown): { args: unknown; truncated: bo
83
104
  }
84
105
  }
85
106
 
107
+ export function createToolCallMetric(name: string, startedAt: number): EvalToolCallMetric {
108
+ return {
109
+ name: capCodePoints(name, MAX_CAPTURED_TOOL_NAME_CODE_POINTS),
110
+ startedAt,
111
+ ok: undefined,
112
+ durationMs: undefined,
113
+ };
114
+ }
115
+
116
+ export function settleToolCallMetric(metric: EvalToolCallMetric, ok: boolean, completedAt: number): void {
117
+ metric.ok = ok;
118
+ metric.durationMs = Math.max(0, completedAt - metric.startedAt);
119
+ }
120
+
121
+ export function recordToolCall(
122
+ toolCalls: EvalToolCallSummary[],
123
+ ok: boolean,
124
+ capture: ToolCallCapture,
125
+ resultPreview: string | undefined,
126
+ error: string | undefined,
127
+ ): void {
128
+ const completedAt = Date.now();
129
+ settleToolCallMetric(capture.metric, ok, completedAt);
130
+ const summary = {
131
+ name: capture.metric.name,
132
+ ok,
133
+ ...(error === undefined ? {} : { error: capCodePoints(error, 512) }),
134
+ ...(capture.includeDetails ? { durationMs: completedAt - capture.startedAt } : {}),
135
+ };
136
+ const enrichedCount = toolCalls.filter((toolCall) => toolCall.callId !== undefined).length;
137
+ if (!capture.includeDetails || enrichedCount >= MAX_ENRICHED_TOOL_CALLS) {
138
+ toolCalls.push(summary);
139
+ return;
140
+ }
141
+ toolCalls.push({
142
+ ...summary,
143
+ callId: capture.callId,
144
+ args: capture.args,
145
+ ...(capture.argsTruncated === true ? { argsTruncated: true } : {}),
146
+ ...(resultPreview === undefined ? {} : { resultPreview }),
147
+ });
148
+ }
149
+
86
150
  export function toolCallResultPreview(result: AgentToolResult<unknown>): string | undefined {
87
151
  for (const part of result.content) {
88
152
  if (part.type !== "text") continue;
@@ -1,5 +1,6 @@
1
1
  import { type AgentToolResult, type ExtensionContext, sanitizeTerminalLabel } from "@code-yeongyu/senpi";
2
2
  import type { KernelToHostMessage } from "../bridge/protocol.ts";
3
+ import { RESERVED_SCHEMA_TOOL } from "../bridge/reserved.ts";
3
4
  import type { AgentExecuteTool } from "../bridges/agent-bridge.ts";
4
5
  import { isReservedToolName, runReservedTool } from "../bridges/reserved-dispatch.ts";
5
6
  import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
@@ -7,7 +8,15 @@ import { appendSchemaHint } from "../bridges/schema-hint.ts";
7
8
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
8
9
  import { handleCompletionToolCall } from "../completion/tool-bridge.ts";
9
10
  import type { ResolvedCodemodeSettings } from "../config/settings.ts";
10
- import { boundToolCallArgs, capCodePoints, MAX_ENRICHED_TOOL_CALLS, toolCallResultPreview } from "./call-capture.ts";
11
+ import {
12
+ boundToolCallArgs,
13
+ capCodePoints,
14
+ createToolCallMetric,
15
+ MAX_CAPTURED_IDENTIFIER_CODE_POINTS,
16
+ recordToolCall,
17
+ type ToolCallCapture,
18
+ toolCallResultPreview,
19
+ } from "./call-capture.ts";
11
20
  import { CellResultBuilder, type CellState } from "./cell-runtime.ts";
12
21
  import { type EvalImageResizer, marshalToolResult, toolResultIsError } from "./image.ts";
13
22
  import { upsertStatusEvent } from "./status-events.ts";
@@ -22,13 +31,6 @@ type ResolvedToolReply = {
22
31
  readonly errorText?: string;
23
32
  };
24
33
 
25
- type ToolCallEnrichment = {
26
- readonly callId: string;
27
- readonly args: unknown;
28
- readonly startedAt: number;
29
- readonly argsTruncated?: true;
30
- };
31
-
32
34
  export interface CellBridgeRuntime {
33
35
  readonly executeTool: AgentExecuteTool;
34
36
  readonly listTools?: () => readonly EvalSchemaToolInfo[];
@@ -111,9 +113,21 @@ export class CellHandler {
111
113
  }
112
114
 
113
115
  async #handleToolCall(message: Extract<KernelToHostMessage, { type: "tool-call" }>): Promise<void> {
116
+ const startedAt = Date.now();
117
+ const metric = createToolCallMetric(message.toolName, startedAt);
118
+ this.#state.toolCallMetrics.push(metric);
119
+ const capturedArgs = boundToolCallArgs(message.args);
120
+ const capture: ToolCallCapture = {
121
+ callId: capCodePoints(message.callId, MAX_CAPTURED_IDENTIFIER_CODE_POINTS),
122
+ args: capturedArgs.args,
123
+ startedAt,
124
+ metric,
125
+ includeDetails: message.toolName !== RESERVED_SCHEMA_TOOL,
126
+ ...(capturedArgs.truncated ? { argsTruncated: true } : {}),
127
+ };
114
128
  if (message.toolName === "eval") {
115
129
  const error = "recursive eval is not allowed";
116
- this.#state.toolCalls.push({ name: message.toolName, ok: false, error });
130
+ recordToolCall(this.#state.toolCalls, false, capture, undefined, error);
117
131
  this.#kernel.deliverToolReply({
118
132
  type: "tool-reply",
119
133
  callId: message.callId,
@@ -123,20 +137,24 @@ export class CellHandler {
123
137
  return;
124
138
  }
125
139
  if (isReservedToolName(message.toolName)) {
126
- await this.#deliverToolReply(message, async () => ({
127
- value: await runReservedTool(message.toolName, {
128
- callId: message.callId,
129
- args: message.args,
130
- executeTool: this.#runtime.executeTool,
131
- taskToolName: this.#runtime.settings.taskTools.task,
132
- taskOutputToolName: this.#runtime.settings.taskTools.output,
133
- listTools: this.#runtime.listTools,
134
- signal: this.#state.signal,
135
- emitStatus: (event) => this.#recordStatus(event),
136
- marshalToolResult,
140
+ await this.#deliverToolReply(
141
+ message,
142
+ async () => ({
143
+ value: await runReservedTool(message.toolName, {
144
+ callId: message.callId,
145
+ args: message.args,
146
+ executeTool: this.#runtime.executeTool,
147
+ taskToolName: this.#runtime.settings.taskTools.task,
148
+ taskOutputToolName: this.#runtime.settings.taskTools.output,
149
+ listTools: this.#runtime.listTools,
150
+ signal: this.#state.signal,
151
+ emitStatus: (event) => this.#recordStatus(event),
152
+ marshalToolResult,
153
+ }),
154
+ toolCallOk: true,
137
155
  }),
138
- toolCallOk: true,
139
- }));
156
+ capture,
157
+ );
140
158
  return;
141
159
  }
142
160
  if (message.toolName === "completion" && this.#runtime.complete) {
@@ -148,16 +166,10 @@ export class CellHandler {
148
166
  isActive: () => this.#state.active,
149
167
  });
150
168
  if (!this.#state.active) return;
151
- this.#state.toolCalls.push(
152
- result.ok
153
- ? { name: message.toolName, ok: true }
154
- : { name: message.toolName, ok: false, error: result.error },
155
- );
169
+ recordToolCall(this.#state.toolCalls, result.ok, capture, undefined, result.ok ? undefined : result.error);
156
170
  this.#resultBuilder.emitUpdate(false);
157
171
  return;
158
172
  }
159
- const capturedArgs = boundToolCallArgs(message.args);
160
- const startedAt = Date.now();
161
173
  await this.#deliverToolReply(
162
174
  message,
163
175
  async () => {
@@ -185,24 +197,19 @@ export class CellHandler {
185
197
  ...(errorText === undefined ? {} : { errorText }),
186
198
  };
187
199
  },
188
- {
189
- callId: message.callId,
190
- args: capturedArgs.args,
191
- startedAt,
192
- ...(capturedArgs.truncated ? { argsTruncated: true } : {}),
193
- },
200
+ capture,
194
201
  );
195
202
  }
196
203
 
197
204
  async #deliverToolReply(
198
205
  message: Extract<KernelToHostMessage, { type: "tool-call" }>,
199
206
  resolve: () => Promise<ResolvedToolReply>,
200
- enrich?: ToolCallEnrichment,
207
+ capture: ToolCallCapture,
201
208
  ): Promise<void> {
202
209
  try {
203
210
  const reply = await resolve();
204
211
  if (!this.#state.active) return;
205
- this.#pushToolCall(message.toolName, reply.toolCallOk, enrich, reply.resultPreview, reply.errorText);
212
+ recordToolCall(this.#state.toolCalls, reply.toolCallOk, capture, reply.resultPreview, reply.errorText);
206
213
  this.#kernel.deliverToolReply({ type: "tool-reply", callId: message.callId, ok: true, value: reply.value });
207
214
  } catch (error) {
208
215
  if (!this.#state.active) return;
@@ -211,7 +218,7 @@ export class CellHandler {
211
218
  message.toolName,
212
219
  this.#toolParameters(message.toolName),
213
220
  );
214
- this.#pushToolCall(message.toolName, false, enrich, undefined, text);
221
+ recordToolCall(this.#state.toolCalls, false, capture, undefined, text);
215
222
  this.#kernel.deliverToolReply({
216
223
  type: "tool-reply",
217
224
  callId: message.callId,
@@ -222,29 +229,6 @@ export class CellHandler {
222
229
  this.#resultBuilder.emitUpdate(false);
223
230
  }
224
231
 
225
- #pushToolCall(
226
- name: string,
227
- ok: boolean,
228
- enrich: ToolCallEnrichment | undefined,
229
- resultPreview: string | undefined,
230
- error: string | undefined,
231
- ): void {
232
- const summary = { name, ok, ...(error === undefined ? {} : { error }) };
233
- const enrichedCount = this.#state.toolCalls.filter((toolCall) => toolCall.callId !== undefined).length;
234
- if (enrich === undefined || enrichedCount >= MAX_ENRICHED_TOOL_CALLS) {
235
- this.#state.toolCalls.push(summary);
236
- return;
237
- }
238
- this.#state.toolCalls.push({
239
- ...summary,
240
- callId: enrich.callId,
241
- args: enrich.args,
242
- durationMs: Date.now() - enrich.startedAt,
243
- ...(enrich.argsTruncated === true ? { argsTruncated: true } : {}),
244
- ...(resultPreview === undefined ? {} : { resultPreview }),
245
- });
246
- }
247
-
248
232
  #toolParameters(toolName: string): unknown {
249
233
  return this.#runtime.listTools?.().find((tool) => tool.name === toolName)?.parameters;
250
234
  }
@@ -1,5 +1,6 @@
1
1
  import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@code-yeongyu/senpi";
2
2
  import type { KernelToHostMessage } from "../bridge/protocol.ts";
3
+ import type { EvalToolCallMetric } from "./call-capture.ts";
3
4
  import { type EvalImageResizer, EvalOutputCollector, type EvalOutputResult } from "./image.ts";
4
5
  import type { EvalStatusEvent, EvalToolDetails, EvalToolInput } from "./types.ts";
5
6
 
@@ -9,14 +10,17 @@ type ToolCall = EvalToolDetails["toolCalls"] extends readonly (infer Item)[] ? I
9
10
 
10
11
  export interface CellState {
11
12
  readonly input: EvalToolInput;
13
+ readonly startedAt: number;
12
14
  readonly signal: AbortSignal;
13
15
  readonly onUpdate: AgentToolUpdateCallback<EvalToolDetails> | undefined;
14
16
  readonly toolCalls: ToolCall[];
17
+ readonly toolCallMetrics: EvalToolCallMetric[];
15
18
  readonly pendingBridgeCalls: Promise<void>[];
16
19
  readonly statusEvents: EvalStatusEvent[];
17
20
  active: boolean;
18
21
  output: string;
19
22
  phase: string | undefined;
23
+ error: string | undefined;
20
24
  durationMs: number;
21
25
  status: "pending" | "running" | "complete" | "error";
22
26
  }
@@ -70,6 +74,7 @@ export class CellResultBuilder {
70
74
  if (result.valueRepr) this.#output.push(`${result.valueRepr}\n`);
71
75
  this.#state.status = "complete";
72
76
  } else {
77
+ this.#state.error = result.error.message;
73
78
  this.#output.push(`${result.error.message}\n`);
74
79
  this.#state.status = "error";
75
80
  }
@@ -77,6 +82,7 @@ export class CellResultBuilder {
77
82
  }
78
83
 
79
84
  async finalizeCancellation(error: Error): Promise<AgentToolResult<EvalToolDetails>> {
85
+ this.#state.error = error.message;
80
86
  this.#output.push(`${error.message}\n`);
81
87
  this.#state.status = "error";
82
88
  return await this.#finish(true);
@@ -0,0 +1,201 @@
1
+ import type { AgentToolResult } from "@code-yeongyu/senpi";
2
+ import {
3
+ capCodePoints,
4
+ MAX_AGGREGATED_TOOL_NAMES,
5
+ MAX_CAPTURED_IDENTIFIER_CODE_POINTS,
6
+ MAX_ENRICHED_TOOL_CALLS,
7
+ MAX_RPC_EVENT_BYTES,
8
+ } from "./call-capture.ts";
9
+ import type { CellState } from "./cell-runtime.ts";
10
+ import type { EvalLanguage, EvalToolCallSummary, EvalToolDetails } from "./types.ts";
11
+
12
+ export const EVAL_EXECUTION_EVENT = "senpi.eval.execution";
13
+
14
+ export interface EvalToolAggregate {
15
+ readonly count: number;
16
+ readonly totalDurationMs: number;
17
+ readonly okCount: number;
18
+ readonly errorCount: number;
19
+ readonly pendingCount: number;
20
+ }
21
+
22
+ export type EvalToolAggregates = Record<string, EvalToolAggregate>;
23
+
24
+ interface EvalExecutionBasePayload {
25
+ readonly version: 1;
26
+ readonly cellId: string;
27
+ readonly language: EvalLanguage;
28
+ readonly ok: boolean;
29
+ readonly startedAt: number;
30
+ readonly completedAt: number;
31
+ readonly durationMs: number;
32
+ readonly kernelDurationMs?: number;
33
+ readonly detached: boolean;
34
+ readonly toolCallCount: number;
35
+ readonly pendingToolCallCount: number;
36
+ readonly distinctToolsCalled: readonly string[];
37
+ readonly toolAggregates: EvalToolAggregates;
38
+ readonly toolAggregatesTruncated: boolean;
39
+ readonly toolAggregateOverflow?: EvalToolAggregate;
40
+ }
41
+
42
+ export interface EvalExecutionEventPayload extends EvalExecutionBasePayload {
43
+ readonly detailLevel: "full";
44
+ readonly error?: string;
45
+ readonly toolCalls: readonly EvalToolCallSummary[];
46
+ }
47
+
48
+ export interface EvalExecutionRpcToolCallSummary {
49
+ readonly name: string;
50
+ readonly ok: boolean;
51
+ readonly durationMs?: number;
52
+ }
53
+
54
+ export interface EvalExecutionRpcPayload extends EvalExecutionBasePayload {
55
+ readonly detailLevel: "metadata";
56
+ readonly rpcTruncated: boolean;
57
+ readonly toolCalls: readonly EvalExecutionRpcToolCallSummary[];
58
+ }
59
+
60
+ export type EvalExecutionSettleOutcome =
61
+ | { readonly result: AgentToolResult<EvalToolDetails> }
62
+ | { readonly error: unknown };
63
+
64
+ export interface BuildEvalExecutionEventPayloadOptions {
65
+ readonly cellId: string;
66
+ readonly state: CellState;
67
+ readonly outcome: EvalExecutionSettleOutcome;
68
+ readonly completedAt: number;
69
+ readonly detached: boolean;
70
+ }
71
+
72
+ export function buildEvalExecutionEventPayload(
73
+ options: BuildEvalExecutionEventPayloadOptions,
74
+ ): EvalExecutionEventPayload {
75
+ const { cellId, state, outcome, completedAt, detached } = options;
76
+ const result = "result" in outcome ? outcome.result : undefined;
77
+ const ok = result?.details.isError !== true && !("error" in outcome);
78
+ const aggregateState = aggregateToolCalls(state, completedAt);
79
+ const error = ok ? undefined : settleError(state, outcome);
80
+ return {
81
+ version: 1,
82
+ detailLevel: "full",
83
+ cellId: capCodePoints(cellId, MAX_CAPTURED_IDENTIFIER_CODE_POINTS),
84
+ language: state.input.language,
85
+ ok,
86
+ ...(error === undefined ? {} : { error: capCodePoints(error, 512) }),
87
+ startedAt: state.startedAt,
88
+ completedAt,
89
+ durationMs: Math.max(0, completedAt - state.startedAt),
90
+ ...(result === undefined ? {} : { kernelDurationMs: result.details.durationMs }),
91
+ detached,
92
+ toolCallCount: state.toolCallMetrics.length,
93
+ pendingToolCallCount: aggregateState.pendingCount,
94
+ toolCalls: state.toolCalls.slice(0, MAX_ENRICHED_TOOL_CALLS),
95
+ distinctToolsCalled: [...aggregateState.aggregates.keys()],
96
+ toolAggregates: Object.fromEntries(aggregateState.aggregates),
97
+ toolAggregatesTruncated: aggregateState.overflow !== undefined,
98
+ ...(aggregateState.overflow === undefined ? {} : { toolAggregateOverflow: aggregateState.overflow }),
99
+ };
100
+ }
101
+
102
+ export function toEvalExecutionRpcPayload(payload: EvalExecutionEventPayload): EvalExecutionRpcPayload {
103
+ const candidate: EvalExecutionRpcPayload = {
104
+ version: payload.version,
105
+ detailLevel: "metadata",
106
+ rpcTruncated: false,
107
+ cellId: payload.cellId,
108
+ language: payload.language,
109
+ ok: payload.ok,
110
+ startedAt: payload.startedAt,
111
+ completedAt: payload.completedAt,
112
+ durationMs: payload.durationMs,
113
+ ...(payload.kernelDurationMs === undefined ? {} : { kernelDurationMs: payload.kernelDurationMs }),
114
+ detached: payload.detached,
115
+ toolCallCount: payload.toolCallCount,
116
+ pendingToolCallCount: payload.pendingToolCallCount,
117
+ toolCalls: payload.toolCalls.map((call) => ({
118
+ name: call.name,
119
+ ok: call.ok,
120
+ ...(call.durationMs === undefined ? {} : { durationMs: call.durationMs }),
121
+ })),
122
+ distinctToolsCalled: payload.distinctToolsCalled,
123
+ toolAggregates: payload.toolAggregates,
124
+ toolAggregatesTruncated: payload.toolAggregatesTruncated,
125
+ ...(payload.toolAggregateOverflow === undefined ? {} : { toolAggregateOverflow: payload.toolAggregateOverflow }),
126
+ };
127
+ if (serializedBytes(candidate) <= MAX_RPC_EVENT_BYTES) return candidate;
128
+ return {
129
+ ...candidate,
130
+ rpcTruncated: true,
131
+ toolCalls: [],
132
+ distinctToolsCalled: [],
133
+ toolAggregates: {},
134
+ toolAggregatesTruncated: true,
135
+ toolAggregateOverflow: totalAggregate(payload),
136
+ };
137
+ }
138
+
139
+ function aggregateToolCalls(
140
+ state: CellState,
141
+ completedAt: number,
142
+ ): {
143
+ readonly aggregates: Map<string, EvalToolAggregate>;
144
+ readonly overflow: EvalToolAggregate | undefined;
145
+ readonly pendingCount: number;
146
+ } {
147
+ const aggregates = new Map<string, EvalToolAggregate>();
148
+ let overflow: EvalToolAggregate | undefined;
149
+ let pendingCount = 0;
150
+ for (const metric of state.toolCallMetrics) {
151
+ const item: EvalToolAggregate = {
152
+ count: 1,
153
+ totalDurationMs: metric.durationMs ?? Math.max(0, completedAt - metric.startedAt),
154
+ okCount: metric.ok === true ? 1 : 0,
155
+ errorCount: metric.ok === false ? 1 : 0,
156
+ pendingCount: metric.ok === undefined ? 1 : 0,
157
+ };
158
+ pendingCount += item.pendingCount;
159
+ const existing = aggregates.get(metric.name);
160
+ if (existing !== undefined) {
161
+ aggregates.set(metric.name, addAggregate(existing, item));
162
+ continue;
163
+ }
164
+ if (aggregates.size < MAX_AGGREGATED_TOOL_NAMES) {
165
+ aggregates.set(metric.name, item);
166
+ continue;
167
+ }
168
+ overflow = overflow === undefined ? item : addAggregate(overflow, item);
169
+ }
170
+ return { aggregates, overflow, pendingCount };
171
+ }
172
+
173
+ function addAggregate(left: EvalToolAggregate, right: EvalToolAggregate): EvalToolAggregate {
174
+ return {
175
+ count: left.count + right.count,
176
+ totalDurationMs: left.totalDurationMs + right.totalDurationMs,
177
+ okCount: left.okCount + right.okCount,
178
+ errorCount: left.errorCount + right.errorCount,
179
+ pendingCount: left.pendingCount + right.pendingCount,
180
+ };
181
+ }
182
+
183
+ function totalAggregate(payload: EvalExecutionEventPayload): EvalToolAggregate {
184
+ let total: EvalToolAggregate = { count: 0, totalDurationMs: 0, okCount: 0, errorCount: 0, pendingCount: 0 };
185
+ for (const aggregate of Object.values(payload.toolAggregates)) total = addAggregate(total, aggregate);
186
+ if (payload.toolAggregateOverflow !== undefined) total = addAggregate(total, payload.toolAggregateOverflow);
187
+ return total;
188
+ }
189
+
190
+ function serializedBytes(value: unknown): number {
191
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
192
+ }
193
+
194
+ function settleError(state: CellState, outcome: EvalExecutionSettleOutcome): string | undefined {
195
+ if (state.error !== undefined) return state.error;
196
+ if ("error" in outcome) return outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
197
+ for (const part of outcome.result.content) {
198
+ if (part.type === "text" && part.text.length > 0) return part.text.trimEnd();
199
+ }
200
+ return undefined;
201
+ }
@@ -5,6 +5,7 @@ import type { ResolvedCodemodeSettings } from "../config/settings.ts";
5
5
  import type { EvalExecutionTracker } from "../extension/session-manager.ts";
6
6
  import type { EvalTimeoutFactory } from "./cell-execution.ts";
7
7
  import type { EvalDetachedCellManager } from "./detached-cell-manager.ts";
8
+ import type { EvalExecutionEventPayload } from "./eval-execution-event.ts";
8
9
  import type { EvalImageResizer } from "./image.ts";
9
10
  import type {
10
11
  EnabledEvalLanguages,
@@ -29,6 +30,7 @@ export interface CreateEvalToolOptions {
29
30
  readonly imageResizer?: EvalImageResizer;
30
31
  readonly executionTracker?: EvalExecutionTracker;
31
32
  readonly cellManager?: EvalDetachedCellManager;
33
+ readonly onCellSettled?: (payload: EvalExecutionEventPayload) => void;
32
34
  readonly timeoutFactory?: EvalTimeoutFactory;
33
35
  readonly proxyExecutor?: (params: EvalToolInput, signal?: AbortSignal) => Promise<AgentToolResult<EvalToolDetails>>;
34
36
  readonly renderers?: Pick<ToolDefinition<EvalInputSchema, EvalToolDetails>, "renderCall" | "renderResult">;
@@ -8,6 +8,7 @@ import { abortError, CellExecution, defaultTimeoutFactory } from "./cell-executi
8
8
  import { CellHandler, type CellState } from "./cell-handler.ts";
9
9
  import { EvalDetachedCellManager } from "./detached-cell-manager.ts";
10
10
  import { detachedKernelBusyError, executeEvalControl, resultAfterDetach } from "./detached-eval-result.ts";
11
+ import { buildEvalExecutionEventPayload, type EvalExecutionSettleOutcome } from "./eval-execution-event.ts";
11
12
  import { clampEvalSummary, evalTimeoutBehavior, isEvalControlRequest, parseEvalRequest } from "./eval-request.ts";
12
13
  import type { CreateEvalToolOptions, EvalCellInvocation } from "./eval-tool-options.ts";
13
14
  import { describeTimeoutState } from "./interrupt-note.ts";
@@ -99,18 +100,22 @@ async function runEvalCell(
99
100
  const bridgeContext: ExtensionContext = { ...invocation.ctx, signal: cellSignal };
100
101
  const state: CellState = {
101
102
  input: invocation.input,
103
+ startedAt: Date.now(),
102
104
  signal: cellSignal,
103
105
  onUpdate: invocation.onUpdate,
104
106
  toolCalls: [],
107
+ toolCallMetrics: [],
105
108
  pendingBridgeCalls: [],
106
109
  statusEvents: [],
107
110
  active: true,
108
111
  output: "",
109
112
  phase: undefined,
113
+ error: undefined,
110
114
  durationMs: 0,
111
115
  status: "pending",
112
116
  };
113
117
  const cell = cellManager.create(invocation.cellId, invocation.input);
118
+ let detached = false;
114
119
  let execution: CellExecution;
115
120
  execution = new CellExecution({
116
121
  callerSignal: invocation.signal,
@@ -119,6 +124,7 @@ async function runEvalCell(
119
124
  timeoutFactory: options.timeoutFactory ?? defaultTimeoutFactory,
120
125
  onTimeout: (error) => {
121
126
  if (timeoutBehavior === "detach" && cellManager.detach(cell)) {
127
+ detached = true;
122
128
  execution.detach();
123
129
  return;
124
130
  }
@@ -139,13 +145,29 @@ async function runEvalCell(
139
145
  bridgeContext,
140
146
  bridgeAbortController,
141
147
  );
148
+ let settleEventEmitted = false;
149
+ const emitSettled = (outcome: EvalExecutionSettleOutcome): void => {
150
+ if (settleEventEmitted) return;
151
+ settleEventEmitted = true;
152
+ options.onCellSettled?.(
153
+ buildEvalExecutionEventPayload({
154
+ cellId: invocation.cellId,
155
+ state,
156
+ outcome,
157
+ completedAt: Date.now(),
158
+ detached,
159
+ }),
160
+ );
161
+ };
142
162
  const finalized = running.then(
143
163
  (result) => {
144
164
  cellManager.complete(cell, result);
165
+ emitSettled({ result });
145
166
  return result;
146
167
  },
147
168
  (error: unknown) => {
148
169
  cellManager.fail(cell, error instanceof Error ? error : new Error(String(error)));
170
+ emitSettled({ error });
149
171
  throw error;
150
172
  },
151
173
  );