fix(opencode): switch session models without restart
This commit is contained in:
@@ -29,10 +29,11 @@ function context(overrides: Partial<ChatCommandContext> = {}): ChatCommandContex
|
||||
describe('chat command registry', () => {
|
||||
it('defines the approved built-ins and aliases', () => {
|
||||
expect(BUILTIN_CHAT_COMMANDS.map((command) => command.name)).toEqual([
|
||||
'share', 'rename', 'timeline', 'compact', 'unshare',
|
||||
'share', 'rename', 'timeline', 'models', 'compact', 'unshare',
|
||||
'undo', 'redo', 'timestamps', 'thinking', 'process', 'copy', 'export',
|
||||
]);
|
||||
const catalog = mergeChatCommandCatalog([]);
|
||||
expect(resolveCommandName('model', catalog)?.name).toBe('models');
|
||||
expect(resolveCommandName('summarize', catalog)?.name).toBe('compact');
|
||||
expect(resolveCommandName('toggle-timestamps', catalog)?.name).toBe('timestamps');
|
||||
expect(resolveCommandName('toggle-thinking', catalog)?.name).toBe('thinking');
|
||||
|
||||
@@ -2597,7 +2597,6 @@ describe('OpencodeChatPanel', () => {
|
||||
body: JSON.stringify({
|
||||
text: 'Use the local proxy',
|
||||
agent: 'manual-agent-1',
|
||||
model: 'niancode-user-models/deepseek-chat',
|
||||
}),
|
||||
}));
|
||||
expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/provider-accounts/import-user-model-config')).toBe(true);
|
||||
@@ -2727,7 +2726,7 @@ describe('OpencodeChatPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the selected partner model as read-only in the composer', async () => {
|
||||
it('switches the current Session model without rewriting the partner provider configuration', async () => {
|
||||
const activeProject = {
|
||||
id: 'prj_1',
|
||||
path: 'D:/repo/packages/ui',
|
||||
@@ -2834,6 +2833,10 @@ describe('OpencodeChatPanel', () => {
|
||||
if (path === '/api/opencode/sessions/ses_1/todos') {
|
||||
return { todos: [] };
|
||||
}
|
||||
if (path === '/api/opencode/sessions/ses_1/model' && init?.method === 'POST') {
|
||||
const payload = JSON.parse(String(init.body)) as { model: string };
|
||||
return { success: true, model: payload.model };
|
||||
}
|
||||
if (path === '/api/provider-accounts/niancode-user-models' && init?.method === 'PUT') {
|
||||
const payload = JSON.parse(String(init.body)) as { updates: Partial<typeof accountState> };
|
||||
accountState = { ...accountState, ...payload.updates };
|
||||
@@ -2856,14 +2859,30 @@ describe('OpencodeChatPanel', () => {
|
||||
|
||||
render(<OpencodeChatPanel variant="main" />);
|
||||
|
||||
const currentModel = await screen.findByTestId('opencode-current-agent-model');
|
||||
expect(currentModel).toHaveTextContent('deepseek-chat');
|
||||
expect(currentModel).not.toHaveTextContent('当前模型:');
|
||||
expect(currentModel).toHaveClass('shrink-0', 'whitespace-nowrap');
|
||||
expect(currentModel).not.toHaveClass('max-w-[200px]', 'truncate');
|
||||
expect(currentModel.tagName).toBe('SPAN');
|
||||
expect(screen.queryByTestId('opencode-model-selector-trigger')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('opencode-model-selector-menu')).not.toBeInTheDocument();
|
||||
const modelSelector = await screen.findByTestId('opencode-model-selector-trigger');
|
||||
expect(modelSelector).toHaveValue('niancode-user-models/deepseek-chat');
|
||||
|
||||
fireEvent.change(modelSelector, {
|
||||
target: { value: 'niancode-user-models/qwen3.6-plus' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(modelSelector).toHaveValue('niancode-user-models/qwen3.6-plus');
|
||||
});
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/opencode/sessions/ses_1/model',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model: 'niancode-user-models/qwen3.6-plus' }),
|
||||
},
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '/model' } });
|
||||
fireEvent.submit(screen.getByTestId('opencode-message-composer'));
|
||||
await waitFor(() => expect(modelSelector).toHaveFocus());
|
||||
|
||||
expect(useProjectConfigStore.getState().configsByProjectId.prj_1?.agents[0]?.model)
|
||||
.toBe('niancode-user-models/deepseek-chat');
|
||||
expect(hostApiFetchMock).not.toHaveBeenCalledWith('/api/provider-accounts/niancode-user-models', expect.objectContaining({ method: 'PUT' }));
|
||||
});
|
||||
|
||||
@@ -5427,7 +5446,7 @@ describe('OpencodeChatPanel', () => {
|
||||
expect(screen.queryByRole('button', { name: '跳到最新消息' })).not.toBeInTheDocument();
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/sessions/ses_1/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text: 'Ship it', agent: 'manual-agent-1', model: 'openai/gpt-5' }),
|
||||
body: JSON.stringify({ text: 'Ship it', agent: 'manual-agent-1' }),
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '刷新对话' })).not.toBeInTheDocument();
|
||||
@@ -5501,7 +5520,7 @@ describe('OpencodeChatPanel', () => {
|
||||
|
||||
await waitFor(() => expect(hostApiFetchMock).toHaveBeenCalledWith('/api/opencode/sessions/ses_1/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text: 'Ship it', agent: 'manual-agent-1', model: 'openai/gpt-5' }),
|
||||
body: JSON.stringify({ text: 'Ship it', agent: 'manual-agent-1' }),
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -6411,8 +6430,8 @@ describe('OpencodeChatPanel', () => {
|
||||
expect(postedBodies[0]).toMatchObject({
|
||||
text: 'Stale generation',
|
||||
agent: 'product-planning',
|
||||
model: 'openai/gpt-5',
|
||||
});
|
||||
expect(postedBodies[0]).not.toHaveProperty('model');
|
||||
});
|
||||
|
||||
it('does not clear a draft changed X to Y to X while the POST is pending', async () => {
|
||||
@@ -6587,9 +6606,7 @@ describe('OpencodeChatPanel', () => {
|
||||
expect(posted.command).toBe('Review');
|
||||
expect(posted.arguments).toBe(' staged changes ');
|
||||
expect(posted.agent).toBe('game-development');
|
||||
expect(posted.model).toBe(
|
||||
'openai/gpt-5',
|
||||
);
|
||||
expect(posted).not.toHaveProperty('model');
|
||||
expect(posted.parts).toEqual([
|
||||
{
|
||||
type: 'text',
|
||||
|
||||
@@ -169,6 +169,35 @@ describe('opencode client', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('switches the model used by subsequent turns through the OpenCode session API', async () => {
|
||||
const fetchImpl = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: 'http://127.0.0.1:4096',
|
||||
directory: 'D:/work/app',
|
||||
fetchImpl,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
|
||||
await client.switchSessionModel('ses_1', {
|
||||
providerID: 'niancode-user-models',
|
||||
modelID: 'qwen3.7-plus',
|
||||
}, { signal: controller.signal });
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:4096/api/session/ses_1/model?directory=D%3A%2Fwork%2Fapp',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
model: {
|
||||
providerID: 'niancode-user-models',
|
||||
modelID: 'qwen3.7-plus',
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('posts a text message to a session scoped to the selected folder', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(JSON.stringify({ id: 'msg_1', role: 'user' }), {
|
||||
|
||||
@@ -568,6 +568,32 @@ describe('opencode host api routes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('switches the active session model without mutating or restarting runtime configuration', async () => {
|
||||
const switchSessionModel = vi.fn(async () => undefined);
|
||||
createOpencodeClientMock.mockReturnValue({ switchSessionModel });
|
||||
const context = runningProjectContext();
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleOpencodeRoutes(
|
||||
createRequest('POST', { model: 'niancode-user-models/qwen3.7-plus' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/opencode/sessions/ses_1/model'),
|
||||
context,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(switchSessionModel).toHaveBeenCalledWith(
|
||||
'ses_1',
|
||||
{
|
||||
providerID: 'niancode-user-models',
|
||||
modelID: 'qwen3.7-plus',
|
||||
},
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
);
|
||||
expect(buildConfigSummaryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['mini_game', ['game.json', 'game.js']],
|
||||
['mini_program', ['app.json', 'pages/index/index.js']],
|
||||
@@ -2227,6 +2253,88 @@ description: Browser debugging.
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps existing Agents usable and accepts a newly added unique-id Agent once it is live', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-hot-add-'));
|
||||
try {
|
||||
const originalAgent = createConfiguredAgent();
|
||||
const originalConfig = await createConfiguredAgentProject(projectPath, originalAgent);
|
||||
const addedAgent = createConfiguredAgent({
|
||||
id: 'agent-new-partner',
|
||||
roleName: '新伙伴',
|
||||
name: '小新',
|
||||
createdAt: '2026-07-11T01:00:00.000Z',
|
||||
updatedAt: '2026-07-11T01:00:00.000Z',
|
||||
});
|
||||
const project = { id: 'prj_1', path: projectPath, name: 'agent-hot-add' };
|
||||
const context = {
|
||||
opencodeManager: {
|
||||
getStatus: () => ({ state: 'running', port: 4096, url: 'http://127.0.0.1:4096' }),
|
||||
getRuntimeGeneration: () => 1,
|
||||
getRuntimeGenerationProvenance: () => 'fresh',
|
||||
},
|
||||
opencodeProjectStore: {
|
||||
listProjects: vi.fn(async () => [project]),
|
||||
getActiveProject: vi.fn(async () => project),
|
||||
},
|
||||
} as never;
|
||||
|
||||
const updateResponse = createResponse();
|
||||
await handleOpencodeRoutes(
|
||||
createRequest('PUT', {
|
||||
projectId: project.id,
|
||||
config: { ...originalConfig, agents: [originalAgent, addedAgent] },
|
||||
}),
|
||||
updateResponse.res,
|
||||
new URL('http://127.0.0.1/api/opencode/projects/config'),
|
||||
context,
|
||||
);
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
|
||||
const promptSessionAsync = vi.fn(async () => undefined);
|
||||
const listAgents = vi.fn(async () => [{ id: originalAgent.id }]);
|
||||
createOpencodeClientMock.mockReturnValue({ listAgents, promptSessionAsync });
|
||||
const existingPromptResponse = createResponse();
|
||||
await handleOpencodeRoutes(
|
||||
createRequest('POST', { text: 'Keep working', agent: originalAgent.id }),
|
||||
existingPromptResponse.res,
|
||||
new URL('http://127.0.0.1/api/opencode/sessions/ses_existing/messages'),
|
||||
context,
|
||||
);
|
||||
expect(existingPromptResponse.statusCode).toBe(202);
|
||||
|
||||
const pendingPromptResponse = createResponse();
|
||||
await handleOpencodeRoutes(
|
||||
createRequest('POST', { text: 'Wait for discovery', agent: addedAgent.id }),
|
||||
pendingPromptResponse.res,
|
||||
new URL('http://127.0.0.1/api/opencode/sessions/ses_new_pending/messages'),
|
||||
context,
|
||||
);
|
||||
expect(pendingPromptResponse.statusCode).toBe(409);
|
||||
expect(pendingPromptResponse.json()).toMatchObject({
|
||||
code: 'OPENCODE_AGENT_REGISTRY_PENDING',
|
||||
promptSent: false,
|
||||
});
|
||||
|
||||
listAgents.mockResolvedValue([
|
||||
{ id: originalAgent.id },
|
||||
{ id: addedAgent.id },
|
||||
]);
|
||||
const promptResponse = createResponse();
|
||||
await handleOpencodeRoutes(
|
||||
createRequest('POST', { text: 'Start immediately', agent: addedAgent.id }),
|
||||
promptResponse.res,
|
||||
new URL('http://127.0.0.1/api/opencode/sessions/ses_new_partner/messages'),
|
||||
context,
|
||||
);
|
||||
|
||||
expect(promptResponse.statusCode).toBe(202);
|
||||
expect(listAgents).toHaveBeenCalledTimes(3);
|
||||
expect(promptSessionAsync).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a same-id live Agent pending when the runtime generation was attached', async () => {
|
||||
const projectPath = await mkdtemp(join(tmpdir(), 'makelore-agent-attached-route-'));
|
||||
try {
|
||||
@@ -2814,7 +2922,7 @@ description: Browser debugging.
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('forces async prompts to use the current runtime model instead of the stale session model', async () => {
|
||||
it('preserves the OpenCode Session model when a prompt has no explicit model override', async () => {
|
||||
const response = createResponse();
|
||||
const promptSessionAsync = vi.fn(async () => undefined);
|
||||
createOpencodeClientMock.mockReturnValue({ promptSessionAsync });
|
||||
@@ -2847,10 +2955,6 @@ description: Browser debugging.
|
||||
expect(handled).toBe(true);
|
||||
expect(promptSessionAsync).toHaveBeenCalledWith('ses_1', {
|
||||
text: 'Ship it',
|
||||
model: {
|
||||
providerID: 'niancode-user-models',
|
||||
modelID: 'deepseek-chat',
|
||||
},
|
||||
}, { signal: expect.any(AbortSignal) });
|
||||
expect(response.statusCode).toBe(202);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('opencode store', () => {
|
||||
sessions: [],
|
||||
sessionsByProjectId: {},
|
||||
selectedSessionId: null,
|
||||
sessionModelBySessionId: {},
|
||||
sessionStatuses: {},
|
||||
projectEventStreamState: 'closed',
|
||||
sessionMessages: [],
|
||||
@@ -5469,6 +5470,54 @@ describe('opencode store', () => {
|
||||
expect(source.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches and remembers the model selected for one OpenCode session', async () => {
|
||||
hostApiFetchMock.mockResolvedValue({
|
||||
success: true,
|
||||
model: 'niancode-user-models/qwen3.7-plus',
|
||||
});
|
||||
|
||||
await useOpencodeStore.getState().switchSessionModel(
|
||||
'ses_1',
|
||||
'niancode-user-models/qwen3.7-plus',
|
||||
);
|
||||
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/opencode/sessions/ses_1/model',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ model: 'niancode-user-models/qwen3.7-plus' }),
|
||||
},
|
||||
);
|
||||
expect(useOpencodeStore.getState().sessionModelBySessionId).toEqual({
|
||||
ses_1: 'niancode-user-models/qwen3.7-plus',
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates the current model owned by OpenCode when sessions are reloaded', async () => {
|
||||
useOpencodeStore.setState({
|
||||
activeProject: {
|
||||
id: 'prj_1',
|
||||
path: 'D:/repo/one',
|
||||
name: 'one',
|
||||
},
|
||||
});
|
||||
hostApiFetchMock.mockResolvedValue({
|
||||
sessions: [{
|
||||
id: 'ses_1',
|
||||
model: {
|
||||
providerID: 'niancode-user-models',
|
||||
id: 'qwen3.7-plus',
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
await useOpencodeStore.getState().loadSessions();
|
||||
|
||||
expect(useOpencodeStore.getState().sessionModelBySessionId).toEqual({
|
||||
ses_1: 'niancode-user-models/qwen3.7-plus',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps one manual compaction identity and releases queued prompts only after idle', async () => {
|
||||
const compactSource = new MockEventSource('/api/opencode/events?sessionId=ses_compact_lifecycle');
|
||||
const promptSource = new MockEventSource('/api/opencode/events?sessionId=ses_compact_lifecycle');
|
||||
|
||||
@@ -231,6 +231,62 @@ describe('project Agent runtime readiness', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a deleted and recreated same-id Agent pending until generation rollover', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-recreated-same-id-'));
|
||||
try {
|
||||
const initial = await createInitialProjectConfig(projectPath);
|
||||
const agent = createAgent();
|
||||
const original = await writeProjectConfig(projectPath, {
|
||||
...initial,
|
||||
initialized: true,
|
||||
agents: [agent],
|
||||
});
|
||||
let runtimeGeneration = 1;
|
||||
const manager = {
|
||||
getRuntimeGeneration: () => runtimeGeneration,
|
||||
getRuntimeGenerationProvenance: () => 'fresh' as const,
|
||||
};
|
||||
const listAgents = vi.fn(async () => [{ id: agent.id }]);
|
||||
|
||||
await expect(preflightProjectAgentRuntime(
|
||||
manager,
|
||||
projectPath,
|
||||
{ listAgents },
|
||||
agent.id,
|
||||
)).resolves.toEqual({ ready: true, runtimeGeneration: 1 });
|
||||
|
||||
const removed = await writeProjectConfig(projectPath, {
|
||||
...original,
|
||||
agents: [],
|
||||
});
|
||||
await observeProjectAgentRuntime(manager, projectPath, removed);
|
||||
const recreated = await writeProjectConfig(projectPath, {
|
||||
...removed,
|
||||
agents: [agent],
|
||||
});
|
||||
await observeProjectAgentRuntime(manager, projectPath, recreated);
|
||||
listAgents.mockClear();
|
||||
|
||||
await expect(preflightProjectAgentRuntime(
|
||||
manager,
|
||||
projectPath,
|
||||
{ listAgents },
|
||||
agent.id,
|
||||
)).resolves.toEqual({ ready: false, runtimeGeneration: 1 });
|
||||
expect(listAgents).not.toHaveBeenCalled();
|
||||
|
||||
runtimeGeneration = 2;
|
||||
await expect(preflightProjectAgentRuntime(
|
||||
manager,
|
||||
projectPath,
|
||||
{ listAgents },
|
||||
agent.id,
|
||||
)).resolves.toEqual({ ready: true, runtimeGeneration: 2 });
|
||||
} finally {
|
||||
await rm(projectPath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never treats an attached runtime generation as an authoritative Agent baseline', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-agent-attached-runtime-'));
|
||||
try {
|
||||
|
||||
@@ -50,6 +50,7 @@ function renderCommandHook(
|
||||
const actions = {
|
||||
rename: vi.fn<ChatCommandActions['rename']>().mockResolvedValue(undefined),
|
||||
timeline: vi.fn<ChatCommandActions['timeline']>(),
|
||||
models: vi.fn<ChatCommandActions['models']>(),
|
||||
compact: vi.fn<ChatCommandActions['compact']>().mockResolvedValue(undefined),
|
||||
share: vi.fn<ChatCommandActions['share']>().mockImplementation(async () => {
|
||||
context.shared = true;
|
||||
@@ -138,6 +139,17 @@ function renderCommandHook(
|
||||
}
|
||||
|
||||
describe('useChatCommands', () => {
|
||||
it('opens the shared model selector for both /models and /model', async () => {
|
||||
const fixture = renderCommandHook('/models');
|
||||
|
||||
await act(async () => fixture.result.current.tryExecuteDraft());
|
||||
expect(fixture.actions.models).toHaveBeenCalledOnce();
|
||||
expect(fixture.getDraft()).toBe('');
|
||||
|
||||
await fixture.run('/model');
|
||||
expect(fixture.actions.models).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fills a palette selection and executes it only on the next Enter', async () => {
|
||||
const fixture = renderCommandHook('/comp');
|
||||
act(() => fixture.result.current.handleKeyDown(keyEvent('Enter')));
|
||||
|
||||
Reference in New Issue
Block a user