Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 140 additions & 48 deletions actions/setup/js/parse_gemini_log.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { createEngineLogParser } = require("./log_parser_shared.cjs");
const { createEngineLogParser, generateConversationMarkdown, generateInformationSection, formatInitializationSummary, formatToolUse } = require("./log_parser_shared.cjs");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good refactoring — importing generateConversationMarkdown, generateInformationSection, formatInitializationSummary, and formatToolUse from the shared module eliminates duplicated formatting logic and ensures consistent output across all engine parsers.


const main = createEngineLogParser({
parserName: "Gemini",
Expand All @@ -10,8 +10,13 @@ const main = createEngineLogParser({
});

/**
* Parse Gemini CLI streaming JSON log output and format as markdown.
* Gemini CLI outputs one JSON object per line when using --output-format stream-json (JSONL).
* Parse Gemini CLI JSONL log output and format as markdown.
* Gemini CLI outputs one JSON object per line (JSONL) with typed entries:
* - type "init": session initialization with model and session_id
* - type "message": user/assistant messages, assistant uses delta:true for streaming chunks
* - type "tool_use": tool invocations with tool_name, tool_id, and parameters
* - type "tool_result": tool responses with tool_id, status, and output
* - type "result": final stats with token usage, duration, and tool call count
* @param {string} logContent - The raw log content to parse
* @returns {{markdown: string, logEntries: Array, mcpFailures: Array<string>, maxTurnsHit: boolean}} Parsed log data
*/
Expand All @@ -25,74 +30,161 @@ function parseGeminiLog(logContent) {
};
}

let markdown = "";
let totalInputTokens = 0;
let totalOutputTokens = 0;
let lastResponse = "";

const lines = logContent.split("\n");
for (const line of lines) {
// Parse JSONL lines
/** @type {Array<any>} */
const rawEntries = [];
for (const line of logContent.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
if (!trimmed || !trimmed.startsWith("{")) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice defensive check — filtering lines that don't start with { avoids unnecessary JSON.parse attempts on non-object lines, which is a good micro-optimization for large log files. 👍

continue;
}

// Try to parse each line as a JSON object (Gemini --output-format json output)
try {
const parsed = JSON.parse(trimmed);

if (parsed.response) {
lastResponse = parsed.response;
}

// Aggregate token usage from stats
if (parsed.stats && parsed.stats.models) {
for (const modelStats of Object.values(parsed.stats.models)) {
if (modelStats && typeof modelStats === "object") {
if (typeof modelStats.input_tokens === "number") {
totalInputTokens += modelStats.input_tokens;
}
if (typeof modelStats.output_tokens === "number") {
totalOutputTokens += modelStats.output_tokens;
}
}
}
}
rawEntries.push(JSON.parse(trimmed));
} catch (_e) {
// Not JSON - skip non-JSON lines
// Skip non-JSON lines
}
}

// Build markdown output
if (lastResponse) {
markdown += "## 🤖 Reasoning\n\n";
markdown += lastResponse + "\n\n";
if (rawEntries.length === 0) {
return {
markdown: "## 🤖 Gemini\n\nLog format not recognized as Gemini JSONL.\n\n",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Providing a friendly error message when the log format isn't recognized is a great UX touch. Makes debugging much easier when a user accidentally points this at a non-Gemini log file. ✨

logEntries: [],
mcpFailures: [],
maxTurnsHit: false,
};
}

markdown += "## 📊 Information\n\n";
const totalTokens = totalInputTokens + totalOutputTokens;
if (totalTokens > 0) {
markdown += `**Total Tokens Used:** ${totalTokens.toLocaleString()}\n\n`;
if (totalInputTokens > 0) {
markdown += `**Input Tokens:** ${totalInputTokens.toLocaleString()}\n\n`;
}
if (totalOutputTokens > 0) {
markdown += `**Output Tokens:** ${totalOutputTokens.toLocaleString()}\n\n`;
}
// Transform Gemini JSONL entries into canonical logEntries format
const logEntries = transformGeminiEntries(rawEntries);

// Extract the final result entry for stats
const resultEntry = rawEntries.find(e => e.type === "result");

// Generate conversation markdown using shared function
const conversationResult = generateConversationMarkdown(logEntries, {
formatToolCallback: (toolUse, toolResult) => formatToolUse(toolUse, toolResult, { includeDetailedParameters: false }),
formatInitCallback: initEntry => formatInitializationSummary(initEntry, { includeSlashCommands: false }),
});

let markdown = conversationResult.markdown;

// Add Information section using Gemini-specific stats from the result entry
if (resultEntry && resultEntry.stats) {
const stats = resultEntry.stats;
const syntheticEntry = {
usage: {
input_tokens: stats.input_tokens || 0,
output_tokens: stats.output_tokens || 0,
cache_read_input_tokens: stats.cached || 0,
},
duration_ms: stats.duration_ms || 0,
num_turns: stats.tool_calls || 0,
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

num_turns is being populated from stats.tool_calls, but generateInformationSection() renders num_turns as Turns. Tool call count is not the same as conversation turns, so this will produce misleading summaries. Consider omitting num_turns for Gemini (or mapping it from an actual turns field if Gemini provides one) and, if desired, render tool call count via an additionalInfoCallback instead.

Suggested change
num_turns: stats.tool_calls || 0,

Copilot uses AI. Check for mistakes.
};
markdown += generateInformationSection(syntheticEntry);
} else {
markdown += generateInformationSection(null);
}

return {
markdown,
logEntries: [],
logEntries,
mcpFailures: [],
maxTurnsHit: false,
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The streaming delta merging logic here correctly handles Gemini's chunked assistant messages. The guard on entry.message.content.length === 1 ensures only single-content entries are merged, preventing accidental data loss. Consider adding a comment clarifying why multi-content entries are excluded from merging.

}

/**
* Checks whether a canonical log entry is an assistant text entry eligible for merging
* with a subsequent streaming delta chunk.
* @param {any} entry - The candidate last entry
* @returns {boolean} True when the entry is a mergeable assistant text entry
*/
function isConsecutiveDeltaEntry(entry) {
return entry && entry.type === "assistant" && entry.message && Array.isArray(entry.message.content) && entry.message.content.length === 1 && entry.message.content[0].type === "text";
}

/**
* Transforms raw Gemini JSONL entries into the canonical logEntries format
* used by the shared generateConversationMarkdown function.
*
* Gemini entry types and their canonical mappings:
* - "init" → {type:"system", subtype:"init", model, session_id}
* - "message" (assistant, delta:true) → merged into {type:"assistant", message:{content:[{type:"text"}]}}
* - "tool_use" → {type:"assistant", message:{content:[{type:"tool_use", id, name, input}]}}
* - "tool_result" → {type:"user", message:{content:[{type:"tool_result", tool_use_id, content, is_error}]}}
*
* @param {Array<any>} rawEntries - Raw parsed JSONL entries
* @returns {Array<any>} Canonical log entries for generateConversationMarkdown
*/
function transformGeminiEntries(rawEntries) {
/** @type {Array<any>} */
const entries = [];

for (const raw of rawEntries) {
if (raw.type === "init") {
entries.push({
type: "system",
subtype: "init",
model: raw.model,
session_id: raw.session_id,
});
} else if (raw.type === "message" && raw.role === "assistant") {
const text = raw.content || "";
if (!text.trim()) {
continue;
}
// Merge consecutive streaming delta chunks into one assistant text entry
const last = entries[entries.length - 1];
if (raw.delta === true && isConsecutiveDeltaEntry(last)) {
last.message.content[0].text += text;
} else {
entries.push({
type: "assistant",
message: {
content: [{ type: "text", text }],
},
});
}
} else if (raw.type === "tool_use") {
entries.push({
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: raw.tool_id,
name: raw.tool_name,
input: raw.parameters || {},
},
],
},
});
} else if (raw.type === "tool_result") {
const output = typeof raw.output === "string" ? raw.output : JSON.stringify(raw.output || "");
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When raw.output is not a string, JSON.stringify(raw.output || "") will incorrectly coerce valid falsy outputs like 0, false, or "" into "", and undefined becomes "\"\"". Use a nullish check (e.g., raw.output == null ? "" : JSON.stringify(raw.output)) so primitives are preserved and missing output stays empty.

Suggested change
const output = typeof raw.output === "string" ? raw.output : JSON.stringify(raw.output || "");
const output =
typeof raw.output === "string"
? raw.output
: raw.output == null
? ""
: JSON.stringify(raw.output);

Copilot uses AI. Check for mistakes.
entries.push({
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: raw.tool_id,
content: output,
is_error: raw.status !== "success",
},
],
},
});
}
}

return entries;
}

// Export for testing
if (typeof module !== "undefined" && module.exports) {
module.exports = {
main,
parseGeminiLog,
transformGeminiEntries,
};
}
Loading
Loading