From 6372491e3bdcd4ebd60ef2581a907b4e9cb64943 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Fri, 26 Jun 2026 14:51:50 +0800 Subject: [PATCH] feat(stage-*,plugin-*): retriable tool call --- .../main/services/airi/plugins/index.test.ts | 227 +++------------- .../plugins/kits/gamelet/orchestration.ts | 172 +----------- .../src/main/services/airi/plugins/types.ts | 7 +- .../main/services/airi/widgets/index.test.ts | 93 +++++++ .../src/main/services/airi/widgets/index.ts | 11 + .../services/airi/widgets/validation.test.ts | 45 ++++ .../main/services/airi/widgets/validation.ts | 59 +++++ .../widgets/iframe-request-coordinator.ts | 124 +++++++++ .../src/main/windows/widgets/index.test.ts | 152 ++++++++++- .../src/main/windows/widgets/index.ts | 55 +++- .../renderer/components/InteractiveArea.vue | 27 ++ .../src/renderer/pages/widgets.vue | 50 +++- .../src/renderer/stores/chat-sync.test.ts | 154 ++++++++++- .../src/renderer/stores/chat-sync.ts | 33 +++ .../components/extension-ui-host.vue | 31 ++- .../components/iframe-request.test.ts | 176 +++++++++++++ .../extension-ui/components/iframe-request.ts | 122 +++++++++ .../composables/use-iframe-message-port.ts | 12 + .../shared/eventa-runtime.test.ts | 89 +------ .../src/shared/eventa/index.ts | 59 +++++ .../eventa/widgets-gamelet-request.test.ts | 13 + .../src/gamelet/events.ts | 34 +++ .../src/gamelet/index.ts | 2 + .../plugin-sdk-tamagotchi/src/index.test.ts | 17 +- .../components/Layouts/InteractiveArea.vue | 4 + .../Layouts/MobileInteractiveArea.vue | 3 + .../src/composables/useChatToolCallRerun.ts | 53 ++++ .../chat/components/assistant-item.vue | 3 + .../chat/components/history.browser.test.ts | 57 +++- .../scenarios/chat/components/history.vue | 15 ++ .../chat/components/tool-call-block.test.ts | 20 ++ .../chat/components/tool-call-block.vue | 76 ++++-- .../src/stores/llm-tool-resolver.test.ts | 61 +++++ .../stage-ui/src/stores/llm-tool-resolver.ts | 142 ++++++++++ packages/stage-ui/src/stores/llm.ts | 55 +--- .../src/stores/tool-call-rerun.test.ts | 246 ++++++++++++++++++ .../stage-ui/src/stores/tool-call-rerun.ts | 170 ++++++++++++ 37 files changed, 2148 insertions(+), 521 deletions(-) create mode 100644 apps/stage-tamagotchi/src/main/services/airi/widgets/index.test.ts create mode 100644 apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts create mode 100644 apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/iframe-request.test.ts create mode 100644 apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/iframe-request.ts create mode 100644 apps/stage-tamagotchi/src/shared/eventa/widgets-gamelet-request.test.ts create mode 100644 packages/plugin-sdk-tamagotchi/src/gamelet/events.ts create mode 100644 packages/stage-layouts/src/composables/useChatToolCallRerun.ts create mode 100644 packages/stage-ui/src/components/scenarios/chat/components/tool-call-block.test.ts create mode 100644 packages/stage-ui/src/stores/llm-tool-resolver.test.ts create mode 100644 packages/stage-ui/src/stores/llm-tool-resolver.ts create mode 100644 packages/stage-ui/src/stores/tool-call-rerun.test.ts create mode 100644 packages/stage-ui/src/stores/tool-call-rerun.ts diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts index 4c976b62f..e736017cc 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts @@ -261,18 +261,6 @@ function createExtensionGameletKitManifest(entrypoint: string, id = 'test-extens function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = {}) { const respondToRequests = options.respondToRequests ?? true const widgetSnapshots = new Map() - const widgetEventListeners = new Set<(event: { id: string, event: Record }) => void>() - const publishWidgetEvent = vi.fn((id: string, event: Record) => { - for (const listener of widgetEventListeners) { - listener({ id, event }) - } - }) - const onWidgetEvent = vi.fn((listener: (event: { id: string, event: Record }) => void) => { - widgetEventListeners.add(listener) - return () => { - widgetEventListeners.delete(listener) - } - }) const openWindow = vi.fn(async (_params?: { id?: string }) => {}) const pushWidget = vi.fn(async (payload: WidgetsAddPayload) => { const snapshot: WidgetSnapshot = { @@ -302,27 +290,14 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = { windowSize: payload.windowSize ?? existing.windowSize, ttlMs: payload.ttlMs ?? existing.ttlMs, }) - - const componentProps = payload.componentProps as Record | undefined - const request = componentProps?.payload && typeof componentProps.payload === 'object' && !Array.isArray(componentProps.payload) - ? (componentProps.payload as Record).request - : undefined - if (respondToRequests && request && typeof request === 'object' && !Array.isArray(request) && typeof (request as Record).requestId === 'string') { - const requestId = (request as Record).requestId - queueMicrotask(() => { - publishWidgetEvent(payload.id, { - route: { - namespace: 'airi.plugin.gamelet', - name: 'response', - }, - payload: { - requestId, - ready: true, - fen: 'fen-after-request', - }, - }) - }) + }) + const requestWidgetIframe = vi.fn() + requestWidgetIframe.mockImplementation(async () => { + if (!respondToRequests) { + throw new Error('Widget iframe request was not handled.') } + + return { fen: 'fen-after-request' } }) const removeWidget = vi.fn(async (id: string) => { widgetSnapshots.delete(id) @@ -337,8 +312,7 @@ function createWidgetsManagerDouble(options: { respondToRequests?: boolean } = { updateWidget, removeWidget, getWidgetSnapshot, - publishWidgetEvent, - onWidgetEvent, + requestWidgetIframe, }, } } @@ -1244,7 +1218,7 @@ describe('setupExtensionHost', () => { ' await gamelets.orchestration.configure(\'kit-module:board\', { command: { requestId: \'ignored-by-test-double\' } })', ' const snapshot = await gamelets.orchestration.request(\'kit-module:board\', { action: \'snapshot\' }, { timeoutMs: 1000 })', ' if (snapshot.fen !== \'fen-after-request\') {', - ' throw new Error(\'Expected request to resolve from response event\')', + ' throw new Error(\'Expected request to resolve from widget iframe request\')', ' }', ' if (!(await gamelets.orchestration.isOpen(\'kit-module:board\'))) {', ' throw new Error(\'Expected gamelet to be open before close\')', @@ -1282,26 +1256,11 @@ describe('setupExtensionHost', () => { payload: { command: { requestId: 'ignored-by-test-double' } }, }, }) - expect(widgetsManager.updateWidget).toHaveBeenCalledWith({ - id: 'kit-module:board', - componentProps: { - moduleId: 'kit-module:board', - payload: { - request: { - route: { - namespace: 'airi.plugin.gamelet', - name: 'request', - }, - responseRoute: { - namespace: 'airi.plugin.gamelet', - name: 'response', - }, - requestId: expect.any(String), - payload: { action: 'snapshot' }, - }, - }, - }, - }) + expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith( + 'kit-module:board', + { action: 'snapshot' }, + { timeoutMs: 1000 }, + ) expect(widgetsManager.getWidgetSnapshot).toHaveBeenCalledWith('kit-module:board') expect(widgetsManager.removeWidget).toHaveBeenCalledWith('kit-module:board') }) @@ -1346,57 +1305,41 @@ describe('setupExtensionHost', () => { /** * @example - * await expect(request).rejects.toThrow('Gamelet request failed.') + * await expect(request).rejects.toThrow('Board rejected the snapshot request.') */ - it('rejects gamelet requests when the iframe response reports failure', async () => { + it('propagates gamelet request rejection from the widget iframe manager', async () => { const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) + widgetsManager.requestWidgetIframe.mockRejectedValueOnce(new Error('Board rejected the snapshot request.')) const gamelets = createGameletOrchestrationRuntime(widgetsManager) await gamelets.open('kit-module:board') - const request = gamelets.request('kit-module:board', { action: 'snapshot' }) - const updatePayload = widgetsManager.updateWidget.mock.calls.at(-1)?.[0] - const requestEnvelope = updatePayload?.componentProps?.payload?.request - if (!requestEnvelope || typeof requestEnvelope !== 'object' || Array.isArray(requestEnvelope) || typeof requestEnvelope.requestId !== 'string') { - throw new Error('Expected gamelet request envelope in widget props.') - } - widgetsManager.publishWidgetEvent('kit-module:board', { - route: { - namespace: 'airi.plugin.gamelet', - name: 'response', - }, - payload: { - requestId: requestEnvelope.requestId, - ok: false, - message: 'Board rejected the snapshot request.', - }, - }) - - await expect(request).rejects.toThrow('Board rejected the snapshot request.') + await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Board rejected the snapshot request.') + expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith( + 'kit-module:board', + { action: 'snapshot' }, + { timeoutMs: 30000 }, + ) gamelets.dispose() }) /** * @example - * await expect(request).rejects.toThrow('Gamelet request timed out after 30000ms.') + * expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) */ it('uses the default gamelet request timeout when no timeout is provided', async () => { - vi.useFakeTimers() - try { - const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) - const gamelets = createGameletOrchestrationRuntime(widgetsManager) + const { widgetsManager } = createWidgetsManagerDouble() + const gamelets = createGameletOrchestrationRuntime(widgetsManager) - await gamelets.open('kit-module:board') - const request = gamelets.request('kit-module:board', { action: 'snapshot' }) - const rejection = expect(request).rejects.toThrow('Gamelet request timed out after 30000ms.') - await vi.advanceTimersByTimeAsync(30000) + await gamelets.open('kit-module:board') + await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).resolves.toEqual({ fen: 'fen-after-request' }) - await rejection - gamelets.dispose() - } - finally { - vi.useRealTimers() - } + expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith( + 'kit-module:board', + { action: 'snapshot' }, + { timeoutMs: 30000 }, + ) + gamelets.dispose() }) /** @@ -1409,109 +1352,25 @@ describe('setupExtensionHost', () => { await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet `kit-module:board` is not open.') expect(widgetsManager.updateWidget).not.toHaveBeenCalled() + expect(widgetsManager.requestWidgetIframe).not.toHaveBeenCalled() gamelets.dispose() }) - /** - * @example - * await expect(request).resolves.toEqual(expect.objectContaining({ fen: 'fen-after-request' })) - */ - it('ignores gamelet responses from a different widget id', async () => { - const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) + it('handles gamelet requests without legacy widget response event APIs', async () => { + const { widgetsManager } = createWidgetsManagerDouble() const gamelets = createGameletOrchestrationRuntime(widgetsManager) await gamelets.open('kit-module:board') - const request = gamelets.request('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) - const updatePayload = widgetsManager.updateWidget.mock.calls.at(-1)?.[0] - const requestEnvelope = updatePayload?.componentProps?.payload?.request - if (!requestEnvelope || typeof requestEnvelope !== 'object' || Array.isArray(requestEnvelope) || typeof requestEnvelope.requestId !== 'string') { - throw new Error('Expected gamelet request envelope in widget props.') - } + await expect(gamelets.request('kit-module:board', { action: 'snapshot' })).resolves.toEqual({ fen: 'fen-after-request' }) - widgetsManager.publishWidgetEvent('kit-module:other-board', { - route: { - namespace: 'airi.plugin.gamelet', - name: 'response', - }, - payload: { - requestId: requestEnvelope.requestId, - fen: 'wrong-board', - }, - }) - await Promise.resolve() - - widgetsManager.publishWidgetEvent('kit-module:board', { - type: 'response', - requestId: requestEnvelope.requestId, - fen: 'legacy-top-level', - }) - await Promise.resolve() - - widgetsManager.publishWidgetEvent('kit-module:board', { - route: { - namespace: 'airi.plugin.other', - name: 'response', - }, - payload: { - requestId: requestEnvelope.requestId, - fen: 'wrong-namespace', - }, - }) - await Promise.resolve() - - widgetsManager.publishWidgetEvent('kit-module:board', { - route: { - namespace: 'airi.plugin.gamelet', - name: 'response', - }, - payload: { - requestId: requestEnvelope.requestId, - fen: 'fen-after-request', - }, - }) - - await expect(request).resolves.toEqual({ fen: 'fen-after-request' }) + expect(widgetsManager.requestWidgetIframe).toHaveBeenCalledWith( + 'kit-module:board', + { action: 'snapshot' }, + { timeoutMs: 30000 }, + ) gamelets.dispose() }) - /** - * @example - * await expect(request).rejects.toThrow('Gamelet was closed before the request completed.') - */ - it('rejects pending gamelet requests when the widget closes', async () => { - const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) - const gamelets = createGameletOrchestrationRuntime(widgetsManager) - - await gamelets.open('kit-module:board') - const request = gamelets.request('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) - const rejection = expect(request).rejects.toThrow('Gamelet was closed before the request completed.') - await gamelets.close('kit-module:board') - - await rejection - expect(widgetsManager.removeWidget).toHaveBeenCalledWith('kit-module:board') - gamelets.dispose() - }) - - /** - * @example - * expect(unsubscribe).toHaveBeenCalled() - * await expect(request).rejects.toThrow('Gamelet orchestration runtime was disposed before the request completed.') - */ - it('unsubscribes and rejects pending gamelet requests on dispose', async () => { - const { widgetsManager } = createWidgetsManagerDouble({ respondToRequests: false }) - const unsubscribe = vi.fn() - widgetsManager.onWidgetEvent.mockReturnValueOnce(unsubscribe) - const gamelets = createGameletOrchestrationRuntime(widgetsManager) - - await gamelets.open('kit-module:board') - const request = gamelets.request('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) - const rejection = expect(request).rejects.toThrow('Gamelet orchestration runtime was disposed before the request completed.') - gamelets.dispose() - - expect(unsubscribe).toHaveBeenCalled() - await rejection - }) - it('rejects module announce when the kit runtime does not match the host runtime', async () => { const { host } = await setupExtensionHost() diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts index 4c12b3665..2d1cb4481 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/orchestration.ts @@ -3,34 +3,22 @@ import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host' import type { ExtensionHostGameletWidgetsManager } from '../../types' -import { randomUUID } from 'node:crypto' - -import { errorMessageFrom } from '@moeru/std' - const DEFAULT_REQUEST_TIMEOUT_MS = 30000 -const GAMELET_ROUTE_NAMESPACE = 'airi.plugin.gamelet' export interface GameletOrchestrationRuntime extends NonNullable { dispose: () => void } -interface PendingRequest { - bindingId: string - resolve: (value: unknown) => void - reject: (error: Error) => void - timeout: ReturnType -} - /** * Creates the Electron host implementation for gamelet lifecycle and request calls. * * Use when: * - Built-in `kit.gamelet` clients need to open iframe-backed extension UI widgets - * - Extension-side gamelet handles need request/response orchestration through widget events + * - Extension-side gamelet handles need request/response orchestration through widget iframe requests * * Expects: * - Widget ids are the same values as gamelet binding ids - * - Widget-side response events echo the original `requestId` at the top level or under `payload` + * - The widget manager owns iframe request correlation, timeout, and cleanup * * Returns: * - A gamelet orchestration runtime backed by the stage widget manager @@ -38,50 +26,6 @@ interface PendingRequest { export function createGameletOrchestrationRuntime( widgetsManager: ExtensionHostGameletWidgetsManager, ): GameletOrchestrationRuntime { - const pendingRequests = new Map() - - const rejectPendingForBinding = (bindingId: string, message: string) => { - for (const [requestId, pending] of pendingRequests.entries()) { - if (pending.bindingId !== bindingId) { - continue - } - - pendingRequests.delete(requestId) - clearTimeout(pending.timeout) - pending.reject(new Error(message)) - } - } - - const rejectAllPending = (message: string) => { - for (const [requestId, pending] of pendingRequests.entries()) { - pendingRequests.delete(requestId) - clearTimeout(pending.timeout) - pending.reject(new Error(message)) - } - } - - const unsubscribe = widgetsManager.onWidgetEvent(({ id, event }) => { - const response = readRequestResponse(event) - if (!response) { - return - } - - const pending = pendingRequests.get(response.requestId) - if (!pending || pending.bindingId !== id) { - return - } - - pendingRequests.delete(response.requestId) - clearTimeout(pending.timeout) - - if (response.ok === false) { - pending.reject(new Error(readResponseErrorMessage(response.value))) - return - } - - pending.resolve(response.value) - }) - return { async open(bindingId, payload) { const componentProps = createComponentProps(bindingId, payload ?? {}) @@ -115,64 +59,21 @@ export function createGameletOrchestrationRuntime( throw new Error(`Gamelet \`${bindingId}\` is not open.`) } - const requestId = randomUUID() - const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS - - const response = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - pendingRequests.delete(requestId) - reject(new Error(`Gamelet request timed out after ${timeoutMs}ms.`)) - }, timeoutMs) - - pendingRequests.set(requestId, { - bindingId, - resolve: value => resolve(value as TResponse), - reject, - timeout, - }) - }) - - try { - await widgetsManager.updateWidget({ - id: bindingId, - componentProps: createComponentProps(bindingId, { - request: { - route: { - namespace: GAMELET_ROUTE_NAMESPACE, - name: 'request', - }, - responseRoute: { - namespace: GAMELET_ROUTE_NAMESPACE, - name: 'response', - }, - requestId, - payload, - }, - }), - }) - } - catch (error) { - const pending = pendingRequests.get(requestId) - if (pending) { - pendingRequests.delete(requestId) - clearTimeout(pending.timeout) - pending.reject(new Error(errorMessageFrom(error) ?? 'Failed to publish gamelet request.')) - } - } - - return await response + return await widgetsManager.requestWidgetIframe>( + bindingId, + payload, + { + timeoutMs: options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + }, + ) as TResponse }, async close(bindingId) { - rejectPendingForBinding(bindingId, 'Gamelet was closed before the request completed.') await widgetsManager.removeWidget(bindingId) }, async isOpen(bindingId) { return Boolean(widgetsManager.getWidgetSnapshot(bindingId)) }, - dispose() { - unsubscribe() - rejectAllPending('Gamelet orchestration runtime was disposed before the request completed.') - }, + dispose() {}, } } @@ -182,56 +83,3 @@ function createComponentProps(bindingId: string, payload: HostDataRecord): HostD payload, } } - -function readRequestResponse(event: Record): { requestId: string, ok?: boolean, value: unknown } | undefined { - const route = event.route - if (!route || typeof route !== 'object' || Array.isArray(route)) { - return undefined - } - - const routeRecord = route as Record - if (routeRecord.namespace !== GAMELET_ROUTE_NAMESPACE || routeRecord.name !== 'response') { - return undefined - } - - const payload = event.payload - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - return undefined - } - - const payloadRecord = payload as Record - if (typeof payloadRecord.requestId !== 'string') { - return undefined - } - - return { - requestId: payloadRecord.requestId, - ok: typeof payloadRecord.ok === 'boolean' ? payloadRecord.ok : undefined, - value: readResponseValue(payloadRecord), - } -} - -function readResponseValue(response: Record): unknown { - if ('result' in response) { - return response.result - } - - const { type: _type, requestId: _requestId, ...value } = response - return value -} - -function readResponseErrorMessage(response: unknown): string { - if (!response || typeof response !== 'object' || Array.isArray(response)) { - return 'Gamelet request failed.' - } - - const responseRecord = response as Record - if (typeof responseRecord.error === 'string') { - return responseRecord.error - } - if (typeof responseRecord.message === 'string') { - return responseRecord.message - } - - return 'Gamelet request failed.' -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts index 353b4aa67..c8eb9b427 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts @@ -48,8 +48,11 @@ export interface ExtensionHostGameletWidgetsManager { updateWidget: (payload: WidgetsUpdatePayload) => Promise removeWidget: (id: string) => Promise getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined - publishWidgetEvent: (id: string, event: Record) => void - onWidgetEvent: (listener: (event: { id: string, event: Record }) => void) => () => void + requestWidgetIframe: = Record>( + id: string, + payload: Record, + options?: { timeoutMs?: number }, + ) => Promise } /** diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.test.ts new file mode 100644 index 000000000..67c997257 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.test.ts @@ -0,0 +1,93 @@ +import type { BrowserWindow } from 'electron' + +import { createContext } from '@moeru/eventa' +import { describe, expect, it, vi } from 'vitest' + +import { widgetsIframeRequestResultEvent } from '../../../../shared/eventa' +import { createWidgetsService } from './index' + +function createWindow(id: number): BrowserWindow { + return { + webContents: { + id, + }, + } as BrowserWindow +} + +function createWidgetsManager() { + return { + clearWidgets: vi.fn(), + fetchWidget: vi.fn(), + getWindow: vi.fn(), + getWidgetSnapshot: vi.fn(), + hideWindow: vi.fn(), + onWidgetEvent: vi.fn(), + openWindow: vi.fn(), + prepareWidgetWindow: vi.fn(), + publishWidgetEvent: vi.fn(), + publishWidgetIframeRequestResult: vi.fn(), + pushWidget: vi.fn(), + removeWidget: vi.fn(), + requestWidgetIframe: vi.fn(), + updateWidget: vi.fn(), + } +} + +describe('createWidgetsService', () => { + it('routes iframe request results from the widgets window to the manager', () => { + const context = createContext() + const widgetsManager = createWidgetsManager() + const window = createWindow(1) + createWidgetsService({ + context: context as Parameters[0]['context'], + widgetsManager, + window, + }) + + context.emit(widgetsIframeRequestResultEvent, { + id: 'kit-module:board', + requestId: 'req-1', + ok: true, + result: { fen: 'fen-after-request' }, + }, { + raw: { + ipcMainEvent: { + sender: { id: 1 }, + }, + }, + } as never) + + expect(widgetsManager.publishWidgetIframeRequestResult).toHaveBeenCalledWith({ + id: 'kit-module:board', + requestId: 'req-1', + ok: true, + result: { fen: 'fen-after-request' }, + }) + }) + + it('ignores iframe request results from other windows', () => { + const context = createContext() + const widgetsManager = createWidgetsManager() + const window = createWindow(1) + createWidgetsService({ + context: context as Parameters[0]['context'], + widgetsManager, + window, + }) + + context.emit(widgetsIframeRequestResultEvent, { + id: 'kit-module:board', + requestId: 'req-1', + ok: true, + result: { fen: 'fen-after-request' }, + }, { + raw: { + ipcMainEvent: { + sender: { id: 2 }, + }, + }, + } as never) + + expect(widgetsManager.publishWidgetIframeRequestResult).not.toHaveBeenCalled() + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts index 886202f2a..4a3280cb8 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts @@ -11,6 +11,7 @@ import { widgetsFetch, widgetsHideWindow, widgetsIframePublish, + widgetsIframeRequestResultEvent, widgetsOpenWindow, widgetsPrepareWindow, widgetsRemove, @@ -20,6 +21,7 @@ import { normalizeOptionalWidgetId, normalizeRequiredWidgetId, validateWidgetIframeEvent, + validateWidgetIframeRequestResult, validateWidgetsAddPayload, validateWidgetsUpdatePayload, } from './validation' @@ -57,6 +59,15 @@ function isFromWindow(options: InvokeOptions | undefined, window: BrowserWindow) * -> {@link WidgetsWindowManager.pushWidget} */ export function createWidgetsService(params: { context: ReturnType['context'], widgetsManager: WidgetsWindowManager, window: BrowserWindow }) { + params.context.on(widgetsIframeRequestResultEvent, (event, options) => { + if (!isFromWindow(options as InvokeOptions, params.window)) + return + + params.widgetsManager.publishWidgetIframeRequestResult( + validateWidgetIframeRequestResult(event.body), + ) + }) + defineInvokeHandlers( params.context, { diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts index e2a967747..d9bbb2a8b 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { normalizeOptionalWidgetId, normalizeRequiredWidgetId, + validateWidgetIframeRequestResult, validateWidgetsAddPayload, validateWidgetsUpdatePayload, } from './validation' @@ -111,4 +112,48 @@ describe('widget invoke validation', () => { expect(() => normalizeRequiredWidgetId(' ', 'id required')).toThrow('id required') }) }) + + describe('validateWidgetIframeRequestResult', () => { + it('normalizes successful iframe request results', () => { + expect(validateWidgetIframeRequestResult({ + id: ' kit-module:board ', + requestId: ' req-1 ', + ok: true, + result: { fen: 'fen-after-request' }, + })).toEqual({ + id: 'kit-module:board', + requestId: 'req-1', + ok: true, + result: { fen: 'fen-after-request' }, + }) + }) + + it('normalizes failed iframe request results', () => { + expect(validateWidgetIframeRequestResult({ + id: 'kit-module:board', + requestId: 'req-1', + ok: false, + error: 'Board rejected request.', + })).toEqual({ + id: 'kit-module:board', + requestId: 'req-1', + ok: false, + error: 'Board rejected request.', + }) + }) + + it('rejects malformed iframe request results', () => { + expect(() => validateWidgetIframeRequestResult(null)).toThrow('iframe request result must be a plain object.') + expect(() => validateWidgetIframeRequestResult({ + id: 'kit-module:board', + requestId: 'req-1', + ok: true, + })).toThrow('iframe request result payload must be a plain object.') + expect(() => validateWidgetIframeRequestResult({ + id: 'kit-module:board', + requestId: 'req-1', + ok: false, + })).toThrow('iframe request result error is required.') + }) + }) }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts index 9e644c72a..7d8343d46 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts @@ -1,5 +1,6 @@ import type { WidgetsAddPayload, + WidgetsIframeRequestResultPayload, WidgetsUpdatePayload, } from '../../../../shared/eventa' @@ -183,3 +184,61 @@ export function validateWidgetIframeEvent(event: unknown): Record } + +/** + * Validates renderer-to-main iframe request results before they settle pending gamelet requests. + * + * Use when: + * - The widgets renderer reports a response from a mounted extension iframe + * + * Expects: + * - `id` and `requestId` are non-empty strings + * - Successful results contain a plain response record + * - Failed results contain an error message + * + * Returns: + * - A discriminated request result safe to pass into the widgets manager + */ +export function validateWidgetIframeRequestResult(result: unknown): WidgetsIframeRequestResultPayload { + if (!isPlainObject(result)) { + throw new Error('iframe request result must be a plain object.') + } + + const id = normalizeWidgetId(typeof result.id === 'string' ? result.id : undefined) + if (!id) { + throw new Error('iframe request result id is required.') + } + + const requestId = normalizeWidgetId(typeof result.requestId === 'string' ? result.requestId : undefined) + if (!requestId) { + throw new Error('iframe request result requestId is required.') + } + + if (result.ok === true) { + if (!isPlainObject(result.result)) { + throw new Error('iframe request result payload must be a plain object.') + } + + return { + id, + requestId, + ok: true, + result: result.result, + } + } + + if (result.ok === false) { + if (typeof result.error !== 'string' || !result.error.trim()) { + throw new Error('iframe request result error is required.') + } + + return { + id, + requestId, + ok: false, + error: result.error, + } + } + + throw new Error('iframe request result ok must be a boolean.') +} diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts b/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts new file mode 100644 index 000000000..d160fb194 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/widgets/iframe-request-coordinator.ts @@ -0,0 +1,124 @@ +import type { + WidgetsIframeRequestPayload, + WidgetsIframeRequestResultPayload, +} from '../../../shared/eventa' + +import { randomUUID } from 'node:crypto' + +const DEFAULT_WIDGET_IFRAME_REQUEST_TIMEOUT_MS = 30000 +const WIDGET_IFRAME_REQUEST_CLOSED_MESSAGE = 'Gamelet was closed before the request completed.' + +interface PendingWidgetIframeRequest { + id: string + resolve: (result: Record) => void + reject: (error: Error) => void + timeout: ReturnType +} + +/** + * Runtime hooks used by the widget iframe request coordinator. + */ +export interface WidgetIframeRequestCoordinatorOptions { + /** Emits the main-to-renderer iframe request event after pending state is registered. */ + emitRequest: (payload: WidgetsIframeRequestPayload) => void + /** Returns whether the widget id currently has a mounted main-process record. */ + hasWidget: (id: string) => boolean + /** Returns whether a renderer relay is available to receive iframe request events. */ + hasRelay: () => boolean +} + +/** + * Coordinates pending request state for main-to-widget-iframe requests. + * + * The widgets renderer is an asynchronous relay between Electron main and the mounted iframe, + * so this helper owns the correlation, timeout, widget-id isolation, and close cleanup policy + * that would otherwise be hidden inside the window manager's Electron setup code. + */ +export function createWidgetIframeRequestCoordinator(options: WidgetIframeRequestCoordinatorOptions) { + const pendingRequests = new Map() + + function settlePendingRequest(requestId: string, settle: (pending: PendingWidgetIframeRequest) => void) { + const pending = pendingRequests.get(requestId) + if (!pending) + return undefined + + pendingRequests.delete(requestId) + clearTimeout(pending.timeout) + settle(pending) + return pending + } + + function requestWidgetIframe = Record>( + id: string, + payload: Record, + requestOptions?: { timeoutMs?: number }, + ): Promise { + if (!options.hasWidget(id)) + return Promise.reject(new Error(`Gamelet \`${id}\` is not open.`)) + if (!options.hasRelay()) + return Promise.reject(new Error('Gamelet iframe relay is not available.')) + + const requestId = randomUUID() + const timeoutMs = requestOptions?.timeoutMs ?? DEFAULT_WIDGET_IFRAME_REQUEST_TIMEOUT_MS + + const response = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingRequests.delete(requestId) + reject(new Error(`Gamelet request timed out after ${timeoutMs}ms.`)) + }, timeoutMs) + + pendingRequests.set(requestId, { + id, + resolve: result => resolve(result as TResponse), + reject, + timeout, + }) + }) + + options.emitRequest({ + id, + requestId, + payload: payload as WidgetsIframeRequestPayload['payload'], + timeoutMs, + }) + + return response + } + + function publishWidgetIframeRequestResult(result: WidgetsIframeRequestResultPayload) { + const pending = pendingRequests.get(result.requestId) + if (!pending || pending.id !== result.id) + return + + settlePendingRequest(result.requestId, (settled) => { + if (result.ok) { + settled.resolve(result.result) + return + } + + settled.reject(new Error(result.error)) + }) + } + + function rejectPendingWidgetIframeRequests(id: string, message = WIDGET_IFRAME_REQUEST_CLOSED_MESSAGE) { + for (const [requestId, pending] of pendingRequests) { + if (pending.id !== id) + continue + + settlePendingRequest(requestId, settled => settled.reject(new Error(message))) + } + } + + function rejectAllPendingWidgetIframeRequests(message = WIDGET_IFRAME_REQUEST_CLOSED_MESSAGE) { + for (const requestId of pendingRequests.keys()) { + settlePendingRequest(requestId, settled => settled.reject(new Error(message))) + } + } + + return { + requestWidgetIframe, + publishWidgetIframeRequestResult, + rejectPendingWidgetIframeRequests, + rejectAllPendingWidgetIframeRequests, + } +} diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts index 560ae0b2e..3867ad11d 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.test.ts @@ -1,8 +1,9 @@ import type { WidgetWindowSize } from '../../../shared/eventa' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { normalizeWidgetWindowSize } from '../../../shared/utils/electron/windows/window-size' +import { createWidgetIframeRequestCoordinator } from './iframe-request-coordinator' describe('normalizeWidgetWindowSize', () => { it('returns undefined for missing or unusable base sizes', () => { @@ -51,3 +52,152 @@ describe('normalizeWidgetWindowSize', () => { }) }) }) + +describe('createWidgetIframeRequestCoordinator', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('rejects immediately when the target widget is not open', async () => { + const emitRequest = vi.fn() + const coordinator = createWidgetIframeRequestCoordinator({ + emitRequest, + hasWidget: () => false, + hasRelay: () => true, + }) + + await expect(coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet `kit-module:board` is not open.') + expect(emitRequest).not.toHaveBeenCalled() + }) + + it('emits a correlated iframe request and resolves only the matching successful result', async () => { + const emitRequest = vi.fn() + const coordinator = createWidgetIframeRequestCoordinator({ + emitRequest, + hasWidget: id => id === 'kit-module:board', + hasRelay: () => true, + }) + + const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }) + const emitted = emitRequest.mock.calls[0]?.[0] + + expect(emitted).toEqual({ + id: 'kit-module:board', + requestId: expect.any(String), + payload: { action: 'snapshot' }, + timeoutMs: 30000, + }) + + coordinator.publishWidgetIframeRequestResult({ + id: 'kit-module:other-board', + requestId: emitted.requestId, + ok: true, + result: { fen: 'wrong-board' }, + }) + coordinator.publishWidgetIframeRequestResult({ + id: 'kit-module:board', + requestId: 'unknown-request', + ok: true, + result: { fen: 'unknown-request' }, + }) + coordinator.publishWidgetIframeRequestResult({ + id: 'kit-module:board', + requestId: emitted.requestId, + ok: true, + result: { fen: 'fen-after-request' }, + }) + + await expect(request).resolves.toEqual({ fen: 'fen-after-request' }) + }) + + it('rejects a matching failed iframe result', async () => { + const emitRequest = vi.fn() + const coordinator = createWidgetIframeRequestCoordinator({ + emitRequest, + hasWidget: () => true, + hasRelay: () => true, + }) + + const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }) + const emitted = emitRequest.mock.calls[0]?.[0] + coordinator.publishWidgetIframeRequestResult({ + id: 'kit-module:board', + requestId: emitted.requestId, + ok: false, + error: 'Board rejected the snapshot request.', + }) + + await expect(request).rejects.toThrow('Board rejected the snapshot request.') + }) + + it('rejects timed out requests and removes their pending state', async () => { + vi.useFakeTimers() + const emitRequest = vi.fn() + const coordinator = createWidgetIframeRequestCoordinator({ + emitRequest, + hasWidget: () => true, + hasRelay: () => true, + }) + + const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }, { timeoutMs: 50 }) + const emitted = emitRequest.mock.calls[0]?.[0] + const rejection = expect(request).rejects.toThrow('Gamelet request timed out after 50ms.') + await vi.advanceTimersByTimeAsync(50) + await rejection + + coordinator.publishWidgetIframeRequestResult({ + id: 'kit-module:board', + requestId: emitted.requestId, + ok: true, + result: { fen: 'late-result' }, + }) + + await expect(request).rejects.toThrow('Gamelet request timed out after 50ms.') + }) + + it('rejects pending requests for a removed widget', async () => { + const emitRequest = vi.fn() + const coordinator = createWidgetIframeRequestCoordinator({ + emitRequest, + hasWidget: () => true, + hasRelay: () => true, + }) + + const request = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) + const rejection = expect(request).rejects.toThrow('Gamelet was closed before the request completed.') + coordinator.rejectPendingWidgetIframeRequests('kit-module:board') + + await rejection + }) + + it('rejects immediately when no renderer relay is available', async () => { + const emitRequest = vi.fn() + const coordinator = createWidgetIframeRequestCoordinator({ + emitRequest, + hasWidget: () => true, + hasRelay: () => false, + }) + + await expect(coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' })).rejects.toThrow('Gamelet iframe relay is not available.') + expect(emitRequest).not.toHaveBeenCalled() + }) + + it('rejects all pending requests when the widgets window closes', async () => { + const emitRequest = vi.fn() + const coordinator = createWidgetIframeRequestCoordinator({ + emitRequest, + hasWidget: () => true, + hasRelay: () => true, + }) + + const firstRequest = coordinator.requestWidgetIframe('kit-module:board', { action: 'snapshot' }, { timeoutMs: 30000 }) + const secondRequest = coordinator.requestWidgetIframe('kit-module:clock', { action: 'snapshot' }, { timeoutMs: 30000 }) + + const firstRejection = expect(firstRequest).rejects.toThrow('Gamelet was closed before the request completed.') + const secondRejection = expect(secondRequest).rejects.toThrow('Gamelet was closed before the request completed.') + coordinator.rejectAllPendingWidgetIframeRequests() + + await firstRejection + await secondRejection + }) +}) diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts index a7afbc895..7a7123d29 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts @@ -3,6 +3,7 @@ import type { InferOutput } from 'valibot' import type { WidgetsAddPayload, + WidgetsIframeRequestResultPayload, WidgetSnapshot, WidgetsUpdatePayload, } from '../../../shared/eventa' @@ -21,12 +22,13 @@ import { number, object, optional } from 'valibot' import icon from '../../../../resources/icon.png?asset' -import { widgetsClearEvent, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdateEvent } from '../../../shared/eventa' +import { widgetsClearEvent, widgetsIframeRequestEvent, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdateEvent } from '../../../shared/eventa' import { normalizeWidgetWindowSize } from '../../../shared/utils/electron/windows/window-size' import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' import { createConfig } from '../../libs/electron/persistence' import { createReusableWindow } from '../../libs/electron/window-manager' import { spotlightLikeWindowConfig, transparentWindowConfig } from '../shared/window' +import { createWidgetIframeRequestCoordinator } from './iframe-request-coordinator' import { setupWidgetsWindowInvokes } from './rpc/index.electron' /** @@ -140,6 +142,36 @@ export interface WidgetsWindowManager { getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined publishWidgetEvent: (id: string, event: Record) => void onWidgetEvent: (listener: (event: { id: string, event: Record }) => void) => () => void + /** + * Sends a correlated request to a mounted widget iframe through the widgets renderer. + * + * Use when: + * - Main-process gamelet orchestration needs a response from iframe code + * + * Expects: + * - `id` references an open widget with a mounted iframe relay + * + * Returns: + * - Resolves with the iframe response record, or rejects on timeout, close, or iframe error + */ + requestWidgetIframe: = Record>( + id: string, + payload: Record, + options?: { timeoutMs?: number }, + ) => Promise + /** + * Publishes a renderer-to-main iframe request result into the pending request coordinator. + * + * Use when: + * - The widgets renderer reports a completed iframe request + * + * Expects: + * - `result.requestId` matches a request previously emitted by {@link WidgetsWindowManager.requestWidgetIframe} + * + * Returns: + * - Nothing; unknown or mismatched results are ignored + */ + publishWidgetIframeRequestResult: (result: WidgetsIframeRequestResultPayload) => void /** * Reserves a widget id before content is pushed into the widgets window. * @@ -273,6 +305,11 @@ export function setupWidgetsWindowManager(params: { const widgetRecords = new Map() const widgetEventListeners = new Set<(event: { id: string, event: Record }) => void>() const windowContexts = new Map() + const iframeRequests = createWidgetIframeRequestCoordinator({ + hasWidget: id => widgetRecords.has(id), + hasRelay: () => Boolean(eventaContext), + emitRequest: payload => eventaContext?.emit(widgetsIframeRequestEvent, payload), + }) const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) const defaultRoute = '/widgets' @@ -331,6 +368,7 @@ export function setupWidgetsWindowManager(params: { pendingRoute = undefined window.on('closed', () => { + iframeRequests.rejectAllPendingWidgetIframeRequests() eventaContext = undefined currentRoute = undefined if (activeWidgetsWindow === window) @@ -397,6 +435,7 @@ export function setupWidgetsWindowManager(params: { widgetRecords.delete(id) windowContexts.delete(id) + iframeRequests.rejectPendingWidgetIframeRequests(id) if (emitEvent) { eventaContext?.emit(widgetsRemoveEvent, { id }) @@ -684,6 +723,18 @@ export function setupWidgetsWindowManager(params: { } } + function requestWidgetIframe = Record>( + id: string, + payload: Record, + options?: { timeoutMs?: number }, + ) { + return iframeRequests.requestWidgetIframe(id, payload, options) + } + + function publishWidgetIframeRequestResult(result: WidgetsIframeRequestResultPayload) { + iframeRequests.publishWidgetIframeRequestResult(result) + } + async function hideWindow(params?: { id?: string }) { const id = params?.id const context = id ? windowContexts.get(id) : undefined @@ -703,6 +754,8 @@ export function setupWidgetsWindowManager(params: { getWidgetSnapshot, publishWidgetEvent, onWidgetEvent, + requestWidgetIframe, + publishWidgetIframeRequestResult, prepareWidgetWindow, } diff --git a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue index 55f612d23..6536b9af0 100644 --- a/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue +++ b/apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue @@ -48,6 +48,7 @@ const DOUBLE_ENTER_INTERVAL_MS = 300 const TRAILING_NEWLINES_REGEX = /[\r\n]+$/ const SEND_MODES = ['enter', 'ctrl-enter', 'double-enter'] as const type SendMode = (typeof SEND_MODES)[number] +type ToolCallRerunToolset = 'widgets' | 'artistry' const sendMode = useLocalStorage('ui/chat/settings/send-mode', 'enter') const toolCallRenderers = { image_journal: JournalToolCallBlock, @@ -226,6 +227,31 @@ async function handleRetryMessage(index: number) { }) } +function resolveToolCallRerunToolset(toolName: string): ToolCallRerunToolset | undefined { + // TODO: Stop hardcoding tool names to app-local toolsets. Tool registration + // should expose the owning runtime/toolset id so reruns can reuse the exact + // source that created the original tool call. + if (toolName === 'image_journal' || toolName === 'text_journal') + return 'artistry' + + if (toolName === 'stage_widgets' || toolName === 'get_weather') + return 'widgets' + + return undefined +} + +async function handleToolCallRerun(payload: { message: ChatHistoryItem, index: number, key: string | number, toolCallId: string, toolName: string, args: string }) { + await chatSyncStore.requestToolCallRerun({ + sessionId: chatSession.activeSessionId, + messageId: payload.message.id, + index: payload.index, + toolset: resolveToolCallRerunToolset(payload.toolName), + toolCallId: payload.toolCallId, + toolName: payload.toolName, + args: payload.args, + }) +} + async function handleCleanupMessages() { const messageCount = messages.value.filter(message => message.role !== 'system').length await chatSyncStore.requestCleanup() @@ -246,6 +272,7 @@ async function handleCleanupMessages() { :tool-call-renderers="toolCallRenderers" @delete-message="handleDeleteMessage($event.index)" @retry-message="handleRetryMessage($event.index)" + @tool-call-rerun="handleToolCallRerun" /> diff --git a/apps/stage-tamagotchi/src/renderer/pages/widgets.vue b/apps/stage-tamagotchi/src/renderer/pages/widgets.vue index 0ac30427e..3833339a7 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/widgets.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/widgets.vue @@ -1,12 +1,12 @@