mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(hapi): consolidate approved web and Codex recovery fixes (#578)
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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']>
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user