Files
ycode/scripts/claude-append-protocol-probe.ts
T

533 lines
19 KiB
TypeScript

import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createServer, type ServerResponse } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
interface CapturedRequest {
index: number
url: string
headers: Record<string, string | string[] | undefined>
body: Record<string, unknown>
userTexts: string[]
latestText: string
completed: boolean
aborted: boolean
responseText?: string
}
interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
}
class AsyncQueue<T> implements AsyncIterable<T> {
private readonly values: T[] = []
private readonly waiters: Array<(result: IteratorResult<T>) => void> = []
private closed = false
push(value: T): void {
if (this.closed) throw new Error('input queue is closed')
const waiter = this.waiters.shift()
if (waiter) waiter({ value, done: false })
else this.values.push(value)
}
close(): void {
if (this.closed) return
this.closed = true
for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true })
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: () => {
const value = this.values.shift()
if (value !== undefined) return Promise.resolve({ value, done: false })
if (this.closed) return Promise.resolve({ value: undefined, done: true })
return new Promise<IteratorResult<T>>((resolve) => this.waiters.push(resolve))
}
}
}
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
return { promise, resolve }
}
function sse(res: ServerResponse, event: string, data: unknown): void {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
function messageStart(res: ServerResponse, id: string): void {
sse(res, 'message_start', {
type: 'message_start',
message: {
id,
type: 'message',
role: 'assistant',
model: 'claude-append-probe',
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 8, output_tokens: 1 }
}
})
}
function textBlock(res: ServerResponse, text: string): void {
sse(res, 'content_block_start', {
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' }
})
sse(res, 'content_block_delta', {
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text }
})
sse(res, 'content_block_stop', { type: 'content_block_stop', index: 0 })
}
function messageEnd(res: ServerResponse, stopReason = 'end_turn'): 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 plainText(res: ServerResponse, id: string, text: string): void {
messageStart(res, id)
textBlock(res, text)
messageEnd(res)
}
function contentTexts(body: Record<string, unknown>): string[] {
const messages = Array.isArray(body.messages) ? body.messages : []
const texts: string[] = []
for (const rawMessage of messages) {
if (!rawMessage || typeof rawMessage !== 'object') continue
const message = rawMessage as Record<string, unknown>
if (message.role !== 'user') continue
const content = message.content
if (typeof content === 'string') {
texts.push(content)
continue
}
if (!Array.isArray(content)) continue
for (const block of content) {
if (!block || typeof block !== 'object') continue
const item = block as Record<string, unknown>
if (item.type === 'text' && typeof item.text === 'string') texts.push(item.text)
}
}
return texts
}
function requestTrace(body: Record<string, unknown>): Array<Record<string, unknown>> {
const messages = Array.isArray(body.messages) ? body.messages : []
return messages.flatMap((rawMessage) => {
if (!rawMessage || typeof rawMessage !== 'object') return []
const message = rawMessage as Record<string, unknown>
const content = message.content
const parts = typeof content === 'string'
? [{ type: 'text', text: content }]
: Array.isArray(content)
? content.flatMap((block) => {
if (!block || typeof block !== 'object') return []
const item = block as Record<string, unknown>
return item.type === 'text' && typeof item.text === 'string'
? [{ type: 'text', text: item.text }]
: [{ type: String(item.type) }]
})
: []
const text = parts.flatMap((part) => 'text' in part ? [part.text] : []).join('')
if (!text.includes('PROBE_') && !text.includes('answer-')) return []
return [{ role: message.role, content: parts }]
})
}
function compactFrame(message: SDKMessage): Record<string, unknown> {
const value = message as unknown as Record<string, unknown>
const inner = value.message && typeof value.message === 'object'
? value.message as Record<string, unknown>
: undefined
return {
type: value.type,
subtype: value.subtype,
uuid: value.uuid,
prompt_id: value.prompt_id,
user_message_uuid: value.user_message_uuid,
user_message_uuids: value.user_message_uuids,
result_index: value.result_index,
queued_turn_count: value.queued_turn_count,
is_error: value.is_error,
result: value.result,
parent_tool_use_id: value.parent_tool_use_id,
message_id: inner?.id,
message_role: inner?.role,
message_stop_reason: inner?.stop_reason,
content_types: Array.isArray(inner?.content)
? inner.content.map((block) => block && typeof block === 'object'
? (block as Record<string, unknown>).type
: typeof block)
: undefined
}
}
function inputMessage(sessionId: string, uuid: string, text: string): SDKUserMessage {
return {
type: 'user',
message: { role: 'user', content: [{ type: 'text', text }] },
parent_tool_use_id: null,
uuid: uuid as SDKUserMessage['uuid'],
session_id: sessionId
}
}
function executablePath(): string {
const executable = process.platform === 'win32' ? 'claude.exe' : 'claude'
const packageName = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
const candidates = [
join(process.cwd(), 'node_modules', ...packageName.split('/'), executable),
join(process.cwd(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'vendor', executable)
]
const found = candidates.find((candidate) => existsSync(candidate))
if (!found) throw new Error(`Claude executable not found: ${candidates.join(', ')}`)
return found
}
function isolatedEnv(tempRoot: string, baseUrl: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: join(tempRoot, 'home'),
USERPROFILE: join(tempRoot, 'home'),
CLAUDE_CONFIG_DIR: join(tempRoot, 'claude-config'),
CLAUDE_SECURESTORAGE_CONFIG_DIR: join(tempRoot, 'claude-config'),
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_API_KEY: 'ycode-loopback-probe-key',
ANTHROPIC_MODEL: 'claude-append-probe',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-append-probe',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-append-probe',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-append-probe',
ANTHROPIC_SMALL_FAST_MODEL: 'claude-append-probe',
CLAUDE_CODE_MAX_CONTEXT_TOKENS: '200000',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
DISABLE_TELEMETRY: '1'
}
for (const name of [
'ANTHROPIC_AUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_ANTHROPIC_AWS',
'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
'CLAUDE_CODE_USE_MANTLE',
'CLAUDE_CODE_USE_GATEWAY'
]) delete env[name]
return env
}
async function waitFor<T>(description: string, predicate: () => T | undefined | false, timeoutMs = 20_000): Promise<T> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const value = predicate()
if (value) return value
await new Promise((resolve) => setTimeout(resolve, 20))
}
throw new Error(`timed out waiting for ${description}`)
}
const sdkPackage = JSON.parse(await readFile(
join(process.cwd(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'package.json'),
'utf8'
)) as { version?: string; claudeCodeVersion?: string }
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-claude-append-probe-'))
const workspace = join(tempRoot, 'workspace')
const outputPath = join(process.cwd(), '.tmp', 'claude-append-protocol-probe.json')
await mkdir(workspace, { recursive: true })
await mkdir(join(tempRoot, 'home'), { recursive: true })
await mkdir(join(tempRoot, 'claude-config'), { recursive: true })
await mkdir(join(process.cwd(), '.tmp'), { recursive: true })
await writeFile(join(workspace, 'README.md'), 'append protocol probe\n', 'utf8')
const requests: CapturedRequest[] = []
const runtimeMessages: SDKMessage[] = []
const stderr: string[] = []
const firstResponseGate = deferred<void>()
const processAbort = new AbortController()
const server = createServer((req, res) => {
let raw = ''
req.on('data', (chunk) => { raw += chunk })
req.on('end', async () => {
let body: Record<string, unknown> = {}
try { body = JSON.parse(raw || '{}') as Record<string, unknown> } catch { body = { raw } }
if (!req.url?.includes('/messages')) {
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{}')
return
}
const userTexts = contentTexts(body)
const captured: CapturedRequest = {
index: requests.length + 1,
url: req.url ?? '',
headers: req.headers,
body,
userTexts,
latestText: userTexts.at(-1) ?? '',
completed: false,
aborted: false
}
requests.push(captured)
res.on('close', () => {
if (!captured.completed) captured.aborted = true
})
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive'
})
const responseText = `answer-${captured.index}:${captured.userTexts.join('|')}`
captured.responseText = responseText
if (captured.index === 1) {
await firstResponseGate.promise
}
if (!res.destroyed) {
captured.completed = true
plainText(res, `msg_append_${captured.index}`, responseText)
}
})
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('loopback model did not bind')
const baseUrl = `http://127.0.0.1:${address.port}`
const sessionId = randomUUID()
const uuids = {
initial: randomUUID(),
appendA: randomUUID(),
appendB: randomUUID()
}
const input = new AsyncQueue<SDKUserMessage>()
let instance: ReturnType<typeof query> | null = null
let pumpError: unknown
let pump = Promise.resolve()
let appendsQueuedBeforeFirstResponse = false
try {
instance = query({
prompt: input,
options: {
cwd: workspace,
model: 'claude-append-probe',
pathToClaudeCodeExecutable: executablePath(),
env: isolatedEnv(tempRoot, baseUrl),
includePartialMessages: true,
abortController: processAbort,
permissionMode: 'default',
settingSources: [],
strictMcpConfig: true,
tools: [],
allowedTools: [],
plugins: [],
agents: {},
skills: [],
systemPrompt: { type: 'preset', preset: 'claude_code' },
sessionId,
maxTurns: 4,
stderr: (data) => {
stderr.push(data)
if (process.env.DEBUG_CLAUDE_APPEND_PROBE === '1') process.stderr.write(`[claude-probe] ${data}`)
}
}
})
const currentInstance = instance
pump = (async () => {
try {
for await (const message of currentInstance) runtimeMessages.push(message)
} catch (error) {
pumpError = error
}
})()
input.push(inputMessage(sessionId, uuids.initial, 'PROBE_INITIAL'))
await waitFor('first model request', () => requests[0])
// Hold request 1 so both appends are submitted while the turn is active.
input.push(inputMessage(sessionId, uuids.appendA, 'PROBE_APPEND_A'))
input.push(inputMessage(sessionId, uuids.appendB, 'PROBE_APPEND_B'))
appendsQueuedBeforeFirstResponse = true
await new Promise((resolve) => setTimeout(resolve, 250))
firstResponseGate.resolve()
await waitFor('all queued turns to settle', () => {
const results = runtimeMessages.filter((message) => message.type === 'result')
const last = results.at(-1) as unknown as Record<string, unknown> | undefined
return results.length >= 2 && last?.queued_turn_count === 0
}, 30_000)
await new Promise((resolve) => setTimeout(resolve, 300))
const results = runtimeMessages
.filter((message) => message.type === 'result')
.map((message) => message as unknown as Record<string, unknown>)
const assistants = runtimeMessages
.filter((message) => message.type === 'assistant')
.map((message) => message as unknown as Record<string, unknown>)
const userFrames = runtimeMessages
.filter((message) => message.type === 'user')
.map((message) => message as unknown as Record<string, unknown>)
const firstResult = results[0]
const finalResult = results.at(-1)
const firstResultUuids = Array.isArray(firstResult?.user_message_uuids)
? firstResult.user_message_uuids as string[]
: []
const finalResultUuids = Array.isArray(finalResult?.user_message_uuids)
? finalResult.user_message_uuids as string[]
: []
assert.equal(requests[0]?.aborted, false, 'the first model request must complete without cancellation')
assert.ok(requests.length >= 2, 'queued appends must produce another model request')
assert.ok(firstResult, 'the initial turn must emit a result')
assert.ok(finalResult, 'the queued append turn must emit a result')
assert.ok(firstResultUuids.includes(uuids.initial), 'the first result must be attributed to the initial UUID')
assert.ok(!firstResultUuids.includes(uuids.appendA), 'the first result must not claim append A')
assert.ok(!firstResultUuids.includes(uuids.appendB), 'the first result must not claim append B')
assert.ok(
finalResultUuids.includes(uuids.appendA) && finalResultUuids.includes(uuids.appendB),
'a later result must cover both queued appends'
)
assert.ok(results.length >= 2, 'one result must not stand for every queued send')
assert.equal(userFrames.length, 0, 'text sends must not be replayed as SDK user frames')
assert.equal(assistants.length, 2, 'each logical turn must emit one complete assistant frame')
const assistantUuidSets = assistants.map((assistant) => Array.isArray(assistant.user_message_uuids)
? assistant.user_message_uuids as string[]
: [])
assert.ok(!assistantUuidSets[0]?.includes(uuids.appendA), 'the first assistant must not claim append A')
assert.ok(
assistantUuidSets[1]?.includes(uuids.appendA) && assistantUuidSets[1]?.includes(uuids.appendB),
'the second assistant must cover both coalesced appends'
)
for (const [index, frame] of [...assistants, ...results].entries()) {
const frameUuids = Array.isArray(frame.user_message_uuids) ? frame.user_message_uuids as string[] : []
assert.equal(frameUuids.at(-1), frame.user_message_uuid, `frame ${index} must use the last member as user_message_uuid`)
}
assert.equal(pumpError, undefined, 'the SDK iterator must not fail during the probe')
const report = {
result: 'PASS',
sdkVersion: sdkPackage.version,
claudeCodeVersion: sdkPackage.claudeCodeVersion,
loopback: baseUrl,
activeAppend: {
appendsQueuedBeforeFirstResponse,
interruptCalled: false,
inputClosedBeforeResults: false
},
uuids,
requests: requests.map((request) => ({
index: request.index,
url: request.url,
completed: request.completed,
aborted: request.aborted,
latestText: request.latestText,
userTexts: request.userTexts
.filter((text) => text.includes('PROBE_') || text.includes('answer-'))
.slice(-8),
messages: requestTrace(request.body)
})),
results: results.map((result) => ({
subtype: result.subtype,
is_error: result.is_error,
result_index: result.result_index,
queued_turn_count: result.queued_turn_count,
user_message_uuid: result.user_message_uuid,
user_message_uuids: result.user_message_uuids,
prompt_id: result.prompt_id,
result: result.result
})),
assistantKeys: assistants.map((assistant) => Object.keys(assistant).sort()),
resultKeys: results.map((result) => Object.keys(result).sort()),
assistants: assistants.map((assistant) => ({
uuid: assistant.uuid,
user_message_uuid: assistant.user_message_uuid,
user_message_uuids: assistant.user_message_uuids,
prompt_id: assistant.prompt_id,
parent_tool_use_id: assistant.parent_tool_use_id,
message_id: (assistant.message as Record<string, unknown> | undefined)?.id
})),
userFrameCount: userFrames.length,
userFrames: userFrames.map((frame) => ({
uuid: frame.uuid,
user_message_uuid: frame.user_message_uuid,
user_message_uuids: frame.user_message_uuids,
prompt_id: frame.prompt_id,
message: frame.message
})),
promptIdObserved: runtimeMessages.some((message) => {
const value = message as unknown as Record<string, unknown>
return typeof value.prompt_id === 'string'
}),
assistantPromptIdObserved: assistants.some((assistant) => typeof assistant.prompt_id === 'string'),
frames: runtimeMessages.map(compactFrame),
stderr: stderr.slice(-20)
}
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
console.log(JSON.stringify({
result: report.result,
outputPath,
uuids,
activeAppend: report.activeAppend,
requests: report.requests,
results: report.results,
assistants: report.assistants,
userFrameCount: report.userFrameCount,
userFrames: report.userFrames,
assistantKeys: report.assistantKeys,
resultKeys: report.resultKeys,
promptIdObserved: report.promptIdObserved,
assistantPromptIdObserved: report.assistantPromptIdObserved
}, null, 2))
} catch (error) {
const diagnostic = {
result: 'FAIL',
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
pumpError: pumpError instanceof Error ? `${pumpError.name}: ${pumpError.message}` : pumpError,
uuids,
requests: requests.map((request) => ({
index: request.index,
completed: request.completed,
aborted: request.aborted,
userTexts: request.userTexts
})),
frames: runtimeMessages.map(compactFrame),
stderr: stderr.slice(-40)
}
await writeFile(outputPath, `${JSON.stringify(diagnostic, null, 2)}\n`, 'utf8')
console.error(JSON.stringify(diagnostic, null, 2))
throw error
} finally {
input.close()
processAbort.abort()
instance?.close()
await Promise.race([
pump,
new Promise((resolve) => setTimeout(resolve, 2000))
])
await new Promise<void>((resolve) => server.close(() => resolve()))
await rm(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }).catch(() => undefined)
}