feat(stage-*,plugin-*): retriable tool call

This commit is contained in:
Neko Ayaka
2026-06-26 14:52:20 +08:00
parent 445b597041
commit 6372491e3b
37 changed files with 2148 additions and 521 deletions
@@ -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<string, WidgetSnapshot>()
const widgetEventListeners = new Set<(event: { id: string, event: Record<string, unknown> }) => void>()
const publishWidgetEvent = vi.fn((id: string, event: Record<string, unknown>) => {
for (const listener of widgetEventListeners) {
listener({ id, event })
}
})
const onWidgetEvent = vi.fn((listener: (event: { id: string, event: Record<string, unknown> }) => 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<string, unknown> | undefined
const request = componentProps?.payload && typeof componentProps.payload === 'object' && !Array.isArray(componentProps.payload)
? (componentProps.payload as Record<string, unknown>).request
: undefined
if (respondToRequests && request && typeof request === 'object' && !Array.isArray(request) && typeof (request as Record<string, unknown>).requestId === 'string') {
const requestId = (request as Record<string, unknown>).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()
@@ -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<GameletKitRuntime['gamelets']> {
dispose: () => void
}
interface PendingRequest {
bindingId: string
resolve: (value: unknown) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
/**
* 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<string, PendingRequest>()
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<TResponse>((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<TResponse & Record<string, unknown>>(
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<string, unknown>): { 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<string, unknown>
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<string, unknown>
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<string, unknown>): 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<string, unknown>
if (typeof responseRecord.error === 'string') {
return responseRecord.error
}
if (typeof responseRecord.message === 'string') {
return responseRecord.message
}
return 'Gamelet request failed.'
}
@@ -48,8 +48,11 @@ export interface ExtensionHostGameletWidgetsManager {
updateWidget: (payload: WidgetsUpdatePayload) => Promise<void>
removeWidget: (id: string) => Promise<void>
getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined
publishWidgetEvent: (id: string, event: Record<string, unknown>) => void
onWidgetEvent: (listener: (event: { id: string, event: Record<string, unknown> }) => void) => () => void
requestWidgetIframe: <TResponse extends Record<string, unknown> = Record<string, unknown>>(
id: string,
payload: Record<string, unknown>,
options?: { timeoutMs?: number },
) => Promise<TResponse>
}
/**
@@ -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<typeof createWidgetsService>[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<typeof createWidgetsService>[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()
})
})
@@ -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<typeof createContext>['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,
{
@@ -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.')
})
})
})
@@ -1,5 +1,6 @@
import type {
WidgetsAddPayload,
WidgetsIframeRequestResultPayload,
WidgetsUpdatePayload,
} from '../../../../shared/eventa'
@@ -183,3 +184,61 @@ export function validateWidgetIframeEvent(event: unknown): Record<string, unknow
return event as Record<string, unknown>
}
/**
* 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.')
}
@@ -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<string, unknown>) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
/**
* 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<string, PendingWidgetIframeRequest>()
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<TResponse extends Record<string, unknown> = Record<string, unknown>>(
id: string,
payload: Record<string, unknown>,
requestOptions?: { timeoutMs?: number },
): Promise<TResponse> {
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<TResponse>((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,
}
}
@@ -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
})
})
@@ -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<string, unknown>) => void
onWidgetEvent: (listener: (event: { id: string, event: Record<string, unknown> }) => 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: <TResponse extends Record<string, unknown> = Record<string, unknown>>(
id: string,
payload: Record<string, unknown>,
options?: { timeoutMs?: number },
) => Promise<TResponse>
/**
* 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<string, WidgetRecord>()
const widgetEventListeners = new Set<(event: { id: string, event: Record<string, unknown> }) => void>()
const windowContexts = new Map<string, WidgetWindowContext>()
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<TResponse extends Record<string, unknown> = Record<string, unknown>>(
id: string,
payload: Record<string, unknown>,
options?: { timeoutMs?: number },
) {
return iframeRequests.requestWidgetIframe<TResponse>(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,
}
@@ -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<SendMode>('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"
/>
</div>
@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { WidgetSnapshot, WidgetWindowSize } from '../../shared/eventa'
import type { WidgetsIframeRequestPayload, WidgetsIframeRequestResultPayload, WidgetSnapshot, WidgetWindowSize } from '../../shared/eventa'
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { computed, defineAsyncComponent, defineComponent, h, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { widgetsClearEvent, widgetsFetch, widgetsRemove, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdate, widgetsUpdateEvent } from '../../shared/eventa'
import { widgetsClearEvent, widgetsFetch, widgetsIframeRequestEvent, widgetsIframeRequestResultEvent, widgetsRemove, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdate, widgetsUpdateEvent } from '../../shared/eventa'
const { t } = useI18n()
@@ -41,6 +41,8 @@ const removeWidgetInvoke = useElectronEventaInvoke(widgetsRemove)
const fetchWidget = useElectronEventaInvoke(widgetsFetch)
const updateWidgetInvoke = useElectronEventaInvoke(widgetsUpdate)
const pinUpdating = shallowRef(false)
const pendingIframeRequests = shallowRef<WidgetsIframeRequestPayload[]>([])
const eventDisposers: Array<() => void> = []
let ttlTimer: ReturnType<typeof setTimeout> | undefined
@@ -101,6 +103,7 @@ async function requestSnapshot(id: string) {
watch(widgetId, (id) => {
clearTtl()
widget.value = null
pendingIframeRequests.value = []
loading.value = false
if (!id)
return
@@ -109,17 +112,30 @@ watch(widgetId, (id) => {
onMounted(() => {
try {
context.value.on(widgetsRenderEvent, (evt) => {
eventDisposers.push(context.value.on(widgetsIframeRequestEvent, (evt) => {
const body = evt?.body
if (!body || body.id !== widgetId.value)
return
applySnapshot(body)
})
pendingIframeRequests.value = [
...pendingIframeRequests.value,
body,
]
}))
}
catch {}
try {
context.value.on(widgetsUpdateEvent, (evt) => {
eventDisposers.push(context.value.on(widgetsRenderEvent, (evt) => {
const body = evt?.body
if (!body || body.id !== widgetId.value)
return
applySnapshot(body)
}))
}
catch {}
try {
eventDisposers.push(context.value.on(widgetsUpdateEvent, (evt) => {
const body = evt?.body
if (!body || body.id !== widgetId.value)
return
@@ -137,33 +153,38 @@ onMounted(() => {
windowSize: body.windowSize ?? widget.value.windowSize,
ttlMs: body.ttlMs ?? widget.value.ttlMs,
})
})
}))
}
catch {}
try {
context.value.on(widgetsRemoveEvent, (evt) => {
eventDisposers.push(context.value.on(widgetsRemoveEvent, (evt) => {
const body = evt?.body
if (!body || body.id !== widgetId.value)
return
clearTtl()
widget.value = null
pendingIframeRequests.value = []
loading.value = false
})
}))
}
catch {}
try {
context.value.on(widgetsClearEvent, () => {
eventDisposers.push(context.value.on(widgetsClearEvent, () => {
clearTtl()
widget.value = null
pendingIframeRequests.value = []
loading.value = false
})
}))
}
catch {}
})
onBeforeUnmount(() => {
for (const dispose of eventDisposers.splice(0)) {
dispose()
}
clearTtl()
})
@@ -237,6 +258,11 @@ async function toggleAlwaysOnTop() {
pinUpdating.value = false
}
}
function handleIframeRequestResult(result: WidgetsIframeRequestResultPayload) {
pendingIframeRequests.value = pendingIframeRequests.value.filter(request => request.requestId !== result.requestId)
context.value.emit(widgetsIframeRequestResultEvent, result)
}
</script>
<template>
@@ -300,7 +326,9 @@ async function toggleAlwaysOnTop() {
:title="widget.componentName"
:model-value="widget.componentProps"
:size="widget.size"
:pending-iframe-requests="pendingIframeRequests"
v-bind="widget.componentProps"
@iframe-request-result="handleIframeRequestResult"
/>
</div>
<div v-else :class="['h-full flex items-center justify-center']">
@@ -1,21 +1,28 @@
// @vitest-environment jsdom
import type { Tool } from '@xsai/shared-chat'
import type { Ref } from 'vue'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
const mockResolveLlmTools = vi.hoisted(() => vi.fn<(options?: { customTools?: (() => Promise<Tool[]>) | Tool[] }) => Promise<Tool[]>>())
const mockWidgetsTools = vi.hoisted(() => vi.fn<() => Promise<Tool[]>>(async () => []))
const mockWeatherTools = vi.hoisted(() => vi.fn<() => Promise<Tool[]>>(async () => []))
const mockImageJournalTools = vi.hoisted(() => vi.fn<() => Promise<Tool[]>>(async () => []))
interface MockBroadcastMessageEvent<T> {
data: T
}
type MockListener = (event: MockBroadcastMessageEvent<unknown>) => void
interface MockChatMessage {
id?: string
role: string
content: string
slices?: Array<{ type: string, text?: string }>
tool_results?: unknown[]
slices?: unknown[]
tool_results?: Array<{ id: string, isError?: boolean, result: unknown }>
}
class MockBroadcastChannel {
@@ -152,16 +159,25 @@ vi.mock('@proj-airi/stage-ui/stores/modules/consciousness', () => ({
}),
}))
vi.mock('@proj-airi/stage-ui/stores/llm-tool-resolver', async (importOriginal) => {
const original = await importOriginal<typeof import('@proj-airi/stage-ui/stores/llm-tool-resolver')>()
return {
...original,
resolveLlmTools: mockResolveLlmTools,
}
})
vi.mock('./tools/builtin/widgets', () => ({
widgetsTools: vi.fn(async () => []),
widgetsTools: mockWidgetsTools,
}))
vi.mock('./tools/builtin/weather', () => ({
weatherTools: vi.fn(async () => []),
weatherTools: mockWeatherTools,
}))
vi.mock('./tools/builtin/image-journal', () => ({
imageJournalTools: vi.fn(async () => []),
imageJournalTools: mockImageJournalTools,
}))
describe('useChatSyncStore', async () => {
@@ -208,6 +224,15 @@ describe('useChatSyncStore', async () => {
throw new Error('Remote sent 403 response: {"error":{"message":"This model is not available in your region.","code":403}}')
})
mockResolveLlmTools.mockReset()
mockResolveLlmTools.mockResolvedValue([])
mockWidgetsTools.mockReset()
mockWidgetsTools.mockResolvedValue([])
mockWeatherTools.mockReset()
mockWeatherTools.mockResolvedValue([])
mockImageJournalTools.mockReset()
mockImageJournalTools.mockResolvedValue([])
mockState = {
activeSessionId,
sessionMessages,
@@ -461,4 +486,123 @@ describe('useChatSyncStore', async () => {
store.dispose()
vi.useRealTimers()
})
it('reruns a tool call locally when this window is the authority', async () => {
const execute = vi.fn<Tool['execute']>(async () => 'fresh result')
const demoTool: Tool = {
type: 'function',
function: {
name: 'demo-tool',
description: 'Demo tool',
parameters: {
type: 'object',
properties: {},
},
},
execute,
}
mockWidgetsTools.mockResolvedValueOnce([demoTool])
mockResolveLlmTools.mockImplementationOnce(async (options) => {
if (typeof options?.customTools === 'function')
return options.customTools()
return options?.customTools ?? []
})
const initialMessages: MockChatMessage[] = [
{ role: 'user', content: 'run the tool', id: 'user-1' },
{
role: 'assistant',
content: '',
id: 'assistant-1',
slices: [
{
type: 'tool-call',
toolCall: {
toolCallId: 'call-demo',
toolCallType: 'function',
toolName: 'demo-tool',
args: '{ "value": 1 }',
},
},
],
tool_results: [
{
id: 'call-demo',
result: 'stale result',
},
],
},
]
mockState.sessionMessages.value['session-1'] = initialMessages
const store = useChatSyncStore()
store.initialize('authority')
await store.requestToolCallRerun({
sessionId: 'session-1',
messageId: 'assistant-1',
toolset: 'widgets',
toolCallId: 'call-demo',
toolName: 'demo-tool',
args: '{ "value": 2 }',
})
expect(mockResolveLlmTools).toHaveBeenCalledWith({ customTools: expect.any(Function) })
expect(mockWidgetsTools).toHaveBeenCalledTimes(1)
expect(mockWeatherTools).toHaveBeenCalledTimes(1)
expect(execute).toHaveBeenCalledWith({ value: 2 }, {
toolCallId: 'call-demo',
messages: initialMessages,
})
expect(mockState.setSessionMessages).toHaveBeenCalledWith('session-1', [
initialMessages[0],
expect.objectContaining({
id: 'assistant-1',
tool_results: [
{
id: 'call-demo',
result: 'fresh result',
},
],
}),
])
store.dispose()
})
it('sends tool call rerun commands from followers', async () => {
const store = useChatSyncStore()
store.initialize('follower')
const pending = store.requestToolCallRerun({
sessionId: 'session-1',
messageId: 'assistant-1',
toolset: 'artistry',
toolCallId: 'call-demo',
toolName: 'demo-tool',
args: '{ "value": 2 }',
})
pending.catch(() => {})
const rerunCommands = postedMessagesOfType('command')
.filter(message => message.command === 'tool-call-rerun')
expect(rerunCommands).toEqual([
expect.objectContaining({
type: 'command',
command: 'tool-call-rerun',
payload: {
sessionId: 'session-1',
messageId: 'assistant-1',
toolset: 'artistry',
toolCallId: 'call-demo',
toolName: 'demo-tool',
args: '{ "value": 2 }',
},
}),
])
store.dispose()
await expect(pending).rejects.toThrow('Chat sync channel disposed')
})
})
@@ -1,4 +1,5 @@
import type { WebSocketEventInputs } from '@proj-airi/server-sdk'
import type { ToolCallRerunPayload } from '@proj-airi/stage-ui/stores/tool-call-rerun'
import type { ChatHistoryItem, StreamingAssistantMessage } from '@proj-airi/stage-ui/types/chat'
import type { ChatSessionMeta } from '@proj-airi/stage-ui/types/chat-session'
import type { ChatProvider } from '@xsai-ext/providers/utils'
@@ -10,8 +11,10 @@ import { useChatOrchestratorStore } from '@proj-airi/stage-ui/stores/chat'
import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/maintenance'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
import { resolveLlmTools } from '@proj-airi/stage-ui/stores/llm-tool-resolver'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { executeToolCallRerun } from '@proj-airi/stage-ui/stores/tool-call-rerun'
import { defineStore, storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
@@ -82,6 +85,7 @@ type ChatSyncMessage
| ChatCommandMessage<'ingest', IngestCommandPayload>
| ChatCommandMessage<'spotlight-ingest', SpotlightIngestPayload>
| ChatCommandMessage<'retry', RetryCommandPayload>
| ChatCommandMessage<'tool-call-rerun', ToolCallRerunPayload<ToolsetId>>
| ChatCommandMessage<'cleanup', { sessionId?: string }>
| ChatCommandMessage<'delete-message', { sessionId?: string, messageId?: string, index?: number }>
| ({ type: 'response', requestId: string, authorityId: string } & ChatResponsePayload)
@@ -384,6 +388,16 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
})
}
async function executeToolCallRerunCommand(payload: ToolCallRerunPayload<ToolsetId>) {
const sessionId = payload.sessionId || activeSessionId.value
const nextMessages = await executeToolCallRerun({
messages: chatSession.getSessionMessages(sessionId),
payload,
resolveTools: () => resolveLlmTools({ customTools: resolveTools(payload.toolset) }),
})
chatSession.setSessionMessages(sessionId, nextMessages)
}
function executeDeleteMessage(payload: { sessionId?: string, messageId?: string, index?: number }) {
const sessionId = payload.sessionId || activeSessionId.value
const nextMessages = chatSession.getSessionMessages(sessionId).filter((message, index) => {
@@ -444,6 +458,9 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
case 'retry':
await executeRetry(message.payload)
break
case 'tool-call-rerun':
await executeToolCallRerunCommand(message.payload)
break
case 'cleanup':
cleanupMessages(message.payload.sessionId)
break
@@ -641,6 +658,21 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
})
}
async function requestToolCallRerun(payload: ToolCallRerunPayload<ToolsetId>) {
if (mode.value === 'authority') {
await executeToolCallRerunCommand(payload)
return
}
return await dispatch<void>({
type: 'command',
requestId: createRequestId(),
senderId: instanceId,
command: 'tool-call-rerun',
payload,
})
}
async function requestCleanup(sessionId?: string) {
if (mode.value === 'authority') {
cleanupMessages(sessionId)
@@ -688,6 +720,7 @@ export const useChatSyncStore = defineStore('stage-tamagotchi:chat-sync', () =>
requestIngest,
requestSpotlightIngest,
requestRetry,
requestToolCallRerun,
requestCleanup,
requestDeleteMessage,
}
@@ -1,12 +1,16 @@
<script setup lang="ts">
import type { ComponentPublicInstance } from 'vue'
import type {
WidgetsIframeRequestPayload,
WidgetsIframeRequestResultPayload,
} from '../../../../shared/eventa'
import type { PluginHostModuleSummary, PluginModuleWidgetPayload } from '../../../../shared/eventa/plugin/host'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
import { isPlainObject } from 'es-toolkit'
import { computed, shallowRef } from 'vue'
import { computed, shallowRef, watch } from 'vue'
import { widgetsIframePublish } from '../../../../shared/eventa'
import { electronPluginGetAssetBaseUrl } from '../../../../shared/eventa/plugin/assets'
@@ -15,6 +19,10 @@ import { publishWidgetSparkNotifyReaction } from '../composables/use-bridge-spar
import { useExtensionUIForModule } from '../composables/use-extension-ui-for-module'
import { useIframeMessagePort } from '../composables/use-iframe-message-port'
import { canRenderExtensionUi, sanitizeExtensionUiRenderProps } from '../host'
import {
createExtensionUiIframeRequestHandler,
createExtensionUiIframeRequestQueueProcessor,
} from './iframe-request'
const props = withDefaults(defineProps<{
title?: string
@@ -22,14 +30,20 @@ const props = withDefaults(defineProps<{
moduleId?: string
componentProps?: Record<string, any>
payload?: Record<string, any>
pendingIframeRequests?: WidgetsIframeRequestPayload[]
}>(), {
title: 'Extension UI',
modelValue: () => ({}),
moduleId: undefined,
componentProps: undefined,
payload: undefined,
pendingIframeRequests: () => [],
})
const emit = defineEmits<{
iframeRequestResult: [result: WidgetsIframeRequestResultPayload]
}>()
function firstString(...values: unknown[]) {
for (const value of values) {
if (typeof value !== 'string') {
@@ -96,7 +110,7 @@ const iframeSandbox = computed(() => firstString(
const iframeElement = shallowRef<HTMLIFrameElement | null>(null)
const { context: iframeContext, iframeLoadError, onIframeError, onIframeLoad } = useIframeMessagePort(
const { context: iframeContext, iframeReady, iframeLoadError, onIframeError, onIframeLoad } = useIframeMessagePort(
iframeElement,
{
moduleId,
@@ -124,6 +138,19 @@ const { context: iframeContext, iframeLoadError, onIframeError, onIframeLoad } =
},
},
)
const requestWidgetIframe = createExtensionUiIframeRequestHandler({
getContext: () => iframeContext,
})
const processIframeRequests = createExtensionUiIframeRequestQueueProcessor({
shouldHandle: request => request.id === moduleId.value,
isReady: () => iframeReady.value,
requestWidgetIframe,
emitResult: result => emit('iframeRequestResult', result),
})
watch([() => props.pendingIframeRequests, iframeReady], ([requests]) => {
processIframeRequests(requests)
}, { immediate: true })
function setIframeElement(element: Element | ComponentPublicInstance | null) {
iframeElement.value = element instanceof HTMLIFrameElement ? element : null
@@ -0,0 +1,176 @@
import { createContext, defineInvokeHandler } from '@moeru/eventa'
import { gameletIframeRequest } from '@proj-airi/plugin-sdk-tamagotchi/gamelet'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createExtensionUiIframeRequestHandler,
createExtensionUiIframeRequestQueueProcessor,
} from './iframe-request'
describe('createExtensionUiIframeRequestHandler', () => {
afterEach(() => {
vi.useRealTimers()
})
it('invokes the mounted iframe context and returns the gamelet response', async () => {
const iframeContext = createContext()
defineInvokeHandler(iframeContext, gameletIframeRequest, ({ payload }) => ({
fen: `fen:${payload.action}`,
}))
const requestWidgetIframe = createExtensionUiIframeRequestHandler({
getContext: () => iframeContext,
})
await expect(requestWidgetIframe({
id: 'kit-module:board',
requestId: 'req-1',
payload: { action: 'snapshot' },
timeoutMs: 1000,
})).resolves.toEqual({
fen: 'fen:snapshot',
})
})
it('rejects when the iframe context is not ready', async () => {
const requestWidgetIframe = createExtensionUiIframeRequestHandler({
getContext: () => undefined,
})
await expect(requestWidgetIframe({
id: 'kit-module:board',
requestId: 'req-1',
payload: { action: 'snapshot' },
timeoutMs: 1000,
})).rejects.toThrow('Gamelet `kit-module:board` iframe context is not ready.')
})
it('aborts the iframe invoke when the request timeout elapses', async () => {
vi.useFakeTimers()
const iframeContext = createContext()
defineInvokeHandler(iframeContext, gameletIframeRequest, async () => {
await new Promise(() => {})
return {}
})
const requestWidgetIframe = createExtensionUiIframeRequestHandler({
getContext: () => iframeContext,
})
const request = requestWidgetIframe({
id: 'kit-module:board',
requestId: 'req-1',
payload: { action: 'snapshot' },
timeoutMs: 5,
})
await vi.advanceTimersByTimeAsync(5)
await expect(request).rejects.toThrow()
})
})
describe('createExtensionUiIframeRequestQueueProcessor', () => {
it('keeps iframe requests pending until the iframe ready handshake arrives', async () => {
let iframeReady = false
const emitResult = vi.fn()
const requestWidgetIframe = vi.fn(async request => ({
fen: `fen:${request.requestId}`,
}))
const input = {
shouldHandle: (request: { id: string }) => request.id === 'kit-module:board',
isReady: () => iframeReady,
requestWidgetIframe,
emitResult,
}
const processIframeRequests = createExtensionUiIframeRequestQueueProcessor(input)
const requests = [{
id: 'kit-module:board',
requestId: 'req-1',
payload: { action: 'start' },
timeoutMs: 1000,
}]
processIframeRequests(requests)
await Promise.resolve()
expect(requestWidgetIframe).not.toHaveBeenCalled()
expect(emitResult).not.toHaveBeenCalled()
iframeReady = true
processIframeRequests(requests)
await Promise.resolve()
expect(requestWidgetIframe).toHaveBeenCalledOnce()
expect(emitResult).toHaveBeenCalledWith({
id: 'kit-module:board',
requestId: 'req-1',
ok: true,
result: { fen: 'fen:req-1' },
})
})
it('emits results for every queued request delivered in one Vue update', async () => {
const emitResult = vi.fn()
const processIframeRequests = createExtensionUiIframeRequestQueueProcessor({
shouldHandle: request => request.id === 'kit-module:board',
requestWidgetIframe: async request => ({
fen: `fen:${request.requestId}`,
}),
emitResult,
})
processIframeRequests([
{
id: 'kit-module:board',
requestId: 'req-1',
payload: { action: 'snapshot' },
timeoutMs: 1000,
},
{
id: 'kit-module:board',
requestId: 'req-2',
payload: { action: 'snapshot' },
timeoutMs: 1000,
},
])
await Promise.resolve()
expect(emitResult).toHaveBeenCalledWith({
id: 'kit-module:board',
requestId: 'req-1',
ok: true,
result: { fen: 'fen:req-1' },
})
expect(emitResult).toHaveBeenCalledWith({
id: 'kit-module:board',
requestId: 'req-2',
ok: true,
result: { fen: 'fen:req-2' },
})
})
it('does not process the same request id twice', async () => {
const emitResult = vi.fn()
const requestWidgetIframe = vi.fn(async () => ({ fen: 'fen-once' }))
const processIframeRequests = createExtensionUiIframeRequestQueueProcessor({
shouldHandle: () => true,
requestWidgetIframe,
emitResult,
})
const requests = [{
id: 'kit-module:board',
requestId: 'req-1',
payload: { action: 'snapshot' },
timeoutMs: 1000,
}]
processIframeRequests(requests)
processIframeRequests(requests)
await Promise.resolve()
expect(requestWidgetIframe).toHaveBeenCalledOnce()
expect(emitResult).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,122 @@
import type { EventContext } from '@moeru/eventa'
import type { GameletIframeResponsePayload } from '@proj-airi/plugin-sdk-tamagotchi/gamelet'
import type {
WidgetsIframeRequestPayload,
WidgetsIframeRequestResultPayload,
} from '../../../../shared/eventa'
import { defineInvoke } from '@moeru/eventa'
import { errorMessageFrom } from '@moeru/std'
import { gameletIframeRequest } from '@proj-airi/plugin-sdk-tamagotchi/gamelet'
export interface ExtensionUiIframeRequestHandlerInput {
getContext: () => EventContext<any, any> | undefined
}
export interface ExtensionUiIframeRequestQueueProcessorInput {
/** Returns whether this mounted iframe owns the request. */
shouldHandle: (request: WidgetsIframeRequestPayload) => boolean
/** Returns whether the iframe has announced its invoke handlers are ready. */
isReady?: () => boolean
/** Invokes the mounted iframe and returns its response record. */
requestWidgetIframe: (request: WidgetsIframeRequestPayload) => Promise<GameletIframeResponsePayload>
/** Emits one correlated request result back to the widget host. */
emitResult: (result: WidgetsIframeRequestResultPayload) => void
}
function createTimeoutSignal(timeoutMs: number): { signal: AbortSignal, cleanup: () => void } {
const timeout = (AbortSignal as typeof AbortSignal & {
timeout?: (milliseconds: number) => AbortSignal
}).timeout
if (timeout) {
return {
signal: timeout(timeoutMs),
cleanup: () => {},
}
}
const controller = new AbortController()
const timer = setTimeout(() => {
controller.abort(new DOMException('The operation timed out.', 'TimeoutError'))
}, timeoutMs)
return {
signal: controller.signal,
cleanup: () => clearTimeout(timer),
}
}
/**
* Creates the renderer-side relay that invokes one mounted extension iframe.
*
* The widgets renderer receives host requests from Electron main, then this
* helper forwards the request into the iframe Eventa context with the shared
* gamelet invoke contract and caller-provided timeout budget.
*/
export function createExtensionUiIframeRequestHandler(input: ExtensionUiIframeRequestHandlerInput) {
return async function requestWidgetIframe(request: WidgetsIframeRequestPayload) {
const context = input.getContext()
if (!context) {
throw new Error(`Gamelet \`${request.id}\` iframe context is not ready.`)
}
const invokeGameletIframeRequest = defineInvoke(context, gameletIframeRequest)
const timeoutSignal = createTimeoutSignal(request.timeoutMs)
try {
return await invokeGameletIframeRequest({
requestId: request.requestId,
payload: request.payload,
}, {
signal: timeoutSignal.signal,
})
}
finally {
timeoutSignal.cleanup()
}
}
}
/**
* Creates a queue processor for iframe requests delivered through Vue props.
*
* Vue batches parent-to-child prop updates, so iframe requests must be modeled as
* a queue instead of a single latest value. This processor deduplicates by
* `requestId` and emits one correlated result for every unhandled request.
*/
export function createExtensionUiIframeRequestQueueProcessor(input: ExtensionUiIframeRequestQueueProcessorInput) {
const handledRequestIds = new Set<string>()
return function processIframeRequests(requests: readonly WidgetsIframeRequestPayload[] | undefined) {
if (input.isReady && !input.isReady()) {
return
}
for (const request of requests ?? []) {
if (!input.shouldHandle(request) || handledRequestIds.has(request.requestId)) {
continue
}
handledRequestIds.add(request.requestId)
void input.requestWidgetIframe(request)
.then((result) => {
input.emitResult({
id: request.id,
requestId: request.requestId,
ok: true,
result,
})
})
.catch((error: unknown) => {
input.emitResult({
id: request.id,
requestId: request.requestId,
ok: false,
error: errorMessageFrom(error) ?? 'Gamelet request failed.',
})
})
}
}
}
@@ -102,6 +102,7 @@ export function useIframeMessagePort(
},
) {
const iframeLoadError = shallowRef<string>()
const iframeReady = shallowRef(false)
const iframeRuntime = createContext({
channel: widgetsIframeChannel,
@@ -142,15 +143,24 @@ export function useIframeMessagePort(
}
function onIframeLoad() {
// NOTICE:
// A host component can mount after an already-loaded iframe during dev HMR
// or renderer remounts, which means the iframe's one-shot ready event may
// have already been emitted. Treat load as the point where invokes may be
// attempted; Eventa still owns request timeout/error handling if the iframe
// has not registered its handler yet.
iframeReady.value = true
iframeLoadError.value = undefined
emitInitPayload()
}
function onIframeError() {
iframeReady.value = false
iframeLoadError.value = 'Failed to load extension UI iframe source.'
}
iframeRuntime.context.on(widgetsIframeReadyEvent, () => {
iframeReady.value = true
emitInitPayload()
})
@@ -163,6 +173,7 @@ export function useIframeMessagePort(
})
watch(options.moduleId, () => {
iframeReady.value = false
emitInitPayload()
}, { immediate: true })
@@ -180,6 +191,7 @@ export function useIframeMessagePort(
return {
context: iframeRuntime.context,
iframeReady,
iframeLoadError,
onIframeLoad,
onIframeError,
@@ -1,4 +1,6 @@
import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/window-message'
import { gameletIframeRequest } from '@proj-airi/plugin-sdk-tamagotchi/gamelet'
import { widgetsIframeInitEvent, widgetsIframePublishEvent } from '@proj-airi/plugin-sdk-tamagotchi/widgets'
import { describe, expect, it } from 'vitest'
@@ -128,12 +130,7 @@ describe('createContext', () => {
iframe.dispose()
})
/**
* @example
* expect(initPayload.props.request.responseRoute).toEqual({ namespace: 'airi.plugin.gamelet', name: 'response' })
* expect(publishedPayload.route).toEqual({ namespace: 'airi.plugin.gamelet', name: 'response' })
*/
it('relays gamelet request props and iframe response envelopes over the extension UI bridge', async () => {
it('relays gamelet iframe invoke requests over the extension UI bridge', async () => {
const parentWindow = new MockWindow()
const iframeWindow = new MockWindow()
parentWindow.peer = iframeWindow
@@ -152,85 +149,19 @@ describe('createContext', () => {
targetWindow: () => parentWindow as unknown as Window,
})
const initPayload = new Promise<Record<string, unknown>>((resolve) => {
iframe.context.on(widgetsIframeInitEvent, (event) => {
const request = event.body?.props?.request
if (!request || typeof request !== 'object' || Array.isArray(request)) {
return
}
resolve(request as Record<string, unknown>)
})
defineInvokeHandler(iframe.context, gameletIframeRequest, ({ payload }) => {
return {
fen: payload.action === 'snapshot' ? 'fen-after-request' : 'unknown',
}
})
host.context.emit(widgetsIframeInitEvent, {
moduleId: 'chess:board',
config: {},
module: undefined,
props: {
request: {
route: {
namespace: 'airi.plugin.gamelet',
name: 'request',
},
responseRoute: {
namespace: 'airi.plugin.gamelet',
name: 'response',
},
requestId: 'req-1',
payload: {
action: 'snapshot',
},
},
},
})
await expect(initPayload).resolves.toEqual({
route: {
namespace: 'airi.plugin.gamelet',
name: 'request',
},
responseRoute: {
namespace: 'airi.plugin.gamelet',
name: 'response',
},
const invokeGameletIframeRequest = defineInvoke(host.context, gameletIframeRequest)
await expect(invokeGameletIframeRequest({
requestId: 'req-1',
payload: {
action: 'snapshot',
},
})
const publishedPayload = new Promise<Record<string, unknown>>((resolve) => {
host.context.on(widgetsIframePublishEvent, (event) => {
if (!event.body) {
return
}
resolve(event.body)
})
})
iframe.context.emit(widgetsIframePublishEvent, {
route: {
namespace: 'airi.plugin.gamelet',
name: 'response',
},
payload: {
requestId: 'req-1',
fen: 'fen-after-request',
},
})
await expect(publishedPayload).resolves.toEqual({
route: {
namespace: 'airi.plugin.gamelet',
name: 'response',
},
payload: {
requestId: 'req-1',
fen: 'fen-after-request',
},
})
})).resolves.toEqual({ fen: 'fen-after-request' })
host.dispose()
iframe.dispose()
@@ -1,4 +1,8 @@
import type { Locale } from '@intlify/core'
import type {
GameletIframeRequestPayload as GameletIframeInvokePayload,
GameletIframeResponsePayload,
} from '@proj-airi/plugin-sdk-tamagotchi/gamelet'
import type { ServerOptions } from '@proj-airi/server-runtime/server'
import type {
ShortcutAccelerator,
@@ -144,6 +148,57 @@ export interface WidgetSnapshot {
ttlMs: number
}
/**
* Request relayed from Electron main to one mounted widget iframe through the widgets renderer.
*/
export interface WidgetsIframeRequestPayload {
/** Widget id that identifies the mounted iframe target. */
id: string
/** Relay correlation id echoed by the renderer-to-main result event. */
requestId: string
/** Structured-clone-safe request record forwarded into the iframe Eventa runtime. */
payload: GameletIframeInvokePayload['payload']
/** Request timeout budget in milliseconds. */
timeoutMs: number
}
/**
* Shared fields for a renderer-to-main iframe request result.
*/
export interface WidgetsIframeRequestResultBasePayload {
/** Widget id that produced the result. */
id: string
/** Relay correlation id matching the original main-to-renderer request. */
requestId: string
}
/**
* Successful renderer-to-main iframe request result.
*/
export interface WidgetsIframeRequestSuccessPayload extends WidgetsIframeRequestResultBasePayload {
/** Marks this result as a successful iframe response. */
ok: true
/** Structured-clone-safe response record returned by the iframe Eventa runtime. */
result: GameletIframeResponsePayload
}
/**
* Failed renderer-to-main iframe request result.
*/
export interface WidgetsIframeRequestFailurePayload extends WidgetsIframeRequestResultBasePayload {
/** Marks this result as a failed iframe response. */
ok: false
/** Error message returned when the iframe request fails. */
error: string
}
/**
* Result relayed from the widgets renderer back to Electron main for one iframe request.
*/
export type WidgetsIframeRequestResultPayload
= | WidgetsIframeRequestSuccessPayload
| WidgetsIframeRequestFailurePayload
export interface PluginManifestSummary {
extensionId: string
entrypoints: Record<string, string | undefined>
@@ -415,6 +470,10 @@ export const widgetsRenderEvent = defineEventa<WidgetSnapshot>('eventa:event:ele
export const widgetsRemoveEvent = defineEventa<{ id: string }>('eventa:event:electron:windows:widgets:remove')
export const widgetsClearEvent = defineEventa('eventa:event:electron:windows:widgets:clear')
export const widgetsUpdateEvent = defineEventa<WidgetsUpdatePayload>('eventa:event:electron:windows:widgets:update')
/** Main-to-renderer event requesting work from a mounted widget iframe. */
export const widgetsIframeRequestEvent = defineEventa<WidgetsIframeRequestPayload>('eventa:event:electron:windows:widgets:iframe-request')
/** Renderer-to-main event carrying the correlated result for a widget iframe request. */
export const widgetsIframeRequestResultEvent = defineEventa<WidgetsIframeRequestResultPayload>('eventa:event:electron:windows:widgets:iframe-request-result')
// Onboarding window events
export const electronOnboardingClose = defineInvokeEventa('eventa:invoke:electron:windows:onboarding:close')
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import {
widgetsIframeRequestEvent,
widgetsIframeRequestResultEvent,
} from './index'
describe('widgets iframe request events', () => {
it('uses stable event ids for main renderer gamelet request relay', () => {
expect(widgetsIframeRequestEvent.id).toBe('eventa:event:electron:windows:widgets:iframe-request')
expect(widgetsIframeRequestResultEvent.id).toBe('eventa:event:electron:windows:widgets:iframe-request-result')
})
})
@@ -0,0 +1,34 @@
import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
import { defineInvokeEventa } from '@moeru/eventa'
/**
* Request payload sent from the host gamelet runtime to one mounted iframe.
*/
export interface GameletIframeRequestPayload {
/** Host-generated correlation id used outside Eventa invoke for main/renderer relay isolation. */
requestId: string
/** JSON-compatible command payload supplied by the extension-side gamelet client. */
payload: HostDataRecord
}
/**
* Response payload returned by one mounted gamelet iframe.
*/
export type GameletIframeResponsePayload = HostDataRecord
/**
* Stable invoke name shared by the host relay and mounted gamelet iframe handler.
*/
export const gameletIframeRequestEventName = 'eventa:invoke:gamelet:iframe:request'
/**
* Shared invoke contract used by widget hosts to request work from a mounted gamelet iframe.
*
* The Electron main process cannot access iframe windows directly, so renderer code invokes
* this contract on the iframe Eventa context and relays the result back to main.
*/
export const gameletIframeRequest = defineInvokeEventa<
GameletIframeResponsePayload,
GameletIframeRequestPayload
>(gameletIframeRequestEventName)
@@ -3,6 +3,8 @@ import type { HostDataRecord } from '@proj-airi/plugin-sdk/plugin-host'
import { defineKit } from '@proj-airi/plugin-sdk'
export * from './events'
export interface GameletKitClient {
iframe: (input: { assetPath?: string, src?: string, sandbox?: string }) => HostDataRecord
mount: (definition: {
@@ -13,7 +13,13 @@ import { DisposableStore } from '@proj-airi/plugin-sdk'
import { object, optional, string } from 'valibot'
import { describe, expect, it, vi } from 'vitest'
import { gameletKit, TamagotchiToolRegistry, toolKit } from './index'
import {
gameletIframeRequest,
gameletIframeRequestEventName,
gameletKit,
TamagotchiToolRegistry,
toolKit,
} from './index'
import { createGamelet } from './kits/gamelet'
import { registerTools } from './kits/tool'
@@ -158,6 +164,15 @@ function createToolModuleRef(input: {
}
describe('plugin-sdk-tamagotchi', () => {
it('exports shared gamelet iframe request contracts', () => {
expect(gameletIframeRequestEventName).toBe('eventa:invoke:gamelet:iframe:request')
expect(gameletIframeRequest).toEqual(expect.objectContaining({
sendEvent: expect.objectContaining({
id: expect.stringContaining('eventa:invoke:gamelet:iframe:request'),
}),
}))
})
it('exposes gameletKit as a module-scoped kit client', async () => {
const bindings: unknown[] = []
const client = gameletKit.createClient(createGameletRuntime({
@@ -14,6 +14,8 @@ import ChatActionButtons from '../Widgets/ChatActionButtons.vue'
import ChatArea from '../Widgets/ChatArea.vue'
import ChatContainer from '../Widgets/ChatContainer.vue'
import { useChatToolCallRerun } from '../../composables/useChatToolCallRerun'
const { isReady } = useDeferredMount()
const { sending } = storeToRefs(useChatOrchestratorStore())
const { messages } = storeToRefs(useChatSessionStore())
@@ -22,6 +24,7 @@ const { streamingMessage } = storeToRefs(useChatStreamStore())
const isLoading = ref(true)
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
const { trackChatMessageDeleted } = useAnalytics()
const { rerunToolCall } = useChatToolCallRerun()
function handleDeleteMessage(index: number) {
const message = messages.value[index]
@@ -53,6 +56,7 @@ function handleDeleteMessage(index: number) {
h-full
variant="desktop"
@delete-message="handleDeleteMessage($event.index)"
@tool-call-rerun="rerunToolCall"
@vue:mounted="isLoading = false"
/>
</div>
@@ -28,6 +28,7 @@ import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
import ActionAbout from './InteractiveArea/Actions/About.vue'
import { useTranscriptions } from '../../composables/use-transcriptions'
import { useChatToolCallRerun } from '../../composables/useChatToolCallRerun'
import { useStopSpeakingButton } from '../../composables/useStopSpeakingButton'
import { BackgroundDialogPicker } from '../Backgrounds'
@@ -41,6 +42,7 @@ const { streamingMessage } = storeToRefs(chatStream)
const { sending } = storeToRefs(chatOrchestrator)
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
const { trackChatMessageDeleted, trackChatMessagesCleared } = useAnalytics()
const { rerunToolCall } = useChatToolCallRerun()
function handleDeleteMessage(index: number) {
const message = messages.value[index]
@@ -184,6 +186,7 @@ onMounted(() => {
'relative z-20',
]"
@delete-message="handleDeleteMessage($event.index)"
@tool-call-rerun="rerunToolCall"
/>
</Transition>
</KeepAlive>
@@ -0,0 +1,53 @@
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
import { errorMessageFrom } from '@moeru/std'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
import { resolveLlmTools } from '@proj-airi/stage-ui/stores/llm-tool-resolver'
import { executeToolCallRerun } from '@proj-airi/stage-ui/stores/tool-call-rerun'
export interface ChatToolCallRerunEvent {
message: ChatHistoryItem
index: number
key: string | number
toolCallId: string
toolName: string
args: string
}
export function useChatToolCallRerun() {
const chatSession = useChatSessionStore()
async function rerunToolCall(payload: ChatToolCallRerunEvent) {
const sessionId = chatSession.activeSessionId
const currentMessages = chatSession.getSessionMessages(sessionId)
try {
const nextMessages = await executeToolCallRerun({
messages: currentMessages,
payload: {
sessionId,
messageId: payload.message.id,
index: payload.index,
toolCallId: payload.toolCallId,
toolName: payload.toolName,
args: payload.args,
},
resolveTools: () => resolveLlmTools(),
})
chatSession.setSessionMessages(sessionId, nextMessages)
}
catch (error) {
chatSession.setSessionMessages(sessionId, [
...currentMessages,
{
role: 'error',
content: errorMessageFrom(error) ?? 'Failed to rerun tool call.',
},
])
}
}
return {
rerunToolCall,
}
}
@@ -28,6 +28,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(e: 'copy'): void
(e: 'delete'): void
(e: 'toolCallRerun', payload: { toolCallId: string, toolName: string, args: string }): void
}>()
const resolvedSlices = computed<ChatSlices[]>(() => {
@@ -111,10 +112,12 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
<component
:is="getToolCallRenderer(slice)"
v-if="slice.type === 'tool-call'"
:tool-call-id="slice.toolCall.toolCallId"
:tool-name="slice.toolCall.toolName"
:args="slice.toolCall.args"
:state="getToolCallState(slice)"
:result="getToolCallResult(slice)?.result"
@tool-call-rerun="emit('toolCallRerun', $event)"
/>
<template v-else-if="slice.type === 'tool-call-result'" />
<template v-else-if="slice.type === 'text'">
@@ -2,11 +2,13 @@ import type { ChatHistoryItem } from '../../../../types/chat'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-vue'
import { defineComponent, shallowRef } from 'vue'
import { computed, defineComponent, shallowRef } from 'vue'
import { createI18n } from 'vue-i18n'
import ChatHistory from './history.vue'
import { getChatHistoryItemKey } from '../utils'
vi.mock('../composables/use-chat-history-scroll', () => ({
useChatHistoryScroll: () => undefined,
}))
@@ -57,15 +59,24 @@ function createHarness(messages: ChatHistoryItem[]) {
},
setup() {
const lastRetryIndex = shallowRef('none')
const lastToolCallRerunPayload = shallowRef('')
function handleRetryMessage(payload: { index: number }) {
lastRetryIndex.value = String(payload.index)
}
function handleToolCallRerun(payload: unknown) {
lastToolCallRerunPayload.value = JSON.stringify(payload)
}
const toolCallRerunPayload = computed(() => lastToolCallRerunPayload.value)
return {
handleRetryMessage,
handleToolCallRerun,
lastRetryIndex,
messages,
toolCallRerunPayload,
}
},
template: `
@@ -73,8 +84,10 @@ function createHarness(messages: ChatHistoryItem[]) {
<ChatHistory
:messages="messages"
@retry-message="handleRetryMessage"
@tool-call-rerun="handleToolCallRerun"
/>
<output aria-label="retry-index">{{ lastRetryIndex }}</output>
<output aria-label="tool-call-rerun">{{ toolCallRerunPayload }}</output>
</div>
`,
})
@@ -133,4 +146,46 @@ describe('chatHistory retry actions', () => {
expect(document.body.textContent).not.toContain('Retry')
})
it('emits tool-call-rerun with message context when a tool call rerun button is clicked', async () => {
const args = JSON.stringify({ location: 'Tokyo' })
const assistantMessage: ChatHistoryItem = {
role: 'assistant',
content: '',
slices: [
{
type: 'tool-call',
toolCall: {
toolCallId: 'call-weather',
toolCallType: 'function',
toolName: 'weather',
args,
},
},
],
tool_results: [],
createdAt: 1710000000000,
}
const messages: ChatHistoryItem[] = [
{ role: 'user', content: 'weather in Tokyo' },
assistantMessage,
]
const screen = await render(createHarness(messages), {
global: {
plugins: [createTestI18n()],
},
})
await screen.getByLabelText('Re-run tool call').click()
await expect.element(screen.getByLabelText('tool-call-rerun')).toHaveTextContent(JSON.stringify({
message: assistantMessage,
index: 1,
key: getChatHistoryItemKey(assistantMessage, 1),
toolCallId: 'call-weather',
toolName: 'weather',
args,
}))
})
})
@@ -33,6 +33,7 @@ const emit = defineEmits<{
(e: 'copyMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
(e: 'deleteMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
(e: 'retryMessage', payload: { message: ChatHistoryItem, index: number, key: string | number }): void
(e: 'toolCallRerun', payload: { message: ChatHistoryItem, index: number, key: string | number, toolCallId: string, toolName: string, args: string }): void
}>()
const chatHistoryRef = ref<HTMLDivElement>()
@@ -100,6 +101,19 @@ function emitRetryMessage(message: ChatHistoryItem, index: number) {
key: getChatHistoryItemKey(message, index),
})
}
function emitToolCallRerun(
message: ChatHistoryItem,
index: number,
payload: { toolCallId: string, toolName: string, args: string },
) {
emit('toolCallRerun', {
message,
index,
key: getChatHistoryItemKey(message, index),
...payload,
})
}
</script>
<template>
@@ -131,6 +145,7 @@ function emitRetryMessage(message: ChatHistoryItem, index: number) {
:tool-call-renderers="toolCallRenderers"
@copy="emitCopyMessage(message, index)"
@delete="emitDeleteMessage(message, index)"
@tool-call-rerun="emitToolCallRerun(message, index, $event)"
/>
<ChatUserItem
v-else-if="message.role === 'user'"
@@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const source = readFileSync(fileURLToPath(new URL('./tool-call-block.vue', import.meta.url)), 'utf8')
describe('chat tool call block rerun action', () => {
it('wires the rerun button click to the expected toolCallRerun payload', () => {
expect(source).toContain('toolCallId: string')
expect(source).toContain('(e: \'toolCallRerun\', payload: { toolCallId: string, toolName: string, args: string })')
expect(source).toContain('aria-label="Re-run tool call"')
expect(source).toContain('@click.stop="emitToolCallRerun"')
expect(source).toContain('i-solar:refresh-bold')
expect(source).toContain('emit(\'toolCallRerun\', {')
expect(source).toContain('toolCallId: props.toolCallId')
expect(source).toContain('toolName: props.toolName')
expect(source).toContain('args: props.args')
})
})
@@ -5,12 +5,17 @@ import { computed } from 'vue'
import { createToolResultError } from './tool-call-display'
const props = defineProps<{
toolCallId: string
toolName: string
args: string
state?: 'executing' | 'done' | 'error'
result?: unknown
}>()
const emit = defineEmits<{
(e: 'toolCallRerun', payload: { toolCallId: string, toolName: string, args: string }): void
}>()
const resultError = computed(() => props.state === 'error' ? createToolResultError(props.result) : undefined)
const formattedArgs = computed(() => {
@@ -22,6 +27,14 @@ const formattedArgs = computed(() => {
return props.args
}
})
function emitToolCallRerun() {
emit('toolCallRerun', {
toolCallId: props.toolCallId,
toolName: props.toolName,
args: props.args,
})
}
</script>
<template>
@@ -32,31 +45,50 @@ const formattedArgs = computed(() => {
]"
>
<template #trigger="{ visible, setVisible }">
<button
<div
:class="[
'w-full text-start',
'inline-flex items-center',
'w-full',
'inline-flex items-center gap-1',
]"
@click="setVisible(!visible)"
>
<div
v-if="state === 'executing'"
i-eos-icons:loading class="mr-1 inline-block op-50"
/>
<div
v-else-if="state === 'error'"
i-solar:danger-circle-bold-duotone class="mr-1 inline-block text-red-500"
/>
<div
v-else-if="state === 'done'"
i-solar:check-circle-bold-duotone class="mr-1 inline-block text-emerald-500"
/>
<div
v-else
i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50"
/>
<code class="text-xs">{{ toolName }}</code>
</button>
<button
:class="[
'min-w-0 flex-1 text-start',
'inline-flex items-center',
]"
@click="setVisible(!visible)"
>
<div
v-if="state === 'executing'"
i-eos-icons:loading class="mr-1 inline-block op-50"
/>
<div
v-else-if="state === 'error'"
i-solar:danger-circle-bold-duotone class="mr-1 inline-block text-red-500"
/>
<div
v-else-if="state === 'done'"
i-solar:check-circle-bold-duotone class="mr-1 inline-block text-emerald-500"
/>
<div
v-else
i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50"
/>
<code class="truncate text-xs">{{ toolName }}</code>
</button>
<button
aria-label="Re-run tool call"
:class="[
'h-6 w-6 shrink-0 rounded-md',
'inline-flex items-center justify-center',
'text-primary-700/70 hover:bg-primary-200/70 hover:text-primary-800',
'dark:text-primary-100/70 dark:hover:bg-primary-800/70 dark:hover:text-primary-50',
]"
@click.stop="emitToolCallRerun"
>
<div class="i-solar:refresh-bold text-sm" />
</button>
</div>
</template>
<div
:class="[
@@ -0,0 +1,61 @@
import type { Tool } from '@xsai/shared-chat'
import { describe, expect, it, vi } from 'vitest'
import { resolveLlmTools, toolNameFrom } from './llm-tool-resolver'
function createTool(name: string, description = `${name} description`): Tool {
return {
type: 'function',
function: {
name,
description,
parameters: {
type: 'object',
properties: {},
required: [],
additionalProperties: false,
},
},
execute: vi.fn(),
} as Tool
}
describe('toolNameFrom', () => {
it('reads function.name', () => {
expect(toolNameFrom(createTool('runtime_read_context'))).toBe('runtime_read_context')
})
})
describe('resolveLlmTools', () => {
it('prefers a later runtime tool with the same name over an earlier built-in tool', async () => {
const builtInTool = createTool('duplicate_tool', 'Built-in version.')
const runtimeTool = createTool('duplicate_tool', 'Runtime version.')
const tools = await resolveLlmTools({
builtInTools: [builtInTool],
debugTools: [],
sparkCommandTools: [],
activeTools: [runtimeTool],
})
expect(tools).toHaveLength(1)
expect(tools[0]).toBe(runtimeTool)
})
it('places custom tools before active runtime tools so runtime tools can win by name', async () => {
const builtInTool = createTool('built_in_tool')
const customTool = createTool('duplicate_tool', 'Custom version.')
const runtimeTool = createTool('duplicate_tool', 'Runtime version.')
const tools = await resolveLlmTools({
builtInTools: [builtInTool],
debugTools: [],
sparkCommandTools: [],
customTools: [customTool],
activeTools: [runtimeTool],
})
expect(tools).toEqual([builtInTool, runtimeTool])
})
})
@@ -0,0 +1,142 @@
import type { StreamOptions } from '@proj-airi/core-agent'
import type { WebSocketEvents } from '@proj-airi/server-sdk'
import type { Tool } from '@xsai/shared-chat'
import { uniqBy } from 'es-toolkit'
import { createSparkCommandTool, debug, mcp } from '../tools'
import { useLlmToolsStore } from './llm-tools'
import { useModsServerChannelStore } from './mods/api/channel-server'
type ToolSource = Tool[] | (() => Promise<Tool[]>)
/**
* Overrides for resolving the complete LLM-visible tool list.
*
* Production callers normally pass only {@link customTools}; tests can inject
* every source to exercise merge and precedence policy without real stores.
*/
export interface ResolveLlmToolsOptions {
/**
* MCP-backed built-in tools.
*
* @default mcp()
*/
builtInTools?: ToolSource
/**
* Debug tools exposed to the LLM.
*
* @default debug()
*/
debugTools?: ToolSource
/**
* Spark command tools. Supplying this also avoids creating the mods server
* channel store.
*
* @default createSparkCommandTool(...)
*/
sparkCommandTools?: ToolSource
/**
* Request-scoped tools from {@link StreamOptions.tools}. These are ordered
* before active runtime tools so runtime registrations can intentionally
* override a request tool with the same name.
*/
customTools?: StreamOptions['tools']
/**
* Runtime-registered tools currently active in the LLM tool store. Supplying
* this also avoids creating the LLM tool store.
*
* @default useLlmToolsStore().activeTools
*/
activeTools?: Tool[]
}
/**
* Reads the provider-visible name from an xsai tool.
*/
export function toolNameFrom(tool: Tool): string | undefined {
const candidate = tool as Tool & {
name?: string
function?: {
name?: string
}
}
return candidate.function?.name ?? candidate.name
}
async function resolveToolSource(source: ToolSource): Promise<Tool[]> {
return typeof source === 'function' ? await source() : source
}
async function resolveCustomTools(customTools: StreamOptions['tools']): Promise<Tool[]> {
if (typeof customTools === 'function')
return await customTools() ?? []
return customTools ?? []
}
async function resolveActiveTools(activeTools?: Tool[]): Promise<Tool[]> {
if (activeTools != null)
return activeTools
const llmToolsStore = useLlmToolsStore()
await llmToolsStore.awaitPendingRegistrations()
return llmToolsStore.activeTools
}
async function resolveSparkCommandTools(sparkCommandTools?: ToolSource): Promise<Tool[]> {
if (sparkCommandTools != null)
return resolveToolSource(sparkCommandTools)
const modsServerChannelStore = useModsServerChannelStore()
const sendSparkCommand = (command: WebSocketEvents['spark:command']) => {
// TODO(@nekomeowww): instruct the LLM to understand what destination is.
// Currently without skill like prompt injection, many issues occur.
// destination mostly are wrong or hallucinated, we need to find a way to make it more reliable.
//
// For now, since destinations as array will always broadcast to all connected modules/agents, we can set it to
// empty array to avoid wrong routing.
command.destinations = []
modsServerChannelStore.send({
type: 'spark:command',
data: command,
})
}
return createSparkCommandTool({ sendSparkCommand })
}
/**
* Resolves every tool visible to an LLM request.
*
* Runtime tools are placed last before de-duplication. The reverse/uniq/reverse
* pass preserves the existing stable order while letting later runtime
* registrations win when names collide with built-in or custom tools.
*/
export async function resolveLlmTools(options: ResolveLlmToolsOptions = {}): Promise<Tool[]> {
const activeTools = await resolveActiveTools(options.activeTools)
const [
builtInTools,
debugTools,
sparkCommandTools,
customTools,
] = await Promise.all([
resolveToolSource(options.builtInTools ?? mcp),
resolveToolSource(options.debugTools ?? debug),
resolveSparkCommandTools(options.sparkCommandTools),
resolveCustomTools(options.customTools),
])
return uniqBy(
[
...builtInTools,
...debugTools,
...sparkCommandTools,
...customTools,
...activeTools,
].toReversed(),
tool => toolNameFrom(tool) ?? tool,
).toReversed()
}
+5 -50
View File
@@ -1,77 +1,32 @@
import type { StreamOptions } from '@proj-airi/core-agent'
import type { WebSocketEvents } from '@proj-airi/server-sdk'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message, Tool } from '@xsai/shared-chat'
import type { Message } from '@xsai/shared-chat'
import { streamFrom as coreStreamFrom, isContentArrayRelatedError, isToolRelatedError, modelKey } from '@proj-airi/core-agent'
import { listModels } from '@xsai/model'
import { uniqBy } from 'es-toolkit'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { createSparkCommandTool, debug, mcp } from '../tools'
import { useLlmToolsStore } from './llm-tools'
import { useModsServerChannelStore } from './mods/api/channel-server'
import { resolveLlmTools } from './llm-tool-resolver'
export type { StreamEvent, StreamOptions } from '@proj-airi/core-agent'
export { isContentArrayRelatedError, isToolRelatedError } from '@proj-airi/core-agent'
function toolNameFrom(tool: Tool) {
const candidate = tool as Tool & {
name?: string
function?: {
name?: string
}
}
return candidate.function?.name ?? candidate.name
}
export const useLLM = defineStore('llm', () => {
const toolsCompatibility = ref<Map<string, boolean>>(new Map())
const contentArrayCompatibility = ref<Map<string, boolean>>(new Map())
const modsServerChannelStore = useModsServerChannelStore()
const llmToolsStore = useLlmToolsStore()
async function stream(model: string, chatProvider: ChatProvider, messages: Message[], options?: StreamOptions) {
const key = modelKey(model, chatProvider)
// TODO(@nekomeowww,@shinohara-rin): we should not register the command callback on every stream anyway...
const sendSparkCommand = (command: WebSocketEvents['spark:command']) => {
// TODO(@nekomeowww): instruct the LLM to understand what destination is.
// Currently without skill like prompt injection, many issues occur.
// destination mostly are wrong or hallucinated, we need to find a way to make it more reliable.
//
// For now, since destinations as array will always broadcast to all connected modules/agents, we can set it to
// empty array to avoid wrong routing.
command.destinations = []
modsServerChannelStore.send({
type: 'spark:command',
data: command,
})
}
const builtinToolsResolver = async () => {
await llmToolsStore.awaitPendingRegistrations()
// Reverse twice so later runtime registrations win while original tool order stays stable.
return uniqBy(
[
...await mcp(),
...await debug(),
...await createSparkCommandTool({ sendSparkCommand }),
...await llmToolsStore.activeTools,
].toReversed(),
tool => toolNameFrom(tool) ?? tool,
).toReversed()
}
const { tools: customTools, ...streamOptions } = options ?? {}
const builtinToolsResolver = () => resolveLlmTools({ customTools })
const runStream = () => coreStreamFrom({
model,
chatProvider,
messages,
options: {
...options,
...streamOptions,
toolsCompatibility: toolsCompatibility.value,
contentArrayCompatibility: contentArrayCompatibility.value,
},
@@ -0,0 +1,246 @@
import type { Tool } from '@xsai/shared-chat'
import type { ChatAssistantMessage, ChatHistoryItem } from '../types/chat'
import { describe, expect, it, vi } from 'vitest'
import { executeToolCallRerun, replaceToolCallResult } from './tool-call-rerun'
function assistantMessage(overrides: Partial<ChatAssistantMessage> = {}): ChatAssistantMessage {
return {
role: 'assistant',
content: '',
slices: [
{
type: 'tool-call',
toolCall: {
toolCallId: 'call-weather',
toolCallType: 'function',
toolName: 'weather',
args: JSON.stringify({ location: 'Tokyo' }),
},
},
],
tool_results: [],
...overrides,
}
}
function tool(name: string, execute: Tool['execute']): Tool {
return {
type: 'function',
function: {
name,
description: `${name} description`,
parameters: {
type: 'object',
properties: {},
},
},
execute,
}
}
describe('replaceToolCallResult', () => {
it('replaces stored tool_results by id', () => {
const message = assistantMessage({
content: 'assistant content',
tool_results: [
{ id: 'call-weather', result: 'old weather' },
{ id: 'call-news', result: 'news' },
],
})
const next = replaceToolCallResult(message, {
id: 'call-weather',
result: 'new weather',
})
expect(next).not.toBe(message)
expect(next.content).toBe('assistant content')
expect(next.tool_results).toEqual([
{ id: 'call-news', result: 'news' },
{ id: 'call-weather', result: 'new weather' },
])
})
it('replaces matching inline tool-call-result slice', () => {
const message = assistantMessage({
slices: [
{
type: 'tool-call',
toolCall: {
toolCallId: 'call-weather',
toolCallType: 'function',
toolName: 'weather',
args: JSON.stringify({ location: 'Tokyo' }),
},
},
{
type: 'tool-call-result',
id: 'call-weather',
result: 'old weather',
},
],
})
const next = replaceToolCallResult(message, {
id: 'call-weather',
isError: true,
result: 'new error',
})
expect(next.slices).toEqual([
message.slices[0],
{
type: 'tool-call-result',
id: 'call-weather',
isError: true,
result: 'new error',
},
])
expect(next.tool_results).toEqual([
{
id: 'call-weather',
isError: true,
result: 'new error',
},
])
})
})
describe('executeToolCallRerun', () => {
it('executes the matching tool and writes the result', async () => {
const execute = vi.fn<Tool['execute']>(async () => 'clear skies')
const targetMessage: ChatHistoryItem = {
...assistantMessage(),
id: 'assistant-1',
}
const messages: ChatHistoryItem[] = [
{ role: 'user', content: 'weather?', id: 'user-1' },
{ role: 'error', content: 'previous runtime error', id: 'error-1' },
targetMessage,
]
const next = await executeToolCallRerun({
messages,
payload: {
messageId: 'assistant-1',
toolCallId: 'call-weather',
toolName: 'weather',
args: '{ "location": "Tokyo" }',
},
resolveTools: async () => [tool('weather', execute)],
})
expect(execute).toHaveBeenCalledWith({ location: 'Tokyo' }, {
toolCallId: 'call-weather',
messages,
})
expect(next).not.toBe(messages)
expect(next[2]).toMatchObject({
tool_results: [
{
id: 'call-weather',
result: 'clear skies',
},
],
})
})
it('writes an error result when the tool is unavailable', async () => {
const messages: ChatHistoryItem[] = [
{
...assistantMessage(),
id: 'assistant-1',
},
]
const next = await executeToolCallRerun({
messages,
payload: {
messageId: 'assistant-1',
toolCallId: 'call-weather',
toolName: 'weather',
args: '{}',
},
resolveTools: async () => [],
})
expect(next[0]).toMatchObject({
tool_results: [
{
id: 'call-weather',
isError: true,
result: 'Tool "weather" is not available for rerun in this runtime.',
},
],
})
})
it('writes an error result for invalid JSON args', async () => {
const execute = vi.fn<Tool['execute']>(async () => 'unused')
const resolveTools = vi.fn<() => Promise<Tool[]>>(async () => [tool('weather', execute)])
const messages: ChatHistoryItem[] = [
{
...assistantMessage(),
id: 'assistant-1',
},
]
const next = await executeToolCallRerun({
messages,
payload: {
messageId: 'assistant-1',
toolCallId: 'call-weather',
toolName: 'weather',
args: '{ invalid',
},
resolveTools,
})
expect(resolveTools).toHaveBeenCalledTimes(1)
expect(execute).not.toHaveBeenCalled()
expect(next[0]).toMatchObject({
tool_results: [
{
id: 'call-weather',
isError: true,
},
],
})
expect((next[0] as ChatAssistantMessage).tool_results[0]?.result).toContain('Invalid tool call arguments JSON:')
})
it('writes an error result when the tool throws', async () => {
const messages: ChatHistoryItem[] = [
{
...assistantMessage(),
id: 'assistant-1',
},
]
const next = await executeToolCallRerun({
messages,
payload: {
messageId: 'assistant-1',
toolCallId: 'call-weather',
toolName: 'weather',
args: '',
},
resolveTools: async () => [tool('weather', async () => {
throw new Error('network unavailable')
})],
})
expect(next[0]).toMatchObject({
tool_results: [
{
id: 'call-weather',
isError: true,
result: 'Tool call error for "weather": network unavailable',
},
],
})
})
})
@@ -0,0 +1,170 @@
import type { Tool } from '@xsai/shared-chat'
import type { ChatAssistantMessage, ChatHistoryItem, ChatSlicesToolCallResult } from '../types/chat'
import { errorMessageFrom } from '@moeru/std'
import { toolNameFrom } from './llm-tool-resolver'
export interface ToolCallRerunPayload<TToolset extends string = string> {
sessionId?: string
messageId?: string
index?: number
toolset?: TToolset
toolCallId: string
toolName: string
args: string
}
interface ExecuteToolCallRerunOptions<TToolset extends string = string> {
messages: ChatHistoryItem[]
payload: ToolCallRerunPayload<TToolset>
resolveTools: () => Promise<Tool[]>
}
type ToolCallResultInput = Omit<ChatSlicesToolCallResult, 'type'>
type ToolExecuteOptions = NonNullable<Parameters<Tool['execute']>[1]>
/**
* Returns a copy of an assistant message with the result for one tool call replaced.
*
* The chat UI can read results from `tool_results` or inline `tool-call-result`
* slices. Reruns update both representations for the same id so stored and
* inline messages stay consistent.
*/
export function replaceToolCallResult(message: ChatAssistantMessage, result: ToolCallResultInput): ChatAssistantMessage {
const toolResult = {
id: result.id,
isError: result.isError,
result: result.result,
}
const resultSlice: ChatSlicesToolCallResult = {
type: 'tool-call-result',
...toolResult,
}
return {
...message,
slices: message.slices.map((slice) => {
if (slice.type === 'tool-call-result' && slice.id === result.id)
return resultSlice
return slice
}),
tool_results: [
...message.tool_results.filter(item => item.id !== result.id),
toolResult,
],
}
}
/**
* Re-executes a stored tool call with supplied arguments and returns updated chat history.
*
* The resolver is injected so callers can choose the runtime-specific tool list
* without coupling this helper to app-local stores, Electron IPC, or browser state.
*/
export async function executeToolCallRerun<TToolset extends string = string>(
options: ExecuteToolCallRerunOptions<TToolset>,
): Promise<ChatHistoryItem[]> {
const { messages, payload } = options
const targetIndex = findTargetMessageIndex(messages, payload)
const targetMessage = messages[targetIndex]
if (targetMessage?.role !== 'assistant')
throw new Error('Tool call rerun target must be an assistant message.')
if (!hasMatchingToolCall(targetMessage, payload))
throw new Error(`Assistant message does not contain tool call "${payload.toolCallId}" for "${payload.toolName}".`)
const replaceTargetMessage = (result: ToolCallResultInput) => messages.map((item, itemIndex) => {
if (itemIndex !== targetIndex)
return item
return replaceToolCallResult(targetMessage, result)
})
const tools = await options.resolveTools()
const tool = tools.find(candidate => toolNameFrom(candidate) === payload.toolName)
if (tool == null) {
return replaceTargetMessage({
id: payload.toolCallId,
isError: true,
result: `Tool "${payload.toolName}" is not available for rerun in this runtime.`,
})
}
const parsedArgs = parseToolCallArgs(payload.args)
if (!parsedArgs.ok) {
return replaceTargetMessage({
id: payload.toolCallId,
isError: true,
result: `Invalid tool call arguments JSON: ${parsedArgs.message}`,
})
}
try {
// NOTICE:
// Re-run tools receive AIRI's original chat history so runtime tools can
// inspect the same context the UI is updating. xsai types narrow
// `messages` to provider `Message[]`, while AIRI history can also contain
// local-only `error` entries. Keep the cast at this boundary instead of
// filtering messages and silently changing the tool's context.
// Removal condition: xsai exposes a tool execution context type that can
// accept runtime-owned message envelopes.
const executeOptions: ToolExecuteOptions = {
toolCallId: payload.toolCallId,
messages,
} as ToolExecuteOptions
const result = await tool.execute(parsedArgs.value, executeOptions)
const normalizedResult = typeof result === 'string' || Array.isArray(result)
? result
: JSON.stringify(result)
return replaceTargetMessage({
id: payload.toolCallId,
result: normalizedResult,
})
}
catch (error) {
return replaceTargetMessage({
id: payload.toolCallId,
isError: true,
result: `Tool call error for "${payload.toolName}": ${errorMessageFrom(error) ?? String(error)}`,
})
}
}
function findTargetMessageIndex(messages: ChatHistoryItem[], payload: ToolCallRerunPayload): number {
if (payload.messageId != null) {
const index = messages.findIndex(message => message.id === payload.messageId)
if (index !== -1)
return index
}
if (payload.index != null)
return payload.index
return -1
}
function hasMatchingToolCall(message: ChatAssistantMessage, payload: ToolCallRerunPayload): boolean {
return message.slices.some(slice =>
slice.type === 'tool-call'
&& slice.toolCall.toolCallId === payload.toolCallId
&& slice.toolCall.toolName === payload.toolName,
)
}
function parseToolCallArgs(args: string): { ok: true, value: unknown } | { ok: false, message: string } {
const trimmedArgs = args.trim()
if (trimmedArgs === '')
return { ok: true, value: {} }
try {
return { ok: true, value: JSON.parse(trimmedArgs) as unknown }
}
catch (error) {
return { ok: false, message: errorMessageFrom(error) ?? String(error) }
}
}