Files
hapi/web/src/lib/sessionResume.test.ts
T
1cd4d1137a feat(hub,cli,web): fleet runner version governance (skew, self-upgrade, soft-fail reopen) (#1108)
* fix(hub): govern runner capabilities so Cursor reopen soft-fails on skew

Hub↔runner protocol drift was reported as missing Cursor chat data when
cursor-chat-store-status was unregistered. Soft-fail reopen on probe errors,
advertise required machine capabilities, surface an unmissable upgrade banner,
and stop-runner when a newer CLI binary is already on disk.

Fixes #1084

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web,hub): make runner skew banner dismissible; gate auto-upgrade

Compact the out-of-date banner (minimize + 1h snooze + per-host Restart)
so it no longer blocks the session list. Auto stop-runner on skew stays
opt-in via HAPI_AUTO_UPGRADE_RUNNERS / autoUpgradeRunners (default off).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): tolerate full sessionStorage on skew banner minimize

QuotaExceededError from setItem aborted minimize before React state
updated, leaving the banner stuck over the session list. Persist to
memory when storage fails; only enable Restart when a newer CLI is
already on disk; clarify opt-in is stop-runner only, not package push.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): drop redundant autoUpgradeRunners; runners already self-restart

CLI version handoff already reloads the runner when the on-disk binary
mtime changes. Hub-driven stop-runner on skew duplicated that. Keep the
skew banner and manual Restart only as a stuck/disabled-handoff escape.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli,hub,web): runner-only caps ads; gate Restart on supervisor

Address #1108 bot Majors on the thin tip: terminal/lazy bootstraps no
longer merge CURRENT_MACHINE_CAPABILITIES into the machine row (only
asRunner registration does). Banner Restart refuses unsupervised hosts
so stop-runner cannot leave a detached laptop offline; supervised
runners advertise supervisedRestart via HAPI_RUNNER_SUPERVISED=1.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub,cli,web): clear sticky runner ads; docs SUPERVISED; i18n skew label

Omit-means-clear on runner registration so rollback cannot leave
supervisedRestart/capabilities sticky; always advertise boolean
supervisedRestart from asRunner. Document HAPI_RUNNER_SUPERVISED=1
and localize MachineSelector UPDATE REQUIRED.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 22:24:39 +08:00

