Preserve history when deduplicating agent sessions#471
Preserve history when deduplicating agent sessions#471Liu-KM wants to merge 2 commits intotiann:mainfrom
Conversation
There was a problem hiding this comment.
Findings
- [Major] Active-duplicate permission requests are now routed to the wrong CLI session. The new active path calls
mergeSessionHistory()for a live duplicate (hub/src/sync/sessionCache.ts:683), which still copiesoldStored.agentState.requestsontonewSessionId; approvals then look up the request on the visible session and dispatch RPCs by that same session id (hub/src/web/routes/permissions.ts:52,hub/src/sync/rpcGateway.ts:54). If the pending request originated on the kept-alive duplicate, the UI will show it on the canonical session but the approve/deny call will go to the wrong socket. - [Major] History moved from an active duplicate will not appear in an already-open chat until a manual refresh. This new path moves rows in SQLite but only emits
session-updated(hub/src/sync/sessionCache.ts:538), while the web message window is refreshed only onmessage-receivedor explicitfetchLatestMessages(web/src/hooks/useSSE.ts:500,web/src/hooks/queries/useMessages.ts:59).
Summary
Review mode: initial
Two regressions found in the new active-duplicate merge path; details are attached inline.
Testing
Not run (automation)
HAPI Bot
| // persisted history into the visible dedup target. This preserves | ||
| // left-sidebar dedup while making resumed/restarted sessions show | ||
| // the full conversation history. | ||
| await this.mergeSessionHistory(id, targetId, targetNamespace) |
There was a problem hiding this comment.
[MAJOR] This new branch sends active duplicates through mergeSessionHistory(), which also merges oldStored.agentState into targetId. The problem is that permission approval/deny still routes by session id, so a pending request raised by the kept-alive duplicate will now be shown on the canonical session and then sent to the wrong CLI socket when the user acts on it.
Suggested fix:
if (candidate?.active) {
await this.mergeSessionHistory(id, targetId, targetNamespace, {
mergeAgentState: false,
})
}| this.refreshSession(newSessionId) | ||
| const refreshed = this.refreshSession(newSessionId) | ||
| if (refreshed) { | ||
| this.publisher.emit({ type: 'session-updated', sessionId: newSessionId, data: refreshed }) |
There was a problem hiding this comment.
[MAJOR] This history-only merge rewrites message rows in SQLite, but it only publishes session-updated afterwards. The web cache appends on message-received and otherwise reloads messages only on mount/refetch, so an already-open canonical chat keeps showing the pre-merge transcript until the user manually refreshes.
Suggested fix:
const moved = this.store.messages.mergeSessionMessages(oldSessionId, newSessionId)
if (moved.moved > 0) {
this.publisher.emit({ type: 'messages-invalidated', sessionId: newSessionId })
}dde0e53 to
9e74cd7
Compare
There was a problem hiding this comment.
Findings
- [Major] Active-active dedup can move history off the session the web still keeps visible. The hub now picks the newest active duplicate as the canonical target (
hub/src/sync/sessionCache.ts:670-680), but the web deliberately keeps the currently selected duplicate visible (web/src/components/SessionList.tsx:61-67,web/src/App.tsx:120-121). If a user is already ons1and a resumeds2becomes active, this path can moves1's history intos2, so the visible chat is no longer the one that owns the preserved transcript.
Suggested fix:const activeCandidates = candidates.filter(({ session }) => session.active) if (activeCandidates.length > 1) { // Don't move history between two live session ids until the UI can follow the canonical target. continue }
- [Major] History-only merge invalidates only the target session, leaving open source duplicates stale.
mergeSessionMessages()rewrites both sessions' message sets, but this branch only emitsmessages-invalidatedfornewSessionId(hub/src/sync/sessionCache.ts:441-443); the web only refetches when the event session id matches the current route (web/src/App.tsx:232-240). The new test also only asserts invalidation fors2(hub/src/sync/sessionModel.test.ts:652), so this regression is currently untested.
Suggested fix:const movedMessages = this.store.messages.mergeSessionMessages(oldSessionId, newSessionId) if (movedMessages.moved > 0) { if (!options.deleteOldSession) { this.publisher.emit({ type: 'messages-invalidated', sessionId: oldSessionId, namespace }) } this.publisher.emit({ type: 'messages-invalidated', sessionId: newSessionId, namespace }) }
Summary
Review mode: follow-up after new commits
Two regressions remain in the active-duplicate merge path: canonical-target selection can diverge from the session the web still keeps visible, and source duplicates do not get a message refresh after their rows are moved.
Testing
Not run (automation)
HAPI Bot
| // active sessions win, then the most recently updated session wins. | ||
| // If timestamps tie, prefer the session that triggered this dedup run | ||
| // so callers can intentionally preserve the visible/resumed session. | ||
| candidates.sort((a, b) => { |
There was a problem hiding this comment.
[MAJOR] Active duplicate target selection can still move history off the session the web keeps visible. The hub now chooses the newest active duplicate here, but the web preserves the currently selected duplicate in deduplicateSessionsByAgentId() (web/src/components/SessionList.tsx:61-67, web/src/App.tsx:120-121). In the common resume case, that means s1 can stay selected while this branch moves its history into s2.
Suggested fix:
const activeCandidates = candidates.filter(({ session }) => session.active)
if (activeCandidates.length > 1) {
// Don't move history between two live session ids until the UI can follow the canonical target.
continue
}| } | ||
|
|
||
| this.store.messages.mergeSessionMessages(oldSessionId, newSessionId) | ||
| const movedMessages = this.store.messages.mergeSessionMessages(oldSessionId, newSessionId) |
There was a problem hiding this comment.
[MAJOR] This history-only merge mutates both sessions' message sets, but only the target session gets messages-invalidated. An already-open source duplicate will never refetch because the app only reloads when event.sessionId matches the current route (web/src/App.tsx:232-240), and the new test currently asserts only s2 invalidation (hub/src/sync/sessionModel.test.ts:652).
Suggested fix:
const movedMessages = this.store.messages.mergeSessionMessages(oldSessionId, newSessionId)
if (movedMessages.moved > 0) {
if (!options.deleteOldSession) {
this.publisher.emit({ type: 'messages-invalidated', sessionId: oldSessionId, namespace })
}
this.publisher.emit({ type: 'messages-invalidated', sessionId: newSessionId, namespace })
}
Summary
Preserve conversation history when multiple HAPI sessions point at the same underlying agent session ID.
Why
The sidebar deduplicates sessions by agent session ID, but historical messages could remain attached to the hidden duplicate. That made the visible session look like it had lost history.
Validation
cd hub && bun test src/sync/sessionModel.test.tsNotes
AI-assisted with GPT-5.4.