mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(opencode): open a fresh session on clear (#1300)
* test(opencode): specify fresh-session clear * feat(opencode): open a fresh session on clear * fix(opencode): release clear latch on cancel * fix(opencode): retry transient clear handoffs * fix(opencode): confirm clear archive delivery * fix(web): preserve superseded session access * fix(clear): invalidate transferred schedules * fix(runner): restore live spawn dedupe * fix(clear): preserve latched scheduled prompts * fix(runner): quarantine unverified children * fix(clear): retain handoff retry ownership * fix(runner): release recovered spawn dedupe * fix(clear): retain archive retry ownership * fix(clear): settle rejected immediate prompts * fix(clear): block reopening replaced sources * fix(clear): settle prompts when clear is cancelled * fix(clear): make fresh-session handoff durable * fix(clear): finalize only after native cleanup * fix(clear): abort failed native handoffs * fix(clear): gate recovery on cleanup proof * fix(clear): retry metadata persistence failures * fix(clear): preserve handoff ownership through teardown * fix(clear): abort incomplete cleanup reservations * fix(clear): require explicit exit before abort * fix(clear): verify owner exit before recovery * fix(clear): guard recovery handoff races * fix(clear): serialize cleanup callbacks * fix(clear): make callback retries idempotent * fix(clear): bind callbacks to reservations * fix(clear): recover pending spawns * fix(clear): deduplicate held prompts * fix(clear): validate redirect ownership * fix(clear): replay prompts in FIFO order * fix(clear): gate replacement delivery
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionSummary } from '@/types/api'
|
||||
import type { Session } from '@/types/api'
|
||||
import { isGlobalScopedMessageStreamEvent, isRenderIrrelevantPatch, isRenderIrrelevantSessionPatch } from './useSSE'
|
||||
import { isGlobalScopedMessageStreamEvent, isRenderIrrelevantPatch, isRenderIrrelevantSessionPatch, shouldInvalidateSessionListForEvent } from './useSSE'
|
||||
|
||||
function makeSummary(overrides: Partial<SessionSummary> = {}): SessionSummary {
|
||||
return {
|
||||
@@ -25,6 +25,11 @@ function makeSummary(overrides: Partial<SessionSummary> = {}): SessionSummary {
|
||||
}
|
||||
|
||||
describe('useSSE scope handling', () => {
|
||||
it('invalidates the global session list when message ownership changes', () => {
|
||||
expect(shouldInvalidateSessionListForEvent('global', 'messages-invalidated')).toBe(true)
|
||||
expect(shouldInvalidateSessionListForEvent('full', 'messages-invalidated')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats message stream events as global-scoped skips', () => {
|
||||
expect(isGlobalScopedMessageStreamEvent('global', 'message-received')).toBe(true)
|
||||
expect(isGlobalScopedMessageStreamEvent('global', 'messages-consumed')).toBe(true)
|
||||
|
||||
@@ -34,6 +34,10 @@ export function isGlobalScopedMessageStreamEvent(scope: SSEScope, eventType: Syn
|
||||
return scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(eventType)
|
||||
}
|
||||
|
||||
export function shouldInvalidateSessionListForEvent(scope: SSEScope, eventType: SyncEvent['type']): boolean {
|
||||
return scope === 'global' && eventType === 'messages-invalidated'
|
||||
}
|
||||
|
||||
type VisibilityState = 'visible' | 'hidden'
|
||||
|
||||
type ToastEvent = Extract<SyncEvent, { type: 'toast' }>
|
||||
@@ -528,6 +532,10 @@ export function useSSE(options: {
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldInvalidateSessionListForEvent(scope, event.type)) {
|
||||
queueSessionListInvalidation()
|
||||
}
|
||||
|
||||
if (scope === 'global' && MESSAGE_STREAM_EVENT_TYPES.has(event.type)) {
|
||||
if (event.type === 'message-received' && event.message.scheduledAt != null) {
|
||||
queueSessionListInvalidation()
|
||||
|
||||
@@ -46,6 +46,7 @@ import { inactiveSessionCanResume } from '@/lib/sessionResume'
|
||||
import { markSessionSeen } from '@/lib/sessionLastSeen'
|
||||
import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle'
|
||||
import { clearCodexImportedSession } from '@/lib/codexImportedSessions'
|
||||
import { getSupersedingSessionId, shouldFollowSupersedingSession } from '@/routes/sessions/followSupersedingSession'
|
||||
import { migrateSuppressedSendError } from '@/lib/suppressed-send-error'
|
||||
import FilesPage from '@/routes/sessions/files'
|
||||
import FilePage from '@/routes/sessions/file'
|
||||
@@ -764,6 +765,29 @@ function SessionDetailRoute() {
|
||||
useSessionBrowserTitle(session)
|
||||
const basePath = `/sessions/${sessionId}`
|
||||
const isChat = pathname === basePath || pathname === `${basePath}/`
|
||||
const supersedingSessionId = getSupersedingSessionId(sessionId, session?.metadata)
|
||||
const observedSessionRef = useRef<{
|
||||
sessionId: string
|
||||
supersedingSessionId: string | null
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
const shouldFollow = shouldFollowSupersedingSession(
|
||||
observedSessionRef.current,
|
||||
sessionId,
|
||||
session.metadata
|
||||
)
|
||||
observedSessionRef.current = { sessionId, supersedingSessionId }
|
||||
if (!shouldFollow || !supersedingSessionId) return
|
||||
navigate({
|
||||
to: '/sessions/$sessionId',
|
||||
params: { sessionId: supersedingSessionId },
|
||||
replace: true
|
||||
})
|
||||
}, [navigate, session, sessionId, supersedingSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionNotFound) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getSupersedingSessionId, shouldFollowSupersedingSession } from './followSupersedingSession'
|
||||
|
||||
describe('getSupersedingSessionId', () => {
|
||||
it('follows a different persisted replacement identity', () => {
|
||||
expect(getSupersedingSessionId('source', { supersededBySessionId: 'fresh' })).toBe('fresh')
|
||||
})
|
||||
|
||||
it('does not self-navigate for missing, blank, or identical values', () => {
|
||||
expect(getSupersedingSessionId('source', undefined)).toBeNull()
|
||||
expect(getSupersedingSessionId('source', { supersededBySessionId: ' ' })).toBeNull()
|
||||
expect(getSupersedingSessionId('source', { supersededBySessionId: 'source' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldFollowSupersedingSession', () => {
|
||||
it('follows a replacement only when the open view witnessed the source session before supersession', () => {
|
||||
expect(shouldFollowSupersedingSession({
|
||||
sessionId: 'source',
|
||||
supersedingSessionId: null
|
||||
}, 'source', {
|
||||
supersededBySessionId: 'fresh'
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an archived conversation accessible when opened after it was already superseded', () => {
|
||||
expect(shouldFollowSupersedingSession(null, 'source', {
|
||||
supersededBySessionId: 'fresh'
|
||||
})).toBe(false)
|
||||
expect(shouldFollowSupersedingSession({
|
||||
sessionId: 'other-session',
|
||||
supersedingSessionId: null
|
||||
}, 'source', {
|
||||
supersededBySessionId: 'fresh'
|
||||
})).toBe(false)
|
||||
expect(shouldFollowSupersedingSession({
|
||||
sessionId: 'source',
|
||||
supersedingSessionId: 'fresh'
|
||||
}, 'source', {
|
||||
supersededBySessionId: 'fresh'
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
export function getSupersedingSessionId(
|
||||
currentSessionId: string,
|
||||
metadata: { supersededBySessionId?: string } | null | undefined
|
||||
): string | null {
|
||||
const replacement = metadata?.supersededBySessionId?.trim()
|
||||
if (!replacement || replacement === currentSessionId) {
|
||||
return null
|
||||
}
|
||||
return replacement
|
||||
}
|
||||
|
||||
export function shouldFollowSupersedingSession(
|
||||
previous: { sessionId: string; supersedingSessionId: string | null } | null,
|
||||
currentSessionId: string,
|
||||
metadata: { supersededBySessionId?: string } | null | undefined
|
||||
): boolean {
|
||||
return previous?.sessionId === currentSessionId
|
||||
&& previous.supersedingSessionId === null
|
||||
&& getSupersedingSessionId(currentSessionId, metadata) !== null
|
||||
}
|
||||
Reference in New Issue
Block a user