fix(stage-tamagotchi): plugin tools should have faster fail

This commit is contained in:
Neko Ayaka
2026-05-07 16:01:08 +08:00
parent 39b119641f
commit 35c3471ecd
2 changed files with 85 additions and 15 deletions
@@ -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'),
)
})
})
@@ -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() {