diff --git a/packages/senpi-codemode/CHANGELOG.md b/packages/senpi-codemode/CHANGELOG.md index 7222026c7..5320a4dc7 100644 --- a/packages/senpi-codemode/CHANGELOG.md +++ b/packages/senpi-codemode/CHANGELOG.md @@ -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 diff --git a/packages/senpi-codemode/README.md b/packages/senpi-codemode/README.md index 2329b8cc7..e221fcb4c 100644 --- a/packages/senpi-codemode/README.md +++ b/packages/senpi-codemode/README.md @@ -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` diff --git a/packages/senpi-codemode/src/tool/render.ts b/packages/senpi-codemode/src/tool/render.ts index 475a532ab..c5a82b316 100644 --- a/packages/senpi-codemode/src/tool/render.ts +++ b/packages/senpi-codemode/src/tool/render.ts @@ -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); diff --git a/packages/senpi-codemode/src/tool/status-events.ts b/packages/senpi-codemode/src/tool/status-events.ts index dcdcd453e..6815f3926 100644 --- a/packages/senpi-codemode/src/tool/status-events.ts +++ b/packages/senpi-codemode/src/tool/status-events.ts @@ -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); } diff --git a/packages/senpi-codemode/test/eval-render.test.ts b/packages/senpi-codemode/test/eval-render.test.ts index 19183cdb0..63f82807a 100644 --- a/packages/senpi-codemode/test/eval-render.test.ts +++ b/packages/senpi-codemode/test/eval-render.test.ts @@ -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" }, @@ -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", diff --git a/packages/senpi-codemode/test/eval-tool-output.test.ts b/packages/senpi-codemode/test/eval-tool-output.test.ts index 49a8d9d11..d43400dc2 100644 --- a/packages/senpi-codemode/test/eval-tool-output.test.ts +++ b/packages/senpi-codemode/test/eval-tool-output.test.ts @@ -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"; @@ -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 = (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); + }); }); diff --git a/packages/senpi-codemode/test/status-events.test.ts b/packages/senpi-codemode/test/status-events.test.ts index 9dd48fa97..1a8c36ea7 100644 --- a/packages/senpi-codemode/test/status-events.test.ts +++ b/packages/senpi-codemode/test/status-events.test.ts @@ -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", () => { @@ -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" }); + }); });