From dbb063c1120a1b6136da548c113fe72d21821c34 Mon Sep 17 00:00:00 2001 From: King Star Date: Sun, 26 Jul 2026 15:04:12 +0800 Subject: [PATCH] fix(cli): preserve active Claude model on local handoff (#1168) --- cli/src/claude/claudeLocal.test.ts | 115 +++++++++++++++++++++ cli/src/claude/claudeLocal.ts | 34 +++++- cli/src/claude/claudeLocalLauncher.test.ts | 9 ++ cli/src/claude/claudeLocalLauncher.ts | 1 + cli/src/commands/claude.test.ts | 63 +++++++++++ cli/src/commands/claude.ts | 7 +- 6 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 cli/src/claude/claudeLocal.test.ts create mode 100644 cli/src/commands/claude.test.ts diff --git a/cli/src/claude/claudeLocal.test.ts b/cli/src/claude/claudeLocal.test.ts new file mode 100644 index 00000000..7ecc67fd --- /dev/null +++ b/cli/src/claude/claudeLocal.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const harness = vi.hoisted(() => ({ + spawn: vi.fn(async (_opts: unknown) => {}) +})) + +vi.mock('node:fs', () => ({ + mkdirSync: vi.fn() +})) + +vi.mock('@/ui/logger', () => ({ + logger: { debug: vi.fn() } +})) + +vi.mock('./utils/claudeCheckSession', () => ({ + claudeCheckSession: () => true +})) + +vi.mock('./utils/path', () => ({ + getProjectPath: () => '/tmp/claude-project' +})) + +vi.mock('./utils/mcpConfig', () => ({ + appendMcpConfigArg: () => undefined +})) + +vi.mock('./utils/systemPrompt', () => ({ + systemPrompt: 'HAPI system prompt' +})) + +vi.mock('@/utils/bunRuntime', () => ({ + withBunRuntimeEnv: (env: NodeJS.ProcessEnv) => env +})) + +vi.mock('@/utils/spawnWithTerminalGuard', () => ({ + spawnWithTerminalGuard: harness.spawn +})) + +vi.mock('@/constants/uploadPaths', () => ({ + getHapiBlobsDir: () => '/tmp/hapi-blobs' +})) + +vi.mock('@/utils/shellEscape', () => ({ + stripNewlinesForWindowsShellArg: (value: string) => value +})) + +vi.mock('./sdk/utils', () => ({ + getDefaultClaudeCodePath: () => '/usr/bin/claude' +})) + +import { claudeLocal } from './claudeLocal' + +function getSpawnArgs(): string[] { + const call = harness.spawn.mock.calls[0] + expect(call).toBeDefined() + return (call[0] as { args: string[] }).args +} + +describe('claudeLocal model arguments', () => { + beforeEach(() => { + harness.spawn.mockClear() + }) + + it('launches Claude with the current session model', async () => { + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/workspace', + hookSettingsPath: '/tmp/hooks.json', + model: 'claude-opus-4-1' + }) + + expect(getSpawnArgs()).toEqual([ + '--append-system-prompt', 'HAPI system prompt', + '--model', 'claude-opus-4-1', + '--settings', '/tmp/hooks.json', + '--add-dir', '/tmp/hapi-blobs' + ]) + }) + + it('replaces a stale startup model with the current session model', async () => { + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/workspace', + hookSettingsPath: '/tmp/hooks.json', + claudeArgs: ['--model', 'claude-haiku-4-5', '--verbose'], + model: 'claude-opus-4-1' + }) + + const args = getSpawnArgs() + expect(args).not.toContain('claude-haiku-4-5') + expect(args).toContain('--verbose') + expect(args.filter((arg) => arg === '--model')).toHaveLength(1) + expect(args.slice(args.indexOf('--model'), args.indexOf('--model') + 2)).toEqual([ + '--model', 'claude-opus-4-1' + ]) + }) + + it('removes a stale startup model when the session returns to the default', async () => { + await claudeLocal({ + abort: new AbortController().signal, + sessionId: null, + path: '/workspace', + hookSettingsPath: '/tmp/hooks.json', + claudeArgs: ['--model=claude-haiku-4-5', '--verbose'], + model: null + }) + + const args = getSpawnArgs() + expect(args).not.toContain('--model=claude-haiku-4-5') + expect(args).not.toContain('--model') + expect(args).toContain('--verbose') + }) +}) diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/claude/claudeLocal.ts index 04edc2ee..07b28c78 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/claude/claudeLocal.ts @@ -9,6 +9,27 @@ import { spawnWithTerminalGuard } from "@/utils/spawnWithTerminalGuard"; import { getHapiBlobsDir } from "@/constants/uploadPaths"; import { stripNewlinesForWindowsShellArg } from "@/utils/shellEscape"; import { getDefaultClaudeCodePath } from "./sdk/utils"; +import type { SessionModel } from "@/api/types"; + +function withoutTrackedModelArgs(args: string[]): string[] { + const filtered: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--model') { + if (i + 1 < args.length && !args[i + 1].startsWith('-')) { + i++; + } + continue; + } + if (arg.startsWith('--model=')) { + continue; + } + filtered.push(arg); + } + + return filtered; +} export async function claudeLocal(opts: { abort: AbortSignal, @@ -17,6 +38,7 @@ export async function claudeLocal(opts: { path: string, claudeEnvVars?: Record, claudeArgs?: string[] + model?: SessionModel allowedTools?: string[] hookSettingsPath: string }) { @@ -61,9 +83,15 @@ export async function claudeLocal(opts: { args.push('--allowedTools', opts.allowedTools.join(',')); } - // Add custom Claude arguments - if (opts.claudeArgs) { - args.push(...opts.claudeArgs); + // Once model state is available, it is authoritative over startup args. + const claudeArgs = opts.model === undefined || !opts.claudeArgs + ? opts.claudeArgs + : withoutTrackedModelArgs(opts.claudeArgs); + if (claudeArgs) { + args.push(...claudeArgs); + } + if (opts.model) { + args.push('--model', opts.model); } // Add hook settings for session tracking diff --git a/cli/src/claude/claudeLocalLauncher.test.ts b/cli/src/claude/claudeLocalLauncher.test.ts index 711ca09b..73758a14 100644 --- a/cli/src/claude/claudeLocalLauncher.test.ts +++ b/cli/src/claude/claudeLocalLauncher.test.ts @@ -45,6 +45,7 @@ function createSessionStub() { startingMode: 'local' as const, claudeEnvVars: {}, claudeArgs: [], + getModel: () => 'claude-opus-4-1', mcpServers: [], allowedTools: [], hookSettingsPath: null, @@ -83,6 +84,14 @@ describe('claudeLocalLauncher message filtering', () => { expect(getMetadata().summary?.text).toBe('Native title') }) + it('passes the current session model to the local Claude process', async () => { + const { session } = createSessionStub() + + await claudeLocalLauncher(session as never) + + expect(harness.launches[0]).toMatchObject({ model: 'claude-opus-4-1' }) + }) + it('converts Claude Code ai-title metadata into a HAPI title', async () => { const { session, sentMessages, getMetadata } = createSessionStub() await claudeLocalLauncher(session as never) diff --git a/cli/src/claude/claudeLocalLauncher.ts b/cli/src/claude/claudeLocalLauncher.ts index 88732790..736540be 100644 --- a/cli/src/claude/claudeLocalLauncher.ts +++ b/cli/src/claude/claudeLocalLauncher.ts @@ -58,6 +58,7 @@ export async function claudeLocalLauncher(session: Session): Promise<'switch' | abort: abortSignal, claudeEnvVars: session.claudeEnvVars, claudeArgs: session.claudeArgs, + model: session.getModel(), mcpServers: session.mcpServers, allowedTools: session.allowedTools, hookSettingsPath: session.hookSettingsPath, diff --git a/cli/src/commands/claude.test.ts b/cli/src/commands/claude.test.ts new file mode 100644 index 00000000..d9c979bd --- /dev/null +++ b/cli/src/commands/claude.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + initializeTokenMock, + maybeAutoStartServerMock, + authAndSetupMachineIfNeededMock, + runClaudeMock +} = vi.hoisted(() => ({ + initializeTokenMock: vi.fn(async () => {}), + maybeAutoStartServerMock: vi.fn(async () => {}), + authAndSetupMachineIfNeededMock: vi.fn(async () => {}), + runClaudeMock: vi.fn(async () => {}) +})) + +vi.mock('@/ui/tokenInit', () => ({ + initializeToken: initializeTokenMock +})) + +vi.mock('@/utils/autoStartServer', () => ({ + maybeAutoStartServer: maybeAutoStartServerMock +})) + +vi.mock('@/ui/auth', () => ({ + authAndSetupMachineIfNeeded: authAndSetupMachineIfNeededMock +})) + +vi.mock('@/runner/controlClient', () => ({ + isRunnerRunningCurrentlyInstalledHappyVersion: async () => true +})) + +vi.mock('@/claude/runClaude', () => ({ + runClaude: runClaudeMock +})) + +import { claudeCommand } from './claude' + +function createCommandContext(commandArgs: string[]) { + return { + args: commandArgs, + commandArgs + } +} + +describe('claudeCommand model arguments', () => { + beforeEach(() => { + initializeTokenMock.mockClear() + maybeAutoStartServerMock.mockClear() + authAndSetupMachineIfNeededMock.mockClear() + runClaudeMock.mockClear() + }) + + it('tracks --model as session state instead of an opaque Claude argument', async () => { + await claudeCommand.run(createCommandContext(['--model', 'claude-opus-4-1'])) + + expect(runClaudeMock).toHaveBeenCalledWith({ model: 'claude-opus-4-1' }) + }) + + it('supports the --model=value form as session state', async () => { + await claudeCommand.run(createCommandContext(['--model=claude-opus-4-1'])) + + expect(runClaudeMock).toHaveBeenCalledWith({ model: 'claude-opus-4-1' }) + }) +}) diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index 367a0d12..4b959383 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -57,7 +57,12 @@ export const claudeCommand: CommandDefinition = { throw new Error('Missing --model value') } options.model = model - unknownArgs.push('--model', model) + } else if (arg.startsWith('--model=')) { + const model = arg.slice('--model='.length) + if (!model) { + throw new Error('Missing --model value') + } + options.model = model } else if (arg === '--effort') { const effort = args[++i] if (!effort) {