diff --git a/WORKLOG.md b/WORKLOG.md index 6758e44ee..fbfd6e27e 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,5 +1,13 @@ # Worklog +## 2026-07-14 + +### nx2/blocks/chat — tests for compact_context handling (ew-shell-compact-client, PR #452) + +PR #452 added the auto-compact branch to `ChatController._onToolEvent` but shipped with no coverage. Added unit tests in `test/nx2/blocks/chat/chat-controller.test.js` pinning the history-destroying path: trims to the compacted summary + clears cards + persists on the happy path, keys off either the prior tool-call card or the tool-result's own toolName, and (the important guards) does NOT trim when `compacted !== true` or when `summary` is missing/non-string, so a malformed agent payload can't silently wipe the conversation. + +Open nit, not addressed: `compact_context` is a magic string in `_onToolEvent`; sibling tool names live in the `TOOL_NAME` enum in `constants.js`. + ## 2026-06-26 ### nx2/blocks/chat/chat.js — skill selection preserves pending attachments (feat/da-skill-attachment-fix) diff --git a/docs/chat-ui-component.md b/docs/chat-ui-component.md index 66ae0dd39..786dcd259 100644 --- a/docs/chat-ui-component.md +++ b/docs/chat-ui-component.md @@ -26,6 +26,7 @@ The component manages its own controller internally. No external wiring needed. ```js { role: 'user', content: string } +{ role: 'user', content: string, compacted: true } // synthetic summary after context compaction { role: 'assistant', content: string } { role: 'tool', ... } // filtered from display automatically ``` @@ -149,6 +150,25 @@ Within a single stream connection, events for a given `toolCallId` are expected **Reconnect:** The stream is a live feed — events are not replayed on reconnect. A new stream starts fresh; any in-flight tool state from the previous connection is lost. +### Context compaction (`compact_context`) + +When the conversation exceeds the model's context window, da-agent triggers the `compact_context` tool. The agent produces a markdown summary and emits a `tool-result` with: + +```js +{ toolCallId, toolName: 'compact_context', output: { compacted: true, summary: string } } +``` + +**Client behaviour on receipt:** + +1. Replace `this._messages` with a single synthetic message: `{ role: 'user', content: summary, compacted: true }` +2. Clear all in-flight tool cards +3. Persist trimmed messages to IndexedDB (preserving `sessionId`) +4. The model's follow-up `text-end` appends normally, giving a final state of `[compacted-summary, assistant-confirmation]` + +> **Contract:** The `compact_context` event shape (`output.compacted === true` + `output.summary` as string) is the shared contract between da-agent and da-nx. Changes require coordinated updates. + +The `compacted: true` marker on the synthetic message is reserved for future UI use (e.g. a visual indicator that the session was compacted). It has no runtime effect today. + The approval popover accepts keyboard shortcuts: `Esc` = Reject, `↵` = Approve, `⌘↵` = Always approve. **If the agent team adds or renames event types, `processEvent` in `utils.js` must be updated to match.** diff --git a/nx2/blocks/chat/chat-controller.js b/nx2/blocks/chat/chat-controller.js index 02da447a1..4ad2b3eb6 100644 --- a/nx2/blocks/chat/chat-controller.js +++ b/nx2/blocks/chat/chat-controller.js @@ -220,6 +220,20 @@ export default class ChatController { const prior = next.get(toolCallId) ?? { toolName, input: {} }; const state = isError ? TOOL_STATE.ERROR : TOOL_STATE.DONE; next.set(toolCallId, { ...prior, state, output }); + + // Auto-compact: replace history with agent summary (see docs/chat-ui-component.md). + if ( + prior.toolName === 'compact_context' + && output?.compacted === true + && typeof output.summary === 'string' + ) { + this._messages = [{ role: ROLE.USER, content: output.summary, compacted: true }]; + this._toolCards = new Map(); + this._getRoom().then((room) => saveMessages(room, this._messages, this._sessionId)); + this._update(); + return; + } + if (state === TOOL_STATE.DONE) { // Skip if a real message already exists for this toolCallId (approval flow adds one). const hasApprovalMessage = this._messages.some( diff --git a/test/nx2/blocks/chat/chat-controller.test.js b/test/nx2/blocks/chat/chat-controller.test.js index 40d488e38..b163c2a6f 100644 --- a/test/nx2/blocks/chat/chat-controller.test.js +++ b/test/nx2/blocks/chat/chat-controller.test.js @@ -103,3 +103,86 @@ describe('chat-controller _messagesForAgent', () => { expect(result).to.deep.equal([]); // no orphan tool-call emitted }); }); + +// ─── auto-compact (compact_context tool-result) ────────────────────────────── + +const COMPACT = 'compact_context'; + +const history = () => ([ + { role: 'user', content: 'first question' }, + { role: 'assistant', content: 'a long answer' }, +]); + +// Build a controller whose history is `messages` and that has already seen the +// compact_context tool-call, so a following tool-result has a prior card to key +// off. The trimmed history is persisted fire-and-forget via _getRoom().then(...); +// we stub _getRoom so it never touches IndexedDB but we can still count that the +// persist was attempted. +function compactController(messages) { + const controller = new ChatController({ onUpdate() {}, onToolDone() {} }); + controller._messages = messages; + controller._sessionId = 'sess-1'; + controller._roomCalls = 0; + controller._getRoom = () => { + controller._roomCalls += 1; + return new Promise(() => {}); + }; + controller._onToolEvent({ type: 'tool-call', toolCallId: 'c1', toolName: COMPACT, input: {} }); + return controller; +} + +describe('chat-controller _onToolEvent compact_context', () => { + it('replaces history with the compacted summary, clears cards, and persists', () => { + const controller = compactController(history()); + // real da-agent tool-result events do not repeat toolName; it keys off the card + controller._onToolEvent({ + type: 'tool-result', + toolCallId: 'c1', + output: { compacted: true, summary: 'compacted summary' }, + }); + expect(controller._messages).to.deep.equal([ + { role: 'user', content: 'compacted summary', compacted: true }, + ]); + expect(controller._toolCards.size).to.equal(0); + expect(controller._roomCalls).to.equal(1); + }); + + it('trims using the result toolName when there is no prior tool-call card', () => { + const controller = new ChatController({ onUpdate() {}, onToolDone() {} }); + controller._messages = history(); + controller._sessionId = 'sess-1'; + controller._getRoom = () => new Promise(() => {}); + controller._onToolEvent({ + type: 'tool-result', + toolCallId: 'c1', + toolName: COMPACT, + output: { compacted: true, summary: 'S' }, + }); + expect(controller._messages).to.deep.equal([ + { role: 'user', content: 'S', compacted: true }, + ]); + }); + + it('does not trim history when compacted is not true', () => { + const controller = compactController(history()); + controller._onToolEvent({ + type: 'tool-result', + toolCallId: 'c1', + output: { compacted: false, summary: 'ignored' }, + }); + expect(controller._messages.some((m) => m.compacted)).to.equal(false); + expect(controller._messages[0]).to.deep.equal({ role: 'user', content: 'first question' }); + expect(controller._roomCalls).to.equal(0); + }); + + it('does not trim history when summary is missing or not a string', () => { + const controller = compactController(history()); + controller._onToolEvent({ + type: 'tool-result', + toolCallId: 'c1', + output: { compacted: true }, // malformed payload: no summary must not wipe history + }); + expect(controller._messages.some((m) => m.compacted)).to.equal(false); + expect(controller._messages[0]).to.deep.equal({ role: 'user', content: 'first question' }); + }); +});