363 lines
13 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { Session } from '@/types/api'
import {
inactiveSessionCanResume,
resolveAgentSessionIdFromMetadata,
resolveCursorReopenGate,
} from './sessionResume'
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: 'session-1',
active: false,
thinking: false,
activeAt: 0,
updatedAt: 0,
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'cursor' },
...overrides,
} as Session
}
describe('sessionResume', () => {
it('resolveAgentSessionIdFromMetadata picks the id matching the session flavor', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p',
host: 'h',
flavor: 'codex',
codexSessionId: 'codex-1',
cursorSessionId: 'cursor-1',
})).toBe('codex-1')
expect(resolveAgentSessionIdFromMetadata({
path: '/p',
host: 'h',
flavor: 'cursor',
cursorSessionId: 'cursor-1',
})).toBe('cursor-1')
})
it('resolveAgentSessionIdFromMetadata ignores stale cross-flavor ids', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p',
host: 'h',
flavor: 'cursor',
codexSessionId: 'codex-1',
})).toBeUndefined()
})
it('resolveAgentSessionIdFromMetadata defaults to claude when flavor is missing', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p',
host: 'h',
claudeSessionId: 'claude-1',
})).toBe('claude-1')
})
it('inactiveSessionCanResume is true for active sessions', () => {
expect(inactiveSessionCanResume(makeSession({ active: true }), 0)).toBe(true)
})
it('inactiveSessionCanResume allows fresh spawn when no agent id and no messages', () => {
expect(inactiveSessionCanResume(makeSession(), 0)).toBe(true)
})
it('inactiveSessionCanResume allows resume when agent id exists', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-1',
},
}), 5, true)).toBe(true)
})
it('allows cursor resume when chat-store probe is unverified (skew / missing handler)', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-1',
},
}), 5)).toBe(true)
})
it('rejects cursor resume when the recorded chat store is missing on its machine', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'cursor',
cursorSessionId: 'cursor-thread-1',
},
}), 5, false)).toBe(false)
})
it('resolveCursorReopenGate only disables for definitive onDisk:false', () => {
expect(resolveCursorReopenGate({
applicable: true,
onDisk: false,
error: null,
isLoading: false,
})).toEqual({ disabledReason: 'missing', probeUnverified: false })
expect(resolveCursorReopenGate({
applicable: true,
onDisk: undefined,
error: 'RPC handler not registered',
isLoading: false,
})).toEqual({ disabledReason: null, probeUnverified: true })
expect(resolveCursorReopenGate({
applicable: true,
onDisk: true,
error: null,
isLoading: false,
})).toEqual({ disabledReason: null, probeUnverified: false })
expect(resolveCursorReopenGate({
applicable: true,
onDisk: undefined,
error: null,
isLoading: true,
})).toEqual({ disabledReason: 'checking', probeUnverified: false })
})
it('does not apply Cursor chat store status to other agent flavors', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'codex',
codexSessionId: 'codex-thread-1',
},
}), 5, false)).toBe(true)
})
it('resolveAgentSessionIdFromMetadata still returns cursorSessionId regardless of protocol', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p',
host: 'h',
flavor: 'cursor',
cursorSessionId: 'acp-thread-1',
cursorSessionProtocol: 'acp',
})).toBe('acp-thread-1')
expect(resolveAgentSessionIdFromMetadata({
path: '/p',
host: 'h',
flavor: 'cursor',
cursorSessionId: 'legacy-thread-1',
cursorSessionProtocol: 'stream-json',
})).toBe('legacy-thread-1')
})
it('inactiveSessionCanResume rejects inactive sessions with messages but no agent id', () => {
expect(inactiveSessionCanResume(makeSession(), 3)).toBe(false)
})
it('inactiveSessionCanResume rejects when stale cross-flavor agent id is present but no messages', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'cursor',
codexSessionId: 'stale-codex-1',
},
}), 0)).toBe(true)
expect(inactiveSessionCanResume(makeSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'cursor',
codexSessionId: 'stale-codex-1',
},
}), 3)).toBe(false)
})
it('inactiveSessionCanResume rejects when metadata path is missing', () => {
expect(inactiveSessionCanResume(makeSession({ metadata: { path: '', host: 'localhost' } }), 0)).toBe(false)
})
it('inactiveSessionCanResume allows claude resume by message recovery when no claudeSessionId is stored', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'claude' },
}), 3)).toBe(true)
})
it('inactiveSessionCanResume allows codex resume by message recovery when no codexSessionId is stored', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'codex' },
}), 3)).toBe(true)
})
it('inactiveSessionCanResume allows claude recovery when flavor is missing (defaults to claude)', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: { path: '/tmp/project', host: 'localhost' },
}), 3)).toBe(true)
})
it('inactiveSessionCanResume rejects non-recovering flavors with messages but no flavor-specific id', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'grok' },
}), 3)).toBe(false)
})
it('resolveAgentSessionIdFromMetadata returns agySessionId for agy flavor', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p',
host: 'h',
flavor: 'agy',
agySessionId: 'brain-uuid-1234',
})).toBe('brain-uuid-1234')
})
it('inactiveSessionCanResume allows agy resume when agySessionId exists', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'agy',
agySessionId: 'brain-uuid-1234',
},
}), 5)).toBe(true)
})
it('inactiveSessionCanResume rejects agy with messages but no agySessionId', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'agy' },
}), 3)).toBe(false)
})
})
describe('sessionResume — pi flavor', () => {
it('resolveAgentSessionIdFromMetadata returns piSessionId when flavor is pi', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p',
host: 'h',
flavor: 'pi',
piSessionId: 'pi-sess-123',
})).toBe('pi-sess-123')
})
it('resolveAgentSessionIdFromMetadata returns undefined when flavor is pi but no piSessionId', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p',
host: 'h',
flavor: 'pi',
})).toBeUndefined()
})
it('resolveAgentSessionIdFromMetadata ignores stale cross-flavor ids when flavor is pi', () => {
// Stale ids from other flavors must not satisfy a Pi resume — hub
// will reject them and the web layer would otherwise claim the
// session is resumable.
expect(resolveAgentSessionIdFromMetadata({
path: '/p',
host: 'h',
flavor: 'pi',
claudeSessionId: 'claude-stale',
codexSessionId: 'codex-stale',
})).toBeUndefined()
})
it('resolveAgentSessionIdFromMetadata prefers piSessionId over other ids when flavor is pi', () => {
// Defensive: even if a stale id slipped in, the pi id should win.
expect(resolveAgentSessionIdFromMetadata({
path: '/p',
host: 'h',
flavor: 'pi',
piSessionId: 'pi-sess-real',
claudeSessionId: 'claude-stale',
})).toBe('pi-sess-real')
})
it('inactiveSessionCanResume allows resume of pi session when piSessionId is present', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'pi',
piSessionId: 'pi-sess-abc',
},
}), 0)).toBe(true)
})
it('inactiveSessionCanResume allows fresh pi spawn when path is set and there are no messages', () => {
expect(inactiveSessionCanResume(makeSession({
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' },
}), 0)).toBe(true)
})
it('inactiveSessionCanResume rejects inactive pi session with messages but no piSessionId (no Pi recovery fallback)', () => {
// Pi does not have a recover-from-messages path the way Claude does.
// If the cli lost the session id, the user must start a new session
// (or click resume in the cli to re-establish the id).
expect(inactiveSessionCanResume(makeSession({
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' },
}), 3)).toBe(false)
})
it('inactiveSessionCanResume rejects pi session whose only id is a stale cross-flavor id', () => {
// Stale codexSessionId alone does NOT satisfy Pi resume.
expect(inactiveSessionCanResume(makeSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'pi',
codexSessionId: 'stale-codex',
},
}), 3)).toBe(false)
})
it('inactiveSessionCanResume allows active pi session unconditionally', () => {
expect(inactiveSessionCanResume(makeSession({
active: true,
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'pi' },
}), 3)).toBe(true)
})
})
describe('sessionResume — regression for all other flavor ids', () => {
// Every flavor-specific id resolver must still work; the switch in
// sessionResume.ts grew a new 'pi' branch and the existing branches
// must not be regressed.
it('codex', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p', host: 'h', flavor: 'codex', codexSessionId: 'cx-1',
})).toBe('cx-1')
})
it('gemini', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p', host: 'h', flavor: 'gemini', geminiSessionId: 'gm-1',
})).toBe('gm-1')
})
it('opencode', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p', host: 'h', flavor: 'opencode', opencodeSessionId: 'oc-1',
})).toBe('oc-1')
})
it('cursor', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p', host: 'h', flavor: 'cursor', cursorSessionId: 'cu-1',
})).toBe('cu-1')
})
it('kimi', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p', host: 'h', flavor: 'kimi', kimiSessionId: 'ki-1',
})).toBe('ki-1')
})
it('copilot', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p', host: 'h', flavor: 'copilot', copilotSessionId: 'cp-1',
})).toBe('cp-1')
})
it('claude (default branch)', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p', host: 'h', flavor: 'claude', claudeSessionId: 'cl-1',
})).toBe('cl-1')
})
it('unknown flavor falls back to claude branch', () => {
expect(resolveAgentSessionIdFromMetadata({
path: '/p', host: 'h', flavor: 'mystery', claudeSessionId: 'cl-1',
})).toBe('cl-1')
})
})