Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/senpi-codemode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Fixed

- Bound each eval cell's retained status history while preserving the newest events and an exact omitted-event count.

### Removed

## [2026.7.23] - 2026-07-23
Expand Down
2 changes: 1 addition & 1 deletion packages/senpi-codemode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Configuration is loaded in this order:
| `taskTools.output` | `"task_output"` | Registered tool name used by `output()`. |
| `outputSink.headBytes` | `20480` | Bytes retained from the beginning of a middle-truncated preview; `0` disables it. |
| `outputSink.maxColumns` | `768` | Maximum rendered output columns; `0` disables column clamping. |
| `statusEvents` | `true` | Enables kernel status-event forwarding and rendering. |
| `statusEvents` | `true` | Enables kernel status-event forwarding and rendering. Each cell retains at most 100 status rows; after overflow, one omitted-count row precedes the latest 99 events. |

`SENPI_CODEMODE_PY`, `SENPI_CODEMODE_JS`, `SENPI_CODEMODE_RB`, and
`SENPI_CODEMODE_JL` override the corresponding file setting. `1` or `true`
Expand Down
3 changes: 3 additions & 0 deletions packages/senpi-codemode/src/tool/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,9 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme | undefined): st
case "phase":
parts.push(eventString(event.title) ?? "");
break;
case "status-events-omitted":
parts.push(`${eventNumber(event.count)} earlier events omitted`);
break;
default: {
if (event.count !== undefined) parts.push(String(event.count));
const path = eventString(event.path);
Expand Down
20 changes: 20 additions & 0 deletions packages/senpi-codemode/src/tool/status-events.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
import type { EvalStatusEvent } from "./types.ts";

export const STATUS_EVENT_HISTORY_LIMIT = 100;
const OMITTED_STATUS_EVENTS_OP = "status-events-omitted";

function trimStatusHistory(events: EvalStatusEvent[]): void {
if (events.length <= STATUS_EVENT_HISTORY_LIMIT) return;

const first = events[0];
if (first?.op === OMITTED_STATUS_EVENTS_OP && typeof first.count === "number") {
const removeCount = events.length - STATUS_EVENT_HISTORY_LIMIT;
events.splice(1, removeCount);
events[0] = { op: OMITTED_STATUS_EVENTS_OP, count: first.count + removeCount };
return;
}

const removeCount = events.length - STATUS_EVENT_HISTORY_LIMIT + 1;
events.splice(0, removeCount, { op: OMITTED_STATUS_EVENTS_OP, count: removeCount });
}

export function upsertStatusEvent(events: EvalStatusEvent[], event: EvalStatusEvent): void {
if (event.op === "agent" && typeof event.id === "string") {
const index = events.findIndex((candidate) => candidate.op === "agent" && candidate.id === event.id);
if (index >= 0) {
events[index] = event;
trimStatusHistory(events);
return;
}
}
events.push(event);
trimStatusHistory(events);
}
2 changes: 2 additions & 0 deletions packages/senpi-codemode/test/eval-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ describe("eval renderer", () => {
output: "ok",
status: "complete",
statusEvents: [
{ op: "status-events-omitted", count: 26 },
{ op: "cat", files: 2, chars: 9 },
{ op: "ls", count: 3 },
{ op: "env", action: "set", key: "TOKEN", value: "secret" },
Expand Down Expand Up @@ -332,6 +333,7 @@ describe("eval renderer", () => {

// Then
for (const summary of [
"status-events-omitted 26 earlier events omitted",
"cat 2 files · 9 chars",
"ls 3 entries",
"env set TOKEN=secret",
Expand Down
45 changes: 45 additions & 0 deletions packages/senpi-codemode/test/eval-tool-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AgentToolResult, AgentToolUpdateCallback } from "@code-yeongyu/sen
import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultCodemodeSettings } from "../src/config/settings.ts";
import { createEvalTool } from "../src/tool/eval-tool.ts";
import { STATUS_EVENT_HISTORY_LIMIT } from "../src/tool/status-events.ts";
import type { EvalToolDetails } from "../src/tool/types.ts";
import { errorResult, FakeKernel, FakeManager, fakeExtensionContext, result } from "./eval/fakes.ts";

Expand Down Expand Up @@ -259,4 +260,48 @@ describe("eval tool output pipeline", () => {
cells: [{ status: "error", output: expect.stringContaining("partial") }],
});
});

it("bounds high-volume helper status history in partial and final details", async () => {
// Given
const totalEvents = STATUS_EVENT_HISTORY_LIMIT + 25;
const kernel = new FakeKernel([
...Array.from({ length: totalEvents }, (_, index) => ({
type: "status" as const,
event: {
op: "read",
path: `/tmp/file-${index}.txt`,
preview: "x".repeat(500),
},
})),
result("status-history", "done", 1),
]);
const tool = createEvalTool({
enabledLanguages: { js: true, py: false, rb: false, jl: false },
kernelManager: new FakeManager([["js", kernel]]),
cellTimeoutSeconds: 30,
executeTool: vi.fn(),
});
const partialHistoryLengths: number[] = [];
const onUpdate: AgentToolUpdateCallback<EvalToolDetails> = (update) => {
const historyLength = update.details.statusEvents?.length;
if (historyLength !== undefined) partialHistoryLengths.push(historyLength);
};

// When
const toolResult = await tool.execute(
"status-history",
{ language: "js", code: "for (const path of paths) read(path)" },
undefined,
onUpdate,
fakeExtensionContext(),
);

// Then
expect(partialHistoryLengths.length).toBeGreaterThanOrEqual(totalEvents);
expect(Math.max(...partialHistoryLengths)).toBe(STATUS_EVENT_HISTORY_LIMIT);
expect(partialHistoryLengths.every((length) => length <= STATUS_EVENT_HISTORY_LIMIT)).toBe(true);
expect(toolResult.details.statusEvents).toHaveLength(STATUS_EVENT_HISTORY_LIMIT);
expect(toolResult.details.statusEvents?.[0]).toEqual({ op: "status-events-omitted", count: 26 });
expect(toolResult.details.cells?.[0]?.statusEvents).toEqual(toolResult.details.statusEvents);
});
});
33 changes: 32 additions & 1 deletion packages/senpi-codemode/test/status-events.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { upsertStatusEvent } from "../src/tool/status-events.ts";
import { STATUS_EVENT_HISTORY_LIMIT, upsertStatusEvent } from "../src/tool/status-events.ts";
import type { EvalStatusEvent } from "../src/tool/types.ts";

describe("upsertStatusEvent", () => {
Expand Down Expand Up @@ -44,4 +44,35 @@ describe("upsertStatusEvent", () => {
{ op: "agent", status: "missing-id" },
]);
});

it("bounds status history while reporting how many earlier events were omitted", () => {
const events: EvalStatusEvent[] = [];
const totalEvents = STATUS_EVENT_HISTORY_LIMIT + 25;

for (let index = 0; index < totalEvents; index += 1) {
upsertStatusEvent(events, {
op: "read",
path: `/tmp/file-${index}.txt`,
preview: "x".repeat(500),
});
}

expect(events).toHaveLength(STATUS_EVENT_HISTORY_LIMIT);
expect(events[0]).toEqual({ op: "status-events-omitted", count: 26 });
expect(events[1]).toMatchObject({ op: "read", path: "/tmp/file-26.txt" });
expect(events.at(-1)).toMatchObject({ op: "read", path: `/tmp/file-${totalEvents - 1}.txt` });
});

it("reserves the omission marker when event 101 arrives", () => {
const events: EvalStatusEvent[] = [];

for (let index = 0; index <= STATUS_EVENT_HISTORY_LIMIT; index += 1) {
upsertStatusEvent(events, { op: "read", path: `/tmp/file-${index}.txt` });
}

expect(events).toHaveLength(STATUS_EVENT_HISTORY_LIMIT);
expect(events[0]).toEqual({ op: "status-events-omitted", count: 2 });
expect(events[1]).toMatchObject({ op: "read", path: "/tmp/file-2.txt" });
expect(events.at(-1)).toMatchObject({ op: "read", path: "/tmp/file-100.txt" });
});
});
Loading