diff --git a/cli/bin/javachat.js b/cli/bin/javachat.js index 25bdd908..bcc5816f 100755 --- a/cli/bin/javachat.js +++ b/cli/bin/javachat.js @@ -568,6 +568,24 @@ function renderEnrichmentMarker(markerBody) { return `\n${title}: ${markerBody.slice(separatorIndex + 1).trim()}\n`; } +/** + * Resolves the marker close index from a run of closing braces. + * + * For a trailing run like `}}}` this selects the final `}}` so a content brace + * ending the marker body stays in the body instead of leaking as a stray. + * Mirrors the backend's EnrichmentPlaceholderizer.resolveCloseIndexFromBraceRun. + */ +function resolveCloseIndexFromBraceRun(text, runStart) { + let runLength = 0; + while (runStart + runLength < text.length && text[runStart + runLength] === "}") { + runLength++; + } + if (runLength < MARKER_CLOSE.length) { + return runStart; + } + return runStart + (runLength - MARKER_CLOSE.length); +} + /** * Converts enrichment markers to terminal blocks while text is still streaming. * @@ -593,8 +611,8 @@ function createEnrichmentMarkerRenderer() { } printable += heldText.slice(0, openIndex); heldText = heldText.slice(openIndex); - const closeIndex = heldText.indexOf(MARKER_CLOSE, MARKER_OPEN.length); - if (closeIndex === -1) { + const firstCloseIndex = heldText.indexOf(MARKER_CLOSE, MARKER_OPEN.length); + if (firstCloseIndex === -1) { if (heldText.length > MARKER_MAX_LENGTH) { printable += heldText; heldText = ""; @@ -602,6 +620,7 @@ function createEnrichmentMarkerRenderer() { } return printable; } + const closeIndex = resolveCloseIndexFromBraceRun(heldText, firstCloseIndex); printable += renderEnrichmentMarker(heldText.slice(MARKER_OPEN.length, closeIndex)); heldText = heldText.slice(closeIndex + MARKER_CLOSE.length); } diff --git a/cli/test/javachat.test.js b/cli/test/javachat.test.js index 286d3daa..2ad63b88 100644 --- a/cli/test/javachat.test.js +++ b/cli/test/javachat.test.js @@ -451,6 +451,67 @@ test("releases an unterminated enrichment marker instead of swallowing it", asyn assert.match(cliExecution.standardOutput, /never closed/); }); +test("keeps a trailing content brace inside an enrichment marker body", async (testContext) => { + const apiServer = createServer((request, response) => { + if (request.url === "/api/me") { + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"userId":"user_cli"}'); + return; + } + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end( + 'event: text\ndata: {"text":"{{example:an empty object literal is {}}}"}\n\n', + ); + }); + apiServer.listen(0, "127.0.0.1"); + await once(apiServer, "listening"); + testContext.after(() => apiServer.close()); + const address = apiServer.address(); + + const cliExecution = await runCli( + ["--host", `http://127.0.0.1:${address.port}`, "ask", "What is an empty object?"], + { JAVACHAT_API_KEY: TEST_API_KEY }, + ); + + assert.equal(cliExecution.exitCode, 0); + assert.match(cliExecution.standardOutput, /Example: an empty object literal is \{\}\n/); + assert.doesNotMatch(cliExecution.standardOutput, /literal is \{\n\}/); +}); + +test("keeps a brace run's content brace when the marker spans stream chunks", async (testContext) => { + const apiServer = createServer((request, response) => { + if (request.url === "/api/me") { + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"userId":"user_cli"}'); + return; + } + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end( + 'event: text\ndata: {"text":"Intro. {{example:try (var scope"}\n\n' + + 'event: text\ndata: {"text":" = open()) { doWork(); }}}"}\n\n' + + 'event: text\ndata: {"text":" Outro."}\n\n', + ); + }); + apiServer.listen(0, "127.0.0.1"); + await once(apiServer, "listening"); + testContext.after(() => apiServer.close()); + const address = apiServer.address(); + + const cliExecution = await runCli( + ["--host", `http://127.0.0.1:${address.port}`, "ask", "What is try-with-resources?"], + { JAVACHAT_API_KEY: TEST_API_KEY }, + ); + + assert.equal(cliExecution.exitCode, 0); + assert.match(cliExecution.standardOutput, /Intro\./); + assert.match(cliExecution.standardOutput, /Outro\./); + assert.match( + cliExecution.standardOutput, + /Example: try \(var scope = open\(\)\) \{ doWork\(\); \}\n/, + ); + assert.doesNotMatch(cliExecution.standardOutput, /doWork\(\);\n\}/); +}); + test("rejects a successful non-SSE response", async (testContext) => { const apiServer = createServer((request, response) => { if (request.url === "/api/me") {