fix(hub,web): deduplicate sessions by agent session ID (#448)

* fix(hub,web): deduplicate sessions by agent session ID

When multiple CLI wrappers independently resume the same Codex thread,
each generates a random tag, causing the hub to create duplicate session
records for a single underlying thread. This leads to duplicate
conversations in the web UI and messages routing to the wrong session.

Add two-layer deduplication:
- Hub: when a metadata update sets an agent session ID (codexSessionId,
  claudeSessionId, etc.) that already exists on another session in the
  same namespace, automatically merge the duplicate into the current
  session using the existing mergeSessions logic.
- Web: deduplicate the session list display by agentSessionId as a
  safety net, keeping the active/most-recent session visible.

Closes #446

* chore: add review-driven comments for dedup clarity

- Explain single-threaded assumption in before/after metadata comparison
- Document merge direction rationale (duplicate → active session)
- Document deduplicateInProgress guard as known limitation
- Add catch comment explaining web safety net fallback

* fix: address review feedback from bot, Opus, and Codex

- Skip active duplicates during hub-side dedup to avoid deleting
  sessions with live CLI sockets and pending agent state
- Pass selectedSessionId into web dedup sort to prevent hiding
  the session the user is currently viewing
- Add test for active-duplicate-not-merged case

* fix: retry dedup on session-end and preserve agentState in merge

- Trigger dedup when a session ends (handleSessionEnd), so active
  duplicates skipped during earlier dedup get merged once they disconnect
- Preserve agentState from old session during mergeSessions when the
  new session has no agentState (mirrors existing model/effort/todos
  preservation pattern)
- Extract triggerDedupIfNeeded helper for reuse across trigger points

* fix(web): prefer active session over selected in dedup sort

Active session always wins the dedup tie-break so the live connection
is never hidden in favor of a selected inactive duplicate. Among
inactive duplicates the selected one is still preferred.

* fix: dedup on inactivity timeout and deep-merge agentState

- expireInactive now returns expired session IDs so SyncEngine can
  trigger dedup for sessions that timed out (crash/network drop)
  instead of only on explicit session-end
- mergeSessions now deep-merges agentState requests/completedRequests
  from both sessions instead of only copying when new is null

* fix: exclude completed requests from merged pending set

Filter out request IDs that already appear in completedRequests when
merging agentState, preventing completed permission prompts from
resurrecting as pending after session dedup.

* fix: guard resume merge against prior auto-dedup

The automatic dedup (triggered when the spawned CLI sets its agent
session ID) can delete the old session before resumeSession reaches
its own explicit mergeSessions call. Skip the merge if the old session
no longer exists instead of failing the resume with a false error.

* test: add coverage for dedup retry paths and web dedup sort

Hub tests:
- session-end triggers dedup retry for previously-active duplicates
- inactivity timeout expiry triggers dedup retry
- agentState deep merge filters completed requests from pending set

Web tests:
- basic dedup by agentSessionId
- active session wins over inactive duplicate
- selected session preferred among inactive duplicates
- active always wins over selected inactive
- sessions without agentSessionId pass through
- independent dedup across different agentSessionIds

* fix: read latest agentState before merge write to avoid overwriting live updates

Re-read the target session's agentState right before writing the merged
result, with a version-mismatch retry loop, so concurrent update-state
events from the active CLI are not lost during dedup merge.

* fix: sort expired sessions by recency before dedup

When multiple duplicates for the same agent thread expire in a single
sweep, process the most recent one first so it becomes the merge target
and survives, rather than keeping the oldest by arbitrary iteration order.

* fix: select most recent session as merge target in dedup

deduplicateByAgentSessionId now collects all inactive candidates
(including the caller) and picks the one with the highest activeAt
(then updatedAt) as the merge target. This ensures the newest session
survives regardless of which trigger point or ordering calls the dedup.
This commit is contained in:
Haoqing Wang
2026-04-13 19:56:22 +08:00
committed by GitHub
parent c32378b3ba
commit 7c6a7fa8ef
6 changed files with 545 additions and 10 deletions
+109 -1
View File
@@ -10,6 +10,7 @@ export class SessionCache {
private readonly sessions: Map<string, Session> = new Map()
private readonly lastBroadcastAtBySessionId: Map<string, number> = new Map()
private readonly todoBackfillAttemptedSessionIds: Set<string> = new Set()
private readonly deduplicateInProgress: Set<string> = new Set()
constructor(
private readonly store: Store,
@@ -280,16 +281,20 @@ export class SessionCache {
this.publisher.emit({ type: 'session-updated', sessionId: session.id, data: { active: false, thinking: false, backgroundTaskCount: 0 } })
}
expireInactive(now: number = Date.now()): void {
expireInactive(now: number = Date.now()): string[] {
const sessionTimeoutMs = 30_000
const expired: string[] = []
for (const session of this.sessions.values()) {
if (!session.active) continue
if (now - session.activeAt <= sessionTimeoutMs) continue
session.active = false
session.thinking = false
expired.push(session.id)
this.publisher.emit({ type: 'session-updated', sessionId: session.id, data: { active: false } })
}
return expired
}
applySessionConfig(
@@ -470,6 +475,27 @@ export class SessionCache {
)
}
// Merge agentState: union requests/completedRequests from both sessions so pending
// approvals on the duplicate are not lost. Only inactive duplicates reach this point
// (active ones are skipped by deduplicateByAgentSessionId).
// Read the latest target state right before writing to avoid overwriting live updates.
if (oldStored.agentState !== null) {
for (let attempt = 0; attempt < 2; attempt += 1) {
const latest = this.store.sessions.getSessionByNamespace(newSessionId, namespace)
if (!latest) break
const mergedAgentState = this.mergeAgentState(oldStored.agentState, latest.agentState)
if (mergedAgentState === null || mergedAgentState === latest.agentState) break
const result = this.store.sessions.updateSessionAgentState(
newSessionId,
mergedAgentState,
latest.agentStateVersion,
namespace
)
if (result.result !== 'version-mismatch') break
// version-mismatch: retry with fresh snapshot
}
}
if (oldStored.teamState !== null && oldStored.teamStateUpdatedAt !== null) {
this.store.sessions.setSessionTeamState(
newSessionId,
@@ -537,4 +563,86 @@ export class SessionCache {
return changed ? merged : newMetadata
}
private mergeAgentState(oldState: unknown | null, newState: unknown | null): unknown | null {
if (oldState === null) return newState
if (newState === null) return oldState
const oldObj = oldState as Record<string, unknown>
const newObj = newState as Record<string, unknown>
const completedRequests = {
...((oldObj.completedRequests as Record<string, unknown> | undefined) ?? {}),
...((newObj.completedRequests as Record<string, unknown> | undefined) ?? {})
}
// Filter out requests that are already completed to avoid resurrecting them as pending
const completedIds = new Set(Object.keys(completedRequests))
const requests = Object.fromEntries(
Object.entries({
...((oldObj.requests as Record<string, unknown> | undefined) ?? {}),
...((newObj.requests as Record<string, unknown> | undefined) ?? {})
}).filter(([id]) => !completedIds.has(id))
)
return { ...oldObj, ...newObj, requests, completedRequests }
}
private extractAgentSessionId(
metadata: NonNullable<Session['metadata']>
): { field: 'codexSessionId' | 'claudeSessionId' | 'geminiSessionId' | 'opencodeSessionId' | 'cursorSessionId'; value: string } | null {
if (metadata.codexSessionId) return { field: 'codexSessionId', value: metadata.codexSessionId }
if (metadata.claudeSessionId) return { field: 'claudeSessionId', value: metadata.claudeSessionId }
if (metadata.geminiSessionId) return { field: 'geminiSessionId', value: metadata.geminiSessionId }
if (metadata.opencodeSessionId) return { field: 'opencodeSessionId', value: metadata.opencodeSessionId }
if (metadata.cursorSessionId) return { field: 'cursorSessionId', value: metadata.cursorSessionId }
return null
}
async deduplicateByAgentSessionId(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId)
if (!session?.metadata) return
const agentId = this.extractAgentSessionId(session.metadata)
if (!agentId) return
// Guard: skip if another dedup for this agent ID is already in progress.
// A skipped trigger is acceptable — the web-side display dedup hides any remaining duplicates.
if (this.deduplicateInProgress.has(agentId.value)) return
this.deduplicateInProgress.add(agentId.value)
try {
const candidates: { id: string; session: Session }[] = [{ id: sessionId, session }]
for (const [existingId, existing] of this.sessions) {
if (existingId === sessionId) continue
if (existing.namespace !== session.namespace) continue
if (!existing.metadata) continue
if (existing.metadata[agentId.field] !== agentId.value) continue
// Only merge inactive duplicates. Active ones still have a live CLI socket
// whose keepalive/messages would fail if we deleted their session record.
// The web-side display dedup hides active duplicates from the UI.
if (existing.active) continue
candidates.push({ id: existingId, session: existing })
}
if (candidates.length <= 1) return
// Keep the most recent session as the merge target so newer state survives.
candidates.sort((a, b) =>
(b.session.activeAt - a.session.activeAt) || (b.session.updatedAt - a.session.updatedAt)
)
const targetId = candidates[0].id
const targetNamespace = candidates[0].session.namespace
for (const { id } of candidates.slice(1)) {
if (id === targetId) continue
try {
await this.mergeSessions(id, targetId, targetNamespace)
} catch {
// best-effort: duplicate remains if merge fails
}
}
} finally {
this.deduplicateInProgress.delete(agentId.value)
}
}
}
+258
View File
@@ -440,4 +440,262 @@ describe('session model', () => {
engine.stop()
}
})
describe('session dedup by agent session ID', () => {
it('merges duplicate when codexSessionId collides', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
// Add a message to s1
store.messages.addMessage(s1.id, { type: 'text', text: 'hello from s1' }, 'local-1')
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
expect(s1.id).not.toBe(s2.id)
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeUndefined()
expect(cache.getSession(s2.id)).toBeDefined()
const messages = store.messages.getMessages(s2.id, 100)
expect(messages.length).toBeGreaterThanOrEqual(1)
})
it('preserves sessions with different agent session IDs', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-Y' },
null,
'default'
)
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeDefined()
expect(cache.getSession(s2.id)).toBeDefined()
})
it('does not merge across namespaces', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'ns1'
)
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'ns2'
)
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeDefined()
expect(cache.getSession(s2.id)).toBeDefined()
})
it('no-op when session has no agent session ID', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex' },
null,
'default'
)
await cache.deduplicateByAgentSessionId(s1.id)
expect(cache.getSession(s1.id)).toBeDefined()
})
it('does not merge active duplicates', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
// Mark s1 as active (simulating a live CLI connection)
cache.handleSessionAlive({ sid: s1.id, time: Date.now(), thinking: false })
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
await cache.deduplicateByAgentSessionId(s2.id)
// s1 is active, so it should NOT be merged/deleted
expect(cache.getSession(s1.id)).toBeDefined()
expect(cache.getSession(s2.id)).toBeDefined()
})
it('merges duplicate after it becomes inactive via session-end', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const s1 = engine.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
const s2 = engine.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
// Mark s1 as active
engine.handleSessionAlive({ sid: s1.id, time: Date.now() })
// s1 is active, dedup from s2 should skip it
const events: SyncEvent[] = []
const cache = (engine as any).sessionCache as SessionCache
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeDefined()
expect(cache.getSession(s2.id)).toBeDefined()
// Now s1 ends — handleSessionEnd should trigger dedup retry
engine.handleSessionEnd({ sid: s1.id, time: Date.now() })
// Give the fire-and-forget dedup a tick to complete
await new Promise((r) => setTimeout(r, 50))
// One of them should be merged away
const s1Exists = cache.getSession(s1.id)
const s2Exists = cache.getSession(s2.id)
expect(!s1Exists || !s2Exists).toBe(true)
} finally {
engine.stop()
}
})
it('merges duplicate after inactivity timeout expires it', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
null,
'default'
)
// Mark s1 as active now
cache.handleSessionAlive({ sid: s1.id, time: Date.now() })
// s1 is active — dedup skips it
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeDefined()
// Simulate time passing beyond the 30s timeout
const expired = cache.expireInactive(Date.now() + 60_000)
expect(expired).toContain(s1.id)
// Now s1 is inactive — dedup should merge it
await cache.deduplicateByAgentSessionId(s2.id)
expect(cache.getSession(s1.id)).toBeUndefined()
expect(cache.getSession(s2.id)).toBeDefined()
})
it('deep-merges agentState and filters completed requests', async () => {
const store = new Store(':memory:')
const events: SyncEvent[] = []
const cache = new SessionCache(store, createPublisher(events))
const s1 = cache.getOrCreateSession(
'tag-1',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
{
requests: {
'req-1': { tool: 'Bash', arguments: {} },
'req-2': { tool: 'Bash', arguments: {} }
},
completedRequests: {}
},
'default'
)
const s2 = cache.getOrCreateSession(
'tag-2',
{ path: '/tmp/project', host: 'localhost', flavor: 'codex', codexSessionId: 'thread-X' },
{
requests: {
'req-3': { tool: 'Bash', arguments: {} }
},
completedRequests: {
'req-1': { tool: 'Bash', arguments: {}, status: 'approved' }
}
},
'default'
)
await cache.deduplicateByAgentSessionId(s2.id)
const session = cache.getSession(s2.id)
expect(session).toBeDefined()
const state = session!.agentState!
// req-1 was completed in s2 — should NOT appear in requests
expect(state.requests?.['req-1']).toBeUndefined()
// req-2 and req-3 are still pending
expect(state.requests?.['req-2']).toBeDefined()
expect(state.requests?.['req-3']).toBeDefined()
// completedRequests has req-1
expect(state.completedRequests?.['req-1']).toBeDefined()
})
})
})
+53 -6
View File
@@ -163,7 +163,16 @@ export class SyncEngine {
handleRealtimeEvent(event: SyncEvent): void {
if (event.type === 'session-updated' && event.sessionId) {
// Snapshot agent session IDs before refresh — safe because JS is single-threaded
// and refreshSession replaces the Map entry with a new object.
const before = this.sessionCache.getSession(event.sessionId)
this.sessionCache.refreshSession(event.sessionId)
const after = this.sessionCache.getSession(event.sessionId)
if (after?.metadata && !this.hasSameAgentSessionIds(before?.metadata ?? null, after.metadata)) {
void this.sessionCache.deduplicateByAgentSessionId(event.sessionId).catch(() => {
// best-effort: dedup failure is harmless, web-side safety net hides remaining duplicates
})
}
return
}
@@ -197,6 +206,9 @@ export class SyncEngine {
handleSessionEnd(payload: { sid: string; time: number }): void {
this.sessionCache.handleSessionEnd(payload)
// Retry dedup now that this session is inactive — a prior dedup may have
// skipped it because it was still active at the time.
this.triggerDedupIfNeeded(payload.sid)
}
handleBackgroundTaskDelta(sessionId: string, delta: { started: number; completed: number }): void {
@@ -208,7 +220,16 @@ export class SyncEngine {
}
private expireInactive(): void {
this.sessionCache.expireInactive()
const expired = this.sessionCache.expireInactive()
// Sort by most recent first so dedup keeps the newest session when multiple
// duplicates for the same agent thread expire in the same sweep.
const sorted = expired
.map((id) => this.sessionCache.getSession(id))
.filter((s): s is NonNullable<typeof s> => s != null)
.sort((a, b) => (b.activeAt - a.activeAt) || (b.updatedAt - a.updatedAt))
for (const session of sorted) {
this.triggerDedupIfNeeded(session.id)
}
this.machineCache.expireInactive()
}
@@ -430,17 +451,43 @@ export class SyncEngine {
}
if (spawnResult.sessionId !== access.sessionId) {
try {
await this.sessionCache.mergeSessions(access.sessionId, spawnResult.sessionId, namespace)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to merge resumed session'
return { type: 'error', message, code: 'resume_failed' }
// The old session may have already been merged by the automatic dedup path
// (triggered when the spawned CLI sets its agent session ID in metadata).
// Only attempt the explicit merge if the old session still exists.
const oldSession = this.sessionCache.getSessionByNamespace(access.sessionId, namespace)
if (oldSession) {
try {
await this.sessionCache.mergeSessions(access.sessionId, spawnResult.sessionId, namespace)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to merge resumed session'
return { type: 'error', message, code: 'resume_failed' }
}
}
}
return { type: 'success', sessionId: spawnResult.sessionId }
}
private hasSameAgentSessionIds(
prev: Session['metadata'] | null,
next: NonNullable<Session['metadata']>
): boolean {
return (prev?.codexSessionId ?? null) === (next.codexSessionId ?? null)
&& (prev?.claudeSessionId ?? null) === (next.claudeSessionId ?? null)
&& (prev?.geminiSessionId ?? null) === (next.geminiSessionId ?? null)
&& (prev?.opencodeSessionId ?? null) === (next.opencodeSessionId ?? null)
&& (prev?.cursorSessionId ?? null) === (next.cursorSessionId ?? null)
}
private triggerDedupIfNeeded(sessionId: string): void {
const session = this.sessionCache.getSession(sessionId)
if (session?.metadata) {
void this.sessionCache.deduplicateByAgentSessionId(sessionId).catch(() => {
// best-effort: web-side safety net hides remaining duplicates
})
}
}
async waitForSessionActive(sessionId: string, timeoutMs: number = 15_000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
+8 -1
View File
@@ -7,6 +7,7 @@ export type SessionSummaryMetadata = {
summary?: { text: string }
flavor?: string | null
worktree?: WorktreeMetadata
agentSessionId?: string
}
export type SessionSummary = {
@@ -31,7 +32,13 @@ export function toSessionSummary(session: Session): SessionSummary {
machineId: session.metadata.machineId ?? undefined,
summary: session.metadata.summary ? { text: session.metadata.summary.text } : undefined,
flavor: session.metadata.flavor ?? null,
worktree: session.metadata.worktree
worktree: session.metadata.worktree,
agentSessionId: session.metadata.codexSessionId
?? session.metadata.claudeSessionId
?? session.metadata.geminiSessionId
?? session.metadata.opencodeSessionId
?? session.metadata.cursorSessionId
?? undefined
} : null
const todoProgress = session.todos?.length ? {
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import type { SessionSummary } from '@/types/api'
import { deduplicateSessionsByAgentId } from './SessionList'
function makeSession(overrides: Partial<SessionSummary> & { id: string }): SessionSummary {
return {
active: false,
thinking: false,
activeAt: 0,
updatedAt: 0,
metadata: null,
todoProgress: null,
pendingRequestsCount: 0,
model: null,
effort: null,
...overrides
}
}
describe('deduplicateSessionsByAgentId', () => {
it('deduplicates sessions with the same agentSessionId', () => {
const sessions = [
makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }),
makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 })
]
const result = deduplicateSessionsByAgentId(sessions)
expect(result).toHaveLength(1)
expect(result[0].id).toBe('b') // more recent wins
})
it('keeps active session over inactive duplicate', () => {
const sessions = [
makeSession({ id: 'a', active: true, metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }),
makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 })
]
const result = deduplicateSessionsByAgentId(sessions)
expect(result).toHaveLength(1)
expect(result[0].id).toBe('a') // active wins despite older updatedAt
})
it('prefers selected session among inactive duplicates', () => {
const sessions = [
makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }),
makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 })
]
const result = deduplicateSessionsByAgentId(sessions, 'a')
expect(result).toHaveLength(1)
expect(result[0].id).toBe('a') // selected wins despite older updatedAt
})
it('active always wins over selected inactive', () => {
const sessions = [
makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 }),
makeSession({ id: 'b', active: true, metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 })
]
const result = deduplicateSessionsByAgentId(sessions, 'a')
expect(result).toHaveLength(1)
expect(result[0].id).toBe('b') // active wins over selected
})
it('passes through sessions without agentSessionId', () => {
const sessions = [
makeSession({ id: 'a', metadata: { path: '/p' } }),
makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' } }),
makeSession({ id: 'c', metadata: null })
]
const result = deduplicateSessionsByAgentId(sessions)
expect(result).toHaveLength(3)
})
it('deduplicates independently across different agentSessionIds', () => {
const sessions = [
makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }),
makeSession({ id: 'b', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 200 }),
makeSession({ id: 'c', metadata: { path: '/p', agentSessionId: 'thread-2' }, updatedAt: 100 }),
makeSession({ id: 'd', metadata: { path: '/p', agentSessionId: 'thread-2' }, updatedAt: 200 })
]
const result = deduplicateSessionsByAgentId(sessions)
expect(result).toHaveLength(2)
expect(result.map(s => s.id).sort()).toEqual(['b', 'd'])
})
})
+35 -2
View File
@@ -39,6 +39,39 @@ function getGroupDisplayName(directory: string): string {
export const UNKNOWN_MACHINE_ID = '__unknown__'
export function deduplicateSessionsByAgentId(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] {
const byAgentId = new Map<string, SessionSummary[]>()
const result: SessionSummary[] = []
for (const session of sessions) {
const agentId = session.metadata?.agentSessionId
if (!agentId) {
result.push(session)
continue
}
const group = byAgentId.get(agentId)
if (group) {
group.push(session)
} else {
byAgentId.set(agentId, [session])
}
}
for (const group of byAgentId.values()) {
group.sort((a, b) => {
// Active session always wins — it's the live connection
if (a.active !== b.active) return a.active ? -1 : 1
// Among inactive duplicates, keep the selected one visible
if (a.id === selectedSessionId) return -1
if (b.id === selectedSessionId) return 1
return b.updatedAt - a.updatedAt
})
result.push(group[0])
}
return result
}
function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] {
const groups = new Map<string, { directory: string; machineId: string | null; sessions: SessionSummary[] }>()
@@ -453,8 +486,8 @@ export function SessionList(props: {
const { t } = useTranslation()
const { renderHeader = true, api, selectedSessionId, machineLabelsById = {} } = props
const groups = useMemo(
() => groupSessionsByDirectory(props.sessions),
[props.sessions]
() => groupSessionsByDirectory(deduplicateSessionsByAgentId(props.sessions, selectedSessionId)),
[props.sessions, selectedSessionId]
)
const [collapseOverrides, setCollapseOverrides] = useState<Map<string, boolean>>(
() => new Map()