From 26ede663a1d7eb6737c4e373b19b93d8cef8d4e4 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 23 Aug 2026 16:33:27 -0400 Subject: [PATCH 1/7] docs: add OpenSpec change for enforce-message-cap-at-data-layer --- .../.openspec.yaml | 2 + .../design.md | 67 +++++++++++++++++++ .../proposal.md | 32 +++++++++ .../specs/message-cap/spec.md | 57 ++++++++++++++++ .../tasks.md | 31 +++++++++ 5 files changed, 189 insertions(+) create mode 100644 openspec/changes/enforce-message-cap-at-data-layer/.openspec.yaml create mode 100644 openspec/changes/enforce-message-cap-at-data-layer/design.md create mode 100644 openspec/changes/enforce-message-cap-at-data-layer/proposal.md create mode 100644 openspec/changes/enforce-message-cap-at-data-layer/specs/message-cap/spec.md create mode 100644 openspec/changes/enforce-message-cap-at-data-layer/tasks.md diff --git a/openspec/changes/enforce-message-cap-at-data-layer/.openspec.yaml b/openspec/changes/enforce-message-cap-at-data-layer/.openspec.yaml new file mode 100644 index 00000000..44f55ffe --- /dev/null +++ b/openspec/changes/enforce-message-cap-at-data-layer/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-23 diff --git a/openspec/changes/enforce-message-cap-at-data-layer/design.md b/openspec/changes/enforce-message-cap-at-data-layer/design.md new file mode 100644 index 00000000..0e339a29 --- /dev/null +++ b/openspec/changes/enforce-message-cap-at-data-layer/design.md @@ -0,0 +1,67 @@ +## Context + +The `MessageList` component in `src/tui/messageList.js` manages conversation messages through four ref-based data structures. Currently, messages are added without any cap at the data layer — the 100-message limit is enforced only at render time via `idsRef.current.slice(-MAX_RENDER_MESSAGES)`. This means the internal state grows unbounded while the UI only displays the last 100 messages. + +## Goals / Non-Goals + +**Goals:** +- Enforce the 100-message cap at the data layer in `addMessage()` and `setMessages()`. +- Clean up orphaned data (dataRef, contentRef, pub/sub topics) when messages are shifted off. +- Maintain backward compatibility with the existing imperative API surface. +- Add regression tests for the cap enforcement. + +**Non-Goals:** +- Making the cap value configurable or parameterized. +- Adding pagination or "load older messages" functionality. +- Modifying session persistence/restore logic outside of messageList.js. +- Changing the render-layer slice (kept as defensive measure). + +## Decisions + +### Decision 1: Enforce cap in addMessage() after push +After pushing the new message ID to `idsRef.current`, check if length exceeds `MAX_RENDER_MESSAGES`. If so, shift off the oldest ID from index 0 and clean up all associated data structures. + +**Rationale:** This is the simplest and most efficient approach. A single check after push handles the common case of one message at a time. The `shift()` operation is O(n) but n is bounded at 100, so the cost is negligible. + +**Alternatives considered:** +- Using a circular buffer: More complex, unnecessary for a fixed cap of 100. +- Using `splice(0, 1)` instead of `shift()`: Functionally equivalent, `shift()` is more idiomatic. + +### Decision 2: Truncate in setMessages() after building full list +After iterating through all input messages and building the internal state, truncate `idsRef.current` to the last `MAX_RENDER_MESSAGES` entries and rebuild `idToIdxRef`. + +**Rationale:** Session restore may pass more than 100 messages. Truncating at the end ensures we keep the most recent 100, consistent with the render-layer behavior. + +**Alternatives considered:** +- Truncating during the build loop: Would require tracking indices dynamically, more complex. +- Accepting only 100 messages from the caller: Shifts responsibility to callers, breaks encapsulation. + +### Decision 3: Clean up orphaned data on shift +When shifting off a message, delete its entries from `dataRef`, `contentRef`, and its pub/sub topic from `topicsRef`. + +**Rationale:** Prevents memory leaks from orphaned message data. The pub/sub topic cleanup is critical — stale callbacks firing on orphaned message IDs could cause unexpected behavior. + +## Risks / Trade-offs + +### Risk: Streaming message updates on shifted messages +If a streaming assistant message is shifted off before completion, its pub/sub topic is deleted and the streaming updates will be lost. + +**Mitigation:** This is acceptable — the message was shifted because it's no longer visible in the UI. Streaming updates for shifted messages are a corner case that the user won't observe. The `updateMessage()` API will silently no-op for shifted messages since `idToIdxRef` won't contain them. + +### Risk: Index invalidation after shift +After shifting, all remaining message indices change by -1. `idToIdxRef` must be rebuilt to reflect new indices. + +**Mitigation:** Rebuild `idToIdxRef` from scratch after shifting by iterating `idsRef.current` and setting each index. This is O(n) with n ≤ 100, negligible cost. + +### Risk: Session restore truncation +If a session has more than 100 messages, only the last 100 will be retained in memory. + +**Mitigation:** This is consistent with existing render-layer behavior — the user never saw more than 100 messages anyway. The conversation history is preserved in the session checkpoint; only the in-memory TUI state is truncated. + +## Migration Plan + +No migration needed. This is a pure code change with no data migration. The behavioral change (messages now actually removed from memory) is consistent with existing render-layer behavior and is invisible to end users. + +## Open Questions + +None. The implementation approach is clear and well-defined by the audit findings and fix steps. \ No newline at end of file diff --git a/openspec/changes/enforce-message-cap-at-data-layer/proposal.md b/openspec/changes/enforce-message-cap-at-data-layer/proposal.md new file mode 100644 index 00000000..983a6e13 --- /dev/null +++ b/openspec/changes/enforce-message-cap-at-data-layer/proposal.md @@ -0,0 +1,32 @@ +## Why + +The conversation panel in `src/tui/messageList.js` enforces a 100-message limit only at the render layer via `idsRef.current.slice(-MAX_RENDER_MESSAGES)`. Messages are never removed from the underlying data structures (`idsRef`, `idToIdxRef`, `dataRef`, `contentRef`), causing unbounded memory growth as conversations accumulate messages. Long-running sessions can accumulate thousands of messages in memory, even though only 100 are visible. + +## What Changes + +- Enforce the 100-message cap at the data layer in `addMessage()` — shift off the oldest message when the array exceeds `MAX_RENDER_MESSAGES`. +- Enforce the 100-message cap at the data layer in `setMessages()` — truncate the input to the last 100 messages before building internal state. +- Clean up orphaned data (`dataRef`, `contentRef`, pub/sub topics) when messages are shifted off. +- Add regression tests verifying the cap at the data layer. + +## Capabilities + +### New Capabilities +- `message-cap`: Defines the requirement that the MessageList data layer enforces a maximum message count, ensuring memory is bounded regardless of conversation length. + +### Modified Capabilities +- None + +## Impact + +- **Affected code:** `src/tui/messageList.js` — `addMessage()` and `setMessages()` imperative APIs. +- **Affected tests:** `tests/unit/messageListApi.test.js` — new test cases for cap enforcement. +- **Behavioral change:** Sessions with more than 100 messages will now only retain the last 100 in memory, consistent with existing render-layer behavior. +- **Non-breaking:** The public imperative API surface (`addMessage`, `setMessages`, `getMessageCount`) remains unchanged. Only internal state management differs. + +## Non-goals + +- Changing the value of `MAX_RENDER_MESSAGES` — that remains a configurable constant. +- Adding a configurable cap limit — the cap value is not parameterized in this change. +- Modifying session persistence/restore logic outside of `messageList.js`. +- Adding pagination or "load older messages" functionality. \ No newline at end of file diff --git a/openspec/changes/enforce-message-cap-at-data-layer/specs/message-cap/spec.md b/openspec/changes/enforce-message-cap-at-data-layer/specs/message-cap/spec.md new file mode 100644 index 00000000..dd139ed2 --- /dev/null +++ b/openspec/changes/enforce-message-cap-at-data-layer/specs/message-cap/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: MessageList enforces data-layer message cap on addMessage +The MessageList imperative API SHALL enforce a maximum message count at the data layer when adding a new message via `addMessage()`. When the internal message array exceeds `MAX_RENDER_MESSAGES` (100), the oldest message ID SHALL be removed from the beginning of the array, and all associated data structures SHALL be cleaned up. + +#### Scenario: Add message when under cap +- **WHEN** the current message count is less than `MAX_RENDER_MESSAGES` (100) +- **THEN** the new message is appended to the end of the array and `getMessageCount()` returns the incremented count + +#### Scenario: Add message when at cap +- **WHEN** the current message count equals `MAX_RENDER_MESSAGES` (100) +- **THEN** the oldest message ID is shifted off the beginning, the new message is appended, and `getMessageCount()` returns `MAX_RENDER_MESSAGES` (100) + +#### Scenario: Add message when over cap +- **WHEN** the current message count exceeds `MAX_RENDER_MESSAGES` (100) — e.g., after a session restore with >100 messages +- **THEN** the oldest message ID is shifted off, the new message is appended, and `getMessageCount()` returns `MAX_RENDER_MESSAGES` (100) + +#### Scenario: Oldest message data is cleaned up on shift +- **WHEN** a message ID is shifted off the beginning of the array +- **THEN** the entry is removed from `dataRef`, `contentRef`, `idToIdxRef`, and its pub/sub topic from `topicsRef` + +### Requirement: MessageList enforces data-layer message cap on setMessages +The MessageList imperative API SHALL enforce a maximum message count at the data layer when initializing from a messages array via `setMessages()`. If the input array exceeds `MAX_RENDER_MESSAGES`, only the last `MAX_RENDER_MESSAGES` entries SHALL be retained. + +#### Scenario: Set messages when under cap +- **WHEN** the input message array has fewer than `MAX_RENDER_MESSAGES` (100) entries +- **THEN** all messages are retained and `getMessageCount()` returns the input array length + +#### Scenario: Set messages when over cap +- **WHEN** the input message array exceeds `MAX_RENDER_MESSAGES` (100) entries +- **THEN** only the last 100 messages are retained, `getMessageCount()` returns 100, and the oldest messages are excluded from all data structures + +#### Scenario: Set messages preserves message order +- **WHEN** the input message array is truncated to the last 100 entries +- **THEN** the relative order of the retained messages is preserved (first retained message is at index 0, last at index 99) + +### Requirement: updateMessage() is a no-op for shifted messages +The MessageList imperative API SHALL silently ignore `updateMessage()` calls for message IDs that have been shifted off the data layer. The function SHALL check `idToIdxRef` for the ID and return early if not found. + +#### Scenario: Update shifted message is no-op +- **WHEN** a message has been shifted off the data layer (e.g., during streaming) +- **THEN** calling `updateMessage()` with that ID does nothing — no error, no crash, no state change + +#### Scenario: Update retained message works normally +- **WHEN** a message is still within the data layer (not shifted off) +- **THEN** `updateMessage()` updates the message data and triggers a pub/sub notification as before + +### Requirement: Render layer cap remains as defensive measure +The render layer SHALL continue to use `idsRef.current.slice(-MAX_RENDER_MESSAGES)` to determine which messages to render, providing a defensive layer independent of the data-layer cap. + +#### Scenario: Render slice matches data layer +- **WHEN** the data layer has enforced the cap (count ≤ 100) +- **THEN** `idsRef.current.slice(-MAX_RENDER_MESSAGES)` returns all messages (no-op truncation) + +#### Scenario: Render slice protects against data layer bypass +- **WHEN** the data layer somehow exceeds the cap (e.g., external mutation) +- **THEN** the render layer still only renders the last 100 messages \ No newline at end of file diff --git a/openspec/changes/enforce-message-cap-at-data-layer/tasks.md b/openspec/changes/enforce-message-cap-at-data-layer/tasks.md new file mode 100644 index 00000000..6e839ee9 --- /dev/null +++ b/openspec/changes/enforce-message-cap-at-data-layer/tasks.md @@ -0,0 +1,31 @@ +## 1. Implement data-layer cap in addMessage() + +- [ ] 1.1 After pushing the new message ID to `idsRef.current`, add a check: if length exceeds `MAX_RENDER_MESSAGES`, shift off the oldest ID from index 0. +- [ ] 1.2 When shifting off an ID, clean up `dataRef`, `contentRef`, `idToIdxRef`, and the pub/sub topic from `topicsRef`. +- [ ] 1.3 Rebuild `idToIdxRef` indices after shifting (all remaining indices decrease by 1). + +## 2. Make updateMessage() a no-op for shifted messages + +- [ ] 2.1 In `updateMessage()`, verify the message ID exists in `idToIdxRef` before proceeding (early return if not found). +- [ ] 2.2 Add a test case verifying that updating a shifted message is a no-op. + +## 2. Implement data-layer cap in setMessages() + +- [ ] 2.1 After building the full internal state from the input array, check if `idsRef.current.length` exceeds `MAX_RENDER_MESSAGES`. +- [ ] 2.2 If over cap, truncate `idsRef.current` to the last 100 entries, rebuild `idToIdxRef`, and prune `dataRef`, `contentRef`, and pub/sub topics for removed messages. + +## 3. Add regression tests + +- [ ] 3.1 Test: addMessage when under cap — verify count increments correctly. +- [ ] 3.2 Test: addMessage when at cap — verify oldest message is shifted, count stays at 100. +- [ ] 3.3 Test: addMessage when over cap — verify oldest message is shifted, count stays at 100. +- [ ] 3.4 Test: setMessages when over cap — verify only last 100 retained, count is 100. +- [ ] 3.5 Test: setMessages preserves order — verify retained messages maintain relative order. +- [ ] 3.6 Test: orphaned data cleanup — verify dataRef, contentRef, idToIdxRef, and topicsRef are cleaned up on shift. + +## 4. Verify and commit + +- [ ] 4.1 Run `npm run test` — all tests pass. +- [ ] 4.2 Run `npm run lint` — no lint errors. +- [ ] 4.3 Run `npm run coverage` — coverage maintained. +- [ ] 4.4 Commit and push implementation code to the PR. \ No newline at end of file From 05391287ea4af9df902c8db2e6449d01406db68d Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 23 Aug 2026 17:18:16 -0400 Subject: [PATCH 2/7] refactor: remove message cap, use virtual render window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the MAX_RENDER_MESSAGES constant and data-layer message cap. The data layer now stores all messages without restriction. The render layer uses a local renderWindow variable (100) to keep the React tree bounded — only the last N messages are rendered as bubbles. Pub/sub topics for messages outside the render window are pruned to keep memory in check. Breaking: removed exported MAX_RENDER_MESSAGES constant from messageList.js. The render window is now a local implementation detail. --- src/tui/messageList.js | 23 +++++++++++++---------- tests/unit/messageListApi.test.js | 4 ++++ tests/unit/tui.test.js | 2 +- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/tui/messageList.js b/src/tui/messageList.js index f9844c0f..e8683aec 100644 --- a/src/tui/messageList.js +++ b/src/tui/messageList.js @@ -22,11 +22,6 @@ export function PubSubProvider({ subscribe, unsubscribe, publish, children }) { ); } -/** - * Maximum number of messages to render in the React tree. - */ -const MAX_RENDER_MESSAGES = 100; - // Monotonic counter for generating stable message IDs. let _messageIdCounter = 0; @@ -132,6 +127,7 @@ export const MessageList = forwardRef(function MessageList( idsRef.current.push(id); idToIdxRef.current.set(id, idsRef.current.length - 1); + triggerRender(); return id; }, @@ -329,17 +325,24 @@ export const MessageList = forwardRef(function MessageList( lastMsgCountRef.current = idsRef.current.length; }; - // Render the last MAX_RENDER_MESSAGES as MessageBubble elements. + // Render the last N messages as MessageBubble elements. // Each bubble subscribes to its own pub/sub topic for streaming updates. // Children array is stabilized in a ref — only rebuilt when message count // changes (new message added, pruned, or cleared). This lets Ink's diffing // reuse existing elements and only update the one bubble that changed. + // + // The data layer stores all messages without a cap. The ScrollView handles + // scrolling through the full conversation history. The render window keeps + // the React tree bounded; pub/sub topics for messages far from the current + // view are pruned to keep memory bounded. const childrenRef = useRef(null); - const renderData = idsRef.current.slice(-MAX_RENDER_MESSAGES); - - // Prune pub/sub topics for messages that fell off the render slice. - const prunedIds = idsRef.current.slice(0, -MAX_RENDER_MESSAGES); + // Virtual render window — keeps the React tree bounded while the data + // layer stores all messages. The ScrollView scrolls through the full + // conversation history; only the last N messages are rendered as bubbles. + const renderWindow = 100; + const renderData = idsRef.current.slice(-renderWindow); + const prunedIds = idsRef.current.slice(0, idsRef.current.length - renderWindow); for (const id of prunedIds) { topicsRef.current.delete(`msg-${id}`); } diff --git a/tests/unit/messageListApi.test.js b/tests/unit/messageListApi.test.js index adf718da..56112969 100644 --- a/tests/unit/messageListApi.test.js +++ b/tests/unit/messageListApi.test.js @@ -6,6 +6,10 @@ import assert from "node:assert"; * Simulates the imperative API used by MessageList without React. * Tests the addMessage, updateMessage, clear, setMessages workflow * including pub/sub topic management. + * + * Note: The data layer stores all messages without a cap. + * The render layer uses a virtual window (last 100 messages) for + * performance, but the underlying data structures grow unbounded. */ describe("messageList imperative API simulation", () => { let pubsub; diff --git a/tests/unit/tui.test.js b/tests/unit/tui.test.js index efba3d50..ac517f69 100644 --- a/tests/unit/tui.test.js +++ b/tests/unit/tui.test.js @@ -1165,7 +1165,7 @@ describe("TUI - scroll throttle behavior", () => { }); describe("MessageList - render window limits React tree size", () => { - it("uses MAX_RENDER_MESSAGES window from messageList", async () => { + it("mounts and unmounts without error", async () => { const { MessageList } = await import("../../src/tui/messageList.js"); const { unmount: um } = render( React.createElement(MessageList, { From a125a926e25e81e855b36d0e615d487834337b12 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 23 Aug 2026 17:18:43 -0400 Subject: [PATCH 3/7] chore: remove stale OpenSpec change (pivoted to virtual render window) --- .../.openspec.yaml | 2 - .../design.md | 67 ------------------- .../proposal.md | 32 --------- .../specs/message-cap/spec.md | 57 ---------------- .../tasks.md | 31 --------- 5 files changed, 189 deletions(-) delete mode 100644 openspec/changes/enforce-message-cap-at-data-layer/.openspec.yaml delete mode 100644 openspec/changes/enforce-message-cap-at-data-layer/design.md delete mode 100644 openspec/changes/enforce-message-cap-at-data-layer/proposal.md delete mode 100644 openspec/changes/enforce-message-cap-at-data-layer/specs/message-cap/spec.md delete mode 100644 openspec/changes/enforce-message-cap-at-data-layer/tasks.md diff --git a/openspec/changes/enforce-message-cap-at-data-layer/.openspec.yaml b/openspec/changes/enforce-message-cap-at-data-layer/.openspec.yaml deleted file mode 100644 index 44f55ffe..00000000 --- a/openspec/changes/enforce-message-cap-at-data-layer/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-08-23 diff --git a/openspec/changes/enforce-message-cap-at-data-layer/design.md b/openspec/changes/enforce-message-cap-at-data-layer/design.md deleted file mode 100644 index 0e339a29..00000000 --- a/openspec/changes/enforce-message-cap-at-data-layer/design.md +++ /dev/null @@ -1,67 +0,0 @@ -## Context - -The `MessageList` component in `src/tui/messageList.js` manages conversation messages through four ref-based data structures. Currently, messages are added without any cap at the data layer — the 100-message limit is enforced only at render time via `idsRef.current.slice(-MAX_RENDER_MESSAGES)`. This means the internal state grows unbounded while the UI only displays the last 100 messages. - -## Goals / Non-Goals - -**Goals:** -- Enforce the 100-message cap at the data layer in `addMessage()` and `setMessages()`. -- Clean up orphaned data (dataRef, contentRef, pub/sub topics) when messages are shifted off. -- Maintain backward compatibility with the existing imperative API surface. -- Add regression tests for the cap enforcement. - -**Non-Goals:** -- Making the cap value configurable or parameterized. -- Adding pagination or "load older messages" functionality. -- Modifying session persistence/restore logic outside of messageList.js. -- Changing the render-layer slice (kept as defensive measure). - -## Decisions - -### Decision 1: Enforce cap in addMessage() after push -After pushing the new message ID to `idsRef.current`, check if length exceeds `MAX_RENDER_MESSAGES`. If so, shift off the oldest ID from index 0 and clean up all associated data structures. - -**Rationale:** This is the simplest and most efficient approach. A single check after push handles the common case of one message at a time. The `shift()` operation is O(n) but n is bounded at 100, so the cost is negligible. - -**Alternatives considered:** -- Using a circular buffer: More complex, unnecessary for a fixed cap of 100. -- Using `splice(0, 1)` instead of `shift()`: Functionally equivalent, `shift()` is more idiomatic. - -### Decision 2: Truncate in setMessages() after building full list -After iterating through all input messages and building the internal state, truncate `idsRef.current` to the last `MAX_RENDER_MESSAGES` entries and rebuild `idToIdxRef`. - -**Rationale:** Session restore may pass more than 100 messages. Truncating at the end ensures we keep the most recent 100, consistent with the render-layer behavior. - -**Alternatives considered:** -- Truncating during the build loop: Would require tracking indices dynamically, more complex. -- Accepting only 100 messages from the caller: Shifts responsibility to callers, breaks encapsulation. - -### Decision 3: Clean up orphaned data on shift -When shifting off a message, delete its entries from `dataRef`, `contentRef`, and its pub/sub topic from `topicsRef`. - -**Rationale:** Prevents memory leaks from orphaned message data. The pub/sub topic cleanup is critical — stale callbacks firing on orphaned message IDs could cause unexpected behavior. - -## Risks / Trade-offs - -### Risk: Streaming message updates on shifted messages -If a streaming assistant message is shifted off before completion, its pub/sub topic is deleted and the streaming updates will be lost. - -**Mitigation:** This is acceptable — the message was shifted because it's no longer visible in the UI. Streaming updates for shifted messages are a corner case that the user won't observe. The `updateMessage()` API will silently no-op for shifted messages since `idToIdxRef` won't contain them. - -### Risk: Index invalidation after shift -After shifting, all remaining message indices change by -1. `idToIdxRef` must be rebuilt to reflect new indices. - -**Mitigation:** Rebuild `idToIdxRef` from scratch after shifting by iterating `idsRef.current` and setting each index. This is O(n) with n ≤ 100, negligible cost. - -### Risk: Session restore truncation -If a session has more than 100 messages, only the last 100 will be retained in memory. - -**Mitigation:** This is consistent with existing render-layer behavior — the user never saw more than 100 messages anyway. The conversation history is preserved in the session checkpoint; only the in-memory TUI state is truncated. - -## Migration Plan - -No migration needed. This is a pure code change with no data migration. The behavioral change (messages now actually removed from memory) is consistent with existing render-layer behavior and is invisible to end users. - -## Open Questions - -None. The implementation approach is clear and well-defined by the audit findings and fix steps. \ No newline at end of file diff --git a/openspec/changes/enforce-message-cap-at-data-layer/proposal.md b/openspec/changes/enforce-message-cap-at-data-layer/proposal.md deleted file mode 100644 index 983a6e13..00000000 --- a/openspec/changes/enforce-message-cap-at-data-layer/proposal.md +++ /dev/null @@ -1,32 +0,0 @@ -## Why - -The conversation panel in `src/tui/messageList.js` enforces a 100-message limit only at the render layer via `idsRef.current.slice(-MAX_RENDER_MESSAGES)`. Messages are never removed from the underlying data structures (`idsRef`, `idToIdxRef`, `dataRef`, `contentRef`), causing unbounded memory growth as conversations accumulate messages. Long-running sessions can accumulate thousands of messages in memory, even though only 100 are visible. - -## What Changes - -- Enforce the 100-message cap at the data layer in `addMessage()` — shift off the oldest message when the array exceeds `MAX_RENDER_MESSAGES`. -- Enforce the 100-message cap at the data layer in `setMessages()` — truncate the input to the last 100 messages before building internal state. -- Clean up orphaned data (`dataRef`, `contentRef`, pub/sub topics) when messages are shifted off. -- Add regression tests verifying the cap at the data layer. - -## Capabilities - -### New Capabilities -- `message-cap`: Defines the requirement that the MessageList data layer enforces a maximum message count, ensuring memory is bounded regardless of conversation length. - -### Modified Capabilities -- None - -## Impact - -- **Affected code:** `src/tui/messageList.js` — `addMessage()` and `setMessages()` imperative APIs. -- **Affected tests:** `tests/unit/messageListApi.test.js` — new test cases for cap enforcement. -- **Behavioral change:** Sessions with more than 100 messages will now only retain the last 100 in memory, consistent with existing render-layer behavior. -- **Non-breaking:** The public imperative API surface (`addMessage`, `setMessages`, `getMessageCount`) remains unchanged. Only internal state management differs. - -## Non-goals - -- Changing the value of `MAX_RENDER_MESSAGES` — that remains a configurable constant. -- Adding a configurable cap limit — the cap value is not parameterized in this change. -- Modifying session persistence/restore logic outside of `messageList.js`. -- Adding pagination or "load older messages" functionality. \ No newline at end of file diff --git a/openspec/changes/enforce-message-cap-at-data-layer/specs/message-cap/spec.md b/openspec/changes/enforce-message-cap-at-data-layer/specs/message-cap/spec.md deleted file mode 100644 index dd139ed2..00000000 --- a/openspec/changes/enforce-message-cap-at-data-layer/specs/message-cap/spec.md +++ /dev/null @@ -1,57 +0,0 @@ -## ADDED Requirements - -### Requirement: MessageList enforces data-layer message cap on addMessage -The MessageList imperative API SHALL enforce a maximum message count at the data layer when adding a new message via `addMessage()`. When the internal message array exceeds `MAX_RENDER_MESSAGES` (100), the oldest message ID SHALL be removed from the beginning of the array, and all associated data structures SHALL be cleaned up. - -#### Scenario: Add message when under cap -- **WHEN** the current message count is less than `MAX_RENDER_MESSAGES` (100) -- **THEN** the new message is appended to the end of the array and `getMessageCount()` returns the incremented count - -#### Scenario: Add message when at cap -- **WHEN** the current message count equals `MAX_RENDER_MESSAGES` (100) -- **THEN** the oldest message ID is shifted off the beginning, the new message is appended, and `getMessageCount()` returns `MAX_RENDER_MESSAGES` (100) - -#### Scenario: Add message when over cap -- **WHEN** the current message count exceeds `MAX_RENDER_MESSAGES` (100) — e.g., after a session restore with >100 messages -- **THEN** the oldest message ID is shifted off, the new message is appended, and `getMessageCount()` returns `MAX_RENDER_MESSAGES` (100) - -#### Scenario: Oldest message data is cleaned up on shift -- **WHEN** a message ID is shifted off the beginning of the array -- **THEN** the entry is removed from `dataRef`, `contentRef`, `idToIdxRef`, and its pub/sub topic from `topicsRef` - -### Requirement: MessageList enforces data-layer message cap on setMessages -The MessageList imperative API SHALL enforce a maximum message count at the data layer when initializing from a messages array via `setMessages()`. If the input array exceeds `MAX_RENDER_MESSAGES`, only the last `MAX_RENDER_MESSAGES` entries SHALL be retained. - -#### Scenario: Set messages when under cap -- **WHEN** the input message array has fewer than `MAX_RENDER_MESSAGES` (100) entries -- **THEN** all messages are retained and `getMessageCount()` returns the input array length - -#### Scenario: Set messages when over cap -- **WHEN** the input message array exceeds `MAX_RENDER_MESSAGES` (100) entries -- **THEN** only the last 100 messages are retained, `getMessageCount()` returns 100, and the oldest messages are excluded from all data structures - -#### Scenario: Set messages preserves message order -- **WHEN** the input message array is truncated to the last 100 entries -- **THEN** the relative order of the retained messages is preserved (first retained message is at index 0, last at index 99) - -### Requirement: updateMessage() is a no-op for shifted messages -The MessageList imperative API SHALL silently ignore `updateMessage()` calls for message IDs that have been shifted off the data layer. The function SHALL check `idToIdxRef` for the ID and return early if not found. - -#### Scenario: Update shifted message is no-op -- **WHEN** a message has been shifted off the data layer (e.g., during streaming) -- **THEN** calling `updateMessage()` with that ID does nothing — no error, no crash, no state change - -#### Scenario: Update retained message works normally -- **WHEN** a message is still within the data layer (not shifted off) -- **THEN** `updateMessage()` updates the message data and triggers a pub/sub notification as before - -### Requirement: Render layer cap remains as defensive measure -The render layer SHALL continue to use `idsRef.current.slice(-MAX_RENDER_MESSAGES)` to determine which messages to render, providing a defensive layer independent of the data-layer cap. - -#### Scenario: Render slice matches data layer -- **WHEN** the data layer has enforced the cap (count ≤ 100) -- **THEN** `idsRef.current.slice(-MAX_RENDER_MESSAGES)` returns all messages (no-op truncation) - -#### Scenario: Render slice protects against data layer bypass -- **WHEN** the data layer somehow exceeds the cap (e.g., external mutation) -- **THEN** the render layer still only renders the last 100 messages \ No newline at end of file diff --git a/openspec/changes/enforce-message-cap-at-data-layer/tasks.md b/openspec/changes/enforce-message-cap-at-data-layer/tasks.md deleted file mode 100644 index 6e839ee9..00000000 --- a/openspec/changes/enforce-message-cap-at-data-layer/tasks.md +++ /dev/null @@ -1,31 +0,0 @@ -## 1. Implement data-layer cap in addMessage() - -- [ ] 1.1 After pushing the new message ID to `idsRef.current`, add a check: if length exceeds `MAX_RENDER_MESSAGES`, shift off the oldest ID from index 0. -- [ ] 1.2 When shifting off an ID, clean up `dataRef`, `contentRef`, `idToIdxRef`, and the pub/sub topic from `topicsRef`. -- [ ] 1.3 Rebuild `idToIdxRef` indices after shifting (all remaining indices decrease by 1). - -## 2. Make updateMessage() a no-op for shifted messages - -- [ ] 2.1 In `updateMessage()`, verify the message ID exists in `idToIdxRef` before proceeding (early return if not found). -- [ ] 2.2 Add a test case verifying that updating a shifted message is a no-op. - -## 2. Implement data-layer cap in setMessages() - -- [ ] 2.1 After building the full internal state from the input array, check if `idsRef.current.length` exceeds `MAX_RENDER_MESSAGES`. -- [ ] 2.2 If over cap, truncate `idsRef.current` to the last 100 entries, rebuild `idToIdxRef`, and prune `dataRef`, `contentRef`, and pub/sub topics for removed messages. - -## 3. Add regression tests - -- [ ] 3.1 Test: addMessage when under cap — verify count increments correctly. -- [ ] 3.2 Test: addMessage when at cap — verify oldest message is shifted, count stays at 100. -- [ ] 3.3 Test: addMessage when over cap — verify oldest message is shifted, count stays at 100. -- [ ] 3.4 Test: setMessages when over cap — verify only last 100 retained, count is 100. -- [ ] 3.5 Test: setMessages preserves order — verify retained messages maintain relative order. -- [ ] 3.6 Test: orphaned data cleanup — verify dataRef, contentRef, idToIdxRef, and topicsRef are cleaned up on shift. - -## 4. Verify and commit - -- [ ] 4.1 Run `npm run test` — all tests pass. -- [ ] 4.2 Run `npm run lint` — no lint errors. -- [ ] 4.3 Run `npm run coverage` — coverage maintained. -- [ ] 4.4 Commit and push implementation code to the PR. \ No newline at end of file From a9bde9b8d272b67a8721e700d765eb001c38ebee Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 23 Aug 2026 17:37:06 -0400 Subject: [PATCH 4/7] feat: make render window configurable via tui.renderWindow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tui.renderWindow config option (default 100) that controls how many messages are rendered as bubbles in the conversation panel. The data layer stores all messages unrestricted; the render window is a virtual view layer optimization. Config flow: config.yaml → TuiSchema → app.js → ConversationPanel → MessageList Users can now tune the render window size without touching code: tui: renderWindow: 200 --- config.yaml | 1 + src/config/schemas/tui.js | 1 + src/tui/app.js | 1 + src/tui/conversationPanel.js | 3 +++ src/tui/messageList.js | 5 +++-- 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/config.yaml b/config.yaml index acf31ffb..f0888810 100644 --- a/config.yaml +++ b/config.yaml @@ -86,6 +86,7 @@ schedules: tui: name: madz cursorChar: "█" + renderWindow: 100 agent: recursionLimit: 1000 autoContinueLimit: 1000 diff --git a/src/config/schemas/tui.js b/src/config/schemas/tui.js index 75dd7e99..276ad080 100644 --- a/src/config/schemas/tui.js +++ b/src/config/schemas/tui.js @@ -3,4 +3,5 @@ import { z } from "zod"; export const TuiSchema = z.object({ name: z.string().default("madz"), cursorChar: z.string().default("\u2588"), + renderWindow: z.number().int().min(1).default(100), }); diff --git a/src/tui/app.js b/src/tui/app.js index fe69d8cc..5fd1f0f7 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -950,6 +950,7 @@ export default function App({ }, React.createElement(ConversationPanel, { assistantName: config?.tui?.name || "Assistant", + renderWindow: config?.tui?.renderWindow ?? 100, messageListRef, }), ), diff --git a/src/tui/conversationPanel.js b/src/tui/conversationPanel.js index 6e097dae..cc78b3f1 100644 --- a/src/tui/conversationPanel.js +++ b/src/tui/conversationPanel.js @@ -67,6 +67,7 @@ export function getBubbleStyle(role) { * @param {Object} props * @param {Array} [props.messages] - Messages to display (for session restore) * @param {string} [props.assistantName] - Name for assistant messages + * @param {number} [props.renderWindow] - Number of messages to render (from config) * @param {React.Ref} [props.scrollRef] - Optional external scroll ref * @param {React.Ref} [props.messageListRef] - Optional ref for imperative access * @returns {React.ReactElement} @@ -74,6 +75,7 @@ export function getBubbleStyle(role) { export function ConversationPanel({ messages = [], assistantName = "Assistant", + renderWindow = 100, scrollRef: externalScrollRef, messageListRef, }) { @@ -93,6 +95,7 @@ export function ConversationPanel({ React.createElement(MessageList, { ref: panelRef, assistantName, + renderWindow, scrollRef: externalScrollRef, }), ); diff --git a/src/tui/messageList.js b/src/tui/messageList.js index e8683aec..e05b70e8 100644 --- a/src/tui/messageList.js +++ b/src/tui/messageList.js @@ -35,12 +35,13 @@ let _messageIdCounter = 0; * @param {Object} props * @param {Array} [props.messages] - Initial messages array for session restore * @param {string} [props.assistantName] - Name to display for assistant messages + * @param {number} [props.renderWindow] - Number of messages to render (from config) * @param {React.Ref} [props.forwardRef] - For exposed imperative API * @param {React.Ref} [props.scrollRef] - Forwarded scroll ref for external keyboard nav * @returns {React.ReactElement} */ export const MessageList = forwardRef(function MessageList( - { messages: _messages = [], assistantName = "Assistant", scrollRef: externalScrollRef }, + { messages: _messages = [], assistantName = "Assistant", renderWindow = 100, scrollRef: externalScrollRef }, forwardRef, ) { const internalRef = useRef(null); @@ -340,7 +341,7 @@ export const MessageList = forwardRef(function MessageList( // Virtual render window — keeps the React tree bounded while the data // layer stores all messages. The ScrollView scrolls through the full // conversation history; only the last N messages are rendered as bubbles. - const renderWindow = 100; + // Configurable via `tui.renderWindow` in config.yaml. const renderData = idsRef.current.slice(-renderWindow); const prunedIds = idsRef.current.slice(0, idsRef.current.length - renderWindow); for (const id of prunedIds) { From deac22688dd58be49db5f10da8fef7198f360dc7 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 23 Aug 2026 17:48:34 -0400 Subject: [PATCH 5/7] fix: format messageList.js with oxfmt --- coverage.txt | 19 ++++++++++++------- src/tui/messageList.js | 7 ++++++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/coverage.txt b/coverage.txt index 11c4dede..180032cc 100644 --- a/coverage.txt +++ b/coverage.txt @@ -16,10 +16,13 @@ ℹ research.js | 100.00 | 100.00 | 100.00 | ℹ search.js | 100.00 | 100.00 | 100.00 | ℹ security-audit.js | 100.00 | 100.00 | 100.00 | +ℹ seo-analyst.js | 100.00 | 100.00 | 100.00 | ℹ testing.js | 100.00 | 100.00 | 100.00 | +ℹ text-editor.js | 100.00 | 100.00 | 100.00 | +ℹ translator.js | 100.00 | 100.00 | 100.00 | ℹ config | | | | ℹ config.js | 100.00 | 100.00 | 100.00 | -ℹ loader.js | 92.63 | 90.48 | 81.82 | 94-96 121 123 173-177 187-190 +ℹ loader.js | 92.82 | 90.48 | 81.82 | 98-100 125 127 178-182 192-195 ℹ patch.js | 54.72 | 100.00 | 0.00 | 11-15 25-37 48-53 ℹ schemas | | | | ℹ agent.js | 100.00 | 100.00 | 100.00 | @@ -30,6 +33,7 @@ ℹ sandbox.js | 100.00 | 100.00 | 100.00 | ℹ schedules.js | 100.00 | 100.00 | 100.00 | ℹ skillAgentMap.js | 100.00 | 100.00 | 100.00 | +ℹ subAgentsTemperature.js | 100.00 | 100.00 | 100.00 | ℹ telemetry.js | 100.00 | 100.00 | 100.00 | ℹ tui.js | 100.00 | 100.00 | 100.00 | ℹ memory | | | | @@ -63,8 +67,8 @@ ℹ shared | | | | ℹ logger.js | 76.61 | 41.67 | 81.82 | 26-34 39 41-43 64-65 73-77 100-106 112-116 131 163-164 166-167 184-185 191-192 198-199 205-206 209-213 216 ℹ skills | | | | -ℹ agentMapper.js | 73.33 | 50.00 | 100.00 | 12-13 23-25 27-29 -ℹ discoverer.js | 94.69 | 85.94 | 100.00 | 63-68 192-193 197-198 233-235 +ℹ agentMapper.js | 83.33 | 62.50 | 100.00 | 12-13 23-25 +ℹ discoverer.js | 94.58 | 85.71 | 100.00 | 63-68 187-188 192-193 228-230 ℹ registry.js | 65.87 | 43.48 | 42.11 | 38-78 106-107 128-129 146-147 155-164 175-177 180-182 208-214 222-226 234-238 245-246 260-267 276-283 291-292 ℹ types.js | 100.00 | 100.00 | 100.00 | ℹ validator.js | 83.21 | 70.59 | 80.00 | 19-20 27-28 68 70 72-73 78 82-84 105-107 119-121 130-134 @@ -106,13 +110,14 @@ ℹ index.js | 100.00 | 94.12 | 100.00 | ℹ memory.js | 96.52 | 83.56 | 93.33 | 55 98-99 194-198 298-300 ℹ moa.js | 100.00 | 94.44 | 84.62 | +ℹ namecom | | | | +ℹ index.js | 70.84 | 100.00 | 0.00 | 21-29 36-44 53-112 129-151 199-202 223-226 229-231 240-242 245-248 251-253 267-271 306-310 425-448 ℹ pdfGenerate.js | 90.48 | 80.00 | 82.61 | 39-40 59-60 66-70 74-75 77 97 113-114 124-125 141-156 164-166 168-169 171 179-188 224-228 249 331-332 366-367 450-451 469-471 473-475 477-479 481-483 504-505 582-583 614-615 660-661 787-790 -ℹ process.js | 89.47 | 76.67 | 80.00 | 38-41 93-94 100-102 108-109 116-117 124-125 127 +ℹ process.js | 87.07 | 76.47 | 80.00 | 41-44 55-58 95 107 137-138 153-154 214-215 226-227 233-235 241-242 244-250 252-258 260 ℹ reflection.js | 95.18 | 82.00 | 91.67 | 58-62 127-128 151-152 206-207 ℹ sampling.js | 94.97 | 81.82 | 80.00 | 27 180-188 ℹ scanAgents.js | 100.00 | 80.00 | 100.00 | ℹ session_search.js | 97.06 | 71.19 | 94.12 | 71-72 118-119 128 181-182 -ℹ shell.js | 97.22 | 77.27 | 90.00 | 40 69-70 ℹ skills.js | 88.47 | 85.48 | 100.00 | 68-69 96-97 124-132 143-150 170-177 193-195 210-211 ℹ spreadsheet | | | | ℹ csv.js | 37.06 | 100.00 | 0.00 | 24-68 84-120 129-131 140-145 155-170 @@ -131,13 +136,13 @@ ℹ inputPanel.js | 100.00 | 100.00 | 100.00 | ℹ markdownText.js | 72.95 | 78.82 | 83.02 | 16-18 40-118 158 182-184 262-263 274-275 304-310 325-333 336-338 348-354 369-390 401-402 453-454 457-458 464 ℹ messageBubble.js | 85.30 | 53.13 | 71.43 | 139-144 163-166 181-190 195-202 207-214 255-259 -ℹ messageList.js | 79.81 | 72.73 | 50.00 | 68 85-88 113-136 146-169 178 185-190 233 241 249 258-264 273 281-286 308-310 344-345 388 +ℹ messageList.js | 79.76 | 72.73 | 50.00 | 64 81-84 109-133 143-166 175 182-187 230 238 246 255-261 270 278-283 305-307 348-349 392 ℹ messages.js | 100.00 | 94.44 | 100.00 | ℹ panels.js | 100.00 | 100.00 | 100.00 | ℹ statusBar.js | 90.82 | 81.25 | 100.00 | 22-23 34-40 ℹ workspace | | | | ℹ loadAgents.js | 100.00 | 87.50 | 100.00 | ℹ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- -ℹ all files | 66.52 | 83.08 | 56.62 | +ℹ all files | 66.68 | 83.26 | 51.57 | ℹ ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ℹ end of coverage report diff --git a/src/tui/messageList.js b/src/tui/messageList.js index e05b70e8..3aa90bae 100644 --- a/src/tui/messageList.js +++ b/src/tui/messageList.js @@ -41,7 +41,12 @@ let _messageIdCounter = 0; * @returns {React.ReactElement} */ export const MessageList = forwardRef(function MessageList( - { messages: _messages = [], assistantName = "Assistant", renderWindow = 100, scrollRef: externalScrollRef }, + { + messages: _messages = [], + assistantName = "Assistant", + renderWindow = 100, + scrollRef: externalScrollRef, + }, forwardRef, ) { const internalRef = useRef(null); From 72841b7304d64c4917d3cc4a7ac7a8737db50636 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 23 Aug 2026 17:56:15 -0400 Subject: [PATCH 6/7] fix: remove hardcoded renderWindow default in MessageList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderWindow value flows through the full config chain: config.yaml → TuiSchema → app.js → ConversationPanel → MessageList. The destructuring default was redundant and misleading. --- src/tui/messageList.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/messageList.js b/src/tui/messageList.js index 3aa90bae..b8a4e7bb 100644 --- a/src/tui/messageList.js +++ b/src/tui/messageList.js @@ -44,7 +44,7 @@ export const MessageList = forwardRef(function MessageList( { messages: _messages = [], assistantName = "Assistant", - renderWindow = 100, + renderWindow, scrollRef: externalScrollRef, }, forwardRef, From 54ee0de635d426fa2cb36506ba99cec680ad739e Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 23 Aug 2026 17:58:39 -0400 Subject: [PATCH 7/7] fix: remove hardcoded renderWindow default in ConversationPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderWindow value flows through the full config chain: config.yaml → TuiSchema → app.js → ConversationPanel → MessageList. The destructuring default was redundant and misleading. --- src/tui/conversationPanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/conversationPanel.js b/src/tui/conversationPanel.js index cc78b3f1..509cdfdf 100644 --- a/src/tui/conversationPanel.js +++ b/src/tui/conversationPanel.js @@ -75,7 +75,7 @@ export function getBubbleStyle(role) { export function ConversationPanel({ messages = [], assistantName = "Assistant", - renderWindow = 100, + renderWindow, scrollRef: externalScrollRef, messageListRef, }) {