Merge pull request #254 from diegosouzapw/fix/issue-253-antigravity-streaming

 Approved — fixes confirmed.

- Fix #253: Antigravity/Gemini streaming chunks now render as continuous text in Claude Code (content_block kept open across chunks)
- Bonus: Download opencode.json button in Agents dashboard
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-03-09 08:47:41 -03:00
committed by GitHub
2 changed files with 133 additions and 32 deletions

View File

@@ -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") {

View File

@@ -31,6 +31,8 @@ export default function AgentsPage() {
const [showAddForm, setShowAddForm] = useState(false);
const [addLoading, setAddLoading] = useState(false);
const [settings, setSettings] = useState<Record<string, any>>({});
const [opencodeConfigLoading, setOpencodeConfigLoading] = useState(false);
const [opencodeConfigDone, setOpencodeConfigDone] = useState(false);
const [newAgent, setNewAgent] = useState({
name: "",
binary: "",
@@ -303,6 +305,94 @@ export default function AgentsPage() {
))}
</div>
{/* OpenCode Config Generator — shown only when opencode is detected */}
{agents.find((a) => a.id === "opencode" && a.installed) && (
<Card>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-violet-500/10 text-violet-500 shrink-0">
<span className="material-symbols-outlined text-[20px]">code_blocks</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-semibold">OpenCode Integration</h3>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-medium">
opencode {agents.find((a) => a.id === "opencode")?.version} detected
</span>
</div>
<p className="text-sm text-text-muted mb-3">
Generate a ready-to-use{" "}
<code className="text-xs bg-black/[0.06] dark:bg-white/[0.08] px-1 py-0.5 rounded">
opencode.json
</code>{" "}
with your OmniRoute base URL and all available models drop it in your project root
and run{" "}
<code className="text-xs bg-black/[0.06] dark:bg-white/[0.08] px-1 py-0.5 rounded">
opencode
</code>
.
</p>
<Button
variant="secondary"
loading={opencodeConfigLoading}
onClick={async () => {
setOpencodeConfigLoading(true);
setOpencodeConfigDone(false);
try {
// Fetch available models
const modelsRes = await fetch("/v1/models");
const modelsData = modelsRes.ok ? await modelsRes.json() : { data: [] };
const models: Record<string, { name: string }> = {};
for (const m of modelsData.data || []) {
models[m.id] = { name: m.id };
}
// Build opencode.json
const baseURL = window.location.origin + "/v1";
const config = {
$schema: "https://opencode.ai/config.json",
provider: {
omniroute: {
npm: "@ai-sdk/openai-compatible",
name: "OmniRoute",
options: {
baseURL,
apiKey: "YOUR_OMNIROUTE_API_KEY",
},
models:
Object.keys(models).length > 0
? models
: { "gpt-4o": { name: "gpt-4o" } },
},
},
};
// Download as file
const blob = new Blob([JSON.stringify(config, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "opencode.json";
a.click();
URL.revokeObjectURL(url);
setOpencodeConfigDone(true);
setTimeout(() => setOpencodeConfigDone(false), 3000);
} catch (err) {
console.error("Failed to generate opencode.json:", err);
} finally {
setOpencodeConfigLoading(false);
}
}}
>
<span className="material-symbols-outlined text-[16px] mr-1">
{opencodeConfigDone ? "check" : "download"}
</span>
{opencodeConfigDone ? "Downloaded!" : "Download opencode.json"}
</Button>
</div>
</div>
</Card>
)}
{/* Add Custom Agent */}
<Card>
<div className="flex items-center justify-between mb-4">