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:
parent
4eb3e75b9e
commit
21d0391881
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) ?? ''
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue