mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(cli): preserve active Claude model on local handoff (#1168)
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user