feat(gemini): remove launchable Gemini CLI agent, keep old sessions readable (#953)

* feat(gemini): remove launchable Gemini CLI agent, keep sessions readable

Google sunset the consumer Gemini CLI (Pro/Ultra/free tiers stopped
serving requests 2026-06-18). This removes the ability to launch/create
Gemini CLI sessions while keeping existing stored Gemini sessions fully
readable in the web UI.

Removed (no longer launchable):
- cli/src/gemini/ runtime (runGemini, loop, local/remote launchers,
  session, ACP backend, config, scanner) + GeminiDisplay ink view
- `hapi gemini` command + registry entry + usage line
- runner spawn branch & buildCliArgs mapping now reject gemini with a
  clear error; resume dispatch throws a clear "no longer supported" error
- gemini dropped from the new-session agent selector via new
  CREATABLE_AGENT_FLAVORS, and from preferred-agent defaults

Kept (read path — existing sessions still validate, load, render):
- `gemini` in AGENT_FLAVORS / AgentFlavorSchema, FLAVOR_CAPS / FLAVOR_LABELS
- AgentFlavorIcon badge, model-option labels, ACP message normalization,
  metadata.geminiSessionId, hub session dedup/resume-id

Note: the Gemini *Live voice* backend is a separate feature and is
untouched.

Adds read-guarantee tests (stored gemini validates; excluded from
creatable). typecheck + full suite green.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gemini): reject gemini resume before handoff (#953 review)

HAPI Bot [Major]: `hapi resume <active-gemini-session>` called
handoffSessionToLocal() — which tells the running remote agent to exit —
before reaching the gemini-unsupported throw in dispatchLocalResume, so
it could stop the live/readable session and then fail locally.

Move the gemini guard into resumeCommand.run before the handoff, so an
active Gemini session is left running/readable instead of being stopped.
Keep the dispatch-layer guard as defense-in-depth. Adds a regression test
asserting handoffSessionToLocal is not called for an active gemini target.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gemini): harden against stale gemini input (#953 review)

Two [Minor] follow-ups from HAPI Bot:
- newSessionFormDraft: coerce a restored browse draft's agent to a
  creatable flavor, so a pre-removal 'gemini' draft cannot submit
  agent:'gemini' even though the selector no longer offers it.
- buildCliArgs: reject 'gemini' explicitly instead of silently falling
  through to the 'claude' command if the exported helper is reused
  outside the guarded spawnSession path.

Updated the buildCliArgs precedence test to a creatable agent and added
a test asserting buildCliArgs('gemini') throws.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gemini): reset dependent draft fields when coercing stale agent (#953 review)

Follow-up [Minor]: coercing a stale gemini draft's agent to claude left
model/base/effort untouched, so a { agent:'gemini', model:'gemini-2.5-pro' }
draft restored as claude *with* a Gemini model, which handleCreate() then
sent to the runner. Now reset model / cursorSelectedBase / effort /
modelReasoningEffort to defaults whenever the agent is coerced.

Adds a regression test.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(gemini): tombstone `hapi gemini` so it errors clearly (#953 review)

HAPI Bot [Major]: after removing geminiCommand from the registry,
resolveCommand() treats `gemini` as an unknown subcommand and falls
through to the default Claude command (forwarding "gemini" as an arg),
so `hapi gemini` silently started Claude instead of reporting the sunset.

Add an explicit tombstone `gemini` command that prints the sunset error
and exits 1.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(web): assert AgentSelector hides the sunset Gemini agent (#953)

Render regression test confirming the new-session AgentSelector offers
exactly CREATABLE_AGENT_FLAVORS and never shows a Gemini radio.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
SSU-WEI HUANG
2026-06-29 11:42:41 +08:00
committed by GitHub
co-authored by HAPI Claude Opus 4.8
parent 26a24bb6ce
commit b44885ae67
28 changed files with 182 additions and 2170 deletions
@@ -0,0 +1,28 @@
import { describe, it, expect, vi } from 'vitest'
import { render } from '@testing-library/react'
import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol'
vi.mock('@/lib/use-translation', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
import { AgentSelector } from './AgentSelector'
import type { AgentType } from './types'
function renderedAgentValues(): string[] {
const { container } = render(
<AgentSelector agent={'claude' as AgentType} isDisabled={false} onAgentChange={() => {}} />
)
return Array.from(container.querySelectorAll('input[type="radio"]'))
.map((el) => (el as HTMLInputElement).value)
}
describe('AgentSelector', () => {
it('does not offer the sunset Gemini CLI as a new-session agent', () => {
expect(renderedAgentValues()).not.toContain('gemini')
})
it('offers exactly the creatable agent flavors', () => {
expect(renderedAgentValues()).toEqual([...CREATABLE_AGENT_FLAVORS])
})
})
@@ -1,4 +1,4 @@
import { AGENT_FLAVORS } from '@hapi/protocol'
import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol'
import type { AgentType } from './types'
import { useTranslation } from '@/lib/use-translation'
@@ -15,7 +15,7 @@ export function AgentSelector(props: {
{t('newSession.agent')}
</label>
<div className="flex flex-wrap gap-x-3 gap-y-2">
{AGENT_FLAVORS.map((agentType) => (
{CREATABLE_AGENT_FLAVORS.map((agentType) => (
<label
key={agentType}
className="flex items-center gap-1.5 cursor-pointer"
@@ -80,4 +80,29 @@ describe('newSessionFormDraft', () => {
const draft = loadNewSessionFormDraft()!
expect(newSessionDraftMatchesMachine(draft, 'machine-b')).toBe(false)
})
it('coerces a stale uncreatable agent (gemini) to claude and resets dependent fields', () => {
saveNewSessionFormDraft({
agent: 'gemini',
model: 'gemini-2.5-pro',
cursorSelectedBase: 'composer-2.5',
machineId: 'machine-1',
effort: 'high',
modelReasoningEffort: 'high',
yoloMode: true,
sessionType: 'simple',
worktreeName: ''
})
const loaded = loadNewSessionFormDraft()!
expect(loaded.agent).toBe('claude')
// agent-dependent fields reset so a Gemini model isn't carried into Claude
expect(loaded.model).toBe('auto')
expect(loaded.cursorSelectedBase).toBe('auto')
expect(loaded.effort).toBe('auto')
expect(loaded.modelReasoningEffort).toBe('default')
// agent-independent fields preserved
expect(loaded.yoloMode).toBe(true)
expect(loaded.machineId).toBe('machine-1')
})
})
@@ -1,3 +1,4 @@
import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol'
import type { AgentType, ClaudeEffort, CodexReasoningEffort, SessionType } from './types'
const DRAFT_STORAGE_KEY = 'hapi:new-session-form-draft'
@@ -32,13 +33,25 @@ export function loadNewSessionFormDraft(): NewSessionFormDraft | null {
if (typeof parsed.agent !== 'string' || typeof parsed.model !== 'string') {
return null
}
// Coerce a stale/uncreatable agent (e.g. a pre-removal 'gemini' draft)
// back to a launchable default. When the agent is coerced, also drop the
// agent-dependent fields (model / cursor base / effort) so a Gemini
// draft does not carry a Gemini model into the Claude fallback.
const restoredAgent: AgentType = (CREATABLE_AGENT_FLAVORS as readonly string[]).includes(parsed.agent)
? (parsed.agent as AgentType)
: 'claude'
const agentPreserved = restoredAgent === parsed.agent
return {
agent: parsed.agent as AgentType,
model: parsed.model,
cursorSelectedBase: typeof parsed.cursorSelectedBase === 'string' ? parsed.cursorSelectedBase : 'auto',
agent: restoredAgent,
model: agentPreserved ? parsed.model : 'auto',
cursorSelectedBase: agentPreserved && typeof parsed.cursorSelectedBase === 'string'
? parsed.cursorSelectedBase
: 'auto',
machineId: typeof parsed.machineId === 'string' ? parsed.machineId : null,
effort: (parsed.effort as ClaudeEffort | undefined) ?? 'auto',
modelReasoningEffort: (parsed.modelReasoningEffort as CodexReasoningEffort | undefined) ?? 'default',
effort: agentPreserved ? ((parsed.effort as ClaudeEffort | undefined) ?? 'auto') : 'auto',
modelReasoningEffort: agentPreserved
? ((parsed.modelReasoningEffort as CodexReasoningEffort | undefined) ?? 'default')
: 'default',
yoloMode: Boolean(parsed.yoloMode),
sessionType: (parsed.sessionType as SessionType | undefined) ?? 'simple',
worktreeName: typeof parsed.worktreeName === 'string' ? parsed.worktreeName : ''
+4 -2
View File
@@ -1,10 +1,12 @@
import { AGENT_FLAVORS } from '@hapi/protocol'
import { CREATABLE_AGENT_FLAVORS } from '@hapi/protocol'
import type { AgentType } from './types'
const AGENT_STORAGE_KEY = 'hapi:newSession:agent'
const YOLO_STORAGE_KEY = 'hapi:newSession:yolo'
const VALID_AGENTS = AGENT_FLAVORS
// Only launchable flavors are valid defaults; a stale 'gemini' preference
// (no longer creatable) falls back to 'claude'.
const VALID_AGENTS = CREATABLE_AGENT_FLAVORS
export function loadPreferredAgent(): AgentType {
try {