Add unit tests for skill capabilities, skill planner, and UV setup

- Implement tests for random ID generation, ensuring preference for crypto.randomUUID.
- Create tests for runtime context capabilities, validating the injection of enabled skill capabilities.
- Add tests for skill capability parsing, including classification and command example extraction.
- Introduce tests for the skill planner, verifying tool call planning based on user requests and attachment requirements.
- Establish tests for UV setup, ensuring proper handling of Python installation scenarios and environment checks.
This commit is contained in:
DEV_DSW
2026-04-24 17:02:59 +08:00
parent e11a2296cc
commit 4c61e93c3e
42 changed files with 12560 additions and 224 deletions

View File

@@ -1,21 +1,48 @@
import { createProvider } from '@electron/providers';
import type { BaseProvider } from '@electron/providers/BaseProvider';
import type {
BaseProvider,
ProviderCapabilities,
GatewayChatContentBlock,
GatewayChatMessage,
} from '@electron/providers/BaseProvider';
import { DEFAULT_PROVIDER_CAPABILITIES } from '@electron/providers/BaseProvider';
import { providerApiService } from '@electron/service/provider-api-service';
import logManager from '@electron/service/logger';
import { normalizeAgentSessionKey } from '@runtime/lib/models';
import type { RawMessage } from '@runtime/shared/chat-model';
import { sessionStore } from '../session-store';
import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types';
import type {
ContentBlock,
RawMessage,
ToolCallPayload,
ToolStatus,
} from '@runtime/shared/chat-model';
import { appendTranscriptLine } from '@electron/utils/token-usage-writer';
import { maybeHandleBrowserOpenMessage } from '../browser-shortcut';
import { maybeHandleSkillInstallMessage } from '../skill-install-shortcut';
import { buildRuntimeContextMessages } from '../runtime-context';
import {
createChatToolRuntime,
createGatewayToolDefinitions,
mapSkillCapabilitiesToRegistryInputs,
} from '../chat-tooling';
import { createRandomId } from '../random-id';
import { buildRuntimeContextMessages } from '../runtime-context';
import { sessionStore } from '../session-store';
import { getEnabledSkillCapabilities } from '../skill-capability-registry';
import { planToolCall } from '../skill-planner';
import { createToolRegistry } from '../tool-registry';
import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types';
import type { ToolRuntime } from '../tool-runtime';
export interface GatewayChatMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
}
type ResolvedProviderTarget = {
accountId: string;
model: string;
provider: BaseProvider;
providerName: string;
};
type StreamedToolCallState = {
index: number;
id: string;
name: string;
argumentsText: string;
};
function flattenMessageContent(content: RawMessage['content']): string {
if (typeof content === 'string') {
@@ -32,6 +59,10 @@ function flattenMessageContent(content: RawMessage['content']): string {
return block.text;
}
if (block.type === 'thinking' && typeof block.thinking === 'string') {
return block.thinking;
}
if ((block.type === 'tool_result' || block.type === 'toolResult') && typeof block.content === 'string') {
return block.content;
}
@@ -40,31 +71,438 @@ function flattenMessageContent(content: RawMessage['content']): string {
return flattenMessageContent(block.content as RawMessage['content']);
}
if ((block.type === 'tool_result' || block.type === 'toolResult') && typeof block.summary === 'string') {
return block.summary;
}
if (
(block.type === 'tool_result' || block.type === 'toolResult')
&& block.result
&& typeof block.result === 'object'
&& 'summary' in block.result
&& typeof block.result.summary === 'string'
) {
return block.result.summary;
}
return '';
})
.filter(Boolean)
.join('\n');
}
function contentBlockToGatewayBlock(block: ContentBlock): GatewayChatContentBlock | null {
switch (block.type) {
case 'text':
return typeof block.text === 'string'
? {
type: 'text',
text: block.text,
}
: null;
case 'thinking':
return typeof block.thinking === 'string'
? {
type: 'thinking',
thinking: block.thinking,
}
: null;
case 'tool_use':
case 'toolCall':
return {
type: 'tool_use',
id: block.id || block.toolCallId || createRandomId(),
name: block.name || 'tool',
input: block.input ?? block.arguments,
summary: block.summary,
};
case 'tool_result':
case 'toolResult':
return {
type: 'tool_result',
toolCallId: block.toolCallId || block.id,
content: Array.isArray(block.content)
? block.content
.map((child) => contentBlockToGatewayBlock(child))
.filter((child): child is GatewayChatContentBlock => child !== null)
: block.content,
result: block.result,
summary: block.summary,
ok: block.ok,
error: block.error,
};
default:
return null;
}
}
function buildChatMessages(sessionMessages: RawMessage[]): GatewayChatMessage[] {
return sessionMessages
.map((msg): GatewayChatMessage | null => {
if (!msg.role || !msg.content) return null;
const role = msg.role;
const content = flattenMessageContent(msg.content).trim();
if (!content) {
.map((message): GatewayChatMessage | null => {
if (!message.role || !message.content) {
return null;
}
if (role === 'user' || role === 'assistant' || role === 'system') {
const role = message.role;
const normalizedRole = role === 'toolresult' ? 'tool_result' : role;
if (typeof message.content === 'string') {
const content = message.content.trim();
if (!content) {
return null;
}
if (normalizedRole === 'user' || normalizedRole === 'assistant' || normalizedRole === 'system' || normalizedRole === 'tool_result') {
return {
role: normalizedRole,
content,
name: message.toolName,
toolCallId: message.toolCallId,
};
}
return null;
}
const blocks = message.content
.map((block) => contentBlockToGatewayBlock(block))
.filter((block): block is GatewayChatContentBlock => block !== null);
if (blocks.length === 0) {
const content = flattenMessageContent(message.content).trim();
if (!content) {
return null;
}
return {
role,
role: normalizedRole,
content,
name: message.toolName,
toolCallId: message.toolCallId,
};
}
// Skip toolresult and unsupported roles for now
return null;
return {
role: normalizedRole,
content: blocks,
name: message.toolName,
toolCallId: message.toolCallId,
};
})
.filter((m): m is GatewayChatMessage => m !== null);
.filter((message): message is GatewayChatMessage => message !== null);
}
function appendTranscriptMessage(
sessionKey: string,
message: RawMessage,
extras?: Record<string, unknown>,
): void {
appendTranscriptLine(sessionKey, {
type: 'message',
timestamp: new Date().toISOString(),
message: {
role: message.role === 'tool_result' || message.role === 'toolresult' ? 'toolResult' : message.role,
content: flattenMessageContent(message.content),
toolCallId: message.toolCallId,
tool: message.toolName,
details: message.toolResult,
...extras,
},
});
}
function buildToolUseMessage(toolCallId: string, toolCall: ToolCallPayload): RawMessage {
const toolName = toolCall.name || 'tool';
return {
role: 'assistant',
content: [
{
type: 'tool_use',
id: toolCallId,
name: toolName,
input: toolCall.input,
summary: toolCall.summary,
},
],
timestamp: Date.now(),
toolCallId,
toolName,
toolCall: {
id: toolCallId,
name: toolName,
input: toolCall.input,
summary: toolCall.summary,
},
};
}
function buildMultiToolUseMessage(toolCalls: Array<ToolCallPayload & { id: string }>): RawMessage {
const firstToolCall = toolCalls[0];
return {
role: 'assistant',
content: toolCalls.map((toolCall) => ({
type: 'tool_use' as const,
id: toolCall.id,
name: toolCall.name || 'tool',
input: toolCall.input,
summary: toolCall.summary,
})),
timestamp: Date.now(),
toolCallId: firstToolCall?.id,
toolName: toolCalls.length === 1 ? firstToolCall?.name : undefined,
toolCall: toolCalls.length === 1 && firstToolCall
? {
id: firstToolCall.id,
name: firstToolCall.name,
input: firstToolCall.input,
summary: firstToolCall.summary,
}
: null,
};
}
function buildToolStatus(
toolCallId: string,
toolCall: ToolCallPayload,
status: ToolStatus['status'],
summary: string,
updatedAt: number,
result?: unknown,
durationMs?: number,
): ToolStatus {
return {
id: toolCallId,
toolCallId,
name: toolCall.name || 'tool',
status,
updatedAt,
durationMs,
summary,
input: toolCall.input,
result,
};
}
function collectSessionFiles(sessionMessages: RawMessage[]): RawMessage['_attachedFiles'] {
const files = new Map<string, NonNullable<RawMessage['_attachedFiles']>[number]>();
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
const message = sessionMessages[index];
for (const attachment of message?._attachedFiles || []) {
const key = `${attachment.filePath || ''}|${attachment.fileName || ''}|${attachment.mimeType || ''}`;
if (!key.trim() || files.has(key)) {
continue;
}
files.set(key, attachment);
}
}
return Array.from(files.values());
}
function parseProviderToolCallInput(argumentsText: string): unknown {
const trimmed = argumentsText.trim();
if (!trimmed) {
return {};
}
try {
return JSON.parse(trimmed) as unknown;
} catch {
return {
rawArguments: trimmed,
};
}
}
function applyProviderToolCallDelta(
states: Map<number, StreamedToolCallState>,
delta: NonNullable<Awaited<ReturnType<BaseProvider['chat']>> extends AsyncIterable<infer T> ? T : never>['toolCalls'][number],
): void {
const index = typeof delta.index === 'number' ? delta.index : states.size;
const existing = states.get(index) || {
index,
id: delta.id || createRandomId(),
name: delta.name || 'tool',
argumentsText: '',
};
if (typeof delta.id === 'string' && delta.id.trim()) {
existing.id = delta.id;
}
if (typeof delta.name === 'string' && delta.name.trim()) {
existing.name = delta.name;
}
if (typeof delta.argumentsDelta === 'string') {
existing.argumentsText += delta.argumentsDelta;
}
states.set(index, existing);
}
function finalizeProviderToolCalls(
states: Map<number, StreamedToolCallState>,
): Array<ToolCallPayload & { id: string }> {
return Array.from(states.values())
.sort((left, right) => left.index - right.index)
.filter((state) => state.name.trim())
.map((state) => ({
id: state.id,
name: state.name,
input: parseProviderToolCallInput(state.argumentsText),
summary: `Model requested ${state.name}.`,
}));
}
function finalizeAssistantMessage(
sessionKey: string,
runId: string,
message: RawMessage,
broadcast: (event: GatewayEvent) => void,
extras?: Record<string, unknown>,
): void {
sessionStore.appendMessage(sessionKey, message);
sessionStore.clearActiveRun(sessionKey);
appendTranscriptMessage(sessionKey, message, extras);
broadcast({
type: 'chat:final',
sessionKey,
runId,
message,
});
}
async function executeToolCallAndPersist(
sessionKey: string,
runId: string,
runtime: ToolRuntime,
toolCallId: string,
toolCall: ToolCallPayload,
broadcast: (event: GatewayEvent) => void,
): Promise<{ finalStatus: ToolStatus; toolResultMessage: RawMessage }> {
const startedAt = Date.now();
const runningStatus = buildToolStatus(
toolCallId,
toolCall,
'running',
toolCall.summary || `Running ${toolCall.name || 'tool'}`,
startedAt,
);
broadcast({
type: 'tool:status',
sessionKey,
runId,
toolCallId,
toolName: runningStatus.name,
status: runningStatus.status,
updatedAt: runningStatus.updatedAt,
summary: runningStatus.summary,
input: runningStatus.input,
});
const toolRun = await runtime.run(
{
toolCallId,
toolName: toolCall.name || 'tool',
input: toolCall.input,
summary: toolCall.summary,
source: 'planner',
},
{
sessionKey,
runId,
signal: sessionStore.getActiveRun(sessionKey)?.abortController.signal,
files: collectSessionFiles(sessionStore.getOrCreate(sessionKey).messages),
metadata: {
requestedBy: 'chat.send',
},
},
);
const finalStatus = buildToolStatus(
toolCallId,
toolCall,
toolRun.execution.status,
toolRun.normalized.summary || toolCall.summary || `Finished ${toolCall.name || 'tool'}`,
Date.now(),
toolRun.normalized.payload,
toolRun.execution.durationMs,
);
const toolResultMessage: RawMessage = {
...toolRun.normalized.transcriptMessage,
_toolStatuses: [finalStatus],
};
sessionStore.appendMessage(sessionKey, toolResultMessage);
appendTranscriptMessage(sessionKey, toolResultMessage, {
tool: toolCall.name,
toolCallId,
});
broadcast({
type: 'tool:status',
sessionKey,
runId,
toolCallId,
toolName: finalStatus.name,
status: finalStatus.status,
updatedAt: finalStatus.updatedAt,
durationMs: finalStatus.durationMs,
summary: finalStatus.summary,
input: finalStatus.input,
result: finalStatus.result,
});
return {
finalStatus,
toolResultMessage,
};
}
function resolveProviderTarget(
options?: GatewayRpcParams['chat.send']['options'],
): ResolvedProviderTarget {
const accountId = options?.providerAccountId || providerApiService.getDefault().accountId;
if (!accountId) {
throw new Error('No provider account selected');
}
const account = providerApiService.getAccounts().find((candidate) => candidate.id === accountId);
if (!account) {
throw new Error(`Provider account ${accountId} not found`);
}
const model = account.model;
if (!model) {
throw new Error(`Provider account ${accountId} has no model configured`);
}
return {
accountId,
model,
provider: createProvider(accountId),
providerName: account.vendorId || account.label || account.model || 'unknown',
};
}
function tryResolveProviderTarget(
options?: GatewayRpcParams['chat.send']['options'],
): ResolvedProviderTarget | null {
try {
return resolveProviderTarget(options);
} catch (error) {
logManager.warn('Provider resolution skipped for this chat turn:', error);
return null;
}
}
function getProviderCapabilities(provider: BaseProvider): ProviderCapabilities {
if (typeof provider.getCapabilities === 'function') {
return provider.getCapabilities();
}
return DEFAULT_PROVIDER_CAPABILITIES;
}
async function processChatStream(
@@ -75,62 +513,115 @@ async function processChatStream(
providerName: string,
messages: GatewayChatMessage[],
signal: AbortSignal,
broadcast: (event: GatewayEvent) => void
broadcast: (event: GatewayEvent) => void,
) {
let assistantContent = '';
let finalUsage: any = undefined;
const capabilities = getEnabledSkillCapabilities();
const capabilityInputs = mapSkillCapabilitiesToRegistryInputs(capabilities);
const registry = createToolRegistry({
capabilities: capabilityInputs,
});
const runtime = createChatToolRuntime(capabilities);
const providerCapabilities = getProviderCapabilities(provider);
const toolDefinitions = providerCapabilities.toolCalls
? createGatewayToolDefinitions(registry)
: undefined;
const maxToolRounds = providerCapabilities.toolCalls && toolDefinitions && toolDefinitions.length > 0 ? 4 : 1;
let currentMessages = [...messages];
let finalUsage: unknown = undefined;
try {
const chunks = await provider.chat(messages, model, { signal });
for await (const chunk of chunks) {
if (signal.aborted) break;
if (chunk.result) {
assistantContent += chunk.result;
broadcast({
type: 'chat:delta',
for (let round = 0; round < maxToolRounds; round += 1) {
let assistantContent = '';
const streamedToolCalls = new Map<number, StreamedToolCallState>();
const chunks = await provider.chat(currentMessages, model, {
signal,
...(toolDefinitions?.length ? { tools: toolDefinitions, toolChoice: 'auto' as const } : {}),
metadata: {
sessionKey,
runId,
delta: chunk.result,
});
}
if (chunk.usage !== undefined) {
finalUsage = chunk.usage;
}
// Do not break on isEnd; the iterable may still yield a trailing usage chunk.
// The loop will finish naturally when the generator is done.
}
if (!signal.aborted) {
const finalMessage: RawMessage = {
role: 'assistant',
content: assistantContent,
timestamp: Date.now(),
};
sessionStore.appendMessage(sessionKey, finalMessage);
sessionStore.clearActiveRun(sessionKey);
appendTranscriptLine(sessionKey, {
type: 'message',
timestamp: new Date().toISOString(),
message: {
role: 'assistant',
content: assistantContent,
model,
provider: providerName,
usage: finalUsage,
round,
},
});
broadcast({
type: 'chat:final',
sessionKey,
runId,
message: finalMessage,
for await (const chunk of chunks) {
if (signal.aborted) {
break;
}
if (chunk.result) {
assistantContent += chunk.result;
if (!providerCapabilities.toolCalls) {
broadcast({
type: 'chat:delta',
sessionKey,
runId,
delta: chunk.result,
});
}
}
if (chunk.toolCalls?.length) {
for (const toolCallDelta of chunk.toolCalls) {
applyProviderToolCallDelta(streamedToolCalls, toolCallDelta);
}
}
if (chunk.usage !== undefined) {
finalUsage = chunk.usage;
}
}
if (signal.aborted) {
break;
}
const providerToolCalls = finalizeProviderToolCalls(streamedToolCalls);
if (providerToolCalls.length === 0) {
if (providerCapabilities.toolCalls && assistantContent) {
broadcast({
type: 'chat:delta',
sessionKey,
runId,
delta: assistantContent,
});
}
const finalMessage: RawMessage = {
role: 'assistant',
content: assistantContent,
timestamp: Date.now(),
};
finalizeAssistantMessage(sessionKey, runId, finalMessage, broadcast, {
model,
provider: providerName,
usage: finalUsage,
});
return;
}
const toolUseMessage = buildMultiToolUseMessage(providerToolCalls);
sessionStore.appendMessage(sessionKey, toolUseMessage);
appendTranscriptMessage(sessionKey, toolUseMessage, {
toolCalls: providerToolCalls.map((toolCall) => ({
id: toolCall.id,
name: toolCall.name,
})),
});
currentMessages.push(...buildChatMessages([toolUseMessage]));
for (const providerToolCall of providerToolCalls) {
const { toolResultMessage } = await executeToolCallAndPersist(
sessionKey,
runId,
runtime,
providerToolCall.id,
providerToolCall,
broadcast,
);
currentMessages.push(...buildChatMessages([toolResultMessage]));
}
}
} catch (error) {
sessionStore.clearActiveRun(sessionKey);
@@ -143,93 +634,194 @@ async function processChatStream(
}
}
async function processPlannedToolRun(
sessionKey: string,
runId: string,
userMessage: RawMessage,
toolCallId: string,
toolCall: ToolCallPayload,
options: GatewayRpcParams['chat.send']['options'] | undefined,
broadcast: (event: GatewayEvent) => void,
): Promise<void> {
const capabilities = getEnabledSkillCapabilities();
const runtime = createChatToolRuntime(capabilities);
try {
const { finalStatus, toolResultMessage } = await executeToolCallAndPersist(
sessionKey,
runId,
runtime,
toolCallId,
toolCall,
broadcast,
);
const providerTarget = tryResolveProviderTarget(options);
if (!providerTarget) {
finalizeAssistantMessage(
sessionKey,
runId,
{
role: 'assistant',
content: toolRun.normalized.summary || flattenMessageContent(toolResultMessage.content),
timestamp: Date.now(),
_toolStatuses: [finalStatus],
},
broadcast,
{
tool: toolCall.name,
},
);
return;
}
const session = sessionStore.getOrCreate(sessionKey);
const messages = [
...buildRuntimeContextMessages(sessionKey),
...buildChatMessages(session.messages),
];
await processChatStream(
sessionKey,
runId,
providerTarget.provider,
providerTarget.model,
providerTarget.providerName,
messages,
sessionStore.getActiveRun(sessionKey)?.abortController.signal || new AbortController().signal,
broadcast,
);
} catch (error) {
sessionStore.clearActiveRun(sessionKey);
broadcast({
type: 'chat:error',
sessionKey,
runId,
error: error instanceof Error ? error.message : String(error),
});
}
}
function buildPlannerResponse(
sessionKey: string,
runId: string,
summary: string,
broadcast: (event: GatewayEvent) => void,
): GatewayRpcReturns['chat.send'] {
const finalMessage: RawMessage = {
role: 'assistant',
content: summary,
timestamp: Date.now(),
};
finalizeAssistantMessage(sessionKey, runId, finalMessage, broadcast);
return { runId };
}
export function handleChatSend(
params: GatewayRpcParams['chat.send'],
broadcast: (event: GatewayEvent) => void
broadcast: (event: GatewayEvent) => void,
): GatewayRpcReturns['chat.send'] {
const sessionKey = normalizeAgentSessionKey(params.sessionKey);
const { message, options } = params;
const runId = createRandomId();
// 1. Append user message
const userMessage: RawMessage = {
...message,
timestamp: message.timestamp || Date.now(),
};
sessionStore.appendMessage(sessionKey, userMessage);
appendTranscriptLine(sessionKey, {
type: 'message',
timestamp: new Date().toISOString(),
message: {
role: 'user',
content: typeof userMessage.content === 'string' ? userMessage.content : '',
},
sessionStore.appendMessage(sessionKey, userMessage);
appendTranscriptMessage(sessionKey, userMessage);
const session = sessionStore.getOrCreate(sessionKey);
const capabilities = getEnabledSkillCapabilities();
const capabilityInputs = mapSkillCapabilitiesToRegistryInputs(capabilities);
const registry = createToolRegistry({
capabilities: capabilityInputs,
});
const decision = planToolCall({
message: userMessage,
attachments: userMessage._attachedFiles,
history: session.messages.slice(0, -1),
capabilities: capabilityInputs,
registry,
});
if (maybeHandleBrowserOpenMessage(sessionKey, runId, userMessage, broadcast)) {
if (decision.kind === 'tool' && decision.toolCall) {
const toolCallId = `${decision.toolCall.name || 'tool'}:${runId}`;
const toolUseMessage = buildToolUseMessage(toolCallId, decision.toolCall);
sessionStore.appendMessage(sessionKey, toolUseMessage);
appendTranscriptMessage(sessionKey, toolUseMessage, {
tool: decision.toolCall.name,
toolCallId,
});
const abortController = new AbortController();
sessionStore.setActiveRun(sessionKey, runId, abortController);
void processPlannedToolRun(
sessionKey,
runId,
userMessage,
toolCallId,
decision.toolCall,
options,
broadcast,
);
return { runId };
}
if (maybeHandleSkillInstallMessage(sessionKey, runId, userMessage, broadcast)) {
return { runId };
if (decision.kind === 'no-tool' && decision.blockingIssue) {
return buildPlannerResponse(
sessionKey,
runId,
decision.blockingIssue.message,
broadcast,
);
}
// 2. Resolve provider account
const accountId = options?.providerAccountId || providerApiService.getDefault().accountId;
if (!accountId) {
throw new Error('No provider account selected');
}
const account = providerApiService.getAccounts().find((a) => a.id === accountId);
if (!account) {
throw new Error(`Provider account ${accountId} not found`);
}
const model = account.model;
if (!model) {
throw new Error(`Provider account ${accountId} has no model configured`);
}
// 3. Build messages array from session history
const session = sessionStore.getOrCreate(sessionKey);
const providerTarget = resolveProviderTarget(options);
const messages = [
...buildRuntimeContextMessages(sessionKey),
...buildChatMessages(session.messages),
];
// 4. Start streaming
const abortController = new AbortController();
sessionStore.setActiveRun(sessionKey, runId, abortController);
// Run async stream processing in background
const provider = createProvider(accountId);
const providerName = account.vendorId || account.label || account.model || 'unknown';
processChatStream(sessionKey, runId, provider, model, providerName, messages, abortController.signal, broadcast).catch(
(err) => {
logManager.error('Unexpected error in processChatStream:', err);
sessionStore.clearActiveRun(sessionKey);
broadcast({
type: 'chat:error',
sessionKey,
runId,
error: err instanceof Error ? err.message : String(err),
});
}
);
void processChatStream(
sessionKey,
runId,
providerTarget.provider,
providerTarget.model,
providerTarget.providerName,
messages,
abortController.signal,
broadcast,
).catch((error) => {
logManager.error('Unexpected error in processChatStream:', error);
sessionStore.clearActiveRun(sessionKey);
broadcast({
type: 'chat:error',
sessionKey,
runId,
error: error instanceof Error ? error.message : String(error),
});
});
return { runId };
}
export function handleChatHistory(
params: GatewayRpcParams['chat.history']
params: GatewayRpcParams['chat.history'],
): GatewayRpcReturns['chat.history'] {
return sessionStore.getMessages(normalizeAgentSessionKey(params.sessionKey), params.limit ?? 50);
}
export function handleChatAbort(
params: GatewayRpcParams['chat.abort'],
broadcast: (event: GatewayEvent) => void
broadcast: (event: GatewayEvent) => void,
): GatewayRpcReturns['chat.abort'] {
const sessionKey = normalizeAgentSessionKey(params.sessionKey);
const activeRun = sessionStore.getActiveRun(sessionKey);
@@ -249,7 +841,7 @@ export function handleSessionList(): GatewayRpcReturns['session.list'] {
}
export function handleSessionDelete(
params: GatewayRpcParams['session.delete']
params: GatewayRpcParams['session.delete'],
): GatewayRpcReturns['session.delete'] {
sessionStore.deleteSession(normalizeAgentSessionKey(params.sessionKey));
return { success: true };

View File

@@ -1,4 +1,10 @@
import { BrowserWindow } from 'electron';
import { ClawHubService } from '@electron/gateway/clawhub';
import {
hydrateSkillCapabilityRegistry,
refreshSkillCapabilityRegistry,
} from '@electron/gateway/skill-capability-registry';
import { windowManager } from '@electron/service/window-service';
import {
SkillInstallService,
SkillInstallServiceError,
@@ -9,17 +15,34 @@ import type { GatewayEvent, GatewayRpcParams, GatewayRpcReturns } from '../types
type GatewayBroadcast = (event: GatewayEvent) => void;
function broadcastGatewayEvent(event: GatewayEvent): void {
const mainWindow = BrowserWindow.getAllWindows().find(
(win) => windowManager.getName(win) === 'main',
) ?? BrowserWindow.getAllWindows().find((win) => !win.isDestroyed());
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('gateway:event', event);
}
}
function broadcastSkillsRuntimeChanged(broadcast?: GatewayBroadcast, reason = 'skills:changed'): void {
broadcast?.({
const event: GatewayEvent = {
type: 'runtime:changed',
topics: ['skills'],
reason,
syncedAt: new Date().toISOString(),
});
};
if (broadcast) {
broadcast(event);
return;
}
broadcastGatewayEvent(event);
}
export async function handleSkillsStatus(): Promise<GatewayRpcReturns['skills.status']> {
const configs = await getAllSkillConfigs();
hydrateSkillCapabilityRegistry(configs);
return {
skills: Object.entries(configs).map(([skillKey, config]) => ({
@@ -47,6 +70,7 @@ export async function handleSkillsStatus(): Promise<GatewayRpcReturns['skills.st
export async function handleSkillsUpdate(
params: { skillKey: string; enabled?: boolean },
broadcast?: GatewayBroadcast,
): Promise<GatewayRpcReturns['skills.update']> {
const { skillKey, enabled } = params;
if (!skillKey || !String(skillKey).trim()) {
@@ -58,6 +82,17 @@ export async function handleSkillsUpdate(
throw new Error(result.error || 'Failed to update skill');
}
try {
const configs = await getAllSkillConfigs();
hydrateSkillCapabilityRegistry(configs);
} catch (error) {
console.warn('Failed to refresh skill capability registry after skills.update:', error);
}
const normalizedSkillKey = String(skillKey).trim();
const action = enabled === false ? 'disabled' : enabled === true ? 'enabled' : 'updated';
broadcastSkillsRuntimeChanged(broadcast, `skills:update:${normalizedSkillKey}:${action}`);
return { success: true };
}
@@ -72,6 +107,11 @@ export async function handleSkillsInstall(
try {
const result = await installService.install(request);
try {
await refreshSkillCapabilityRegistry();
} catch (error) {
console.warn('Failed to refresh skill capability registry after skills.install:', error);
}
broadcastSkillsRuntimeChanged(
broadcast,
`skills:install:${result.source}:${result.slug}`,