Skip to content

Preserve history when deduplicating agent sessions#471

Open
Liu-KM wants to merge 2 commits intotiann:mainfrom
Liu-KM:liu/pr-agent-session-history-merge
Open

Preserve history when deduplicating agent sessions#471
Liu-KM wants to merge 2 commits intotiann:mainfrom
Liu-KM:liu/pr-agent-session-history-merge

Conversation

@Liu-KM
Copy link
Copy Markdown

@Liu-KM Liu-KM commented Apr 16, 2026

Summary

Preserve conversation history when multiple HAPI sessions point at the same underlying agent session ID.

  • merge persisted messages from duplicate agent sessions into the canonical visible session
  • keep active duplicate session records alive so existing sockets/keepalives do not break
  • delete inactive duplicates after their history is merged
  • coalesce dedup triggers so a session that becomes inactive during dedup is handled by a follow-up pass

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.ts

Notes

AI-assisted with GPT-5.4.

@Liu-KM Liu-KM marked this pull request as ready for review April 16, 2026 15:48
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 copies oldStored.agentState.requests onto newSessionId; 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 on message-received or explicit fetchLatestMessages (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

Comment thread hub/src/sync/sessionCache.ts Outdated
// 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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 })
}

@Liu-KM Liu-KM force-pushed the liu/pr-agent-session-history-merge branch from dde0e53 to 9e74cd7 Compare April 20, 2026 04:10
Copy link
Copy Markdown

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 on s1 and a resumed s2 becomes active, this path can move s1's history into s2, 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 emits messages-invalidated for newSessionId (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 for s2 (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) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 })
}

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