711 lines
32 KiB
TypeScript
711 lines
32 KiB
TypeScript
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
import { createServer, type IncomingMessage } from 'node:http'
|
|
import { createInterface } from 'node:readline'
|
|
import { existsSync } from 'node:fs'
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { homedir, tmpdir } from 'node:os'
|
|
import { join, parse, relative, resolve, sep } from 'node:path'
|
|
import { randomUUID } from 'node:crypto'
|
|
|
|
interface Frame {
|
|
type?: string
|
|
id?: string
|
|
command?: string
|
|
success?: boolean
|
|
data?: unknown
|
|
error?: { message?: string }
|
|
[key: string]: unknown
|
|
}
|
|
|
|
interface ProbeRun {
|
|
child: ChildProcessWithoutNullStreams
|
|
frames: Frame[]
|
|
rawLines: string[]
|
|
stderrLines: string[]
|
|
}
|
|
|
|
interface MockRequest {
|
|
model?: string
|
|
stream: boolean
|
|
roles: string[]
|
|
toolNames: string[]
|
|
toolProperties: Record<string, string[]>
|
|
containsImage: boolean
|
|
messageShape: Array<Record<string, unknown>>
|
|
}
|
|
|
|
const STARTUP_WAIT_MS = 12_000
|
|
const COMMAND_WAIT_MS = 20_000
|
|
const SHUTDOWN_WAIT_MS = 1_500
|
|
const PROVIDER_ID = 'loopback-probe'
|
|
const MODEL_ID = 'probe-model'
|
|
const MODEL = `${PROVIDER_ID}/${MODEL_ID}`
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms))
|
|
}
|
|
|
|
function makeIsolatedEnvironment(home: string, tempRoot: string, profile: string): NodeJS.ProcessEnv {
|
|
const env: NodeJS.ProcessEnv = {}
|
|
for (const name of ['PATH', 'SystemRoot', 'SystemDrive', 'ComSpec', 'WINDIR']) {
|
|
const value = process.env[name]
|
|
if (value) env[name] = value
|
|
}
|
|
|
|
const root = parse(home).root
|
|
env.USERPROFILE = home
|
|
env.HOME = home
|
|
env.HOMEDRIVE = root.replace(/[\\/]$/, '')
|
|
env.HOMEPATH = relative(root, home).replaceAll('/', '\\')
|
|
env.APPDATA = join(home, 'AppData', 'Roaming')
|
|
env.LOCALAPPDATA = join(home, 'AppData', 'Local')
|
|
env.TEMP = tempRoot
|
|
env.TMP = tempRoot
|
|
env.OMP_PROFILE = profile
|
|
return env
|
|
}
|
|
|
|
function summarizeFrame(frame: Frame): Record<string, unknown> {
|
|
const data = frame.data
|
|
const dataKeys = data && typeof data === 'object' ? Object.keys(data as object) : []
|
|
const event = frame.assistantMessageEvent
|
|
const eventKeys = event && typeof event === 'object' ? Object.keys(event as object) : []
|
|
return {
|
|
type: frame.type,
|
|
frameKeys: Object.keys(frame),
|
|
id: frame.id,
|
|
command: frame.command,
|
|
success: frame.success,
|
|
status: frame.status,
|
|
assistantEventType: event && typeof event === 'object' ? (event as Record<string, unknown>).type : undefined,
|
|
toolCallId: frame.toolCallId,
|
|
toolName: frame.toolName,
|
|
argumentKeys: frame.arguments && typeof frame.arguments === 'object' ? Object.keys(frame.arguments as object) : [],
|
|
eventKeys,
|
|
dataKeys,
|
|
error: typeof frame.error === 'string' ? frame.error : frame.error?.message
|
|
}
|
|
}
|
|
|
|
async function waitForFrame(
|
|
run: ProbeRun,
|
|
predicate: (frame: Frame) => boolean,
|
|
timeoutMs = COMMAND_WAIT_MS,
|
|
startIndex = 0
|
|
): Promise<Frame | null> {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
const frame = run.frames.slice(startIndex).find(predicate)
|
|
if (frame) return frame
|
|
if (run.child.exitCode !== null || run.child.signalCode !== null) break
|
|
await delay(20)
|
|
}
|
|
return run.frames.slice(startIndex).find(predicate) ?? null
|
|
}
|
|
|
|
async function sendCommand(run: ProbeRun, id: string, command: Record<string, unknown>): Promise<Frame | null> {
|
|
run.child.stdin.write(`${JSON.stringify({ id, ...command })}\n`)
|
|
return waitForFrame(run, (frame) => frame.id === id)
|
|
}
|
|
|
|
async function sendLooseCommand(run: ProbeRun, id: string, command: Record<string, unknown>): Promise<Frame | null> {
|
|
const startIndex = run.frames.length
|
|
run.child.stdin.write(`${JSON.stringify({ id, ...command })}\n`)
|
|
return waitForFrame(run, (frame) => frame.id === id || frame.type === 'response', 3_000, startIndex)
|
|
}
|
|
|
|
async function installProbeHostTool(run: ProbeRun, suffix: string): Promise<Frame | null> {
|
|
return sendCommand(run, `probe-host-tools-${suffix}`, {
|
|
type: 'set_host_tools',
|
|
tools: [{
|
|
name: 'read',
|
|
label: 'Read local probe file',
|
|
description: 'Read a file from the isolated protocol probe directory.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: { path: { type: 'string' } },
|
|
required: ['path'],
|
|
additionalProperties: false
|
|
}
|
|
}]
|
|
})
|
|
}
|
|
|
|
async function stopRun(run: ProbeRun): Promise<void> {
|
|
try {
|
|
run.child.stdin.end()
|
|
} catch {
|
|
// The sidecar may already have exited.
|
|
}
|
|
|
|
await new Promise<void>((resolveExit) => {
|
|
if (run.child.exitCode !== null || run.child.signalCode !== null) {
|
|
resolveExit()
|
|
return
|
|
}
|
|
let settled = false
|
|
const finish = (): void => {
|
|
if (settled) return
|
|
settled = true
|
|
clearTimeout(timer)
|
|
resolveExit()
|
|
}
|
|
const timer = setTimeout(finish, SHUTDOWN_WAIT_MS)
|
|
run.child.once('exit', finish)
|
|
})
|
|
|
|
if (run.child.exitCode === null && run.child.signalCode === null) {
|
|
run.child.kill()
|
|
await new Promise<void>((resolveExit) => {
|
|
if (run.child.exitCode !== null || run.child.signalCode !== null) resolveExit()
|
|
else run.child.once('exit', () => resolveExit())
|
|
})
|
|
}
|
|
}
|
|
|
|
async function startRun(
|
|
exePath: string,
|
|
tempRoot: string,
|
|
profile: string,
|
|
home: string,
|
|
sessionDir: string,
|
|
sessionPath: string
|
|
): Promise<ProbeRun> {
|
|
const args = [
|
|
'--mode', 'rpc',
|
|
'--profile', profile,
|
|
'--cwd', tempRoot,
|
|
`--model=${MODEL}`,
|
|
'--no-tools',
|
|
`--session-dir=${sessionDir}`,
|
|
`--resume=${sessionPath}`,
|
|
'--no-ui',
|
|
'--no-title',
|
|
'--no-extensions',
|
|
'--no-skills',
|
|
'--no-rules',
|
|
'--no-lsp'
|
|
]
|
|
const child = spawn(exePath, args, {
|
|
cwd: tempRoot,
|
|
env: makeIsolatedEnvironment(home, tempRoot, profile),
|
|
windowsHide: true,
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
})
|
|
const run: ProbeRun = { child, frames: [], rawLines: [], stderrLines: [] }
|
|
const stdout = createInterface({ input: child.stdout })
|
|
const stderr = createInterface({ input: child.stderr })
|
|
stdout.on('line', (line) => {
|
|
run.rawLines.push(line)
|
|
try {
|
|
run.frames.push(JSON.parse(line) as Frame)
|
|
} catch {
|
|
// Keep non-JSON stdout separate from RPC frames.
|
|
}
|
|
})
|
|
stderr.on('line', (line) => run.stderrLines.push(line))
|
|
const ready = await waitForFrame(run, (frame) => frame.type === 'ready', STARTUP_WAIT_MS)
|
|
if (!ready) {
|
|
const stderrText = run.stderrLines.join('\n').slice(0, 1000)
|
|
await stopRun(run)
|
|
throw new Error(`OMP did not send ready; exit=${child.exitCode}; stderr=${stderrText}`)
|
|
}
|
|
return run
|
|
}
|
|
|
|
async function readRequest(request: IncomingMessage): Promise<Record<string, unknown>> {
|
|
const chunks: Buffer[] = []
|
|
for await (const chunk of request) chunks.push(Buffer.from(chunk))
|
|
try {
|
|
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as Record<string, unknown>
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
function toolNamesFrom(body: Record<string, unknown>): string[] {
|
|
if (!Array.isArray(body.tools)) return []
|
|
return body.tools.flatMap((tool) => {
|
|
if (!tool || typeof tool !== 'object') return []
|
|
const item = tool as Record<string, unknown>
|
|
const fn = item.function && typeof item.function === 'object' ? item.function as Record<string, unknown> : null
|
|
const name = fn?.name ?? item.name
|
|
return typeof name === 'string' ? [name] : []
|
|
})
|
|
}
|
|
|
|
function requestHasImage(body: Record<string, unknown>): boolean {
|
|
if (!Array.isArray(body.messages)) return false
|
|
return body.messages.some((message) => {
|
|
if (!message || typeof message !== 'object') return false
|
|
const content = (message as Record<string, unknown>).content
|
|
return Array.isArray(content) && content.some((part) => {
|
|
if (!part || typeof part !== 'object') return false
|
|
const item = part as Record<string, unknown>
|
|
return item.type === 'image_url' || item.type === 'image'
|
|
})
|
|
})
|
|
}
|
|
|
|
async function startMockProvider(probeFile: string): Promise<{
|
|
server: ReturnType<typeof createServer>
|
|
url: string
|
|
requests: MockRequest[]
|
|
toolCallSent: boolean
|
|
}> {
|
|
const requests: MockRequest[] = []
|
|
let toolCallSent = false
|
|
const server = createServer(async (request, response) => {
|
|
const body = await readRequest(request)
|
|
const names = toolNamesFrom(body)
|
|
const messages = Array.isArray(body.messages) ? body.messages as Array<Record<string, unknown>> : []
|
|
const latestUser = [...messages].reverse().find((message) => message.role === 'user')
|
|
const latestUserContent = Array.isArray(latestUser?.content)
|
|
? (latestUser.content as Array<Record<string, unknown>>).map((block) => block.text).filter((value) => typeof value === 'string').join('\n')
|
|
: typeof latestUser?.content === 'string' ? latestUser.content : ''
|
|
const toolProperties: Record<string, string[]> = {}
|
|
for (const tool of Array.isArray(body.tools) ? body.tools : []) {
|
|
if (!tool || typeof tool !== 'object') continue
|
|
const item = tool as Record<string, unknown>
|
|
const fn = item.function && typeof item.function === 'object' ? item.function as Record<string, unknown> : null
|
|
const name = fn?.name ?? item.name
|
|
const parameters = fn?.parameters && typeof fn.parameters === 'object' ? fn.parameters as Record<string, unknown> : null
|
|
const properties = parameters?.properties && typeof parameters.properties === 'object'
|
|
? parameters.properties as Record<string, unknown> : null
|
|
if (typeof name === 'string') toolProperties[name] = Object.keys(properties ?? {})
|
|
}
|
|
requests.push({
|
|
model: typeof body.model === 'string' ? body.model : undefined,
|
|
stream: body.stream === true,
|
|
roles: messages.map((message) => typeof message.role === 'string' ? message.role : '?'),
|
|
toolNames: names,
|
|
toolProperties,
|
|
containsImage: requestHasImage(body),
|
|
messageShape: messages.map((message) => {
|
|
const content = message.content
|
|
const blocks = Array.isArray(content) ? content as Array<Record<string, unknown>> : []
|
|
const calls = Array.isArray(message.tool_calls) ? message.tool_calls as Array<Record<string, unknown>> : []
|
|
return {
|
|
role: message.role,
|
|
contentType: typeof content === 'string' ? 'string' : Array.isArray(content) ? 'array' : typeof content,
|
|
contentBlockTypes: blocks.map((block) => block.type),
|
|
toolCallCount: calls.length,
|
|
hasToolCallId: typeof message.tool_call_id === 'string'
|
|
}
|
|
})
|
|
})
|
|
|
|
const readToolName = names.find((name) => name === 'read' || name.endsWith('__read'))
|
|
const sendReadTool = latestUserContent.includes('TOOL_PROBE') && readToolName && !toolCallSent
|
|
const completion = sendReadTool
|
|
? {
|
|
message: {
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [{
|
|
id: 'call_local_read_1',
|
|
type: 'function',
|
|
function: { name: readToolName, arguments: JSON.stringify({ path: probeFile }) }
|
|
}]
|
|
},
|
|
finish_reason: 'tool_calls'
|
|
}
|
|
: {
|
|
message: { role: 'assistant', content: 'PROBE_OK' },
|
|
finish_reason: 'stop'
|
|
}
|
|
if (sendReadTool) toolCallSent = true
|
|
if (body.stream === true) {
|
|
response.writeHead(200, {
|
|
'content-type': 'text/event-stream',
|
|
'cache-control': 'no-cache',
|
|
connection: 'keep-alive'
|
|
})
|
|
const completionId = `chatcmpl-local-${requests.length}`
|
|
const writeChunk = (delta: Record<string, unknown>, finishReason: string | null = null): void => {
|
|
response.write(`data: ${JSON.stringify({
|
|
id: completionId,
|
|
object: 'chat.completion.chunk',
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: MODEL_ID,
|
|
choices: [{ index: 0, delta, finish_reason: finishReason }]
|
|
})}\n\n`)
|
|
}
|
|
writeChunk({ role: 'assistant' })
|
|
if (sendReadTool) {
|
|
writeChunk({ tool_calls: [{
|
|
index: 0,
|
|
id: 'call_local_read_1',
|
|
type: 'function',
|
|
function: { name: readToolName, arguments: JSON.stringify({ path: probeFile }) }
|
|
}] })
|
|
writeChunk({}, 'tool_calls')
|
|
} else {
|
|
writeChunk({ reasoning_content: 'LOCAL_THINKING_PROBE' })
|
|
writeChunk({ content: 'PROBE_OK' })
|
|
writeChunk({}, 'stop')
|
|
}
|
|
response.write(`data: ${JSON.stringify({
|
|
id: completionId,
|
|
object: 'chat.completion.chunk',
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: MODEL_ID,
|
|
choices: [],
|
|
usage: { prompt_tokens: 17, completion_tokens: 4, total_tokens: 21 }
|
|
})}\n\n`)
|
|
response.end('data: [DONE]\n\n')
|
|
return
|
|
}
|
|
response.writeHead(200, { 'content-type': 'application/json' })
|
|
response.end(JSON.stringify({
|
|
id: `chatcmpl-local-${requests.length}`,
|
|
object: 'chat.completion',
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: MODEL_ID,
|
|
choices: [{ index: 0, ...completion }],
|
|
usage: { prompt_tokens: 17, completion_tokens: 4, total_tokens: 21 }
|
|
}))
|
|
})
|
|
await new Promise<void>((resolveListen, reject) => {
|
|
server.once('error', reject)
|
|
server.listen(0, '127.0.0.1', () => {
|
|
server.off('error', reject)
|
|
resolveListen()
|
|
})
|
|
})
|
|
const address = server.address()
|
|
if (!address || typeof address === 'string') throw new Error('Loopback mock did not bind to a TCP port')
|
|
return { server, url: `http://127.0.0.1:${address.port}/v1`, requests, get toolCallSent() { return toolCallSent } }
|
|
}
|
|
|
|
function summarizeMessages(data: unknown): Record<string, unknown> {
|
|
const root = data && typeof data === 'object' ? data as Record<string, unknown> : {}
|
|
const possibleArrays = [root.messages, root.entries, root.branchMessages]
|
|
const records = possibleArrays.find(Array.isArray) as unknown[] | undefined
|
|
const items = (records ?? (Array.isArray(data) ? data : [])).map((value) => {
|
|
if (!value || typeof value !== 'object') return { valueType: typeof value }
|
|
const item = value as Record<string, unknown>
|
|
const message = item.message && typeof item.message === 'object' ? item.message as Record<string, unknown> : item
|
|
const content = Array.isArray(message.content) ? message.content as Array<Record<string, unknown>> : []
|
|
const toolCalls = Array.isArray(message.toolCalls) ? message.toolCalls as Array<Record<string, unknown>>
|
|
: Array.isArray(message.tool_calls) ? message.tool_calls as Array<Record<string, unknown>> : []
|
|
return {
|
|
itemKeys: Object.keys(item),
|
|
messageKeys: Object.keys(message),
|
|
type: item.type,
|
|
role: message.role,
|
|
entryId: item.entryId ?? item.id,
|
|
parentId: item.parentId,
|
|
textBlocks: content.filter((block) => block.type === 'text').length,
|
|
toolCalls: toolCalls.map((call) => call.name ?? (call.function as Record<string, unknown> | undefined)?.name)
|
|
}
|
|
})
|
|
return { keys: Object.keys(root), count: items.length, messages: items }
|
|
}
|
|
|
|
async function inspectTranscript(path: string): Promise<Record<string, unknown>> {
|
|
try {
|
|
const text = await readFile(path, 'utf8')
|
|
const records = text.split(/\r?\n/).filter(Boolean).map((record) => JSON.parse(record) as Record<string, unknown>)
|
|
return {
|
|
exists: true,
|
|
records: records.length,
|
|
entries: records.map((record) => ({
|
|
type: record.type,
|
|
id: record.id,
|
|
parentId: record.parentId,
|
|
messageRole: (record.message as Record<string, unknown> | undefined)?.role,
|
|
toolName: (record.toolName ?? (record.message as Record<string, unknown> | undefined)?.toolName) ?? undefined
|
|
}))
|
|
}
|
|
} catch {
|
|
return { exists: false }
|
|
}
|
|
}
|
|
|
|
function summarizeTree(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(summarizeTree)
|
|
if (!value || typeof value !== 'object') return value
|
|
const node = value as Record<string, unknown>
|
|
const entry = node.entry && typeof node.entry === 'object' ? node.entry as Record<string, unknown> : node
|
|
const message = entry.message && typeof entry.message === 'object' ? entry.message as Record<string, unknown> : null
|
|
return {
|
|
nodeKeys: Object.keys(node),
|
|
entryKeys: Object.keys(entry),
|
|
id: entry.id,
|
|
parentId: entry.parentId,
|
|
type: entry.type,
|
|
role: message?.role,
|
|
label: node.label,
|
|
children: Array.isArray(node.children) ? summarizeTree(node.children) : []
|
|
}
|
|
}
|
|
|
|
function findUserNode(value: unknown): { id: string; parentId: string | null } | null {
|
|
if (Array.isArray(value)) {
|
|
let latest: { id: string; parentId: string | null } | null = null
|
|
for (const child of value) {
|
|
const found = findUserNode(child)
|
|
if (found) latest = found
|
|
}
|
|
return latest
|
|
}
|
|
if (!value || typeof value !== 'object') return null
|
|
const node = value as Record<string, unknown>
|
|
const entry = node.entry && typeof node.entry === 'object' ? node.entry as Record<string, unknown> : node
|
|
const message = entry.message && typeof entry.message === 'object' ? entry.message as Record<string, unknown> : null
|
|
let latest: { id: string; parentId: string | null } | null = null
|
|
if (entry.type === 'message' && message?.role === 'user' && typeof entry.id === 'string') {
|
|
latest = { id: entry.id, parentId: typeof entry.parentId === 'string' ? entry.parentId : null }
|
|
}
|
|
const child = Array.isArray(node.children) ? findUserNode(node.children) : null
|
|
return child ?? latest
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const exePath = process.env.YCODE_OMP_PATH ?? 'C:\\Users\\Administrator\\AppData\\Local\\Ygent\\omp.exe'
|
|
if (!existsSync(exePath)) throw new Error(`OMP executable not found: ${exePath}`)
|
|
|
|
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-omp-protocol-'))
|
|
const home = join(tempRoot, 'isolated-home')
|
|
const sessionDir = join(tempRoot, 'sessions')
|
|
const profile = `protocol-probe-${randomUUID().replaceAll('-', '')}`
|
|
const sessionPath = join(sessionDir, 'probe.jsonl')
|
|
const probeFile = join(tempRoot, 'probe.txt')
|
|
const realProfilePath = resolve(homedir(), '.omp', 'profiles', profile)
|
|
const realProfileRoot = resolve(homedir(), '.omp', 'profiles')
|
|
const realProfileRelative = relative(realProfileRoot, realProfilePath)
|
|
if (!realProfileRelative || realProfileRelative.startsWith(`..${sep}`) || resolve(realProfileRoot, realProfileRelative) !== realProfilePath) {
|
|
throw new Error('Refusing an invalid isolated profile path')
|
|
}
|
|
if (existsSync(realProfilePath)) throw new Error('The unique probe profile unexpectedly exists')
|
|
|
|
await mkdir(home, { recursive: true })
|
|
await mkdir(sessionDir, { recursive: true })
|
|
await writeFile(probeFile, 'LOCAL_OMP_TOOL_SENTINEL\n', 'utf8')
|
|
const profileAgentDir = join(home, '.omp', 'profiles', profile, 'agent')
|
|
await mkdir(profileAgentDir, { recursive: true })
|
|
const mock = await startMockProvider(probeFile)
|
|
await writeFile(join(profileAgentDir, 'models.yml'), [
|
|
'providers:',
|
|
` ${PROVIDER_ID}:`,
|
|
` baseUrl: ${mock.url}`,
|
|
' api: openai-completions',
|
|
' auth: none',
|
|
' models:',
|
|
` - id: ${MODEL_ID}`,
|
|
' name: Local protocol probe',
|
|
' reasoning: true',
|
|
' input: [text, image]',
|
|
' contextWindow: 64000',
|
|
' maxTokens: 4096',
|
|
''
|
|
].join('\n'), 'utf8')
|
|
|
|
const runs: ProbeRun[] = []
|
|
try {
|
|
const version = spawnSync(exePath, ['--version'], {
|
|
encoding: 'utf8',
|
|
windowsHide: true,
|
|
env: makeIsolatedEnvironment(home, tempRoot, profile)
|
|
})
|
|
if (version.error) throw version.error
|
|
if (version.status !== 0) throw new Error(`OMP --version failed with exit ${version.status}`)
|
|
console.log(`OMP_VERSION: ${version.stdout.trim()}`)
|
|
console.log(`ISOLATED_PROFILE: ${profile}`)
|
|
console.log('PROVIDER_NETWORK: loopback only')
|
|
|
|
const first = await startRun(exePath, tempRoot, profile, home, sessionDir, sessionPath)
|
|
runs.push(first)
|
|
const hostToolsFirst = await installProbeHostTool(first, '1')
|
|
const stateResponse = await sendCommand(first, 'probe-state-1', { type: 'get_state' })
|
|
console.log(`FIRST_READY: true`)
|
|
console.log(`SET_HOST_TOOLS: ${JSON.stringify(hostToolsFirst ? summarizeFrame(hostToolsFirst) : null)}`)
|
|
console.log(`GET_STATE: ${JSON.stringify(stateResponse ? summarizeFrame(stateResponse) : null)}`)
|
|
|
|
const image = {
|
|
type: 'image' as const,
|
|
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/dXEAAAAASUVORK5CYII=',
|
|
mimeType: 'image/png'
|
|
}
|
|
first.child.stdin.write(`${JSON.stringify({
|
|
id: 'probe-prompt-1',
|
|
type: 'prompt',
|
|
message: 'Reply exactly PROBE_OK.',
|
|
images: [image]
|
|
})}\n`)
|
|
const promptAck = await waitForFrame(first, (frame) => frame.id === 'probe-prompt-1')
|
|
const turnDone = await waitForFrame(first, (frame) =>
|
|
frame.type === 'agent_end' || (frame.type === 'prompt_result' && frame.agentInvoked === false), 60_000)
|
|
const toolTurnStart = first.frames.length
|
|
first.child.stdin.write(`${JSON.stringify({
|
|
id: 'probe-prompt-tool',
|
|
type: 'prompt',
|
|
message: 'TOOL_PROBE: call the read tool for probe.txt, then reply exactly PROBE_OK.'
|
|
})}\n`)
|
|
const toolPromptAck = await waitForFrame(first, (frame) => frame.id === 'probe-prompt-tool')
|
|
const hostToolCall = await waitForFrame(first, (frame) => frame.type === 'host_tool_call', 20_000, toolTurnStart)
|
|
let hostToolResponseSent = false
|
|
if (hostToolCall && typeof hostToolCall.id === 'string') {
|
|
const args = hostToolCall.arguments && typeof hostToolCall.arguments === 'object'
|
|
? hostToolCall.arguments as Record<string, unknown> : {}
|
|
const targetPath = typeof args.path === 'string' ? args.path : probeFile
|
|
const content = await readFile(targetPath, 'utf8')
|
|
first.child.stdin.write(`${JSON.stringify({
|
|
type: 'host_tool_result',
|
|
id: hostToolCall.id,
|
|
result: { content: [{ type: 'text', text: content }] }
|
|
})}\n`)
|
|
hostToolResponseSent = true
|
|
}
|
|
const toolTurnDone = await waitForFrame(first, (frame) =>
|
|
frame.type === 'agent_end' || (frame.type === 'prompt_result' && frame.agentInvoked === false), 60_000, toolTurnStart)
|
|
const getMessages = await sendCommand(first, 'probe-messages-1', { type: 'get_messages' })
|
|
const tree = await sendCommand(first, 'probe-tree-1', { type: 'get_tree' })
|
|
const branchMessages = await sendCommand(first, 'probe-branch-messages-1', { type: 'get_branch_messages' })
|
|
const stateAfterTurn = await sendCommand(first, 'probe-state-after-turn', { type: 'get_state' })
|
|
|
|
const entries = getMessages ? summarizeMessages(getMessages.data) : null
|
|
const treeData = tree?.data && typeof tree.data === 'object' ? tree.data as Record<string, unknown> : {}
|
|
const treeShape = summarizeTree(treeData.tree)
|
|
const firstUserNode = findUserNode(treeData.tree)
|
|
const fork = firstUserNode
|
|
? await sendCommand(first, 'probe-fork-before-branch', { type: 'fork', entryId: firstUserNode.id })
|
|
: null
|
|
const stateAfterFork = await sendCommand(first, 'probe-state-after-fork', { type: 'get_state' })
|
|
const forkStateData = stateAfterFork?.data && typeof stateAfterFork.data === 'object'
|
|
? stateAfterFork.data as Record<string, unknown> : {}
|
|
const forkSessionPath = typeof forkStateData.sessionFile === 'string' ? forkStateData.sessionFile : ''
|
|
const relativeForkSession = forkSessionPath ? relative(tempRoot, resolve(forkSessionPath)) : ''
|
|
const forkSessionInProbe = !!relativeForkSession && relativeForkSession !== '..' &&
|
|
!relativeForkSession.startsWith(`..${sep}`) && !relativeForkSession.startsWith(sep)
|
|
const messagesAfterFork = await sendCommand(first, 'probe-messages-after-fork', { type: 'get_messages' })
|
|
const treeAfterFork = await sendCommand(first, 'probe-tree-after-fork', { type: 'get_tree' })
|
|
const treeAfterForkData = treeAfterFork?.data && typeof treeAfterFork.data === 'object'
|
|
? treeAfterFork.data as Record<string, unknown> : {}
|
|
const forkData = fork?.data && typeof fork.data === 'object' ? fork.data as Record<string, unknown> : {}
|
|
const stateBeforeForkData = stateAfterTurn?.data && typeof stateAfterTurn.data === 'object'
|
|
? stateAfterTurn.data as Record<string, unknown> : {}
|
|
|
|
console.log(`PROMPT_ACK: ${JSON.stringify(promptAck ? summarizeFrame(promptAck) : null)}`)
|
|
console.log(`FIRST_TURN_END_FRAME: ${JSON.stringify(turnDone ? summarizeFrame(turnDone) : null)}`)
|
|
console.log(`TOOL_PROMPT_ACK: ${JSON.stringify(toolPromptAck ? summarizeFrame(toolPromptAck) : null)}`)
|
|
console.log(`HOST_TOOL_CALL: ${JSON.stringify(hostToolCall ? summarizeFrame(hostToolCall) : null)}`)
|
|
console.log(`HOST_TOOL_RESPONSE_SENT: ${hostToolResponseSent}`)
|
|
console.log(`TOOL_TURN_END_FRAME: ${JSON.stringify(toolTurnDone ? summarizeFrame(toolTurnDone) : null)}`)
|
|
console.log(`TURN_EVENTS: ${JSON.stringify(first.frames.filter((frame) => [
|
|
'agent_start', 'message_start', 'message_update', 'message_end', 'tool_execution_start',
|
|
'tool_execution_update', 'tool_execution_end', 'host_tool_call', 'turn_start', 'turn_end', 'agent_end', 'prompt_result'
|
|
].includes(frame.type ?? '')).map(summarizeFrame))}`)
|
|
console.log(`GET_MESSAGES_RESPONSE: ${JSON.stringify(getMessages ? {
|
|
...summarizeFrame(getMessages),
|
|
dataSummary: entries
|
|
} : null)}`)
|
|
console.log(`GET_TREE_RESPONSE: ${JSON.stringify(tree ? {
|
|
...summarizeFrame(tree), leafId: treeData.leafId, tree: treeShape
|
|
} : null)}`)
|
|
console.log(`GET_BRANCH_MESSAGES_RESPONSE: ${JSON.stringify(branchMessages ? {
|
|
...summarizeFrame(branchMessages), dataSummary: summarizeMessages(branchMessages.data)
|
|
} : null)}`)
|
|
console.log(`FORK_ENTRY_ID: ${JSON.stringify(firstUserNode?.id ?? null)}`)
|
|
console.log(`FORK_RESPONSE: ${JSON.stringify(fork ? summarizeFrame(fork) : null)}`)
|
|
console.log(`FORK_CANCELLED: ${forkData.cancelled}`)
|
|
console.log(`FORK_STATE: ${JSON.stringify(stateAfterFork ? summarizeFrame(stateAfterFork) : null)}`)
|
|
console.log(`FORK_SESSION_ID_CHANGED: ${stateBeforeForkData.sessionId !== forkStateData.sessionId}`)
|
|
console.log(`FORK_SESSION_FILE_IS_PROBE_PATH: ${forkSessionInProbe}`)
|
|
console.log(`FORK_SESSION_FILE_EXISTS: ${forkSessionInProbe ? existsSync(forkSessionPath) : false}`)
|
|
console.log(`FORK_MESSAGES: ${JSON.stringify(messagesAfterFork ? summarizeMessages(messagesAfterFork.data) : null)}`)
|
|
console.log(`FORK_TREE_LEAF_ID: ${treeAfterForkData.leafId ?? null}`)
|
|
console.log(`TRANSCRIPT_AFTER_FORK: ${JSON.stringify(await inspectTranscript(sessionPath))}`)
|
|
console.log(`STATE_AFTER_TURN: ${JSON.stringify(stateAfterTurn ? summarizeFrame(stateAfterTurn) : null)}`)
|
|
console.log(`LOOPBACK_REQUESTS: ${JSON.stringify(mock.requests)}`)
|
|
console.log(`TOOL_CALL_SENT: ${mock.toolCallSent}`)
|
|
console.log(`TRANSCRIPT_AFTER_TURN: ${JSON.stringify(await inspectTranscript(sessionPath))}`)
|
|
|
|
await stopRun(first)
|
|
const second = await startRun(exePath, tempRoot, profile, home, sessionDir, sessionPath)
|
|
runs.push(second)
|
|
const hostToolsSecond = await installProbeHostTool(second, '2')
|
|
const resumedState = await sendCommand(second, 'probe-state-2', { type: 'get_state' })
|
|
const resumedMessages = await sendCommand(second, 'probe-messages-2', { type: 'get_messages' })
|
|
console.log(`RESUME_READY: true`)
|
|
console.log(`RESUME_SET_HOST_TOOLS: ${JSON.stringify(hostToolsSecond ? summarizeFrame(hostToolsSecond) : null)}`)
|
|
console.log(`RESUME_GET_STATE: ${JSON.stringify(resumedState ? summarizeFrame(resumedState) : null)}`)
|
|
console.log(`RESUME_GET_MESSAGES: ${JSON.stringify(resumedMessages ? {
|
|
...summarizeFrame(resumedMessages),
|
|
dataSummary: summarizeMessages(resumedMessages.data)
|
|
} : null)}`)
|
|
console.log(`RESUMED_TRANSCRIPT: ${JSON.stringify(await inspectTranscript(sessionPath))}`)
|
|
let currentRun: ProbeRun = second
|
|
|
|
const resumeTree = await sendCommand(second, 'probe-tree-2', { type: 'get_tree' })
|
|
const resumeTreeData = resumeTree?.data && typeof resumeTree.data === 'object' ? resumeTree.data as Record<string, unknown> : {}
|
|
const resumeUserNode = findUserNode(resumeTreeData.tree)
|
|
console.log(`RESUME_TREE: ${JSON.stringify(resumeTree ? {
|
|
...summarizeFrame(resumeTree), leafId: resumeTreeData.leafId, tree: summarizeTree(resumeTreeData.tree)
|
|
} : null)}`)
|
|
|
|
if (resumeUserNode) {
|
|
const branch = await sendCommand(second, 'probe-branch-1', { type: 'branch', entryId: resumeUserNode.id })
|
|
const branchState = await sendCommand(second, 'probe-state-after-branch', { type: 'get_state' })
|
|
const branchHistory = await sendCommand(second, 'probe-messages-after-branch', { type: 'get_messages' })
|
|
const branchTree = await sendCommand(second, 'probe-tree-after-branch', { type: 'get_tree' })
|
|
const branchTreeData = branchTree?.data && typeof branchTree.data === 'object' ? branchTree.data as Record<string, unknown> : {}
|
|
console.log(`BRANCH_ENTRY_ID: ${JSON.stringify(resumeUserNode.id)}`)
|
|
console.log(`BRANCH_RESPONSE: ${JSON.stringify(branch ? summarizeFrame(branch) : null)}`)
|
|
console.log(`BRANCH_STATE: ${JSON.stringify(branchState ? summarizeFrame(branchState) : null)}`)
|
|
console.log(`BRANCH_MESSAGES: ${JSON.stringify(branchHistory ? {
|
|
...summarizeFrame(branchHistory),
|
|
dataSummary: summarizeMessages(branchHistory.data)
|
|
} : null)}`)
|
|
console.log(`BRANCH_TREE: ${JSON.stringify(branchTree ? {
|
|
...summarizeFrame(branchTree), leafId: branchTreeData.leafId, tree: summarizeTree(branchTreeData.tree)
|
|
} : null)}`)
|
|
if (branch?.success === true) {
|
|
await stopRun(second)
|
|
const branchResume = await startRun(exePath, tempRoot, profile, home, sessionDir, sessionPath)
|
|
runs.push(branchResume)
|
|
currentRun = branchResume
|
|
const resumeHostTools = await installProbeHostTool(branchResume, 'after-branch')
|
|
const messagesAfterBranchResume = await sendCommand(branchResume, 'probe-messages-after-branch-resume', { type: 'get_messages' })
|
|
const treeAfterBranchResume = await sendCommand(branchResume, 'probe-tree-after-branch-resume', { type: 'get_tree' })
|
|
const treeAfterBranchResumeData = treeAfterBranchResume?.data && typeof treeAfterBranchResume.data === 'object'
|
|
? treeAfterBranchResume.data as Record<string, unknown> : {}
|
|
console.log(`BRANCH_RESUME_READY: true`)
|
|
console.log(`BRANCH_RESUME_SET_HOST_TOOLS: ${JSON.stringify(resumeHostTools ? summarizeFrame(resumeHostTools) : null)}`)
|
|
console.log(`BRANCH_RESUME_MESSAGES: ${JSON.stringify(messagesAfterBranchResume ? summarizeMessages(messagesAfterBranchResume.data) : null)}`)
|
|
console.log(`BRANCH_RESUME_TREE_LEAF_ID: ${treeAfterBranchResumeData.leafId ?? null}`)
|
|
console.log(`TRANSCRIPT_AFTER_BRANCH_WITHOUT_PROMPT: ${JSON.stringify(await inspectTranscript(sessionPath))}`)
|
|
}
|
|
|
|
const navigateTree = await sendLooseCommand(currentRun, 'probe-navigate-tree', {
|
|
type: 'navigate_tree', entryId: resumeUserNode.id
|
|
})
|
|
const navigateToEntry = await sendLooseCommand(currentRun, 'probe-navigate-to-entry', {
|
|
type: 'navigate_to_entry', entryId: resumeUserNode.id
|
|
})
|
|
const switchSession = await sendCommand(currentRun, 'probe-switch-session', {
|
|
type: 'switch_session', sessionPath
|
|
})
|
|
const clear = await sendLooseCommand(currentRun, 'probe-clear', { type: 'clear' })
|
|
console.log(`NAVIGATE_TREE_RESPONSE: ${JSON.stringify(navigateTree ? summarizeFrame(navigateTree) : null)}`)
|
|
console.log(`NAVIGATE_TO_ENTRY_RESPONSE: ${JSON.stringify(navigateToEntry ? summarizeFrame(navigateToEntry) : null)}`)
|
|
console.log(`SWITCH_SESSION_SAME_PATH: ${JSON.stringify(switchSession ? summarizeFrame(switchSession) : null)}`)
|
|
console.log(`CLEAR_RESPONSE: ${JSON.stringify(clear ? summarizeFrame(clear) : null)}`)
|
|
} else {
|
|
console.log('BRANCH_ENTRY_ID: unavailable from get_tree parent relation')
|
|
}
|
|
await stopRun(currentRun)
|
|
} finally {
|
|
for (const run of runs) await stopRun(run)
|
|
await new Promise<void>((resolveClose) => mock.server.close(() => resolveClose()))
|
|
await rm(tempRoot, { recursive: true, force: true })
|
|
if (existsSync(realProfilePath)) await rm(realProfilePath, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
main().catch((error: unknown) => {
|
|
console.error('OMP protocol probe failed:', error)
|
|
process.exitCode = 1
|
|
})
|