diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index 4614edf96..9421c6959 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -52,6 +52,19 @@ function normalizeProviderBaseUrl( return normalized.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic'; } + if (config.type === 'ollama') { + // Strip any trailing chat endpoint suffix, then ensure /v1 is present. + // Ollama's OpenAI-compatible API is always served under /v1, so requests to + // bare URLs like http://localhost:11434 (without /v1) result in 410 errors. + const withoutEndpoint = normalized + .replace(/\/v1\/chat\/completions$/i, '/v1') + .replace(/\/chat\/completions$/i, ''); + if (!withoutEndpoint.endsWith('/v1')) { + return withoutEndpoint + '/v1'; + } + return withoutEndpoint; + } + if (isUnregisteredProviderType(config.type)) { const protocol = apiProtocol || config.apiProtocol || 'openai-completions'; if (protocol === 'openai-responses') { diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 0fd0ced83..ffd4589a8 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -1386,6 +1386,23 @@ export const useChatStore = create((set, get) => ({ } } + // Guard: during an active send, don't let a history poll replace the local + // conversation with fewer messages from the gateway. This can happen when + // the gateway is reconnecting after a brief disconnect or hasn't fully + // persisted the conversation yet. Without this guard, the history poll + // causes chat history to vanish mid-conversation (issue #709). + { + const preApplyState = get(); + if ( + preApplyState.sending && + preApplyState.lastUserMessageAt && + finalMessages.length < preApplyState.messages.length && + preApplyState.messages.length > 1 + ) { + finalMessages = preApplyState.messages; + } + } + set({ messages: finalMessages, thinkingLevel, loading: false }); // Extract first user message text as a session label for display in the toolbar. diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 5c74ab33b..77daea842 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -138,7 +138,7 @@ export const useSkillsStore = create((set, get) => ({ // Merge with ClawHub results if (clawhubResult.success && clawhubResult.results) { clawhubResult.results.forEach((cs: ClawHubListResult) => { - const existing = combinedSkills.find(s => s.id === cs.slug); + const existing = combinedSkills.find(s => s.id === cs.slug || s.slug === cs.slug); if (existing) { if (!existing.baseDir && cs.baseDir) { existing.baseDir = cs.baseDir; diff --git a/tests/unit/provider-runtime-sync.test.ts b/tests/unit/provider-runtime-sync.test.ts index 2bed33bb8..3ec5a5169 100644 --- a/tests/unit/provider-runtime-sync.test.ts +++ b/tests/unit/provider-runtime-sync.test.ts @@ -284,6 +284,56 @@ describe('provider-runtime-sync refresh strategy', () => { expect(gateway.debouncedReload).toHaveBeenCalledTimes(1); }); + it('auto-appends /v1 to Ollama base URL when missing to prevent 410 errors', async () => { + const ollamaProvider = createProvider({ + id: 'ollamafd', + type: 'ollama', + name: 'Ollama', + model: 'llama3:latest', + baseUrl: 'http://localhost:11434', + }); + + mocks.getProviderConfig.mockReturnValue(undefined); + mocks.getProviderSecret.mockResolvedValue({ type: 'local', apiKey: 'ollama-local' }); + + const gateway = createGateway('running'); + await syncSavedProviderToRuntime(ollamaProvider, undefined, gateway as GatewayManager); + + expect(mocks.syncProviderConfigToOpenClaw).toHaveBeenCalledWith( + 'ollama-ollamafd', + 'llama3:latest', + expect.objectContaining({ + baseUrl: 'http://localhost:11434/v1', + api: 'openai-completions', + }), + ); + }); + + it('normalizes Ollama base URL with trailing /chat/completions by stripping and ensuring /v1', async () => { + const ollamaProvider = createProvider({ + id: 'ollamafd', + type: 'ollama', + name: 'Ollama', + model: 'llama3:latest', + baseUrl: 'http://localhost:11434/v1/chat/completions', + }); + + mocks.getProviderConfig.mockReturnValue(undefined); + mocks.getProviderSecret.mockResolvedValue({ type: 'local', apiKey: 'ollama-local' }); + + const gateway = createGateway('running'); + await syncSavedProviderToRuntime(ollamaProvider, undefined, gateway as GatewayManager); + + expect(mocks.syncProviderConfigToOpenClaw).toHaveBeenCalledWith( + 'ollama-ollamafd', + 'llama3:latest', + expect.objectContaining({ + baseUrl: 'http://localhost:11434/v1', + api: 'openai-completions', + }), + ); + }); + it('syncs Ollama as default provider with correct baseUrl and api protocol', async () => { const ollamaProvider = createProvider({ id: 'ollamafd', diff --git a/tests/unit/skills-errors.test.ts b/tests/unit/skills-errors.test.ts index 76769c5c9..6a600c232 100644 --- a/tests/unit/skills-errors.test.ts +++ b/tests/unit/skills-errors.test.ts @@ -15,6 +15,34 @@ vi.mock('@/stores/gateway', () => ({ }, })); +describe('skills store slug matching', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('matches ClawHub skill to gateway skill when gateway slug differs from skillKey', async () => { + // Gateway returns skillKey "foo-v2" but slug "foo" + rpcMock.mockResolvedValueOnce({ + skills: [{ skillKey: 'foo-v2', slug: 'foo', name: 'Foo Skill', description: 'A skill', disabled: false }], + }); + // ClawHub lists "foo" as installed (matching by slug, not skillKey) + hostApiFetchMock + .mockResolvedValueOnce({ success: true, results: [{ slug: 'foo', version: '1.0.0' }] }) + .mockResolvedValueOnce({}); + + const { useSkillsStore } = await import('@/stores/skills'); + await useSkillsStore.getState().fetchSkills(); + + const skills = useSkillsStore.getState().skills; + // Should be exactly one skill, not two (no placeholder duplicate) + expect(skills).toHaveLength(1); + // The skill should be the gateway skill (not the "Recently installed" placeholder) + expect(skills[0].name).toBe('Foo Skill'); + expect(skills[0].description).not.toBe('Recently installed, initializing...'); + }); +}); + describe('skills store error mapping', () => { beforeEach(() => { vi.resetModules();