From 0a7dd64033c19add7497f2cf59cc6d40cece2559 Mon Sep 17 00:00:00 2001 From: Natalia Venditto Date: Wed, 20 May 2026 09:14:23 +0200 Subject: [PATCH 1/4] feat(chat): handle compact_context tool-result to trim message history When da-agent triggers auto-compact and the model calls compact_context, replace the full message array with the compacted summary and persist to IndexedDB. The model's follow-up confirmation appends normally, giving a clean [summary, confirmation] state for the next turn. Co-authored-by: Cursor --- nx2/blocks/chat/chat-controller.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nx2/blocks/chat/chat-controller.js b/nx2/blocks/chat/chat-controller.js index 0b1f9e072..9e4615bfe 100644 --- a/nx2/blocks/chat/chat-controller.js +++ b/nx2/blocks/chat/chat-controller.js @@ -150,6 +150,22 @@ 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: when the agent compacts the conversation, replace the full + // message history with just the summary. The model's follow-up text-end will + // append normally after this, giving: [compacted-summary, assistant-confirmation]. + 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._update(); + return; + } + if (state === TOOL_STATE.DONE) { // Add a virtual message so the tool renders in the conversation at the right // position and persists across refreshes, without being sent back to the agent. From 9021aabaa4f05d9973780601d0bf481e33cd9f2f Mon Sep 17 00:00:00 2001 From: Natalia Venditto Date: Thu, 4 Jun 2026 11:42:43 +0200 Subject: [PATCH 2/4] fix(chat): address compact_context review feedback - pass sessionId to saveMessages so compaction preserves the session - trim inline comment and move contract details to docs/chat-ui-component.md - document compacted message shape and compact_context event contract Co-authored-by: Cursor --- docs/chat-ui-component.md | 20 ++++++++++++++++++++ nx2/blocks/chat/chat-controller.js | 6 ++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/chat-ui-component.md b/docs/chat-ui-component.md index 8fe3983b1..546686ccc 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 ``` @@ -145,6 +146,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 74428a2f8..2f2955a16 100644 --- a/nx2/blocks/chat/chat-controller.js +++ b/nx2/blocks/chat/chat-controller.js @@ -221,9 +221,7 @@ export default class ChatController { const state = isError ? TOOL_STATE.ERROR : TOOL_STATE.DONE; next.set(toolCallId, { ...prior, state, output }); - // Auto-compact: when the agent compacts the conversation, replace the full - // message history with just the summary. The model's follow-up text-end will - // append normally after this, giving: [compacted-summary, assistant-confirmation]. + // Auto-compact: replace history with agent summary (see docs/chat-ui-component.md). if ( prior.toolName === 'compact_context' && output?.compacted === true @@ -231,7 +229,7 @@ export default class ChatController { ) { this._messages = [{ role: ROLE.USER, content: output.summary, compacted: true }]; this._toolCards = new Map(); - this._getRoom().then((room) => saveMessages(room, this._messages)); + this._getRoom().then((room) => saveMessages(room, this._messages, this._sessionId)); this._update(); return; } From a69434d99e590d7b7ab2bf15dd30ba792e880e42 Mon Sep 17 00:00:00 2001 From: Natalia Venditto Date: Tue, 14 Jul 2026 13:03:30 +0200 Subject: [PATCH 3/4] test(chat): cover compact_context tool-result handling Adds unit tests for the auto-compact branch in ChatController._onToolEvent: - replaces history with the compacted summary, clears tool cards, persists - keys off the tool-result toolName when there is no prior tool-call card - does not trim when compacted is not true - does not trim when summary is missing or not a string (malformed payload guard) --- test/nx2/blocks/chat/chat-controller.test.js | 83 ++++++++++++++++++++ 1 file changed, 83 insertions(+) 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' }); + }); +}); From 62e121b3610dd4d245bac7f3cf0ea216e159f212 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:03:53 +0200 Subject: [PATCH 4/4] Update worklog --- WORKLOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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)