mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 19:00:38 +09:00
Handle Gemini CLI v0.38 stream-json wire format across parser, UI, and CLI formatter (#5273)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Each agent uses an adapter that drives a CLI (Claude, Gemini, Codex, etc.) > - The Gemini adapter parses a JSONL transcript stream the CLI emits to learn what the model said > - Gemini CLI v0.38 changed the transcript shape: assistant text now comes through `type=message` with `role`/`content` and terminal status comes through `type=status` / `type=stats` > - The existing parser was written against the older `type=assistant` / `type=result` shape, so post-v0.38 outputs left the parsed summary empty and downgraded the SSH hello probe to "unexpected output" > - This pull request updates every Gemini consumer (server parser, UI parser, CLI formatter) to accept the v0.38 shape while keeping the legacy shape working > - The benefit is the Gemini adapter handles current upstream output without losing backward compatibility, with explicit test coverage for both shapes ## What Changed - `packages/adapters/gemini-local/src/server/parse.ts` recognizes `type=message` events with role/content and stops downgrading them - `packages/adapters/gemini-local/src/ui/parse-stdout.ts` mirrors the parser changes for the live UI transcript - `packages/adapters/gemini-local/src/cli/format-event.ts` formats the new event shape correctly for CLI output - `parse.test.ts` and `parse-stdout.test.ts` add v0.38 coverage; `gemini-local-adapter.test.ts` and `execute.remote.test.ts` switch happy-path fixtures to the current real wire format and keep dedicated tests for the older schema ## Verification - `pnpm vitest run --no-coverage --project @paperclipai/adapter-gemini-local` — full suite passes including new v0.38 cases and preserved legacy cases - `pnpm typecheck` clean ## Risks Low risk — additive event handling. Legacy event shape path is preserved with its own tests, so existing fixtures continue to parse identically. ## Model Used Claude Opus 4.7 (1M context) ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots — N/A (no UI) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
3c73ed26b5
commit
ea7f53fd7d
7 changed files with 253 additions and 60 deletions
|
|
@ -19,13 +19,12 @@ const {
|
|||
timedOut: false,
|
||||
stdout: [
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
|
||||
JSON.stringify({ type: "assistant", message: { content: [{ type: "output_text", text: "hello" }] } }),
|
||||
JSON.stringify({ type: "message", role: "assistant", content: "hello" }),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
status: "success",
|
||||
session_id: "gemini-session-1",
|
||||
usage: { promptTokenCount: 1, cachedContentTokenCount: 0, candidatesTokenCount: 1 },
|
||||
result: "hello",
|
||||
stats: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 },
|
||||
}),
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
|
|
|
|||
|
|
@ -43,4 +43,91 @@ describe("parseGeminiJsonl", () => {
|
|||
|
||||
expect(parsed.summary).toBe("visible response");
|
||||
});
|
||||
|
||||
it("captures assistant text from gemini CLI v0.38 stream-json schema", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "init",
|
||||
timestamp: "2026-05-04T05:43:41.203Z",
|
||||
session_id: "session-abc",
|
||||
model: "auto-gemini-3",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
timestamp: "2026-05-04T05:43:41.205Z",
|
||||
role: "user",
|
||||
content: "Respond with hello.",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
timestamp: "2026-05-04T05:43:45.198Z",
|
||||
role: "assistant",
|
||||
content: "hello.",
|
||||
delta: true,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
timestamp: "2026-05-04T05:43:45.819Z",
|
||||
status: "success",
|
||||
stats: {
|
||||
total_tokens: 9468,
|
||||
input_tokens: 9095,
|
||||
output_tokens: 29,
|
||||
cached: 8132,
|
||||
duration_ms: 4616,
|
||||
},
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const result = parseGeminiJsonl(stdout);
|
||||
expect(result.summary).toBe("hello.");
|
||||
expect(result.sessionId).toBe("session-abc");
|
||||
expect(result.errorMessage).toBeNull();
|
||||
expect(result.usage.inputTokens).toBe(9095);
|
||||
expect(result.usage.outputTokens).toBe(29);
|
||||
expect(result.usage.cachedInputTokens).toBe(8132);
|
||||
});
|
||||
|
||||
it("ignores user messages and only collects assistant content", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({ type: "message", role: "user", content: "ignore me" }),
|
||||
JSON.stringify({ type: "message", role: "assistant", content: "first" }),
|
||||
JSON.stringify({ type: "message", role: "assistant", content: "second" }),
|
||||
].join("\n");
|
||||
|
||||
const result = parseGeminiJsonl(stdout);
|
||||
expect(result.summary).toBe("first\n\nsecond");
|
||||
});
|
||||
|
||||
it("preserves the legacy claude-style `assistant` event handler", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "legacy-session",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "output_text", text: "legacy hello" }] },
|
||||
}),
|
||||
JSON.stringify({ type: "result", subtype: "success", result: "legacy hello" }),
|
||||
].join("\n");
|
||||
|
||||
const result = parseGeminiJsonl(stdout);
|
||||
expect(result.summary).toBe("legacy hello");
|
||||
expect(result.sessionId).toBe("legacy-session");
|
||||
});
|
||||
|
||||
it("flags result events with status=error", () => {
|
||||
const stdout = [
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
status: "error",
|
||||
error: "boom",
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
const result = parseGeminiJsonl(stdout);
|
||||
expect(result.errorMessage).toBe("boom");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -64,7 +64,10 @@ function accumulateUsage(
|
|||
);
|
||||
target.cachedInputTokens += asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
|
||||
asNumber(
|
||||
source.cachedInputTokens,
|
||||
asNumber(source.cachedContentTokenCount, asNumber(source.cached, 0)),
|
||||
),
|
||||
);
|
||||
target.outputTokens += asNumber(
|
||||
source.output_tokens,
|
||||
|
|
@ -121,14 +124,14 @@ export function parseGeminiJsonl(stdout: string) {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Gemini CLI v0.38+ stream-json schema emits assistant turns as:
|
||||
// {"type":"message","role":"assistant","content":"...","delta":true}
|
||||
// These are discrete final messages (one per assistant turn), not
|
||||
// cumulative streaming tokens, so collecting all of them produces the
|
||||
// expected concatenated turn-by-turn summary rather than duplicated text.
|
||||
if (type === "message") {
|
||||
const role = asString(event.role, "").trim().toLowerCase();
|
||||
if (role === "assistant") {
|
||||
// Mirror the assistant-event handling above: collect every assistant
|
||||
// message including deltas. Gemini CLI emits these as discrete final
|
||||
// messages (one per assistant turn), not as cumulative streaming
|
||||
// tokens, so collecting all of them produces the expected concatenated
|
||||
// turn-by-turn summary rather than duplicated text.
|
||||
messages.push(...collectMessageText(event.content));
|
||||
}
|
||||
continue;
|
||||
|
|
@ -136,14 +139,19 @@ export function parseGeminiJsonl(stdout: string) {
|
|||
|
||||
if (type === "result") {
|
||||
resultEvent = event;
|
||||
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
||||
accumulateUsage(usage, event.usage ?? event.usageMetadata ?? event.stats);
|
||||
const resultText =
|
||||
asString(event.result, "").trim() ||
|
||||
asString(event.text, "").trim() ||
|
||||
asString(event.response, "").trim();
|
||||
if (resultText && messages.length === 0) messages.push(resultText);
|
||||
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
||||
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
|
||||
const status = asString(event.status, "").toLowerCase();
|
||||
const isError =
|
||||
event.is_error === true ||
|
||||
asString(event.subtype, "").toLowerCase() === "error" ||
|
||||
status === "error" ||
|
||||
status === "failed";
|
||||
if (isError) {
|
||||
const text = asErrorText(event.error ?? event.message ?? event.result).trim();
|
||||
if (text) errorMessage = text;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue