fix(opencode): switch session models without restart

This commit is contained in:
2026-08-20 22:38:18 +08:00
parent 1c6b004436
commit c0163bc507
18 changed files with 718 additions and 92 deletions

View File

@@ -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');

View File

@@ -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',

View File

@@ -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' }), {

View File

@@ -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);
});

View File

@@ -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');

View File

@@ -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 {

View File

@@ -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')));