mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
* 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>
363 lines
13 KiB
TypeScript
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')
|
|
})
|
|
})
|