From eab96cc94b85df949e460fa21c5b8524787120c9 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 9 Mar 2026 08:38:33 -0300 Subject: [PATCH] fix(streaming): keep Gemini/Antigravity text block open across chunks (#253) Before this fix, geminiToClaudeResponse() called content_block_start + content_block_stop on EVERY streaming chunk. Claude Code interprets each content_block as a separate text element and renders them on separate lines, making responses unreadable. Fix: introduce state.openTextBlockIdx to track an open text block across chunks. The block is opened on the first text chunk and only closed when: - switching to a different block type (thinking, tool_use) - the stream finishes (at finishReason) All other block types (thinking, tool_use) are still opened+closed per chunk as before since they are naturally atomic events. Also: add 'Download opencode.json' button to the Agents dashboard. The button appears when opencode is detected as installed and generates a ready-to-use opencode.json config with the OmniRoute provider, base URL and all models fetched from /v1/models. --- .../translator/response/gemini-to-claude.ts | 75 +++++++++------- src/app/(dashboard)/dashboard/agents/page.tsx | 90 +++++++++++++++++++ 2 files changed, 133 insertions(+), 32 deletions(-) diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index ba1034fe27..03578ea1b1 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -5,6 +5,10 @@ import { FORMATS } from "../formats.ts"; * Direct Gemini → Claude response translator. * Converts Gemini streaming chunks directly to Claude Messages API * streaming events, skipping the OpenAI hub intermediate step. + * + * Fix (issue #253): Keep the text content_block open across streaming chunks + * instead of opening+closing it on every chunk. This prevents Claude Code + * from rendering each delta on a separate line. */ export function geminiToClaudeResponse(chunk, state) { if (!chunk) return null; @@ -22,6 +26,8 @@ export function geminiToClaudeResponse(chunk, state) { state.messageId = response.responseId || `msg_${Date.now()}`; state.model = response.modelVersion || "gemini"; state.contentBlockIndex = 0; + // Track open text block so we can keep it open across chunks + state.openTextBlockIdx = null; results.push({ type: "message_start", @@ -44,8 +50,13 @@ export function geminiToClaudeResponse(chunk, state) { const hasThoughtSig = part.thoughtSignature || part.thought_signature; const isThought = part.thought === true; - // Thinking content → thinking block + // Thinking content → thinking block (always open+close per chunk) if (isThought && part.text) { + // Close any open text block first + if (state.openTextBlockIdx !== null) { + results.push({ type: "content_block_stop", index: state.openTextBlockIdx }); + state.openTextBlockIdx = null; + } const idx = state.contentBlockIndex++; results.push({ type: "content_block_start", @@ -61,8 +72,13 @@ export function geminiToClaudeResponse(chunk, state) { continue; } - // Function call → tool_use block (with or without thoughtSignature) + // Function call → tool_use block if (part.functionCall) { + // Close any open text block first + if (state.openTextBlockIdx !== null) { + results.push({ type: "content_block_stop", index: state.openTextBlockIdx }); + state.openTextBlockIdx = null; + } const fc = part.functionCall; const idx = state.contentBlockIndex++; const toolId = fc.id || `toolu_${Date.now()}_${idx}`; @@ -78,7 +94,6 @@ export function geminiToClaudeResponse(chunk, state) { }, }); - // Send args as a single JSON delta const argsStr = JSON.stringify(fc.args || {}); results.push({ type: "content_block_delta", @@ -91,42 +106,32 @@ export function geminiToClaudeResponse(chunk, state) { continue; } - // Text content → text block - if (part.text !== undefined && part.text !== "" && !hasThoughtSig) { - const idx = state.contentBlockIndex++; - results.push({ - type: "content_block_start", - index: idx, - content_block: { type: "text", text: "" }, - }); - results.push({ - type: "content_block_delta", - index: idx, - delta: { type: "text_delta", text: part.text }, - }); - results.push({ type: "content_block_stop", index: idx }); - } - - // Text with thoughtSignature but not a thought (model output after thinking) - if ( + // Regular text content → keep text block open across streaming chunks + const isRegularText = part.text !== undefined && part.text !== "" && !hasThoughtSig; + const isTextAfterThinking = hasThoughtSig && part.text !== undefined && part.text !== "" && !isThought && - !part.functionCall - ) { - const idx = state.contentBlockIndex++; - results.push({ - type: "content_block_start", - index: idx, - content_block: { type: "text", text: "" }, - }); + !part.functionCall; + + if (isRegularText || isTextAfterThinking) { + // Open a new text block only if none is open yet + if (state.openTextBlockIdx === null) { + const idx = state.contentBlockIndex++; + state.openTextBlockIdx = idx; + results.push({ + type: "content_block_start", + index: idx, + content_block: { type: "text", text: "" }, + }); + } + // Always emit delta into the SAME open block (no open+close per chunk) results.push({ type: "content_block_delta", - index: idx, + index: state.openTextBlockIdx, delta: { type: "text_delta", text: part.text }, }); - results.push({ type: "content_block_stop", index: idx }); } } } @@ -152,8 +157,14 @@ export function geminiToClaudeResponse(chunk, state) { } } - // ── Finish reason → message_delta + message_stop ─────────────── + // ── Finish reason → close open blocks + message_delta + message_stop ── if (candidate.finishReason) { + // Close any still-open text block before finishing + if (state.openTextBlockIdx !== null) { + results.push({ type: "content_block_stop", index: state.openTextBlockIdx }); + state.openTextBlockIdx = null; + } + let stopReason; const reason = candidate.finishReason.toLowerCase(); if (state.hasToolUse || reason === "tool_calls") { diff --git a/src/app/(dashboard)/dashboard/agents/page.tsx b/src/app/(dashboard)/dashboard/agents/page.tsx index 3660e6cba9..5df960c502 100644 --- a/src/app/(dashboard)/dashboard/agents/page.tsx +++ b/src/app/(dashboard)/dashboard/agents/page.tsx @@ -31,6 +31,8 @@ export default function AgentsPage() { const [showAddForm, setShowAddForm] = useState(false); const [addLoading, setAddLoading] = useState(false); const [settings, setSettings] = useState>({}); + const [opencodeConfigLoading, setOpencodeConfigLoading] = useState(false); + const [opencodeConfigDone, setOpencodeConfigDone] = useState(false); const [newAgent, setNewAgent] = useState({ name: "", binary: "", @@ -303,6 +305,94 @@ export default function AgentsPage() { ))} + {/* OpenCode Config Generator — shown only when opencode is detected */} + {agents.find((a) => a.id === "opencode" && a.installed) && ( + +
+
+ code_blocks +
+
+
+

OpenCode Integration

+ + opencode {agents.find((a) => a.id === "opencode")?.version} detected + +
+

+ Generate a ready-to-use{" "} + + opencode.json + {" "} + with your OmniRoute base URL and all available models — drop it in your project root + and run{" "} + + opencode + + . +

+ +
+
+
+ )} + {/* Add Custom Agent */}