fix(hapi): consolidate approved web and Codex recovery fixes (#578)

This commit is contained in:
xiaobaifly7
2026-05-06 20:02:46 +08:00
committed by GitHub
parent 8185f0287e
commit 6df84df756
14 changed files with 1091 additions and 53 deletions
+11
View File
@@ -13,6 +13,17 @@ export class MessageService {
) {
}
getMessages(sessionId: string, limit: number = 200): DecryptedMessage[] {
const stored = this.store.messages.getMessages(sessionId, limit)
return stored.map((message) => ({
id: message.id,
seq: message.seq,
localId: message.localId,
content: message.content,
createdAt: message.createdAt
}))
}
getMessagesPage(sessionId: string, options: { limit: number; beforeSeq: number | null }): {
messages: DecryptedMessage[]
page: {
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'bun:test'
import type { Server } from 'socket.io'
import type { RpcRegistry } from '../socket/rpcRegistry'
import { RpcGateway } from './rpcGateway'
function createGateway() {
const timeouts: number[] = []
const socket = {
timeout(timeoutMs: number) {
timeouts.push(timeoutMs)
return {
async emitWithAck(_event: string, payload: { method: string; params: string }) {
return JSON.stringify({
success: true,
method: payload.method,
params: JSON.parse(payload.params) as unknown
})
}
}
}
}
const io = {
of() {
return {
sockets: {
get() {
return socket
}
}
}
}
} as unknown as Server
const rpcRegistry = {
getSocketIdForMethod() {
return 'socket-1'
}
} as unknown as RpcRegistry
return {
gateway: new RpcGateway(io, rpcRegistry),
timeouts
}
}
describe('RpcGateway RPC timeouts', () => {
it('uses the default RPC timeout for regular machine RPCs', async () => {
const { gateway, timeouts } = createGateway()
await gateway.listMachineDirectory('machine-1', 'C:\\workspace')
expect(timeouts).toEqual([30_000])
})
it('uses an extended RPC timeout when listing Codex models', async () => {
const { gateway, timeouts } = createGateway()
await gateway.listCodexModelsForMachine('machine-1')
expect(timeouts).toEqual([120_000])
})
})
+21 -8
View File
@@ -2,6 +2,9 @@ import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/type
import type { Server } from 'socket.io'
import type { RpcRegistry } from '../socket/rpcRegistry'
const DEFAULT_RPC_TIMEOUT_MS = 30_000
const MODEL_LIST_RPC_TIMEOUT_MS = 120_000
export type RpcCommandResponse = {
success: boolean
stdout?: string
@@ -267,11 +270,11 @@ export class RpcGateway {
}
async listCodexModelsForSession(sessionId: string): Promise<RpcListCodexModelsResponse> {
return await this.sessionRpc(sessionId, 'listCodexModels', {}) as RpcListCodexModelsResponse
return await this.sessionRpc(sessionId, 'listCodexModels', {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCodexModelsResponse
}
async listCodexModelsForMachine(machineId: string): Promise<RpcListCodexModelsResponse> {
return await this.machineRpc(machineId, 'listCodexModels', {}) as RpcListCodexModelsResponse
return await this.machineRpc(machineId, 'listCodexModels', {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCodexModelsResponse
}
async listOpencodeModelsForSession(sessionId: string): Promise<RpcListOpencodeModelsResponse> {
@@ -282,15 +285,25 @@ export class RpcGateway {
return await this.machineRpc(machineId, 'listOpencodeModelsForCwd', { cwd }) as RpcListOpencodeModelsResponse
}
private async sessionRpc(sessionId: string, method: string, params: unknown): Promise<unknown> {
return await this.rpcCall(`${sessionId}:${method}`, params)
private async sessionRpc(
sessionId: string,
method: string,
params: unknown,
timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS
): Promise<unknown> {
return await this.rpcCall(`${sessionId}:${method}`, params, timeoutMs)
}
private async machineRpc(machineId: string, method: string, params: unknown): Promise<unknown> {
return await this.rpcCall(`${machineId}:${method}`, params)
private async machineRpc(
machineId: string,
method: string,
params: unknown,
timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS
): Promise<unknown> {
return await this.rpcCall(`${machineId}:${method}`, params, timeoutMs)
}
private async rpcCall(method: string, params: unknown): Promise<unknown> {
private async rpcCall(method: string, params: unknown, timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS): Promise<unknown> {
const socketId = this.rpcRegistry.getSocketIdForMethod(method)
if (!socketId) {
throw new Error(`RPC handler not registered: ${method}`)
@@ -301,7 +314,7 @@ export class RpcGateway {
throw new Error(`RPC socket disconnected: ${method}`)
}
const response = await socket.timeout(30_000).emitWithAck('rpc-request', {
const response = await socket.timeout(timeoutMs).emitWithAck('rpc-request', {
method,
params: JSON.stringify(params)
}) as unknown
+193
View File
@@ -605,6 +605,199 @@ describe('session model', () => {
}
})
it('recovers claude resume session ID from stored messages when metadata is missing it', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-claude-resume-from-message',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'claude'
},
null,
'default',
'sonnet'
)
store.messages.addMessage(session.id, {
role: 'agent',
content: {
type: 'output',
data: {
type: 'assistant',
sessionId: '7f5cd4ee-3a76-4601-a7b4-f9eb976bf515'
}
}
})
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
let capturedResumeSessionId: string | undefined
;(engine as any).rpcGateway.spawnSession = async (
_machineId: string,
_directory: string,
_agent: string,
_model?: string,
_modelReasoningEffort?: string,
_yolo?: boolean,
_sessionType?: 'simple' | 'worktree',
_worktreeName?: string,
resumeSessionId?: string
) => {
capturedResumeSessionId = resumeSessionId
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(capturedResumeSessionId).toBe('7f5cd4ee-3a76-4601-a7b4-f9eb976bf515')
expect(store.sessions.getSession(session.id)?.metadata).toMatchObject({
claudeSessionId: '7f5cd4ee-3a76-4601-a7b4-f9eb976bf515'
})
} finally {
engine.stop()
}
})
it('recovers the newest claude session ID when stored messages contain multiple IDs', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-claude-resume-newest-message',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'claude'
},
null,
'default',
'sonnet'
)
store.messages.addMessage(session.id, {
role: 'agent',
content: {
type: 'output',
data: {
type: 'assistant',
sessionId: '11111111-1111-4111-8111-111111111111'
}
}
})
store.messages.addMessage(session.id, {
role: 'agent',
content: {
type: 'output',
data: {
type: 'assistant',
sessionId: '22222222-2222-4222-8222-222222222222'
}
}
})
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
let capturedResumeSessionId: string | undefined
;(engine as any).rpcGateway.spawnSession = async (
_machineId: string,
_directory: string,
_agent: string,
_model?: string,
_modelReasoningEffort?: string,
_yolo?: boolean,
_sessionType?: 'simple' | 'worktree',
_worktreeName?: string,
resumeSessionId?: string
) => {
capturedResumeSessionId = resumeSessionId
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(capturedResumeSessionId).toBe('22222222-2222-4222-8222-222222222222')
expect(store.sessions.getSession(session.id)?.metadata).toMatchObject({
claudeSessionId: '22222222-2222-4222-8222-222222222222'
})
} finally {
engine.stop()
}
})
it('does not recover a non-UUID sessionId from stored messages', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-claude-resume-no-token',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'claude'
},
null,
'default',
'sonnet'
)
store.messages.addMessage(session.id, {
role: 'agent',
content: {
type: 'output',
data: {
sessionId: 'hapi-session-id-not-claude-uuid'
}
}
})
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({
type: 'error',
message: 'Resume session ID unavailable',
code: 'resume_unavailable'
})
} finally {
engine.stop()
}
})
it('passes the cached permissionMode when respawning a resumed session', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
+74 -2
View File
@@ -59,7 +59,7 @@ export class SyncEngine {
private inactivityTimer: NodeJS.Timeout | null = null
constructor(
store: Store,
private readonly store: Store,
io: Server,
rpcRegistry: RpcRegistry,
sseManager: SSEManager
@@ -458,7 +458,7 @@ export class SyncEngine {
? metadata.opencodeSessionId
: flavor === 'cursor'
? metadata.cursorSessionId
: metadata.claudeSessionId
: (metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(access.sessionId, namespace))
if (!resumeToken) {
return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' }
@@ -527,6 +527,78 @@ export class SyncEngine {
return { type: 'success', sessionId: spawnResult.sessionId }
}
private recoverClaudeSessionIdFromMessages(sessionId: string, namespace: string): string | null {
const messages = this.messageService.getMessages(sessionId, 200)
for (let i = messages.length - 1; i >= 0; i -= 1) {
const found = this.extractClaudeSessionId(messages[i].content)
if (!found) continue
this.persistRecoveredClaudeSessionId(sessionId, namespace, found)
return found
}
return null
}
private extractClaudeSessionId(value: unknown): string | null {
if (!value || typeof value !== 'object') {
return null
}
const obj = value as Record<string, unknown>
const direct = this.normalizeClaudeSessionId(obj.session_id) ?? this.normalizeClaudeSessionId(obj.sessionId)
if (direct) {
return direct
}
const content = obj.content
if (content && typeof content === 'object') {
const found = this.extractClaudeSessionId(content)
if (found) return found
}
const data = obj.data
if (data && typeof data === 'object') {
const found = this.extractClaudeSessionId(data)
if (found) return found
}
return null
}
private normalizeClaudeSessionId(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)
? trimmed
: null
}
private persistRecoveredClaudeSessionId(sessionId: string, namespace: string, claudeSessionId: string): void {
for (let attempt = 0; attempt < 2; attempt += 1) {
const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace)
?? this.sessionCache.refreshSession(sessionId)
if (!latest?.metadata) return
if (latest.metadata.claudeSessionId === claudeSessionId) return
const result = this.store.sessions.updateSessionMetadata(
sessionId,
{ ...latest.metadata, claudeSessionId },
latest.metadataVersion,
namespace,
{ touchUpdatedAt: false }
)
if (result.result === 'success') {
this.sessionCache.refreshSession(sessionId)
return
}
if (result.result !== 'version-mismatch') {
return
}
}
}
private hasSameAgentSessionIds(
prev: Session['metadata'] | null,
next: NonNullable<Session['metadata']>
+68 -1
View File
@@ -52,6 +52,7 @@ function createSession(overrides?: Partial<Session>): Session {
function createApp(session: Session, opts?: {
resumeSession?: (sessionId: string, namespace: string, resumeOpts?: { permissionMode?: string }) => Promise<{ type: string; sessionId?: string; message?: string; code?: string }>
listSlashCommands?: SyncEngine['listSlashCommands']
}) {
const applySessionConfigCalls: Array<[string, Record<string, unknown>]> = []
const applySessionConfig = async (sessionId: string, config: Record<string, unknown>) => {
@@ -77,7 +78,11 @@ function createApp(session: Session, opts?: {
applySessionConfig,
listCodexModelsForSession,
listOpencodeModelsForSession,
resumeSession
resumeSession,
listSlashCommands: opts?.listSlashCommands ?? (async () => ({
success: true,
commands: []
}))
} as Partial<SyncEngine>
const app = new Hono<WebAppEnv>()
@@ -457,4 +462,66 @@ describe('sessions routes', () => {
expect(response.status).toBe(200)
expect(capturedResumeOpts).toEqual({ permissionMode: 'bypassPermissions' })
})
it('falls back to metadata slash commands when RPC listing fails', async () => {
const session = createSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'claude',
slashCommands: ['help', 'memory', 'status']
}
})
const { app } = createApp(session, {
listSlashCommands: async () => {
throw new Error('RPC unavailable')
}
})
const response = await app.request('/api/sessions/session-1/slash-commands')
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
success: true,
commands: [
{ name: 'help', source: 'builtin' },
{ name: 'memory', source: 'builtin' },
{ name: 'status', source: 'builtin' }
]
})
})
it('merges RPC and metadata slash commands without hiding built-ins', async () => {
const session = createSession({
metadata: {
path: '/tmp/project',
host: 'localhost',
flavor: 'claude',
slashCommands: ['help', 'memory']
}
})
const { app } = createApp(session, {
listSlashCommands: async () => ({
success: true,
commands: [
{ name: 'clear', source: 'builtin' },
{ name: 'project-only', source: 'project', content: 'Project prompt' }
]
})
})
const response = await app.request('/api/sessions/session-1/slash-commands')
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
success: true,
commands: [
{ name: 'help', source: 'builtin' },
{ name: 'memory', source: 'builtin' },
{ name: 'clear', source: 'builtin' },
{ name: 'project-only', source: 'project', content: 'Project prompt' }
]
})
})
})
+52
View File
@@ -46,6 +46,39 @@ const uploadDeleteSchema = z.object({
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024
type SlashCommand = {
name: string
description?: string
source: 'builtin' | 'user' | 'plugin' | 'project'
content?: string
pluginName?: string
}
function commandsFromMetadataSlashCommands(names: readonly string[] | undefined): SlashCommand[] {
if (!names?.length) {
return []
}
return names
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0)
.map((name) => ({
name,
source: 'builtin'
}))
}
function mergeSlashCommands(
primary: readonly SlashCommand[],
fallback: readonly SlashCommand[]
): SlashCommand[] {
const commandMap = new Map<string, SlashCommand>()
for (const command of [...fallback, ...primary]) {
commandMap.set(command.name, command)
}
return Array.from(commandMap.values())
}
function estimateBase64Bytes(base64: string): number {
const len = base64.length
if (len === 0) return 0
@@ -498,10 +531,29 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
// Get agent type from session metadata, default to 'claude'
const agent = sessionResult.session.metadata?.flavor ?? 'claude'
const metadataCommands = commandsFromMetadataSlashCommands(
sessionResult.session.metadata?.slashCommands
)
try {
const result = await engine.listSlashCommands(sessionResult.sessionId, agent)
if (result.success && result.commands) {
return c.json({
...result,
commands: mergeSlashCommands(result.commands, metadataCommands)
})
}
if (metadataCommands.length > 0) {
return c.json({ success: true, commands: metadataCommands })
}
return c.json(result)
} catch (error) {
if (metadataCommands.length > 0) {
return c.json({ success: true, commands: metadataCommands })
}
return c.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to list slash commands'