fix(cli): preserve active Claude model on local handoff (#1168)

This commit is contained in:
King Star
2026-07-26 15:04:12 +08:00
committed by GitHub
parent 60d7489a59
commit dbb063c112
6 changed files with 225 additions and 4 deletions
+115
View File
@@ -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')
})
})
+31 -3
View File
@@ -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<string, string>,
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
@@ -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)
+1
View File
@@ -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,
+63
View File
@@ -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' })
})
})
+6 -1
View File
@@ -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) {