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 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, 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 = {} try { body = JSON.parse(raw || '{}') as Record } catch { body = { raw } } const messages = Array.isArray(body.messages) ? body.messages as Array> : [] 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 (latestContent.includes('APPEND_ACTIVE_SMOKE')) { setTimeout(() => { if (!res.destroyed) plainText(res, id, 'Initial append turn.') }, 400) return } if (userText.includes('APPEND_GUIDE_A') || userText.includes('APPEND_GUIDE_B')) { plainText(res, id, 'Appended answer.') return } if (latestContent.includes('HOLD_SMOKE')) { setTimeout(() => { if (!res.destroyed) plainText(res, id, 'Held response.') }, 500) return } if (latestContent.includes('AFTER_CANCEL_SMOKE')) { plainText(res, id, 'After cancellation.') return } if (userText.includes('AFTER_COMPACT_SMOKE')) { plainText(res, id, 'After compaction.') return } 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((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')) const beforeSummary = requests.length const summary = await resumed.getContextSummary() assert.ok(summary && summary.categories.length > 0, 'native summary must return actual structured categories') assert.equal(requests.length, beforeSummary, 'summary must not call the model or per-category count API') const controller = new AbortController() const held = resumed.runTurn(request('HOLD_SMOKE', randomUUID(), controller)) const rejection = assert.rejects(held, /取消/) for (let index = 0; index < 500 && !requests.some(item => item.userText.includes('HOLD_SMOKE')); index++) { await new Promise(resolve => setTimeout(resolve, 10)) } assert.ok(requests.some(item => item.userText.includes('HOLD_SMOKE'))) controller.abort() await rejection const afterCancel = await resumed.runTurn(request('AFTER_CANCEL_SMOKE', randomUUID(), new AbortController())) assert.equal(afterCancel.content, 'After cancellation.', 'cancelled producer must not settle the next run') const groups: string[][] = [] const segmentResults: string[] = [] const appendController = new AbortController() const appendRun = resumed.runTurn({ ...request('APPEND_ACTIVE_SMOKE', randomUUID(), appendController), onSegmentComplete: async result => { segmentResults.push(result.content) }, onSegmentStart: async inputs => { groups.push(inputs.map(input => input.userMessageId)) } }) void appendRun.catch(() => {}) for (let index = 0; index < 500 && !requests.some(item => JSON.stringify(item.body.messages ?? []).includes('APPEND_ACTIVE_SMOKE')); index++) await new Promise(resolve => setTimeout(resolve, 10)) assert.ok(requests.some(item => JSON.stringify(item.body.messages ?? []).includes('APPEND_ACTIVE_SMOKE'))) const guideA = randomUUID(), guideB = randomUUID() let acknowledged = false const appended = Promise.all([ resumed.appendInput({ userMessageId: guideA, prompt: 'APPEND_GUIDE_A' }), resumed.appendInput({ userMessageId: guideB, prompt: 'APPEND_GUIDE_B' }) ]).then(() => { acknowledged = true }) assert.equal(acknowledged, false, 'pushing into the input stream is not delivery acknowledgement') const appendResult = await appendRun await appended assert.equal(appendResult.content, 'Appended answer.') assert.deepEqual(segmentResults, ['Initial append turn.'], 'first result must not settle appended inputs') assert.deepEqual(groups.flat(), [guideA, guideB], 'native merged UUIDs must be associated exactly once') assert.equal(appendController.signal.aborted, false, 'guidance must not cancel the active run') await assert.rejects(resumed.appendInput({ userMessageId: randomUUID(), prompt: 'idle' }), error => (error as { submitted?: boolean }).submitted === false) if (process.env.CLAUDE_PROTOCOL_PROBE === '1') { const observed: Array> = [] const target = resumed as unknown as { handleMessage(message: unknown): Promise } const original = target.handleMessage.bind(target) target.handleMessage = message => { observed.push(message as Record); return original(message) } const compact = await resumed.runTurn({ ...request('/compact', randomUUID(), new AbortController()), command: 'compact' }) console.log(JSON.stringify({ probe: 'compact', status: compact.status, frames: observed.map(frame => ({ type: frame.type, subtype: frame.subtype, promptId: frame.prompt_id, ids: frame.user_message_uuids })), boundary: observed.some(frame => frame.subtype === 'compact_boundary') })) assert.ok(observed.some(frame => frame.subtype === 'compact_boundary'), 'native compaction must emit its boundary') assert.equal(compact.content, '上下文压缩完成') const postCompact = await resumed.runTurn(request('AFTER_COMPACT_SMOKE', randomUUID(), new AbortController())) if (postCompact.content !== 'After compaction.') console.error(JSON.stringify({ postCompact, requests: requests.slice(-3).map(item => ({ url: item.url, userText: item.userText.slice(-1200) })), frames: observed.map(frame => ({ type: frame.type, subtype: frame.subtype, ids: frame.user_message_uuids })) })) assert.equal(postCompact.content, 'After compaction.') const postRequest = requests.findLast(item => item.userText.includes('AFTER_COMPACT_SMOKE')) assert.ok(postRequest) assert.equal(postRequest.userText.includes('ASK_SMOKE'), false, 'native compaction must replace old full inputs on the next upstream request') } console.log(JSON.stringify({ result: 'PASS', requests: requests.length, events: events.length, turns: process.env.CLAUDE_PROTOCOL_PROBE === '1' ? 9 : 7, basePath: requests.find((item) => item.url.includes('/v1/messages'))?.url })) } finally { await resumed?.close().catch(() => undefined) await kernel?.close().catch(() => undefined) await new Promise((resolveClose) => server.close(() => resolveClose())) await rm(temp, { recursive: true, force: true }) }