refactor(agent): replace OMP with Claude Code kernel

This commit is contained in:
xiaohan.yuan committed 2026-10-20 14:47:56 +08:00
1 parent 0fb2226f8c
commit 9a1f182397
232 files changed
+2561 -25564

No files matched your search

+315
View File
@@ -0,0 +1,315 @@
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import { existsSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import type { ChatEvent } from '../src/shared/types'
import type { ResolvedModel } from '../src/shared/models'
import type { InteractionBroker } from '../src/main/interaction-broker'
import { ClaudeKernel } from '../src/main/agent/claude/claude-kernel'
interface CapturedRequest {
url: string
body: Record<string, unknown>
userText: string
hasToolResult: boolean
}
const requests: CapturedRequest[] = []
const events: ChatEvent[] = []
const persistedSessionIds: string[] = []
const temp = await mkdtemp(join(tmpdir(), 'ycode-claude-kernel-'))
process.env.CLAUDE_TEST_USER_DATA = join(temp, 'user-data')
let askToolIssued = false
let planToolIssued = false
let pendingToolCompletion: 'ask' | 'plan' | 'write' | null = null
function sse(res: import('node:http').ServerResponse, event: string, data: unknown): void {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
function messageStart(res: import('node:http').ServerResponse, id: string): void {
sse(res, 'message_start', {
type: 'message_start',
message: {
id, type: 'message', role: 'assistant', model: 'claude-mock',
content: [], stop_reason: null, stop_sequence: null,
usage: { input_tokens: 8, output_tokens: 1 }
}
})
}
function messageEnd(res: import('node:http').ServerResponse, stopReason: string): void {
sse(res, 'message_delta', {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: { output_tokens: 4 }
})
sse(res, 'message_stop', { type: 'message_stop' })
res.end()
}
function textBlock(res: import('node:http').ServerResponse, text: string, index = 0): void {
sse(res, 'content_block_start', {
type: 'content_block_start', index,
content_block: { type: 'text', text: '' }
})
sse(res, 'content_block_delta', {
type: 'content_block_delta', index,
delta: { type: 'text_delta', text }
})
sse(res, 'content_block_stop', { type: 'content_block_stop', index })
}
function toolBlock(
res: import('node:http').ServerResponse,
id: string,
name: string,
input: Record<string, unknown>,
index: number
): void {
sse(res, 'content_block_start', {
type: 'content_block_start', index,
content_block: { type: 'tool_use', id, name, input: {} }
})
sse(res, 'content_block_delta', {
type: 'content_block_delta', index,
delta: { type: 'input_json_delta', partial_json: JSON.stringify(input) }
})
sse(res, 'content_block_stop', { type: 'content_block_stop', index })
}
function plainText(res: import('node:http').ServerResponse, id: string, text: string): void {
messageStart(res, id)
textBlock(res, text)
messageEnd(res, 'end_turn')
}
const server = createServer((req, res) => {
let raw = ''
req.on('data', (chunk) => { raw += chunk })
req.on('end', () => {
let body: Record<string, unknown> = {}
try { body = JSON.parse(raw || '{}') as Record<string, unknown> } catch { body = { raw } }
const messages = Array.isArray(body.messages) ? body.messages as Array<Record<string, unknown>> : []
const latest = messages.at(-1)
const latestContent = JSON.stringify(latest?.content ?? '')
const userText = messages
.filter((message) => message.role === 'user')
.map((message) => JSON.stringify(message.content))
.join('\n')
requests.push({ url: req.url ?? '', body, userText, hasToolResult: latestContent.includes('"tool_result"') })
if (process.env.DEBUG_CLAUDE_KERNEL === '1') {
console.error('[kernel-test] request', requests.length, req.url, {
ask: userText.includes('ASK_SMOKE'),
plan: userText.includes('PLAN_SMOKE'),
resume: userText.includes('RESUME_SMOKE'),
hasToolResult: requests.at(-1)?.hasToolResult
})
}
if (!req.url?.includes('/messages')) {
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{}')
return
}
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive'
})
const id = `msg_kernel_${requests.length}`
if (userText.includes('Write the title in the predominant language')) {
plainText(res, id, 'Plan Smoke')
return
}
if (userText.includes('RESUME_SMOKE')) {
plainText(res, id, 'Resumed.')
return
}
if (pendingToolCompletion === 'ask') {
pendingToolCompletion = null
plainText(res, id, 'Question answered.')
return
}
if (pendingToolCompletion === 'plan') {
pendingToolCompletion = 'write'
messageStart(res, id)
toolBlock(res, 'write_tool', 'Write', {
file_path: join(temp, 'blocked.txt'),
content: 'This must not be written in plan mode.'
}, 0)
messageEnd(res, 'tool_use')
return
}
if (pendingToolCompletion === 'write') {
pendingToolCompletion = null
plainText(res, id, 'Plan ready.')
return
}
if (userText.includes('ASK_SMOKE') && !askToolIssued) {
askToolIssued = true
pendingToolCompletion = 'ask'
messageStart(res, id)
toolBlock(res, 'ask_tool', 'AskUserQuestion', {
questions: [{
question: 'Which library?',
header: 'Library',
multiSelect: false,
options: [
{ label: 'Alpha', description: 'First option' },
{ label: 'Beta', description: 'Second option' }
]
}]
}, 0)
messageEnd(res, 'tool_use')
return
}
if (userText.includes('PLAN_SMOKE') && !planToolIssued) {
planToolIssued = true
pendingToolCompletion = 'plan'
messageStart(res, id)
textBlock(res, 'Plan:\n1. Inspect files\n2. Implement change')
toolBlock(res, 'plan_tool', 'ExitPlanMode', {
plan: 'Plan:\n1. Inspect files\n2. Implement change',
planFilePath: join(temp, 'plan.md')
}, 1)
messageEnd(res, 'tool_use')
return
}
plainText(res, id, 'Unexpected prompt.')
})
})
await new Promise<void>((resolveListen) => server.listen(0, '127.0.0.1', resolveListen))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('mock server did not bind')
const model = {
profile: {
id: 'mock-model', name: 'Mock Claude', model: 'claude-mock', authMode: 'api-key',
protocol: 'anthropic', contextWindowTokens: 200000
},
config: {
api: 'anthropic-messages',
baseUrl: `http://127.0.0.1:${address.port}/v1`,
model: 'claude-mock',
apiKey: 'ycode-test-key',
authMode: 'api-key'
}
} as unknown as ResolvedModel
const questions: unknown[] = []
const interactions = {
async question(_toolCallId: string, _title: string, items: Array<{ id: string; question: string; options: unknown[] }>) {
questions.push(items)
return {
kind: 'question' as const,
answer: {
questions: items.map((item) => ({ id: item.id, question: item.question, answers: ['Alpha'] }))
}
}
},
async approval() {
throw new Error('approval was not expected in this regression')
}
} as unknown as InteractionBroker
function request(prompt: string, userMessageId: string, controller: AbortController) {
return {
runId: randomUUID(),
userMessageId,
prompt,
emit: (event: ChatEvent) => events.push(event),
interactions,
signal: controller.signal
}
}
let kernel: ClaudeKernel | null = null
let resumed: ClaudeKernel | null = null
try {
const sessionId = randomUUID()
kernel = new ClaudeKernel({
sessionId,
workspaceRoot: temp,
model,
permissionMode: 'default-sandbox',
agentMode: 'craft',
thinkingLevel: 'off',
forceNewSession: true,
onSessionId: (id) => { persistedSessionIds.push(id) }
})
const first = await kernel.runTurn(request('ASK_SMOKE', randomUUID(), new AbortController()))
if (process.env.DEBUG_CLAUDE_KERNEL === '1') console.error('[kernel-test] first complete')
assert.equal(first.content, 'Question answered.')
assert.equal(first.tools.length, 1)
assert.equal(questions.length, 1)
assert.deepEqual(
(questions[0] as Array<{ options: Array<{ question: string; option: string; id: string }> }>)[0].options.map((option) => option.question),
['Alpha', 'Beta']
)
assert.ok(persistedSessionIds.includes(kernel.sessionId), 'the initial Claude session id should be persisted')
// Simulate an abnormal process exit while keeping the same ClaudeKernel alive.
const runningKernel = kernel as unknown as { instance: { close(): void } | null }
runningKernel.instance?.close()
for (let index = 0; index < 200 && runningKernel.instance; index++) {
await new Promise((resolve) => setTimeout(resolve, 10))
}
assert.equal(runningKernel.instance, null, 'Claude process should be released before restart')
await kernel.setPolicy('default-sandbox', 'plan')
const second = await kernel.runTurn(request('PLAN_SMOKE', randomUUID(), new AbortController()))
if (process.env.DEBUG_CLAUDE_KERNEL === '1') console.error('[kernel-test] second complete')
assert.ok(second.content.endsWith('Plan ready.'))
if (process.env.DEBUG_CLAUDE_KERNEL === '1') {
console.error('[kernel-test] events', events.map((event) => event.type === 'part' ? `${event.type}:${event.part.type}` : event.type))
console.error('[kernel-test] second tools', second.tools)
}
const plan = events.find((event) => event.type === 'part' && event.part.type === 'plan')
assert.ok(plan && plan.type === 'part' && plan.part.type === 'plan')
assert.deepEqual(plan.part.plan.steps.map((step) => step.text), ['Plan:', 'Inspect files', 'Implement change'])
assert.equal(second.tools.find((tool) => tool.name === 'ExitPlanMode')?.status, 'error')
assert.equal(second.tools.find((tool) => tool.name === 'Write')?.status, 'error')
assert.equal(existsSync(join(temp, 'blocked.txt')), false)
const runtimeSessionId = kernel.sessionId
assert.ok(runtimeSessionId)
assert.ok(requests.some((item) => item.url.includes('/v1/messages')))
assert.equal(requests.some((item) => item.url.includes('/v1/v1/')), false)
await kernel.close()
kernel = null
resumed = new ClaudeKernel({
sessionId,
workspaceRoot: temp,
model,
permissionMode: 'default-sandbox',
agentMode: 'craft',
thinkingLevel: 'off',
resumeSessionId: runtimeSessionId,
onSessionId: () => undefined
})
const third = await resumed.runTurn(request('RESUME_SMOKE', randomUUID(), new AbortController()))
if (process.env.DEBUG_CLAUDE_KERNEL === '1') console.error('[kernel-test] third complete')
assert.equal(third.content, 'Resumed.')
const resumedRequest = [...requests].reverse().find((item) => item.userText.includes('RESUME_SMOKE'))
assert.ok(resumedRequest)
assert.ok(resumedRequest.userText.includes('ASK_SMOKE'))
assert.ok(resumedRequest.userText.includes('PLAN_SMOKE'))
console.log(JSON.stringify({
result: 'PASS',
requests: requests.length,
events: events.length,
turns: 3,
basePath: requests.find((item) => item.url.includes('/v1/messages'))?.url
}))
} finally {
await resumed?.close().catch(() => undefined)
await kernel?.close().catch(() => undefined)
await new Promise<void>((resolveClose) => server.close(() => resolveClose()))
await rm(temp, { recursive: true, force: true })
}