316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
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 })
|
|
}
|