fix(kimi): map AskUserQuestion to waiting and expose hook status

Maps Kimi AskUserQuestion PreToolUse events to waiting, exposes the Kimi hook status IPC/preload surface, and hardens Kimi session parsing plus remote hook path construction.
This commit is contained in:
Prethish Kumar 2026-06-20 05:15:34 +05:30 committed by GitHub
parent 4eb3e75b9e
commit 21d0391881
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 122 additions and 15 deletions

View File

@ -125,6 +125,13 @@ async function writeKimiSession(args: {
}
describe('parseKimiSessionFile', () => {
it('returns null for malformed state.json', async () => {
const { file } = await writeKimiSession({})
await writeFile(file.path, '{not-json')
await expect(parseKimiSessionFile(file, 'darwin')).resolves.toBeNull()
})
it('parses a full session from state.json + index + wire transcript', async () => {
const { file } = await writeKimiSession({})
const session = await parseKimiSessionFile(file, 'darwin')

View File

@ -34,7 +34,12 @@ export async function parseKimiSessionFile(
file: FileWithMtime,
platform: NodeJS.Platform = process.platform
): Promise<AiVaultSession | null> {
const stateRecord = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
let stateRecord: Record<string, unknown> | null
try {
stateRecord = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
} catch {
return null
}
if (!stateRecord) {
return null
}

View File

@ -83,6 +83,9 @@ vi.mock('../hermes/hook-service', () => ({
vi.mock('../devin/hook-service', () => ({
devinHookService: { getStatus: vi.fn(() => ({ agent: 'devin', state: 'absent' })) }
}))
vi.mock('../kimi/hook-service', () => ({
kimiHookService: { getStatus: vi.fn(() => ({ agent: 'kimi', state: 'absent' })) }
}))
beforeEach(() => {
dropStatusEntry.mockReset()
@ -237,6 +240,17 @@ describe('agentHooks:devinStatus IPC', () => {
})
})
describe('agentHooks:kimiStatus IPC', () => {
it('returns Kimi hook installation status', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentHooks:kimiStatus')
expect(handler).toBeDefined()
expect(handler!({})).toEqual({ agent: 'kimi', state: 'absent' })
})
})
describe('agentStatus:inferInterrupt IPC', () => {
it('forwards valid inference requests to the hook server', async () => {
inferInterrupt.mockReturnValue(true)

View File

@ -23,6 +23,7 @@ import { grokHookService } from '../grok/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { devinHookService } from '../devin/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
type AgentStatusRuntimeEnrichment = Pick<
@ -70,6 +71,7 @@ export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment
ipcMain.removeHandler('agentHooks:copilotStatus')
ipcMain.removeHandler('agentHooks:hermesStatus')
ipcMain.removeHandler('agentHooks:devinStatus')
ipcMain.removeHandler('agentHooks:kimiStatus')
ipcMain.removeHandler('agentStatus:getSnapshot')
ipcMain.removeHandler('agentStatus:inferInterrupt')
ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot')
@ -286,4 +288,17 @@ export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment
}
}
})
ipcMain.handle('agentHooks:kimiStatus', (): AgentHookInstallStatus => {
try {
return kimiHookService.getStatus()
} catch (err) {
return {
agent: 'kimi',
state: 'error',
configPath: '',
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
})
}

View File

@ -8,7 +8,7 @@ import {
writeFileSync
} from 'fs'
import { homedir } from 'os'
import { dirname, join } from 'path'
import { dirname, join, posix as pathPosix } from 'path'
import { randomUUID } from 'crypto'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
@ -193,9 +193,13 @@ export class KimiHookService {
// the local install. POSIX-only by design (Kimi's shell is sh/Git Bash); the
// managed script body is already platform-independent.
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const home = remoteHome.replace(/\/$/, '')
const remoteConfigPath = `${home}/.kimi-code/config.toml`
const remoteScriptPath = `${home}/.orca/agent-hooks/${MANAGED_SCRIPT_FILE_NAME}`
const remoteConfigPath = pathPosix.join(remoteHome, '.kimi-code', 'config.toml')
const remoteScriptPath = pathPosix.join(
remoteHome,
'.orca',
'agent-hooks',
MANAGED_SCRIPT_FILE_NAME
)
try {
// null (file absent) → start from an empty config; Kimi creates it lazily.
const text = (await readTextFileRemote(sftp, remoteConfigPath)) ?? ''

View File

@ -1699,7 +1699,8 @@ const api = {
copilotStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:copilotStatus'),
hermesStatus: (): Promise<AgentHookInstallStatus> =>
ipcRenderer.invoke('agentHooks:hermesStatus')
ipcRenderer.invoke('agentHooks:hermesStatus'),
kimiStatus: (): Promise<AgentHookInstallStatus> => ipcRenderer.invoke('agentHooks:kimiStatus')
},
agentTrust: {

View File

@ -480,6 +480,55 @@ describe('shared agent-hook-listener', () => {
expect(stopped?.providerSession).toMatchObject({ key: 'session_id', id: 'session_abc' })
})
it('maps Kimi AskUserQuestion PreToolUse to waiting, then back to working on answer', () => {
const question = normalizeHookPayload(
state,
'kimi',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PreToolUse',
session_id: 'session_abc',
tool_name: 'AskUserQuestion',
tool_input: {
questions: [
{
question: 'Which region should I deploy to?',
options: [{ label: 'us-east', description: 'US East' }]
}
]
}
}
},
'production'
)
const answered = normalizeHookPayload(
state,
'kimi',
{
paneKey: PANE_KEY,
payload: {
hook_event_name: 'PostToolUse',
session_id: 'session_abc',
tool_name: 'AskUserQuestion',
tool_response: { selected: ['us-east'] }
}
},
'production'
)
expect(question?.payload).toMatchObject({
agentType: 'kimi',
state: 'waiting',
toolName: 'AskUserQuestion'
})
expect(answered?.payload).toMatchObject({
agentType: 'kimi',
state: 'working',
toolName: 'AskUserQuestion'
})
})
it('rejects oversized paneKey', () => {
const event = normalizeHookPayload(
state,

View File

@ -2069,6 +2069,13 @@ function normalizeDevinEvent(
)
}
// Why: Kimi's AskUserQuestion tool is auto-allowed, so it emits PreToolUse
// instead of PermissionRequest while blocked on a human answer. Treat it as a
// waiting state so the UI shows the attention icon instead of the working spinner.
function isKimiUserInputTool(toolName: string | undefined): boolean {
return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuserquestion'
}
// Why: Kimi Code emits Claude-compatible hook payloads and reuses Claude's
// lifecycle event names (UserPromptSubmit/PreToolUse/Stop/...). Normalize them
// into Orca's shared status states while attributing the status to Kimi so the
@ -2080,17 +2087,22 @@ function normalizeKimiEvent(
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
const stateName =
const toolName = readString(hookPayload, 'tool_name')
const isUserInputTool = isKimiUserInputTool(toolName)
let stateName: 'working' | 'waiting' | 'done' | null = null
if (
eventName === 'UserPromptSubmit' ||
eventName === 'PreToolUse' ||
eventName === 'PostToolUse' ||
eventName === 'PostToolUseFailure'
? 'working'
: eventName === 'PermissionRequest'
? 'waiting'
: eventName === 'Stop' || eventName === 'StopFailure'
? 'done'
: null
eventName === 'PostToolUseFailure' ||
(eventName === 'PreToolUse' && !isUserInputTool)
) {
stateName = 'working'
} else if (eventName === 'PermissionRequest' || (eventName === 'PreToolUse' && isUserInputTool)) {
stateName = 'waiting'
} else if (eventName === 'Stop' || eventName === 'StopFailure') {
stateName = 'done'
}
if (!stateName) {
return null