Merge PR #134: Gemini run mode (third external-CLI mode alongside Codex/OpenCode) (COD-36)

This commit is contained in:
Codeman maintainer
2026-06-25 00:10:52 +02:00
17 changed files with 520 additions and 40 deletions
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
describe('Gemini mode schemas', () => {
it('accepts Gemini session creation config', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'gemini',
geminiConfig: {
model: 'gemini-2.5-pro',
approvalMode: 'yolo',
},
});
expect(parsed.mode).toBe('gemini');
expect(parsed.geminiConfig).toEqual({
model: 'gemini-2.5-pro',
approvalMode: 'yolo',
});
});
it('accepts Gemini quick-start config', () => {
const parsed = QuickStartSchema.parse({
caseName: 'gemini-case',
mode: 'gemini',
geminiConfig: {
model: 'gemini-2.5-flash',
approvalMode: 'auto_edit',
},
});
expect(parsed.mode).toBe('gemini');
expect(parsed.geminiConfig?.model).toBe('gemini-2.5-flash');
});
it('rejects unsafe Gemini model strings', () => {
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'gemini',
geminiConfig: { model: 'gemini; rm -rf /' },
})
).toThrow();
});
});
+40 -4
View File
@@ -76,11 +76,17 @@ vi.mock('../../src/utils/opencode-cli-resolver.js', () => ({
resolveOpenCodeDir: vi.fn(() => null),
}));
vi.mock('../../src/utils/gemini-cli-resolver.js', () => ({
isGeminiAvailable: vi.fn(() => false),
resolveGeminiDir: vi.fn(() => null),
}));
import fs from 'node:fs/promises';
import { existsSync, readdirSync } from 'node:fs';
import { subagentWatcher } from '../../src/subagent-watcher.js';
import { getLifecycleLog } from '../../src/session-lifecycle-log.js';
import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js';
import { isGeminiAvailable, resolveGeminiDir } from '../../src/utils/gemini-cli-resolver.js';
const mockedReadFile = vi.mocked(fs.readFile);
const mockedWriteFile = vi.mocked(fs.writeFile);
@@ -90,6 +96,8 @@ const mockedSubagentWatcher = vi.mocked(subagentWatcher);
const mockedGetLifecycleLog = vi.mocked(getLifecycleLog);
const mockedIsOpenCodeAvailable = vi.mocked(isOpenCodeAvailable);
const mockedResolveOpenCodeDir = vi.mocked(resolveOpenCodeDir);
const mockedIsGeminiAvailable = vi.mocked(isGeminiAvailable);
const mockedResolveGeminiDir = vi.mocked(resolveGeminiDir);
describe('system-routes', () => {
let harness: RouteTestHarness;
@@ -117,6 +125,8 @@ describe('system-routes', () => {
} as never);
mockedIsOpenCodeAvailable.mockReturnValue(false);
mockedResolveOpenCodeDir.mockReturnValue(null);
mockedIsGeminiAvailable.mockReturnValue(false);
mockedResolveGeminiDir.mockReturnValue(null);
});
afterEach(async () => {
@@ -167,7 +177,7 @@ describe('system-routes', () => {
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.message ?? body.error).toBeTruthy();
});
});
@@ -422,7 +432,7 @@ describe('system-routes', () => {
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.message ?? body.error).toBeTruthy();
});
it('saves lastUsedCase as partial update without overwriting other settings', async () => {
@@ -520,7 +530,7 @@ describe('system-routes', () => {
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.message ?? body.error).toBeTruthy();
});
});
@@ -577,7 +587,7 @@ describe('system-routes', () => {
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.message ?? body.error).toBeTruthy();
});
});
@@ -765,6 +775,32 @@ describe('system-routes', () => {
});
});
// ========== GET /api/gemini/status ==========
describe('GET /api/gemini/status', () => {
it('returns unavailable when gemini is not installed', async () => {
mockedIsGeminiAvailable.mockReturnValue(false);
mockedResolveGeminiDir.mockReturnValue(null);
const res = await harness.app.inject({ method: 'GET', url: '/api/gemini/status' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.available).toBe(false);
expect(body.path).toBeNull();
});
it('returns available with path when gemini is installed', async () => {
mockedIsGeminiAvailable.mockReturnValue(true);
mockedResolveGeminiDir.mockReturnValue('/usr/local/bin');
const res = await harness.app.inject({ method: 'GET', url: '/api/gemini/status' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.available).toBe(true);
expect(body.path).toBe('/usr/local/bin');
});
});
// ========== GET /api/execution/model-config ==========
describe('GET /api/execution/model-config', () => {
+10
View File
@@ -61,6 +61,16 @@ describe('run mode UI', () => {
expect(app.runMode).toBe('claude');
expect(runBtnLabel.textContent).toBe('Run');
});
it('accepts Gemini mode from server sync and updates the run button label', async () => {
const { app, storage, runBtnLabel } = loadRunModeHarness();
storage.set('codeman_runMode', 'claude');
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'gemini' }));
expect(app.runMode).toBe('gemini');
expect(runBtnLabel.textContent).toBe('Run GM');
});
});
describe('Codex quick start settings', () => {