From 86884bcaf26426219e33c468ec69426342f6109f Mon Sep 17 00:00:00 2001 From: mehmet turac Date: Sun, 31 May 2026 14:40:53 +0300 Subject: [PATCH] fix: preserve nested ACP startup frames --- apps/daemon/src/acp.ts | 94 +++++++++++++++++++++++++++++++---- apps/daemon/tests/acp.test.ts | 2 +- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/apps/daemon/src/acp.ts b/apps/daemon/src/acp.ts index e376e4ef2..705de8c72 100644 --- a/apps/daemon/src/acp.ts +++ b/apps/daemon/src/acp.ts @@ -305,28 +305,40 @@ export function createJsonLineStream(onMessage: (message: unknown, rawLine: stri pendingJsonLineCount = 1; }; + const resetPendingJson = () => { + pendingJson = ''; + pendingJsonLineCount = 0; + }; + const handleLine = (line: string) => { const trimmed = line.trim(); if (!trimmed) return; if (pendingJson) { - if ((trimmed.startsWith('{') || trimmed.startsWith('[')) && emit(trimmed)) { - pendingJson = ''; - pendingJsonLineCount = 0; - return; - } const nextCandidate = `${pendingJson}\n${trimmed}`; if (emit(nextCandidate)) { - pendingJson = ''; - pendingJsonLineCount = 0; + resetPendingJson(); return; } pendingJsonLineCount += 1; - if (nextCandidate.length <= 128_000 && pendingJsonLineCount <= 256) { + if ( + pendingJsonLineCount === 2 && + pendingJson !== '{' && + pendingJson !== '[' && + emit(trimmed) + ) { + resetPendingJson(); + return; + } + const state = classifyJsonCandidate(nextCandidate); + if ( + state === 'incomplete' && + nextCandidate.length <= 128_000 && + pendingJsonLineCount <= 256 + ) { pendingJson = nextCandidate; return; } - pendingJson = ''; - pendingJsonLineCount = 0; + resetPendingJson(); handleLine(trimmed); return; } @@ -363,6 +375,68 @@ export function createJsonLineStream(onMessage: (message: unknown, rawLine: stri }; } +function classifyJsonCandidate(value: string): 'complete' | 'incomplete' | 'invalid' { + const stack: string[] = []; + let started = false; + let complete = false; + let inString = false; + let escaping = false; + + for (const char of value) { + if (!started) { + if (/\s/.test(char)) continue; + if (char === '{') { + started = true; + stack.push('}'); + continue; + } + if (char === '[') { + started = true; + stack.push(']'); + continue; + } + return 'invalid'; + } + + if (complete) { + if (/\s/.test(char)) continue; + return 'invalid'; + } + + if (inString) { + if (escaping) { + escaping = false; + } else if (char === '\\') { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + if (char === '{') { + stack.push('}'); + continue; + } + if (char === '[') { + stack.push(']'); + continue; + } + if (char === '}' || char === ']') { + if (stack.pop() !== char) return 'invalid'; + if (stack.length === 0) complete = true; + } + } + + if (!started) return 'invalid'; + if (inString || escaping || stack.length > 0) return 'incomplete'; + return complete ? 'complete' : 'invalid'; +} + export async function detectAcpModels({ bin, args, diff --git a/apps/daemon/tests/acp.test.ts b/apps/daemon/tests/acp.test.ts index 435fb9fbd..2359b8a56 100644 --- a/apps/daemon/tests/acp.test.ts +++ b/apps/daemon/tests/acp.test.ts @@ -398,7 +398,7 @@ test('attachAcpSession accepts pretty-printed ACP startup responses', () => { send: (event, payload) => events.push({ event, payload }), }); - child.stdout.write('{\n "id": 1,\n "result": {}\n}\n'); + child.stdout.write('{\n "id": 1,\n "result":\n {}\n}\n'); child.stdout.write('{\n "id": 2,\n "result":\n {\n "sessionId": "session-1"\n }\n}\n'); const methods = parseRpcWrites(writes)