Skip to content

fix: clear stale completedRequests after conversation compaction#18

Open
LucasRoesler wants to merge 5 commits intomainfrom
hapi-fix-old-stray-tool-calls
Open

fix: clear stale completedRequests after conversation compaction#18
LucasRoesler wants to merge 5 commits intomainfrom
hapi-fix-old-stray-tool-calls

Conversation

@LucasRoesler
Copy link
Member

Summary

  • agentState.completedRequests accumulated every approved/denied tool call forever, never pruned after the SDK compacted conversation history — causing orphan tool cards in the frontend
  • The previous server-side fix (null agentState on microcompact_boundary) raced with the CLI's buffered updateAgentState calls, which re-posted stale state immediately after
  • Move cleanup to the CLI, which is the only party with live knowledge of compaction events

Changes:

  • claudeRemoteLauncher.ts: detect microcompact_boundary in onMessage, call updateAgentState to reset requests/completedRequests
  • claudeRemote.ts: add onCompact callback, fired when a user-triggered /compact result arrives; launcher wires it to the same reset
  • sessionHandlers.ts: remove racing handleMessageHistoryModification call from the microcompact_boundary path (CLI reset is now authoritative)
  • syncEngine.ts: add handleMessageHistoryModification to archiveSession and deleteSession for explicit lifecycle events
  • docs/planning/STRAY_TOOL_CALLS.md: documents root cause, approach, tradeoffs, and next steps

Test plan

  • Run bun test server/src/socket/handlers/cli/sessionHandlers.test.ts — 11 tests pass
  • Run vitest run cli/src/claude/claudeRemote.compaction.test.ts — 5 tests pass
  • Manually trigger a long session, observe compaction, verify orphan tool cards no longer appear after compaction

agentState.completedRequests accumulated an entry for every approved/denied
tool call and was never pruned. After the SDK compacted conversation history,
the corresponding entries remained, causing orphan tool cards to render in
the frontend.

The server-side fix (nulling agentState on microcompact_boundary) had a race
condition: the CLI's buffered updateAgentState calls would immediately
re-post the stale state after the server cleared it.

Fix: move cleanup to the CLI, which is the only party with live knowledge of
compaction events:

- claudeRemoteLauncher.ts: detect microcompact_boundary in onMessage and call
  updateAgentState to reset requests and completedRequests to empty objects
- claudeRemote.ts: add onCompact callback, fired when a user-triggered
  /compact command result arrives; the launcher wires this to the same reset
- sessionHandlers.ts: remove the racing handleMessageHistoryModification call
  from the microcompact_boundary handler (the CLI reset is now authoritative)
- syncEngine.ts: add handleMessageHistoryModification to archiveSession and
  deleteSession so agentState is wiped on those explicit lifecycle events

Adds integration tests for both the server handler and the CLI compaction
callbacks, and documents the approach with tradeoffs in
docs/planning/STRAY_TOOL_CALLS.md.
@LucasRoesler LucasRoesler force-pushed the hapi-fix-old-stray-tool-calls branch from d8c1d21 to f745cee Compare February 23, 2026 22:44
…e tests

- Remove 'microcompact' from MessageHistoryModificationReason — no remaining
  call site after the CLI-driven cleanup replaced the server-side handler
- Update jsdoc to drop the microcompact bullet from the when-to-call list
- Remove spurious refreshSession() from deleteSession success branch — the
  call did a DB read and cache insert immediately before deleteSession wiped
  the entry, emitting a spurious session-updated event in the process;
  refreshSession remains correct in archiveSession where the session persists
- Add handleMessageHistoryModification store-level tests covering the
  agentState null-clear path that archiveSession and deleteSession rely on
… boundary

After a /compact or autonomous microcompact, the server records the boundary
message's seq on the session as compaction_boundary_seq.

The messages API uses that value as an afterSeq floor so clients see only
post-compaction messages on initial load. When the user scrolls to the top,
hasMoreBeforeBoundary signals that pre-compaction history exists. The next
"load older" request sends includeAll=true to bypass the floor and fetch
pre-boundary messages (the existing microcompact event card already serves
as a visible divider).

/clear resets compaction_boundary_seq to NULL so clearing still shows an
empty conversation.
Three issues found in code review:

1. hasMessagesBeforeSeq used row !== undefined but bun:sqlite .get()
   returns null (not undefined) on no match. Fixed to row !== null.

2. The boundary existence check was seq <= ? (inclusive of the boundary
   message itself), making hasMoreBeforeBoundary always true when any
   boundary is set, even for sessions with no pre-boundary history.
   Fixed to seq < ? to match the seq > afterSeq filter on the visible
   window — the boundary message is excluded in both directions.

3. archiveSession did not clear compaction_boundary_seq. A resumed
   session would silently hide all messages behind the stale boundary.
   Now clears on archive alongside the agentState wipe.

Also adds regression tests covering the edge case where the boundary
message is the only message in the session.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant