diff --git a/packages/agents/adapter.ts b/packages/agents/adapter.ts index a1d5666e..518ae660 100644 --- a/packages/agents/adapter.ts +++ b/packages/agents/adapter.ts @@ -8,6 +8,19 @@ import { buildStatusMessageByProvider, } from "@/utils/status"; +/** + * Session → provider index. Intentionally unbounded: this map is used by + * `abortSession`, `subscribeToSession`, `ensureSession`, and the task / cron + * schedulers to dispatch to the correct agent client for a given session id. + * Evicting an entry and falling back to a default provider would misroute + * those calls, so this has to stay a correctness index, not a size-capped + * cache. + * + * Practical growth is bounded by the number of unique session ids the daemon + * has observed during its lifetime (~60 bytes/entry). A typical daemon sees + * far fewer sessions than the per-turn event buffers we optimised elsewhere, + * so the unbounded footprint here is acceptable. + */ const sessionProviders = new Map(); function getProviderForChannel(channelId: string): AgentProviderId { diff --git a/packages/agents/claude/client.ts b/packages/agents/claude/client.ts index 003fd8fc..bb95d221 100644 --- a/packages/agents/claude/client.ts +++ b/packages/agents/claude/client.ts @@ -1,5 +1,5 @@ import { setThreadSessionId } from "@/config/local/sessions"; -import { log } from "@/utils"; +import { BoundedSet, log } from "@/utils"; import { buildPromptParts, buildPromptText, buildSystemPrompt } from "../shared"; import { CliAgentRuntime, @@ -18,7 +18,14 @@ export type SessionEnvironment = RuntimeSessionEnvironment; const runtime = new CliAgentRuntime("Claude"); type RuntimeRequestEntry = ReturnType; -const newSessions = new Set(); +/** + * FIFO-bounded cache of session ids that have not yet completed their first + * turn. Evicting the oldest entry on overflow is safe: the flag only gates + * one-time startup behaviour for that session, and any session that has been + * queued for this long without being consumed is effectively abandoned. + */ +const NEW_SESSIONS_MAX_ENTRIES = 1000; +const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; type ClaudeJsonRecord = { diff --git a/packages/agents/gemini/client.ts b/packages/agents/gemini/client.ts index 36f664cb..89a5a1f0 100644 --- a/packages/agents/gemini/client.ts +++ b/packages/agents/gemini/client.ts @@ -1,5 +1,5 @@ import { setThreadSessionId } from "@/config/local/sessions"; -import { log } from "@/utils"; +import { BoundedSet, log } from "@/utils"; import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared"; import { CliAgentRuntime, @@ -37,7 +37,9 @@ type GeminiJsonRecord = { }; const runtime = new CliAgentRuntime("Gemini"); -const newSessions = new Set(); +/** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */ +const NEW_SESSIONS_MAX_ENTRIES = 1000; +const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ providerId: "gemini", providerName: "Gemini", diff --git a/packages/agents/goose/client.ts b/packages/agents/goose/client.ts index 7fa178e4..fd350fd0 100644 --- a/packages/agents/goose/client.ts +++ b/packages/agents/goose/client.ts @@ -1,5 +1,5 @@ import { setThreadSessionId } from "@/config/local/sessions"; -import { log } from "@/utils"; +import { BoundedSet, log } from "@/utils"; import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared"; import { CliAgentRuntime, @@ -41,7 +41,9 @@ type GooseJsonRecord = { }; const runtime = new CliAgentRuntime("Goose"); -const newSessions = new Set(); +/** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */ +const NEW_SESSIONS_MAX_ENTRIES = 1000; +const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ providerId: "goose", providerName: "Goose", diff --git a/packages/agents/kilo/client.ts b/packages/agents/kilo/client.ts index 865acebd..15eb9547 100644 --- a/packages/agents/kilo/client.ts +++ b/packages/agents/kilo/client.ts @@ -1,5 +1,5 @@ import { setThreadSessionId } from "@/config/local/sessions"; -import { log } from "@/utils"; +import { BoundedSet, log } from "@/utils"; import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared"; import { CliAgentRuntime, @@ -63,7 +63,9 @@ type KiloJsonRecord = { }; const runtime = new CliAgentRuntime("Kilo"); -const newSessions = new Set(); +/** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */ +const NEW_SESSIONS_MAX_ENTRIES = 1000; +const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); const kiloSessionPrefix = "ses_"; function resolveKiloBinary(): string { diff --git a/packages/agents/kiro/client.ts b/packages/agents/kiro/client.ts index 8284d43d..fb3e7fae 100644 --- a/packages/agents/kiro/client.ts +++ b/packages/agents/kiro/client.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcess } from "child_process"; -import { log } from "@/utils"; +import { BoundedSet, log } from "@/utils"; import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared"; import { CliAgentRuntime, @@ -17,7 +17,9 @@ import type { export type SessionEnvironment = RuntimeSessionEnvironment; const runtime = new CliAgentRuntime("Kiro"); -const newSessions = new Set(); +/** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */ +const NEW_SESSIONS_MAX_ENTRIES = 1000; +const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ providerId: "kiro", providerName: "Kiro", diff --git a/packages/agents/qwen/client.ts b/packages/agents/qwen/client.ts index 344b4eaa..5cbccac7 100644 --- a/packages/agents/qwen/client.ts +++ b/packages/agents/qwen/client.ts @@ -1,5 +1,5 @@ import { setThreadSessionId } from "@/config/local/sessions"; -import { log } from "@/utils"; +import { BoundedSet, log } from "@/utils"; import { buildPromptParts, buildPromptText, buildSystemPrompt, buildSystemWrappedPrompt } from "../shared"; import { CliAgentRuntime, @@ -35,7 +35,9 @@ type QwenJsonRecord = { }; const runtime = new CliAgentRuntime("Qwen"); -const newSessions = new Set(); +/** See note in claude/client.ts — FIFO-bounded so abandoned sessions don't leak. */ +const NEW_SESSIONS_MAX_ENTRIES = 1000; +const newSessions = new BoundedSet(NEW_SESSIONS_MAX_ENTRIES); export const { createSession, getOrCreateSession } = createCliThreadSessionManager({ providerId: "qwen", providerName: "Qwen", diff --git a/packages/agents/runtime/cli-session.ts b/packages/agents/runtime/cli-session.ts index 94e14d29..88e95029 100644 --- a/packages/agents/runtime/cli-session.ts +++ b/packages/agents/runtime/cli-session.ts @@ -9,11 +9,21 @@ type CliSessionRuntime = { setSessionEnvironment: (sessionId: string, env: SessionEnvironment) => void; }; +/** + * Minimal surface a "new session" tracker needs to expose. We accept either a + * raw `Set` or the bounded variant from `@/utils` — both satisfy this + * contract — so agent clients can pick whichever they want without forcing a + * circular dependency here. + */ +type NewSessionTracker = { + add: (value: string) => unknown; +}; + type CliThreadSessionManagerOptions = { providerId: AgentProviderId; providerName: string; runtime: CliSessionRuntime; - newSessions?: Set; + newSessions?: NewSessionTracker; sessionIdFactory?: () => string; validateSessionId?: (sessionId: string) => boolean; }; diff --git a/packages/core/kernel/request-run.ts b/packages/core/kernel/request-run.ts index 94374580..dfdb5269 100644 --- a/packages/core/kernel/request-run.ts +++ b/packages/core/kernel/request-run.ts @@ -31,6 +31,7 @@ import { buildSessionMessageState, extractEventSessionId, getStatusMessageKey, + truncateEventPayload, type SessionEvent, type SessionMessageState, log, @@ -290,10 +291,33 @@ async function startKernelEventStreamWatcher(params: { } } + // Decide up-front which string fields inside this event must be kept + // verbatim (i.e. never replaced by a truncation marker). Assistant text / + // reasoning / thinking parts inside `message.part.updated` feed + // `state.currentText` in session-inspector.ts, which request-run.ts later + // publishes to Slack as the final reply on `stop` and tool-only turns — + // truncating them here would post `...[truncated N bytes]` to the user. + const preserveAssistantText = + event?.type === "message.part.updated" && + ((): boolean => { + const partType = (event as any)?.properties?.part?.type; + return partType === "text" || partType === "reasoning" || partType === "thinking"; + })(); + const preserveStringAtPath = preserveAssistantText + ? (path: string): boolean => path === "properties.part.text" + : undefined; + const sessionEvent: SessionEvent = { timestamp: Date.now(), type: event.type || "unknown", - data: event as Record, + // Truncate any multi-KB strings inside the raw payload before we buffer + // it for the turn. The live-status renderer never shows full tool output + // — it only reads a short preview of tool input and a 90-char slice of + // thinking text — so capping most strings at ~4 KB is lossless for the + // UI. See packages/utils/event-truncation.ts. + data: truncateEventPayload(event as Record, { + preserveStringAtPath, + }), }; eventHistory.push(sessionEvent); diff --git a/packages/core/runtime/message-updates.ts b/packages/core/runtime/message-updates.ts index e79bea60..f043fda6 100644 --- a/packages/core/runtime/message-updates.ts +++ b/packages/core/runtime/message-updates.ts @@ -1,16 +1,29 @@ import type { IMAdapter } from "@/core/types"; import { getMessageUpdateIntervalMs } from "@/config"; -import { log } from "@/utils"; +import { BoundedMap, BoundedSet, log } from "@/utils"; import { CoalescedUpdateQueue } from "@/shared/queue/coalesced-update-queue"; import { isRateLimitError } from "@/shared/delivery/rate-limit"; +/** + * Soft cap for the per-daemon record of which messages we have finalized or + * hit rate limits on. The runtime is a long-lived singleton (one per daemon, + * see `packages/core/kernel/runtime-facade.ts`) so an unbounded Set here leaks + * ~40 bytes per finalized message for the life of the daemon. + * + * A bounded FIFO is fine because the only consumers (`updateMessage`, + * `wasRateLimited`, `getRateLimitError`) only care about recent messages — a + * message finalized millions of updates ago will not receive further updates. + */ +const FINALIZED_SET_MAX_ENTRIES = 5000; +const RATE_LIMIT_MAP_MAX_ENTRIES = 5000; + export function createRateLimitedImAdapter( im: IMAdapter, intervalMs = getMessageUpdateIntervalMs() ): IMAdapter { - const rateLimitedMessages = new Set(); - const finalizedMessages = new Set(); - const rateLimitErrors = new Map(); + const rateLimitedMessages = new BoundedSet(FINALIZED_SET_MAX_ENTRIES); + const finalizedMessages = new BoundedSet(FINALIZED_SET_MAX_ENTRIES); + const rateLimitErrors = new BoundedMap(RATE_LIMIT_MAP_MAX_ENTRIES); const updateErrors = new Map(); function key(channelId: string, messageTs: string): string { diff --git a/packages/live-status-harness/test/truncation-stability.test.ts b/packages/live-status-harness/test/truncation-stability.test.ts new file mode 100644 index 00000000..54d7c4fc --- /dev/null +++ b/packages/live-status-harness/test/truncation-stability.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { truncateEventPayload } from "@/utils/event-truncation"; +import { renderStatusesFromRun } from "../renderer"; +import type { HarnessCapturedEvent, HarnessRunMeta } from "../types"; + +type FixtureShape = { + meta: HarnessRunMeta; + events: HarnessCapturedEvent[]; +}; + +/** + * Ensures the memory-saving truncation applied in `request-run.ts` before events + * are buffered doesn't change the rendered live-status output. The live-status + * renderer (`packages/utils/status.ts::buildLiveStatusMessage`) only reads short + * previews of tool.input and thinking text, never full tool output or assistant + * body — so capping strings inside event.data at ~4 KB must be lossless for UI. + */ +async function loadFixture(path: string): Promise { + const file = Bun.file(`${import.meta.dir}/fixtures/${path}`); + return JSON.parse(await file.text()) as FixtureShape; +} + +function truncateFixtureEvents(fixture: FixtureShape): FixtureShape { + return { + meta: fixture.meta, + events: fixture.events.map((e) => ({ + ...e, + event: truncateEventPayload(e.event as Record, { + maxStringBytes: 256, // aggressive: catches even small payloads + }), + })), + }; +} + +describe("live status renderer is stable under event truncation", () => { + const fixtures = [ + "claude-basic-run.json", + "codex-basic-run.json", + "kiro-basic-run.json", + "kilo-basic-run.json", + "qwen-basic-run.json", + "goose-basic-run.json", + ]; + + for (const name of fixtures) { + it(`${name}: statuses match before vs after truncation`, async () => { + const original = await loadFixture(name); + const truncated = truncateFixtureEvents(original); + + const statusesOriginal = renderStatusesFromRun(original.meta, original.events); + const statusesTruncated = renderStatusesFromRun(truncated.meta, truncated.events); + + // Same number of distinct status snapshots + expect(statusesTruncated.length).toBe(statusesOriginal.length); + + // Each snapshot's rendered text is identical. The renderer only uses + // short-preview fields (tool name, path, command first N chars, thinking + // 90-char slice), which sit well below the truncation cap. + for (let i = 0; i < statusesOriginal.length; i++) { + expect(statusesTruncated[i]?.text).toBe(statusesOriginal[i]!.text); + } + }); + } +}); diff --git a/packages/utils/bounded-collections.ts b/packages/utils/bounded-collections.ts new file mode 100644 index 00000000..6a2c465d --- /dev/null +++ b/packages/utils/bounded-collections.ts @@ -0,0 +1,98 @@ +/** + * FIFO-bounded Set and Map used to cap long-lived runtime caches that would + * otherwise grow without bound across the daemon's lifetime (e.g. per-message + * finalization markers, per-session provider lookup, per-session "is new" + * flags). When the cap is reached, the oldest entry is evicted. + * + * Semantics: + * - Insertion order is preserved by the underlying `Set` / `Map`. + * - `BoundedSet.add(existing)` is a no-op and does NOT refresh order. + * - `BoundedMap.set(existingKey, v)` updates the value in place and does + * NOT refresh order. This matches the historical behaviour of the bespoke + * copy that previously lived in `packages/core/runtime/message-updates.ts`. + * - Eviction only happens on `add` / `set` for new keys; `get`/`has` are + * read-only. + */ + +export class BoundedSet { + private readonly max: number; + private readonly set = new Set(); + + constructor(max: number) { + if (!Number.isFinite(max) || max <= 0) { + throw new Error(`BoundedSet requires a positive max (got ${max})`); + } + this.max = Math.floor(max); + } + + add(value: T): this { + if (this.set.has(value)) return this; + this.set.add(value); + if (this.set.size > this.max) { + const oldest = this.set.values().next().value; + if (oldest !== undefined) this.set.delete(oldest); + } + return this; + } + + has(value: T): boolean { + return this.set.has(value); + } + + delete(value: T): boolean { + return this.set.delete(value); + } + + get size(): number { + return this.set.size; + } + + clear(): void { + this.set.clear(); + } +} + +export class BoundedMap { + private readonly max: number; + private readonly map = new Map(); + + constructor(max: number) { + if (!Number.isFinite(max) || max <= 0) { + throw new Error(`BoundedMap requires a positive max (got ${max})`); + } + this.max = Math.floor(max); + } + + set(key: K, value: V): this { + if (this.map.has(key)) { + this.map.set(key, value); + return this; + } + this.map.set(key, value); + if (this.map.size > this.max) { + const oldest = this.map.keys().next().value; + if (oldest !== undefined) this.map.delete(oldest); + } + return this; + } + + get(key: K): V | undefined { + return this.map.get(key); + } + + has(key: K): boolean { + return this.map.has(key); + } + + delete(key: K): boolean { + return this.map.delete(key); + } + + get size(): number { + return this.map.size; + } + + clear(): void { + this.map.clear(); + } +} diff --git a/packages/utils/event-truncation.ts b/packages/utils/event-truncation.ts new file mode 100644 index 00000000..94f448a6 --- /dev/null +++ b/packages/utils/event-truncation.ts @@ -0,0 +1,142 @@ +/** + * Memory-saving truncation for buffered session events. + * + * Context: `packages/core/kernel/request-run.ts` keeps every raw provider event + * of a turn in `eventHistory: SessionEvent[]` (unbounded within a turn) so the + * renderer can re-fold the whole stream every flush. Some events carry multi-KB + * blobs (tool output, command stdout, file contents, streamed assistant text). + * Over a long turn these can accumulate to many MB per active turn. + * + * The live-status renderer (`status.ts::buildLiveStatusMessage`) never displays + * `tool.output` or full assistant body; it only renders a short preview of + * `tool.input` (truncated to 30-200 chars) and the thinking text (first 90 + * chars). So truncating long strings inside `event.data` to a few KB is lossless + * for the UI. It only affects the string content that later lands in + * `SessionTool.output` for session persistence / replay. + * + * This utility walks the event payload once and replaces any overlong string + * with a marker string of the form `<...[truncated Nbytes]>`, keeping all ids, + * types, statuses, and other discriminators intact. + */ + +const DEFAULT_MAX_STRING_BYTES = 4 * 1024; // 4 KB per string +const DEFAULT_MAX_DEPTH = 12; +const DEFAULT_MAX_ARRAY_LENGTH = 2048; // guardrail, not usually hit + +export type TruncateEventOptions = { + /** + * Maximum byte length (utf-8) for any string inside the payload. Strings + * longer than this are replaced with a shorter marker. Defaults to 4 KB. + */ + maxStringBytes?: number; + /** + * Maximum recursion depth. Beyond this, nested structures are replaced with + * "[truncated: max depth reached]". Safety net against pathological input. + */ + maxDepth?: number; + /** + * Maximum array length to walk into. Longer arrays keep the first N items + * and append a count marker. Safety net only. + */ + maxArrayLength?: number; + /** + * Optional escape hatch for strings that must be kept verbatim. Called with + * the dotted JSON path to the string (e.g. `properties.part.text`). Return + * `true` to skip truncation for this string. Used by the request runner to + * preserve assistant text / reasoning parts that feed the Slack final-reply + * fallback. + */ + preserveStringAtPath?: (path: string) => boolean; +}; + +/** + * Recursively truncate long strings inside an arbitrary JSON-like value. + * + * - Preserves all keys, types, numbers, booleans, nulls, and the overall tree + * shape. + * - Strings longer than `maxStringBytes` are replaced with + * "...[truncated Mbytes]" (keeps a head snippet for debug). + * - Circular references are short-circuited via a `WeakSet`. + * + * Returns a new value (or the original reference if nothing needed changing). + */ +export function truncateEventPayload(value: T, options: TruncateEventOptions = {}): T { + const maxStringBytes = options.maxStringBytes ?? DEFAULT_MAX_STRING_BYTES; + const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; + const maxArrayLength = options.maxArrayLength ?? DEFAULT_MAX_ARRAY_LENGTH; + const preserveStringAtPath = options.preserveStringAtPath; + const seen = new WeakSet(); + + function visit(node: unknown, depth: number, path: string): unknown { + if (depth > maxDepth) { + return "[truncated: max depth reached]"; + } + if (node === null || node === undefined) return node; + const t = typeof node; + if (t === "string") { + if (preserveStringAtPath && preserveStringAtPath(path)) { + return node; + } + return truncateString(node as string, maxStringBytes); + } + if (t !== "object") return node; // number, boolean, bigint, symbol, function + if (seen.has(node as object)) { + return "[truncated: circular]"; + } + seen.add(node as object); + + if (Array.isArray(node)) { + const arr = node as unknown[]; + const limit = Math.min(arr.length, maxArrayLength); + const out: unknown[] = new Array(limit); + let changed = arr.length !== limit; + for (let i = 0; i < limit; i++) { + const childPath = path ? `${path}[${i}]` : `[${i}]`; + const next = visit(arr[i], depth + 1, childPath); + out[i] = next; + if (next !== arr[i]) changed = true; + } + if (arr.length > maxArrayLength) { + out.push(`[truncated: ${arr.length - maxArrayLength} more items]`); + } + return changed ? out : arr; + } + + // Plain object. Only walk own enumerable keys. + const obj = node as Record; + const keys = Object.keys(obj); + const out: Record = {}; + let changed = false; + for (const key of keys) { + const childPath = path ? `${path}.${key}` : key; + const next = visit(obj[key], depth + 1, childPath); + out[key] = next; + if (next !== obj[key]) changed = true; + } + return changed ? out : obj; + } + + return visit(value, 0, "") as T; +} + +/** + * Truncate a single utf-8 string to at most `maxBytes` bytes, appending a + * marker that records the dropped size. Returns the original reference if the + * string is short enough. + */ +export function truncateString(value: string, maxBytes: number): string { + if (maxBytes <= 0 || value.length === 0) return value; + // Fast path: if the string is clearly short enough to fit in maxBytes even + // assuming every char becomes 4 bytes, return as-is. Avoids encoding cost. + if (value.length * 4 <= maxBytes) return value; + const byteLen = Buffer.byteLength(value, "utf8"); + if (byteLen <= maxBytes) return value; + // Keep a head snippet (leave room for the marker suffix). The head is + // measured in characters, not bytes, which is fine because the marker itself + // is tiny and we only need an approximate cap — this buffer is a memory + // safeguard, not a strict contract. + const headChars = Math.max(0, Math.floor(maxBytes / 4) - 64); + const head = value.slice(0, headChars); + const droppedBytes = byteLen - Buffer.byteLength(head, "utf8"); + return `${head}...[truncated ${droppedBytes} bytes]`; +} diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 68f25fa6..cf40be60 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -18,3 +18,9 @@ export { } from "./status"; export { extractEventSessionId } from "./session-id"; export { ensureSessionWorktree, resolveRepoRoot } from "./worktree"; +export { + truncateEventPayload, + truncateString, + type TruncateEventOptions, +} from "./event-truncation"; +export { BoundedSet, BoundedMap } from "./bounded-collections"; diff --git a/packages/utils/test/bounded-collections.test.ts b/packages/utils/test/bounded-collections.test.ts new file mode 100644 index 00000000..5a5beea9 --- /dev/null +++ b/packages/utils/test/bounded-collections.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "bun:test"; +import { BoundedMap, BoundedSet } from "../bounded-collections"; + +describe("BoundedSet", () => { + it("rejects non-positive capacity", () => { + expect(() => new BoundedSet(0)).toThrow(); + expect(() => new BoundedSet(-3)).toThrow(); + expect(() => new BoundedSet(Number.NaN)).toThrow(); + }); + + it("stores values up to the cap", () => { + const set = new BoundedSet(3); + set.add("a"); + set.add("b"); + set.add("c"); + expect(set.size).toBe(3); + expect(set.has("a")).toBe(true); + expect(set.has("b")).toBe(true); + expect(set.has("c")).toBe(true); + }); + + it("evicts the oldest entry on overflow (FIFO)", () => { + const set = new BoundedSet(2); + set.add("a"); + set.add("b"); + set.add("c"); + expect(set.size).toBe(2); + expect(set.has("a")).toBe(false); + expect(set.has("b")).toBe(true); + expect(set.has("c")).toBe(true); + }); + + it("does not refresh insertion order when re-adding an existing value", () => { + const set = new BoundedSet(2); + set.add("a"); + set.add("b"); + set.add("a"); // no-op, should NOT move "a" to the end + set.add("c"); + // If "a" had been refreshed, "b" would be evicted. Instead "a" is the oldest. + expect(set.has("a")).toBe(false); + expect(set.has("b")).toBe(true); + expect(set.has("c")).toBe(true); + }); + + it("supports delete and clear", () => { + const set = new BoundedSet(4); + set.add("a"); + set.add("b"); + expect(set.delete("a")).toBe(true); + expect(set.delete("missing")).toBe(false); + expect(set.size).toBe(1); + set.clear(); + expect(set.size).toBe(0); + expect(set.has("b")).toBe(false); + }); + + it("returns `this` from add (chainable, Set-compatible)", () => { + const set = new BoundedSet(2); + const result = set.add("a"); + expect(result).toBe(set); + }); +}); + +describe("BoundedMap", () => { + it("rejects non-positive capacity", () => { + expect(() => new BoundedMap(0)).toThrow(); + expect(() => new BoundedMap(-1)).toThrow(); + }); + + it("stores entries up to the cap", () => { + const map = new BoundedMap(3); + map.set("a", 1); + map.set("b", 2); + expect(map.get("a")).toBe(1); + expect(map.get("b")).toBe(2); + expect(map.size).toBe(2); + }); + + it("evicts the oldest entry on overflow (FIFO)", () => { + const map = new BoundedMap(2); + map.set("a", 1); + map.set("b", 2); + map.set("c", 3); + expect(map.get("a")).toBeUndefined(); + expect(map.get("b")).toBe(2); + expect(map.get("c")).toBe(3); + }); + + it("updates in place without refreshing order for existing keys", () => { + const map = new BoundedMap(2); + map.set("a", 1); + map.set("b", 2); + map.set("a", 99); // update in place, must NOT move "a" to the end + map.set("c", 3); + // If "a" were refreshed, "b" would be evicted. Instead "a" is evicted. + expect(map.has("a")).toBe(false); + expect(map.get("b")).toBe(2); + expect(map.get("c")).toBe(3); + }); + + it("supports delete and clear", () => { + const map = new BoundedMap(4); + map.set("a", 1); + map.set("b", 2); + expect(map.delete("a")).toBe(true); + expect(map.delete("missing")).toBe(false); + expect(map.size).toBe(1); + map.clear(); + expect(map.size).toBe(0); + }); + + it("returns `this` from set (chainable, Map-compatible)", () => { + const map = new BoundedMap(2); + const result = map.set("a", 1); + expect(result).toBe(map); + }); +}); diff --git a/packages/utils/test/event-truncation.test.ts b/packages/utils/test/event-truncation.test.ts new file mode 100644 index 00000000..b4bd0aa9 --- /dev/null +++ b/packages/utils/test/event-truncation.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "bun:test"; +import { truncateEventPayload, truncateString } from "../event-truncation"; + +describe("truncateString", () => { + it("returns the original string when under the cap", () => { + expect(truncateString("hello", 1024)).toBe("hello"); + }); + + it("truncates long strings and reports dropped bytes", () => { + const big = "x".repeat(10_000); + const out = truncateString(big, 1024); + expect(out.length).toBeLessThan(big.length); + expect(out).toContain("[truncated"); + expect(out).toContain("bytes]"); + // head prefix preserved + expect(out.startsWith("x")).toBeTrue(); + }); + + it("handles multibyte utf-8 correctly", () => { + const big = "中".repeat(5000); // each char is 3 bytes + const out = truncateString(big, 2048); + expect(Buffer.byteLength(out, "utf8")).toBeLessThan(Buffer.byteLength(big, "utf8")); + expect(out).toContain("[truncated"); + }); +}); + +describe("truncateEventPayload", () => { + it("leaves small payloads unchanged (reference equality)", () => { + const input = { type: "t", id: "abc", status: "ok", nested: { n: 1 } }; + const out = truncateEventPayload(input); + expect(out).toBe(input); + }); + + it("preserves all ids, types, statuses when truncating strings", () => { + const big = "x".repeat(10_000); + const event = { + type: "opencode.message.part.updated", + properties: { + part: { + id: "part-123", + callID: "call-456", + tool: "read", + type: "tool", + state: { + status: "completed", + title: "Read file", + output: big, + input: { filePath: "/tmp/a.txt" }, + }, + }, + }, + }; + const out = truncateEventPayload(event, { maxStringBytes: 1024 }); + const p: any = (out as any).properties.part; + // structural identity fields preserved + expect(p.id).toBe("part-123"); + expect(p.callID).toBe("call-456"); + expect(p.tool).toBe("read"); + expect(p.type).toBe("tool"); + expect(p.state.status).toBe("completed"); + expect(p.state.title).toBe("Read file"); + expect(p.state.input.filePath).toBe("/tmp/a.txt"); + // big string was cut down + expect(p.state.output.length).toBeLessThan(big.length); + expect(p.state.output).toContain("[truncated"); + }); + + it("truncates strings inside arrays of content blocks", () => { + const big = "x".repeat(10_000); + const event = { + type: "claude.raw.message", + properties: { + record: { + type: "user", + message: { + content: [ + { type: "tool_result", tool_use_id: "abc", content: big, is_error: false }, + { type: "text", text: "short" }, + ], + }, + }, + }, + }; + const out: any = truncateEventPayload(event, { maxStringBytes: 1024 }); + const blocks = out.properties.record.message.content; + expect(blocks[0].type).toBe("tool_result"); + expect(blocks[0].tool_use_id).toBe("abc"); + expect(blocks[0].is_error).toBe(false); + expect(blocks[0].content.length).toBeLessThan(big.length); + expect(blocks[0].content).toContain("[truncated"); + expect(blocks[1]).toEqual({ type: "text", text: "short" }); + }); + + it("keeps numbers, booleans, nulls, and undefined unchanged", () => { + const event = { + type: "t", + properties: { + usage: { input_tokens: 1234, output_tokens: 567, reasoning_tokens: 0 }, + isError: false, + metadata: null, + missing: undefined, + }, + }; + const out: any = truncateEventPayload(event); + expect(out.properties.usage.input_tokens).toBe(1234); + expect(out.properties.usage.output_tokens).toBe(567); + expect(out.properties.isError).toBe(false); + expect(out.properties.metadata).toBeNull(); + }); + + it("handles circular references without throwing", () => { + const a: any = { type: "x" }; + a.self = a; + const out: any = truncateEventPayload(a); + // original is returned (or a copy) without stack overflow + expect(out.type).toBe("x"); + }); + + it("respects maxDepth as a safety net", () => { + const deep: any = { v: 1 }; + let cur = deep; + for (let i = 0; i < 20; i++) { + cur.next = { v: i }; + cur = cur.next; + } + const out = truncateEventPayload(deep, { maxDepth: 3 }); + expect(out).toBeDefined(); + // just ensures no stack overflow; exact shape beyond maxDepth is not asserted + }); + + it("honours preserveStringAtPath for whitelisted paths", () => { + const big = "a".repeat(10_000); + const event = { + type: "message.part.updated", + properties: { + part: { + id: "p1", + type: "text", + text: big, + }, + }, + }; + const out: any = truncateEventPayload(event, { + maxStringBytes: 512, + preserveStringAtPath: (path) => path === "properties.part.text", + }); + // whitelisted string kept verbatim + expect(out.properties.part.text).toBe(big); + expect(out.properties.part.id).toBe("p1"); + }); + + it("truncates other strings while preserving whitelisted ones", () => { + const big = "a".repeat(10_000); + const alsoBig = "b".repeat(10_000); + const event = { + type: "message.part.updated", + properties: { + part: { + type: "text", + text: big, + // A sibling long string should still be truncated so callers only + // opt-in to the exact path they need to keep verbatim. + debug: alsoBig, + }, + }, + }; + const out: any = truncateEventPayload(event, { + maxStringBytes: 512, + preserveStringAtPath: (path) => path === "properties.part.text", + }); + expect(out.properties.part.text).toBe(big); + expect(out.properties.part.debug.length).toBeLessThan(alsoBig.length); + expect(out.properties.part.debug).toContain("[truncated"); + }); +}); diff --git a/packages/utils/test/final-reply-preservation.test.ts b/packages/utils/test/final-reply-preservation.test.ts new file mode 100644 index 00000000..7d50664c --- /dev/null +++ b/packages/utils/test/final-reply-preservation.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "bun:test"; +import { truncateEventPayload } from "../event-truncation"; +import { buildSessionMessageState, type SessionEvent } from "../session-inspector"; + +/** + * Regression: the final Slack reply must NEVER be replaced with a truncation + * marker. + * + * `request-run.ts` rebuilds `request.currentText` from `eventHistory` via + * `buildSessionMessageState(...).currentText`, and publishes it to Slack as + * the final reply on `stop` and tool-only turns. If `truncateEventPayload` + * rewrites the assistant text part, the user sees + * `...[truncated N bytes]` instead of the actual response. + * + * These tests simulate the exact ingestion path: raw event → truncate → + * push → fold → read `currentText`. + */ + +function ingestLikeRequestRun(event: Record): SessionEvent { + // Must match the logic in `packages/core/kernel/request-run.ts` around the + // `truncateEventPayload` call site. + const type = (event as any)?.type; + const partType = (event as any)?.properties?.part?.type; + const preserveAssistantText = + type === "message.part.updated" && + (partType === "text" || partType === "reasoning" || partType === "thinking"); + const preserveStringAtPath = preserveAssistantText + ? (path: string) => path === "properties.part.text" + : undefined; + return { + timestamp: Date.now(), + type: type ?? "unknown", + data: truncateEventPayload(event, { preserveStringAtPath }), + }; +} + +describe("request-run truncation keeps final assistant reply verbatim", () => { + it("preserves a long assistant text part across the truncate → fold pipeline", () => { + const finalReply = + "Here is the full answer.\n\n" + + "x".repeat(50_000) + + "\n\nEnd of answer."; + const history: SessionEvent[] = [ + ingestLikeRequestRun({ + type: "message.part.updated", + properties: { + part: { + id: "assistant-final", + type: "text", + text: finalReply, + messageID: "msg-1", + }, + }, + }), + ]; + + const state = buildSessionMessageState(history); + expect(state.currentText).toBe(finalReply); + expect(state.currentText).not.toContain("[truncated"); + }); + + it("preserves streamed assistant text across many updates with the same part id", () => { + // Simulate streaming: many intermediate snapshots culminating in the + // full response. + const final = "Here is a very long streamed reply.\n" + "y".repeat(30_000); + const history: SessionEvent[] = []; + for (let i = 1; i <= 10; i++) { + const slice = final.slice(0, Math.floor((final.length * i) / 10)); + history.push( + ingestLikeRequestRun({ + type: "message.part.updated", + properties: { + part: { + id: "assistant-final", + type: "text", + text: slice, + messageID: "msg-1", + }, + }, + }), + ); + } + + const state = buildSessionMessageState(history); + expect(state.currentText).toBe(final); + expect(state.currentText).not.toContain("[truncated"); + }); + + it("still truncates tool output (not part of the final reply)", () => { + const bigOutput = "z".repeat(20_000); + const ingested = ingestLikeRequestRun({ + type: "message.part.updated", + properties: { + part: { + id: "tool-1", + type: "tool", + tool: "read", + state: { + status: "completed", + output: bigOutput, + }, + }, + }, + }); + const data = ingested.data as any; + expect(data.properties.part.state.output.length).toBeLessThan(bigOutput.length); + expect(data.properties.part.state.output).toContain("[truncated"); + }); + + it("preserves reasoning / thinking text used for live status phase updates", () => { + const reasoning = "r".repeat(12_000); + const thinking = "t".repeat(12_000); + const reasoningEvent = ingestLikeRequestRun({ + type: "message.part.updated", + properties: { + part: { id: "r1", type: "reasoning", text: reasoning }, + }, + }); + const thinkingEvent = ingestLikeRequestRun({ + type: "message.part.updated", + properties: { + part: { id: "t1", type: "thinking", text: thinking }, + }, + }); + expect((reasoningEvent.data as any).properties.part.text).toBe(reasoning); + expect((thinkingEvent.data as any).properties.part.text).toBe(thinking); + }); +});