Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions WORKLOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
20 changes: 20 additions & 0 deletions docs/chat-ui-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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.**
Expand Down
14 changes: 14 additions & 0 deletions nx2/blocks/chat/chat-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }];
Comment thread
anfibiacreativa marked this conversation as resolved.
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(
Expand Down
83 changes: 83 additions & 0 deletions test/nx2/blocks/chat/chat-controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
Loading