refactor(agent): replace OMP with Claude Code kernel
This commit is contained in:
1 parent
0fb2226f8c
commit
9a1f182397
232 files changed
+2561
-25564
No files matched your search
@@ -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 })
|
||||
}
|
||||
Reference in new issue
Block a user