Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/daemon/src/lib/agent/model-switch-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { ErrorCategory } from '../error-manager';
import type { Logger } from '../logger';
import { isValidModel, resolveModelAlias, getModelInfo } from '../model-service';
import { getProviderRegistry } from '../providers/factory.js';
import { inferProviderForModel } from '../providers/registry';
import { KimiProvider } from '../providers/kimi-provider.js';
import { stripThinkingBlocksFromSessionFile } from '../sdk-session-file-manager';
import type { ContextTracker } from './context-tracker';
Expand Down Expand Up @@ -172,7 +173,13 @@ export class ModelSwitchHandler {
} = this.ctx;

const previousModel = session.config.model;
const previousProvider = session.config.provider;
// Infer the provider from the stored model when the session config has none.
// Long-horizon/worker agent sessions created before provider inference was added
// may have a model but a blank provider; without this they are hard-blocked from
// ever switching models. Downstream usages (alias resolution, acp handling) only
// become more correct with the inferred value.
const previousProvider =
session.config.provider ?? (previousModel ? inferProviderForModel(previousModel) : undefined);
const previousAcpSessionId = session.acpSessionId;
const previousSdkSessionId = session.sdkSessionId;
const previousSdkOriginPath = session.sdkOriginPath;
Expand Down
24 changes: 17 additions & 7 deletions packages/daemon/src/lib/space/runtime/space-runtime-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { createSpaceAgentMcpServer } from '../tools/space-agent-tools';
import type { ReplyRoutingRegistry } from './reply-routing-registry';
import { buildSpaceChatSystemPrompt } from '../agents/space-chat-agent';
import { resolveCustomAgentPrompt } from '../agents/custom-agent';
import { inferProviderForModel } from '../../providers/registry';
import { Logger } from '../../logger';
import { createDbQueryMcpServer, type DbQueryMcpServer } from '../../db-query/tools';
import { createAgentMemoryMcpServer } from '../tools/agent-memory-tools';
Expand Down Expand Up @@ -515,12 +516,19 @@ export class SpaceRuntimeService {
const customDisallowedBuiltins = deriveWorkerDisallowedTools(customTools);
const agentKey = sanitizeLongTermAgentKey(agent.displayName);

const model =
agent.model ??
space.defaultModel ??
(agent.provider ? undefined : DEFAULT_LONG_HORIZON_AGENT_MODEL);
// Infer provider from the model when the agent record has no explicit provider,
// mirroring the worker agent pattern (custom-agent.ts). Without this, a LH agent
// whose settings only set `model` runs fine but is hard-blocked from switching
// models ("Session has no provider configured").
const provider = (agent.provider ??
(model ? inferProviderForModel(model) : undefined)) as Session['config']['provider'];
return {
model:
agent.model ??
space.defaultModel ??
(agent.provider ? undefined : DEFAULT_LONG_HORIZON_AGENT_MODEL),
provider: (agent.provider ?? undefined) as Session['config']['provider'],
model,
provider,
thinkingLevel: agent.thinkingLevel ?? undefined,
systemPrompt: {
type: 'preset',
Expand Down Expand Up @@ -646,9 +654,11 @@ export class SpaceRuntimeService {
const customTools = agent.tools;
const customDisallowedBuiltins = deriveWorkerDisallowedTools(customTools);
const agentKey = sanitizeLongTermAgentKey(agent.name);
const model = agent.model ?? space.defaultModel;
const regularAgentConfig: Partial<Session['config']> = {
model: agent.model ?? space.defaultModel,
provider: agent.provider as Session['config']['provider'],
model,
provider: (agent.provider ??
(model ? inferProviderForModel(model) : undefined)) as Session['config']['provider'],
thinkingLevel: agent.thinkingLevel,
systemPrompt: {
type: 'preset',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -511,16 +511,45 @@ describe('ModelSwitchHandler', () => {
expect(handleErrorSpy).toHaveBeenCalled();
});

it('should return error when session has no provider configured', async () => {
// Remove provider from session config
it('should return error when session has no provider and no model to infer from', async () => {
// No provider AND no model that could infer one — the guard still throws.
(mockSession.config as Record<string, unknown>).provider = undefined;
(mockSession.config as Record<string, unknown>).model = undefined;
handler = createHandler({ queryObject: null });

const result = await handler.switchModel(VALID_MODEL, 'anthropic');

expect(result.success).toBe(false);
expect(result.error).toContain('Session has no provider configured');
});

it('infers provider from the stored model when provider is empty (Task #768)', async () => {
// Long-horizon/worker agent session: model is set but provider was never
// stored. Previously this was hard-blocked with "Session has no provider
// configured"; the guard now infers the provider from the model.
mockSession.config.model = 'glm-5';
(mockSession.config as Record<string, unknown>).provider = undefined;
handler = createHandler({ queryObject: null });

const result = await handler.switchModel(VALID_MODEL, 'anthropic');

expect(result.success).toBe(true);
expect(mockSession.config.provider).toBe('anthropic');
});

it('switches a kimi session whose provider was never stored, even to the same model (Task #768 regression)', async () => {
// Reproduces the original bug report: an LH agent with model='kimi-...' and a
// blank provider could not switch models — not even kimi → kimi.
mockSession.config.model = 'kimi-k3[1m]';
(mockSession.config as Record<string, unknown>).provider = undefined;
handler = createHandler({ queryObject: null });

const result = await handler.switchModel('k3[1m]', 'kimi');

expect(result.success).toBe(true);
expect(mockSession.config.model).toBe('kimi-k3[1m]');
expect(mockSession.config.provider).toBe('kimi');
});
});

describe('context tracker update', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ import type { TaskAgentManager } from '../../../../src/lib/space/runtime/task-ag
import type { SessionManager } from '../../../../src/lib/session-manager.ts';
import type { AgentSession } from '../../../../src/lib/agent/agent-session.ts';
import type { NodeExecutionRepository } from '../../../../src/storage/repositories/node-execution-repository.ts';
import type { McpServerConfig, Session, Space } from '@hyperneo/shared';
import type {
McpServerConfig,
Session,
Space,
SpaceLongHorizonAgent,
SpaceWorkerAgent,
} from '@hyperneo/shared';
import { createTables, runMigrations } from '../../../../src/storage/schema/index.ts';
import { SpaceWorkflowRepository } from '../../../../src/storage/repositories/space-workflow-repository.ts';
import { SpaceWorkflowRunRepository as SpaceWorkflowRunRepo } from '../../../../src/storage/repositories/space-workflow-run-repository.ts';
Expand Down Expand Up @@ -89,6 +95,32 @@ function makeNoopNodeExecutionRepo(): NodeExecutionRepository {
} as unknown as NodeExecutionRepository;
}

// Minimal long-horizon agent record for config-builder tests. Only the fields the
// builder reads are populated; the rest are defaulted and cast.
function buildLongHorizonAgent(
overrides: Partial<SpaceLongHorizonAgent> = {}
): SpaceLongHorizonAgent {
return {
id: 'lh-agent-1',
spaceId: 'space-1',
handle: 'researcher',
displayName: 'Researcher',
templateKey: null,
status: 'active',
sessionId: null,
instructions: '',
autonomyLevel: null,
model: null,
thinkingLevel: null,
provider: null,
settingSources: null,
toolPermissions: { mode: 'inherit', tools: [] },
createdAt: NOW,
updatedAt: NOW,
...overrides,
} as SpaceLongHorizonAgent;
}

// ─── Tests ────────────────────────────────────────────────────────────────────

describe('SpaceRuntimeService', () => {
Expand Down Expand Up @@ -2202,3 +2234,192 @@ describe('activateWorkflowNode() — InternalEventBus forwarding', () => {
}
});
});

// ─── Long-horizon agent provider inference (Task #768) ──────────────────────
//
// A LH/worker agent whose settings set `model` but left `provider` null used to
// run fine (provider inferred at query time) but was hard-blocked from switching
// models ("Session has no provider configured"). The config builders now infer
// the provider from the model, mirroring the worker pattern in custom-agent.ts.

describe('buildLongHorizonAgentSessionConfig — provider inference (Task #768)', () => {
const service = new SpaceRuntimeService(buildConfig(createMockSpaceManager(mockSpace)));

function callBuilder(agent: SpaceLongHorizonAgent): Partial<Session['config']> {
return (
service as unknown as {
buildLongHorizonAgentSessionConfig: (
space: Space,
a: SpaceLongHorizonAgent
) => Partial<Session['config']>;
}
).buildLongHorizonAgentSessionConfig(mockSpace, agent);
}

test('infers kimi provider when model is kimi and provider is unset', () => {
const config = callBuilder(buildLongHorizonAgent({ model: 'kimi-for-coding' }));
expect(config.model).toBe('kimi-for-coding');
expect(config.provider).toBe('kimi');
});

test('infers anthropic provider for a non-kimi model when provider is unset', () => {
const config = callBuilder(buildLongHorizonAgent({ model: 'claude-sonnet-4.6' }));
expect(config.model).toBe('claude-sonnet-4.6');
expect(config.provider).toBe('anthropic');
});

test('explicit agent.provider wins over inference', () => {
const config = callBuilder(
buildLongHorizonAgent({ model: 'kimi-for-coding', provider: 'openrouter' })
);
expect(config.provider).toBe('openrouter');
});

test('falls back to the default model + inferred anthropic provider when neither is set', () => {
const config = callBuilder(buildLongHorizonAgent({}));
expect(config.model).toBe('claude-sonnet-4-6'); // DEFAULT_LONG_HORIZON_AGENT_MODEL
expect(config.provider).toBe('anthropic');
});
});

describe('refreshLongHorizonAgentSessionConfig — self-heals undefined provider (Task #768)', () => {
const service = new SpaceRuntimeService(buildConfig(createMockSpaceManager(mockSpace)));
const refresh = () =>
service as unknown as {
refreshLongHorizonAgentSessionConfig: (
session: AgentSession,
config: Partial<Session['config']>
) => Promise<void>;
};

test('updates provider undefined → kimi on the next wake', async () => {
// A stranded session (provider never stored) is refreshed with a config whose
// provider was inferred from the model. refresh must detect the change and
// call updateConfig + resetQuery so the session repairs itself.
const updateConfig = mock(async () => {});
const resetQuery = mock(async () => ({ success: true }));
const stranded = {
getSessionData: () => ({ config: { model: 'kimi-for-coding', provider: undefined } }),
updateConfig,
resetQuery,
} as unknown as AgentSession;

const built = (
service as unknown as {
buildLongHorizonAgentSessionConfig: (
s: Space,
a: SpaceLongHorizonAgent
) => Partial<Session['config']>;
}
).buildLongHorizonAgentSessionConfig(
mockSpace,
buildLongHorizonAgent({ model: 'kimi-for-coding' })
);

await refresh().refreshLongHorizonAgentSessionConfig(stranded, built);

expect(updateConfig).toHaveBeenCalledTimes(1);
expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({ provider: 'kimi' }));
expect(resetQuery).toHaveBeenCalledWith({ restartQuery: true });
});

test('is a no-op when the provider already matches', async () => {
const updateConfig = mock(async () => {});
const resetQuery = mock(async () => ({ success: true }));
const session = {
getSessionData: () => ({ config: { model: 'kimi-for-coding', provider: 'kimi' } }),
updateConfig,
resetQuery,
} as unknown as AgentSession;

await refresh().refreshLongHorizonAgentSessionConfig(session, {
model: 'kimi-for-coding',
provider: 'kimi',
});

expect(updateConfig).not.toHaveBeenCalled();
expect(resetQuery).not.toHaveBeenCalled();
});
});

describe('ensureLongTermAgentSession — regular worker agent provider inference (Task #768)', () => {
async function captureRegularAgentConfig(
agent: SpaceWorkerAgent
): Promise<Partial<Session['config']> | undefined> {
const createdConfigs: Partial<Session['config']>[] = [];
let lookups = 0;
const sessionMock = {
getSessionData: () => ({ id: 'sess-1', metadata: {} }),
mergeRuntimeMcpServers: () => {},
};
const sessionManager = {
getSessionAsync: mock(async () => {
lookups += 1;
return lookups === 1 ? null : sessionMock;
}),
createSession: mock(async (opts: { config: Partial<Session['config']> }) => {
createdConfigs.push(opts.config);
}),
} as unknown as SessionManager;
const spaceAgentManager = {
getById: mock(() => agent),
} as unknown as SpaceAgentManager;

const svc = new SpaceRuntimeService({
...buildConfig(createMockSpaceManager(mockSpace)),
sessionManager,
spaceAgentManager,
});
// longHorizonAgentRepo intentionally unset → regular worker-agent branch.
// Avoid real MCP wiring; the config under test is built before attach runs.
(
svc as unknown as { attachLongTermAgentMcpServers: () => void }
).attachLongTermAgentMcpServers = () => {};
(
svc as unknown as { missingLongTermAgentMcpServers: () => boolean }
).missingLongTermAgentMcpServers = () => false;

await (
svc as unknown as { ensureLongTermAgentSession: (a: ActorRef) => Promise<unknown> }
).ensureLongTermAgentSession({
actorId: `agent:${agent.id}`,
spaceId: agent.spaceId,
} as ActorRef);

return createdConfigs[0];
}

test('infers kimi provider for a kimi model with no explicit provider', async () => {
const config = await captureRegularAgentConfig({
id: 'worker-1',
spaceId: 'space-1',
name: 'Worker',
model: 'kimi-for-coding',
provider: null,
thinkingLevel: null,
customPrompt: '',
tools: [],
settingSources: null,
} as unknown as SpaceWorkerAgent);

expect(config).toBeDefined();
expect(config?.model).toBe('kimi-for-coding');
expect(config?.provider).toBe('kimi');
});

test('explicit provider wins over inference for regular agents', async () => {
const config = await captureRegularAgentConfig({
id: 'worker-2',
spaceId: 'space-1',
name: 'Worker',
model: 'kimi-for-coding',
provider: 'openrouter',
thinkingLevel: null,
customPrompt: '',
tools: [],
settingSources: null,
} as unknown as SpaceWorkerAgent);

expect(config?.provider).toBe('openrouter');
});
});
Loading