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
14 changes: 7 additions & 7 deletions .github/workflows/smoke-gemini.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion .github/workflows/smoke-gemini.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ permissions:
issues: read
pull-requests: read
name: Smoke Gemini
engine: gemini
engine:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pinning to a specific model (gemini-2.0-flash-lite) is a good practice for reproducibility. This ensures consistent behavior across runs rather than relying on whatever "latest" model is available.

id: gemini
model: gemini-2.0-flash-lite
strict: true
imports:
- shared/gh.md
Expand Down
98 changes: 98 additions & 0 deletions actions/setup/js/parse_gemini_log.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// @ts-check
/// <reference types="@actions/github-script" />

const { createEngineLogParser } = require("./log_parser_shared.cjs");

const main = createEngineLogParser({
parserName: "Gemini",
parseFunction: parseGeminiLog,
supportsDirectories: false,
});

/**
* 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).
* @param {string} logContent - The raw log content to parse
* @returns {{markdown: string, logEntries: Array, mcpFailures: Array<string>, maxTurnsHit: boolean}} Parsed log data
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The JSDoc return type could be more precise - logEntries is typed as Array without a generic type. Consider adding Array<{type: string, content: string}> or similar for better type safety.

function parseGeminiLog(logContent) {
if (!logContent) {
return {
markdown: "## 🤖 Gemini\n\nNo log content provided.\n\n",
logEntries: [],
mcpFailures: [],
maxTurnsHit: false,
};
}

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

const lines = logContent.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}

// Try to parse each line as a JSON object (Gemini --output-format json output)

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

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

The comment incorrectly refers to "Gemini --output-format json output" but the code is actually parsing output from "--output-format stream-json" (as correctly stated in the docstring at line 14). This should be updated to match the actual output format being parsed.

Suggested change
// Try to parse each line as a JSON object (Gemini --output-format json output)
// Try to parse each line as a JSON object from Gemini --output-format stream-json output (JSONL)

Copilot uses AI. Check for mistakes.
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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment says "Gemini --output-format json output" but the PR changes the format to stream-json (JSONL). The comment on line 50 should be updated to reference stream-json instead of json to match the actual format being used.

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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Silently skipping non-JSON lines is the right approach here since Gemini CLI may mix JSON with other output. The _e naming convention for ignored errors is consistent with the codebase style.

}
}
}
} catch (_e) {
// Not JSON - skip non-JSON lines
}
}

// Build markdown output
if (lastResponse) {
markdown += "## 🤖 Reasoning\n\n";
markdown += lastResponse + "\n\n";
}

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`;
}
}

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

// Export for testing
if (typeof module !== "undefined" && module.exports) {
module.exports = {
main,
parseGeminiLog,
};
}
Comment on lines +1 to +98

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

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

The newly added parse_gemini_log.cjs is missing a corresponding test file. All other log parsers in this directory have associated test files (e.g., parse_claude_log.test.cjs, parse_codex_log.test.cjs, parse_copilot_log.test.cjs). A parse_gemini_log.test.cjs file should be added to maintain consistency with the established testing pattern for log parsers.

Copilot uses AI. Check for mistakes.
3 changes: 2 additions & 1 deletion pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ func BuildAWFArgs(config AWFCommandConfig) []string {
awfArgs = append(awfArgs, "--proxy-logs-dir", string(constants.AWFProxyLogsDir))

// Add --enable-host-access when MCP servers are configured (gateway is used)
if HasMCPServers(config.WorkflowData) {
// OR when the API proxy sidecar is enabled (needs to reach host.docker.internal:<port>)
if HasMCPServers(config.WorkflowData) || config.UsesAPIProxy {
awfArgs = append(awfArgs, "--enable-host-access")
awfHelpersLog.Print("Added --enable-host-access for MCP gateway communication")

Copilot AI Feb 20, 2026

Copy link

Choose a reason for hiding this comment

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

The log message "Added --enable-host-access for MCP gateway communication" is now potentially misleading since this block executes both when MCP servers are present AND when UsesAPIProxy is true (line 174). When --enable-host-access is added only for API proxy support (without MCP servers), the message incorrectly suggests it's for MCP gateway communication. Consider updating the message to reflect both use cases, such as "Added --enable-host-access for MCP gateway and/or API proxy communication".

Suggested change
awfHelpersLog.Print("Added --enable-host-access for MCP gateway communication")
awfHelpersLog.Print("Added --enable-host-access for MCP gateway and/or API proxy communication")

Copilot uses AI. Check for mistakes.
}
Expand Down
27 changes: 27 additions & 0 deletions pkg/workflow/enable_api_proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,31 @@ func TestEngineAWFEnableApiProxy(t *testing.T) {
t.Error("Expected Codex AWF command to contain '--enable-api-proxy' flag")
}
})

t.Run("Gemini AWF command includes enable-api-proxy flag (supports LLM gateway)", func(t *testing.T) {
workflowData := &WorkflowData{
Name: "test-workflow",
EngineConfig: &EngineConfig{
ID: "gemini",
},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
},
},
}

engine := NewGeminiEngine()
steps := engine.GetExecutionSteps(workflowData, "test.log")

if len(steps) == 0 {
t.Fatal("Expected at least one execution step")
}

stepContent := strings.Join(steps[0], "\n")

if !strings.Contains(stepContent, "--enable-api-proxy") {
t.Error("Expected Gemini AWF command to contain '--enable-api-proxy' flag")
}
})
}
18 changes: 14 additions & 4 deletions pkg/workflow/gemini_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,8 @@ func (e *GeminiEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str
// Without this, Gemini CLI's default approval mode rejects tool calls with "Tool execution denied by policy"
geminiArgs = append(geminiArgs, "--yolo")

// Add headless mode with JSON output
geminiArgs = append(geminiArgs, "--output-format", "json")
// Add streaming JSON output (JSONL format, compatible with the log parser)
geminiArgs = append(geminiArgs, "--output-format", "stream-json")

// Add prompt argument
geminiArgs = append(geminiArgs, "--prompt", "\"$(cat /tmp/gh-aw/aw-prompts/prompt.txt)\"")
Expand All @@ -206,18 +206,22 @@ func (e *GeminiEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str
npmPathSetup := GetNpmBinPathSetup()
geminiCommandWithPath := fmt.Sprintf("%s && %s", npmPathSetup, geminiCommand)

// Enable API proxy sidecar if this engine supports LLM gateway
llmGatewayPort := e.SupportsLLMGateway()
usesAPIProxy := llmGatewayPort > 0

command = BuildAWFCommand(AWFCommandConfig{
EngineName: "gemini",
EngineCommand: geminiCommandWithPath,
LogFile: logFile,
WorkflowData: workflowData,
UsesTTY: false,
UsesAPIProxy: false,
UsesAPIProxy: usesAPIProxy,
AllowedDomains: allowedDomains,
})
} else {
command = fmt.Sprintf(`set -o pipefail
%s 2>&1 | tee %s`, geminiCommand, logFile)
%s 2>&1 | tee -a %s`, geminiCommand, logFile)
}

// Build environment variables
Expand All @@ -232,6 +236,12 @@ func (e *GeminiEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str
env["GH_AW_MCP_CONFIG"] = "${{ github.workspace }}/.gemini/settings.json"
}

// When the firewall (AWF) is enabled with --enable-api-proxy, point Gemini CLI at the
// LLM gateway sidecar instead of the real googleapis.com endpoint.
if firewallEnabled {
env["GEMINI_API_BASE_URL"] = fmt.Sprintf("http://host.docker.internal:%d", constants.GeminiLLMGatewayPort)
}

// Add safe outputs env
applySafeOutputEnvToMap(env, workflowData)

Expand Down
5 changes: 4 additions & 1 deletion pkg/workflow/gemini_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ func TestGeminiEngineExecution(t *testing.T) {
assert.Contains(t, stepContent, "id: agentic_execution", "Should have agentic_execution ID")
assert.Contains(t, stepContent, "gemini", "Should invoke gemini command")
assert.Contains(t, stepContent, "--yolo", "Should include --yolo flag for auto-approving tool executions")
assert.Contains(t, stepContent, "--output-format json", "Should use JSON output format")
assert.Contains(t, stepContent, "--output-format stream-json", "Should use streaming JSON output format")
assert.Contains(t, stepContent, `--prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"`, "Should include prompt argument with correct shell quoting")
assert.Contains(t, stepContent, "/tmp/test.log", "Should include log file")
assert.Contains(t, stepContent, "GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}", "Should set GEMINI_API_KEY env var")
Expand Down Expand Up @@ -273,6 +273,8 @@ func TestGeminiEngineFirewallIntegration(t *testing.T) {
// Should use AWF command
assert.Contains(t, stepContent, "awf", "Should use AWF when firewall is enabled")
assert.Contains(t, stepContent, "--allow-domains", "Should include allow-domains flag")
assert.Contains(t, stepContent, "--enable-api-proxy", "Should include --enable-api-proxy flag")
assert.Contains(t, stepContent, "GEMINI_API_BASE_URL: http://host.docker.internal:10003", "Should set GEMINI_API_BASE_URL to LLM gateway URL")
})

t.Run("firewall disabled", func(t *testing.T) {
Expand All @@ -293,5 +295,6 @@ func TestGeminiEngineFirewallIntegration(t *testing.T) {
// Should use simple command without AWF
assert.Contains(t, stepContent, "set -o pipefail", "Should use simple command with pipefail")
assert.NotContains(t, stepContent, "awf", "Should not use AWF when firewall is disabled")
assert.NotContains(t, stepContent, "GEMINI_API_BASE_URL", "Should not set GEMINI_API_BASE_URL when firewall is disabled")
})
}
5 changes: 5 additions & 0 deletions pkg/workflow/strict_mode_llm_gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ func TestSupportsLLMGateway(t *testing.T) {
expectedPort: constants.CopilotLLMGatewayPort,
description: "Copilot engine uses dedicated port for LLM gateway",
},
{
engineID: "gemini",
expectedPort: constants.GeminiLLMGatewayPort,
description: "Gemini engine uses dedicated port for LLM gateway",
},
}

for _, tt := range tests {
Expand Down
1 change: 1 addition & 0 deletions smoke_test_push_22239107636.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Test file for PR push - smoke test run 22239107636
Loading