diff --git a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts index 20c95240e..5f878d364 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.test.ts @@ -2,7 +2,7 @@ import type { Tool } from '@xsai/shared-chat' import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools' import { createPinia, setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const invokeMocks = vi.hoisted(() => ({ invokePluginTool: vi.fn(async (payload: unknown) => payload), @@ -39,6 +39,11 @@ describe('useTamagotchiPluginToolsStore', async () => { invokeMocks.invokePluginTool.mockClear() }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + /** * @example * await store.refresh() @@ -81,4 +86,54 @@ describe('useTamagotchiPluginToolsStore', async () => { expect(llmToolsStore.toolsByProvider['plugin-tools']).toBeUndefined() }) + + /** + * @example + * await store.refresh() + * await vi.advanceTimersByTimeAsync(5_000) + * await llmToolsStore.awaitPendingRegistrations() + */ + it('falls back to empty plugin tools when listing xsai tools never resolves during cold start', async () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + invokeMocks.listPluginXsaiTools.mockImplementationOnce((_req?: undefined, options?: { signal?: AbortSignal }) => new Promise((_, reject) => { + options?.signal?.addEventListener('abort', () => { + reject(options.signal?.reason) + }, { once: true }) + })) + + const llmToolsStore = useLlmToolsStore() + const store = useTamagotchiPluginToolsStore() + const onSettled = vi.fn() + + // ROOT CAUSE: + // + // If the renderer asks the main process for plugin xsai tools before the + // Eventa handler is ready, the invoke promise can remain pending forever. + // The shared LLM store then waits in awaitPendingRegistrations() before + // building chat tools, so no model HTTP request is sent and chat sync + // eventually times out. + // + // Before the fix, this wait never settled. + // + // We fixed this by letting optional plugin tool listing time out and + // complete registration with an empty tool list. + store.refresh() + const pendingRegistrations = llmToolsStore.awaitPendingRegistrations().then(() => { + onSettled() + }) + + await Promise.resolve() + + expect(onSettled).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(5_000) + await pendingRegistrations + + expect(onSettled).toHaveBeenCalledTimes(1) + expect(llmToolsStore.toolsByProvider['plugin-tools']).toEqual([]) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('[plugin-tools] Failed to list plugin xsai tools'), + ) + }) }) diff --git a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts index 38930eabd..85ed41e1d 100644 --- a/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts +++ b/apps/stage-tamagotchi/src/renderer/stores/plugin-tools.ts @@ -1,3 +1,4 @@ +import { errorMessageFrom } from '@moeru/std' import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse' import { useLlmToolsStore } from '@proj-airi/stage-ui/stores/llm-tools' import { rawTool } from '@xsai/tool' @@ -23,20 +24,34 @@ export const useTamagotchiPluginToolsStore = defineStore('tamagotchi-plugin-tool const invokePluginTool = useElectronEventaInvoke(electronPluginInvokeTool) async function refresh() { - return llmToolsStore.registerTools('plugin-tools', listPluginXsaiToolDefinitions().then(definitions => - definitions.map(definition => - rawTool({ - name: definition.name, - description: definition.description, - parameters: definition.parameters, - execute: async input => invokePluginTool({ - ownerPluginId: definition.ownerPluginId, - name: definition.name, - input, - }), - }), - ), - )) + const abortController = new AbortController() + const timeout = setTimeout(() => abortController.abort(new Error(`Timed out after ${5_000}ms`)), 5_000) + + return llmToolsStore.registerTools( + 'plugin-tools', + listPluginXsaiToolDefinitions(undefined, { signal: abortController.signal }) + .catch((error) => { + console.warn(`[plugin-tools] Failed to list plugin xsai tools: ${errorMessageFrom(error) ?? 'Unknown error'}`) + return [] + }) + .finally(() => { + clearTimeout(timeout) + }) + .then(definitions => + definitions.map(definition => + rawTool({ + name: definition.name, + description: definition.description, + parameters: definition.parameters, + execute: async input => invokePluginTool({ + ownerPluginId: definition.ownerPluginId, + name: definition.name, + input, + }), + }), + ), + ), + ) } function dispose() {