diff --git a/config/gni/devtools_grd_files.gni b/config/gni/devtools_grd_files.gni index 7f68872504..ebb3c6ca37 100644 --- a/config/gni/devtools_grd_files.gni +++ b/config/gni/devtools_grd_files.gni @@ -661,6 +661,31 @@ grd_files_bundled_sources = [ "front_end/panels/ai_chat/ui/TodoListDisplay.js", "front_end/panels/ai_chat/ui/FileListDisplay.js", "front_end/panels/ai_chat/ui/FileContentViewer.js", + "front_end/panels/ai_chat/ui/AgentStudioBridge.js", + "front_end/panels/ai_chat/ui/AgentStudioController.js", + "front_end/panels/ai_chat/ui/AgentStudioView.js", + "front_end/panels/ai_chat/ui/SchemaEditor.js", + "front_end/panels/ai_chat/ui/agent_studio/AgentStudioSPA.js", + "front_end/panels/ai_chat/ui/settings/advanced/BrowsingHistorySettings.js", + "front_end/panels/ai_chat/ui/settings/advanced/EvaluationSettings.js", + "front_end/panels/ai_chat/ui/settings/advanced/MCPSettings.js", + "front_end/panels/ai_chat/ui/settings/advanced/TracingSettings.js", + "front_end/panels/ai_chat/ui/settings/advanced/VectorDBSettings.js", + "front_end/panels/ai_chat/ui/settings/components/AdvancedToggle.js", + "front_end/panels/ai_chat/ui/settings/components/ModelSelectorFactory.js", + "front_end/panels/ai_chat/ui/settings/components/SettingsFooter.js", + "front_end/panels/ai_chat/ui/settings/components/SettingsHeader.js", + "front_end/panels/ai_chat/ui/settings/constants.js", + "front_end/panels/ai_chat/ui/settings/i18n-strings.js", + "front_end/panels/ai_chat/ui/settings/providerConfigs.js", + "front_end/panels/ai_chat/ui/settings/providers/BaseProviderSettings.js", + "front_end/panels/ai_chat/ui/settings/providers/GenericProviderSettings.js", + "front_end/panels/ai_chat/ui/settings/providers/LiteLLMSettings.js", + "front_end/panels/ai_chat/ui/settings/providers/OpenRouterSettings.js", + "front_end/panels/ai_chat/ui/settings/types.js", + "front_end/panels/ai_chat/ui/settings/utils/storage.js", + "front_end/panels/ai_chat/ui/settings/utils/styles.js", + "front_end/panels/ai_chat/ui/settings/utils/validation.js", "front_end/panels/ai_chat/core/AgentService.js", "front_end/panels/ai_chat/core/State.js", "front_end/panels/ai_chat/core/Graph.js", @@ -682,6 +707,10 @@ grd_files_bundled_sources = [ "front_end/panels/ai_chat/core/Version.js", "front_end/panels/ai_chat/core/VersionChecker.js", "front_end/panels/ai_chat/core/LLMConfigurationManager.js", + "front_end/panels/ai_chat/core/CustomProviderManager.js", + "front_end/panels/ai_chat/core/AgentStorageManager.js", + "front_end/panels/ai_chat/core/AgentStudioIntegration.js", + "front_end/panels/ai_chat/core/AgentTestRunner.js", "front_end/panels/ai_chat/LLM/LLMTypes.js", "front_end/panels/ai_chat/LLM/LLMProvider.js", "front_end/panels/ai_chat/LLM/LLMProviderRegistry.js", @@ -694,6 +723,10 @@ grd_files_bundled_sources = [ "front_end/panels/ai_chat/LLM/BrowserOperatorProvider.js", "front_end/panels/ai_chat/LLM/LLMClient.js", "front_end/panels/ai_chat/LLM/MessageSanitizer.js", + "front_end/panels/ai_chat/LLM/AnthropicProvider.js", + "front_end/panels/ai_chat/LLM/CerebrasProvider.js", + "front_end/panels/ai_chat/LLM/GenericOpenAIProvider.js", + "front_end/panels/ai_chat/LLM/GoogleAIProvider.js", "front_end/panels/ai_chat/tools/Tools.js", "front_end/panels/ai_chat/tools/SequentialThinkingTool.js", "front_end/panels/ai_chat/tools/CombinedExtractionTool.js", @@ -712,6 +745,7 @@ grd_files_bundled_sources = [ "front_end/panels/ai_chat/tools/RenderWebAppTool.js", "front_end/panels/ai_chat/tools/GetWebAppDataTool.js", "front_end/panels/ai_chat/tools/RemoveWebAppTool.js", + "front_end/panels/ai_chat/tools/SaveResearchReportTool.js", "front_end/panels/ai_chat/tools/FileStorageManager.js", "front_end/panels/ai_chat/tools/CreateFileTool.js", "front_end/panels/ai_chat/tools/UpdateFileTool.js", @@ -721,21 +755,34 @@ grd_files_bundled_sources = [ "front_end/panels/ai_chat/tools/ExecuteCodeTool.js", "front_end/panels/ai_chat/tools/UpdateTodoTool.js", "front_end/panels/ai_chat/tools/VisualIndicatorTool.js", + "front_end/panels/ai_chat/tools/ReadabilityExtractorTool.js", + "front_end/panels/ai_chat/tools/CallCustomAgentTool.js", + "front_end/panels/ai_chat/tools/SearchCustomAgentsTool.js", + "front_end/panels/ai_chat/tools/mini_app/CloseMiniAppTool.js", + "front_end/panels/ai_chat/tools/mini_app/ExecuteMiniAppActionTool.js", + "front_end/panels/ai_chat/tools/mini_app/GetMiniAppStateTool.js", + "front_end/panels/ai_chat/tools/mini_app/LaunchMiniAppTool.js", + "front_end/panels/ai_chat/tools/mini_app/ListMiniAppsTool.js", + "front_end/panels/ai_chat/tools/mini_app/UpdateMiniAppStateTool.js", "front_end/panels/ai_chat/common/utils.js", "front_end/panels/ai_chat/common/log.js", "front_end/panels/ai_chat/common/context.js", "front_end/panels/ai_chat/common/page.js", - "front_end/panels/ai_chat/core/structured_response.js", - "front_end/panels/ai_chat/models/ChatTypes.js", + "front_end/panels/ai_chat/mini_apps/GenericMiniAppBridge.js", + "front_end/panels/ai_chat/mini_apps/MiniAppEventBus.js", + "front_end/panels/ai_chat/mini_apps/MiniAppInitialization.js", + "front_end/panels/ai_chat/mini_apps/MiniAppRegistry.js", + "front_end/panels/ai_chat/mini_apps/MiniAppStorageManager.js", + "front_end/panels/ai_chat/mini_apps/apps/agent_studio/AgentStudioMiniApp.js", + "front_end/panels/ai_chat/mini_apps/types/MiniAppTypes.js", + "front_end/panels/ai_chat/models/ChatTypes.js", "front_end/panels/ai_chat/ui/input/ChatInput.js", "front_end/panels/ai_chat/ui/input/InputBar.js", "front_end/panels/ai_chat/ui/markdown/MarkdownRenderers.js", "front_end/panels/ai_chat/ui/message/MessageList.js", "front_end/panels/ai_chat/ui/message/ModelMessage.js", "front_end/panels/ai_chat/ui/message/MessageCombiner.js", - "front_end/panels/ai_chat/ui/message/StructuredResponseRender.js", - "front_end/panels/ai_chat/ui/message/StructuredResponseController.js", - "front_end/panels/ai_chat/ui/message/GlobalActionsRow.js", + "front_end/panels/ai_chat/ui/message/GlobalActionsRow.js", "front_end/panels/ai_chat/ui/message/ToolResultMessage.js", "front_end/panels/ai_chat/ui/message/UserMessage.js", "front_end/panels/ai_chat/ui/model_selector/ModelSelector.js", @@ -780,6 +827,7 @@ grd_files_bundled_sources = [ "front_end/panels/ai_chat/evaluation/framework/MarkdownReportGenerator.js", "front_end/panels/ai_chat/evaluation/framework/types.js", "front_end/panels/ai_chat/evaluation/test-cases/action-agent-tests.js", + "front_end/panels/ai_chat/evaluation/test-cases/html-to-markdown-tests.js", "front_end/panels/ai_chat/evaluation/test-cases/research-agent-tests.js", "front_end/panels/ai_chat/evaluation/test-cases/schema-extractor-tests.js", "front_end/panels/ai_chat/evaluation/test-cases/streamlined-schema-extractor-tests.js", @@ -794,6 +842,8 @@ grd_files_bundled_sources = [ "front_end/panels/ai_chat/mcp/MCPToolAdapter.js", "front_end/panels/ai_chat/mcp/MCPMetaTools.js", "front_end/panels/ai_chat/tools/LLMTracingWrapper.js", + "front_end/panels/ai_chat/utils/ContentChunker.js", + "front_end/panels/ai_chat/vendor/readability-source.js", "front_end/panels/animation/animation-meta.js", "front_end/panels/animation/animation.js", "front_end/panels/application/application-meta.js", diff --git a/front_end/panels/ai_chat/BUILD.gn b/front_end/panels/ai_chat/BUILD.gn index d8deb34ac0..35ae60a4bc 100644 --- a/front_end/panels/ai_chat/BUILD.gn +++ b/front_end/panels/ai_chat/BUILD.gn @@ -17,6 +17,9 @@ generate_css("css_files") { devtools_module("ai_chat") { sources = [ + "core/AgentStorageManager.ts", + "core/AgentStudioIntegration.ts", + "core/AgentTestRunner.ts", "ui/AIChatPanel.ts", "ui/ChatView.ts", "ui/message/MessageList.ts", @@ -24,8 +27,6 @@ devtools_module("ai_chat") { "ui/message/ModelMessage.ts", "ui/message/ToolResultMessage.ts", "ui/message/MessageCombiner.ts", - "ui/message/StructuredResponseRender.ts", - "ui/message/StructuredResponseController.ts", "ui/message/GlobalActionsRow.ts", "ui/markdown/MarkdownRenderers.ts", "ui/model_selector/ModelSelector.ts", @@ -81,8 +82,7 @@ devtools_module("ai_chat") { "core/AgentService.ts", "core/Constants.ts", "core/BuildConfig.ts", - "core/structured_response.ts", - "core/GraphConfigs.ts", + "core/GraphConfigs.ts", "core/ConfigurableGraph.ts", "core/BaseOrchestratorAgent.ts", "core/AgentDescriptorRegistry.ts", @@ -142,7 +142,23 @@ devtools_module("ai_chat") { "tools/RenderWebAppTool.ts", "tools/GetWebAppDataTool.ts", "tools/RemoveWebAppTool.ts", + "tools/SaveResearchReportTool.ts", + "tools/SearchCustomAgentsTool.ts", + "tools/CallCustomAgentTool.ts", "tools/VisualIndicatorTool.ts", + "tools/mini_app/ListMiniAppsTool.ts", + "tools/mini_app/LaunchMiniAppTool.ts", + "tools/mini_app/GetMiniAppStateTool.ts", + "tools/mini_app/UpdateMiniAppStateTool.ts", + "tools/mini_app/ExecuteMiniAppActionTool.ts", + "tools/mini_app/CloseMiniAppTool.ts", + "mini_apps/types/MiniAppTypes.ts", + "mini_apps/MiniAppRegistry.ts", + "mini_apps/GenericMiniAppBridge.ts", + "mini_apps/MiniAppStorageManager.ts", + "mini_apps/MiniAppEventBus.ts", + "mini_apps/MiniAppInitialization.ts", + "mini_apps/apps/agent_studio/AgentStudioMiniApp.ts", "agent_framework/ConfigurableAgentTool.ts", "agent_framework/AgentRunner.ts", "agent_framework/AgentRunnerEventBus.ts", @@ -201,6 +217,11 @@ devtools_module("ai_chat") { "mcp/MCPMetaTools.ts", "ui/mcp/MCPConnectionsDialog.ts", "ui/mcp/MCPConnectorsCatalogDialog.ts", + "ui/SchemaEditor.ts", + "ui/AgentStudioView.ts", + "ui/AgentStudioController.ts", + "ui/AgentStudioBridge.ts", + "ui/agent_studio/AgentStudioSPA.ts", ] deps = [ @@ -225,6 +246,9 @@ devtools_module("ai_chat") { # List of source files also used to determine JS outputs for metadata _ai_chat_sources = [ + "core/AgentStorageManager.ts", + "core/AgentStudioIntegration.ts", + "core/AgentTestRunner.ts", "ui/AIChatPanel.ts", "ui/ChatView.ts", "ui/message/MessageList.ts", @@ -232,8 +256,6 @@ _ai_chat_sources = [ "ui/message/ModelMessage.ts", "ui/message/ToolResultMessage.ts", "ui/message/MessageCombiner.ts", - "ui/message/StructuredResponseRender.ts", - "ui/message/StructuredResponseController.ts", "ui/message/GlobalActionsRow.ts", "ui/markdown/MarkdownRenderers.ts", "ui/model_selector/ModelSelector.ts", @@ -276,6 +298,11 @@ _ai_chat_sources = [ "ui/FileContentViewer.ts", "ui/mcp/MCPConnectionsDialog.ts", "ui/mcp/MCPConnectorsCatalogDialog.ts", + "ui/SchemaEditor.ts", + "ui/AgentStudioView.ts", + "ui/AgentStudioController.ts", + "ui/AgentStudioBridge.ts", + "ui/agent_studio/AgentStudioSPA.ts", "ai_chat_impl.ts", "models/ChatTypes.ts", "core/Graph.ts", @@ -284,8 +311,7 @@ _ai_chat_sources = [ "core/AgentService.ts", "core/Constants.ts", "core/BuildConfig.ts", - "core/structured_response.ts", - "core/GraphConfigs.ts", + "core/GraphConfigs.ts", "core/ConfigurableGraph.ts", "core/BaseOrchestratorAgent.ts", "core/AgentDescriptorRegistry.ts", @@ -345,7 +371,23 @@ _ai_chat_sources = [ "tools/RenderWebAppTool.ts", "tools/GetWebAppDataTool.ts", "tools/RemoveWebAppTool.ts", + "tools/SaveResearchReportTool.ts", + "tools/SearchCustomAgentsTool.ts", + "tools/CallCustomAgentTool.ts", "tools/VisualIndicatorTool.ts", + "tools/mini_app/ListMiniAppsTool.ts", + "tools/mini_app/LaunchMiniAppTool.ts", + "tools/mini_app/GetMiniAppStateTool.ts", + "tools/mini_app/UpdateMiniAppStateTool.ts", + "tools/mini_app/ExecuteMiniAppActionTool.ts", + "tools/mini_app/CloseMiniAppTool.ts", + "mini_apps/types/MiniAppTypes.ts", + "mini_apps/MiniAppRegistry.ts", + "mini_apps/GenericMiniAppBridge.ts", + "mini_apps/MiniAppStorageManager.ts", + "mini_apps/MiniAppEventBus.ts", + "mini_apps/MiniAppInitialization.ts", + "mini_apps/apps/agent_studio/AgentStudioMiniApp.ts", "agent_framework/ConfigurableAgentTool.ts", "agent_framework/AgentRunner.ts", "agent_framework/AgentRunnerEventBus.ts", @@ -495,11 +537,15 @@ ts_library("unittests") { "ui/__tests__/WebAppCodeViewer.test.ts", "ui/input/__tests__/InputBarClear.test.ts", "ui/message/__tests__/MessageCombiner.test.ts", - "ui/message/__tests__/StructuredResponseController.test.ts", "LLM/__tests__/MessageSanitizer.test.ts", "agent_framework/__tests__/AgentRunner.sanitizeToolResult.test.ts", "agent_framework/__tests__/AgentRunner.computeToolResultText.test.ts", "agent_framework/__tests__/AgentRunner.run.flows.test.ts", + "agent_framework/__tests__/AgentRunner.core.test.ts", + "agent_framework/__tests__/AgentRunner.handoff.test.ts", + "agent_framework/__tests__/ConfigurableAgentTool.test.ts", + "agent_framework/__tests__/ToolRegistry.test.ts", + "agent_framework/__tests__/AgentRunnerEventBus.test.ts", "mcp/__tests__/MCPClientSDK.test.ts", "mcp/__tests__/MCPConfig.test.ts", "mcp/__tests__/MCPIntegration.test.ts", @@ -510,6 +556,7 @@ ts_library("unittests") { "core/__tests__/ToolNameMap.test.ts", "core/__tests__/ToolNameMapping.test.ts", "core/__tests__/ToolSurfaceProvider.test.ts", + "core/__tests__/StateGraph.core.test.ts", "ui/__tests__/AIChatPanel.test.ts", "ui/__tests__/LiveAgentSessionComponent.test.ts", "ui/message/__tests__/MessageList.test.ts", @@ -518,10 +565,15 @@ ts_library("unittests") { "tools/__tests__/ReadFileTool.test.ts", "tools/__tests__/ListFilesTool.test.ts", "tools/__tests__/FileStorageManager.test.ts", + "tools/mini_app/__tests__/MiniAppTools.test.ts", + "mini_apps/__tests__/MiniAppRegistry.test.ts", + "mini_apps/__tests__/GenericMiniAppBridge.test.ts", + "mini_apps/__tests__/MiniAppEventBus.test.ts", ] deps = [ ":ai_chat", + "testing:testing", "../../testing", "../../core/sdk:bundle", "../../generated:protocol", diff --git a/front_end/panels/ai_chat/agent_framework/AgentRunner.ts b/front_end/panels/ai_chat/agent_framework/AgentRunner.ts index 71a8a541c1..0ee2658a5a 100644 --- a/front_end/panels/ai_chat/agent_framework/AgentRunner.ts +++ b/front_end/panels/ai_chat/agent_framework/AgentRunner.ts @@ -402,19 +402,21 @@ export class AgentRunner { ...(actualResult.intermediateSteps || []) // History *from* the recursive call (should exist if flag is true) ]; // Return the result from the target agent, but with combined history + // Always use 'handed_off' since this IS a handoff completion return { ...actualResult, intermediateSteps: combinedIntermediateSteps, - terminationReason: actualResult.terminationReason || 'handed_off', + terminationReason: 'handed_off' as const, agentSession: childSession }; } // Otherwise (default), omit the target's intermediate steps logger.info(`Omitting intermediateSteps from ${targetAgentTool.name} based on its config (default or flag set to false).`); // Return result from target, ensuring intermediateSteps are omitted + // Always use 'handed_off' since this IS a handoff completion const finalResult = { ...actualResult, - terminationReason: actualResult.terminationReason || 'handed_off', + terminationReason: 'handed_off' as const, agentSession: childSession }; // Explicitly delete intermediateSteps if they somehow exist on actualResult (shouldn't due to target config) diff --git a/front_end/panels/ai_chat/agent_framework/__tests__/AgentRunner.core.test.ts b/front_end/panels/ai_chat/agent_framework/__tests__/AgentRunner.core.test.ts new file mode 100644 index 0000000000..ceceaf6f62 --- /dev/null +++ b/front_end/panels/ai_chat/agent_framework/__tests__/AgentRunner.core.test.ts @@ -0,0 +1,808 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Core tests for AgentRunner execution loop. + * Tests basic tool execution, iteration handling, termination reasons, + * abort signal handling, and session metadata. + */ + +import { AgentRunner } from '../AgentRunner.js'; +import type { AgentRunnerConfig, AgentRunnerHooks } from '../AgentRunner.js'; +import { ChatMessageEntity, type ChatMessage, type ToolResultMessage } from '../../models/ChatTypes.js'; +import { AIChatPanel } from '../../ui/AIChatPanel.js'; +import { LLMClient } from '../../LLM/LLMClient.js'; +import type { Tool } from '../../tools/Tools.js'; + +// ============================================================================ +// Test Setup Helpers +// ============================================================================ + +function stubAIChatPanel(): void { + (AIChatPanel as any).getProviderForModel = (_model: string) => 'openai'; + (AIChatPanel as any).isVisionCapable = async (_model: string) => false; +} + +function createMockTool(name: string, executeFn: (args: T) => Promise): Tool { + return { + name, + description: `Mock tool ${name}`, + schema: { type: 'object', properties: {} }, + execute: executeFn, + }; +} + +function createMockToolWithResult(name: string, result: any): Tool { + return createMockTool(name, async () => result); +} + +function createMockToolWithError(name: string, errorMessage: string): Tool { + return createMockTool(name, async () => { + throw new Error(errorMessage); + }); +} + +interface TrackedMockTool extends Tool { + calls: Array<{ args: any }>; +} + +function createTrackedMockTool(name: string, result: any): TrackedMockTool { + const calls: Array<{ args: any }> = []; + const tool = createMockTool(name, async (args: any) => { + calls.push({ args }); + return result; + }) as TrackedMockTool; + tool.calls = calls; + return tool; +} + +function createDefaultConfig(tools: Tool[] = []): AgentRunnerConfig { + return { + apiKey: 'test-api-key', + modelName: 'gpt-4.1-2025-04-14', + systemPrompt: 'You are a helpful assistant.', + tools, + maxIterations: 10, + temperature: 0, + provider: 'openai', + }; +} + +function createDefaultHooks(): AgentRunnerHooks { + return { + prepareInitialMessages: undefined, + createSuccessResult: (output, steps, reason) => ({ + success: true, + output, + terminationReason: reason, + intermediateSteps: steps, + }), + createErrorResult: (error, steps, reason) => ({ + success: false, + error, + terminationReason: reason, + intermediateSteps: steps, + }), + }; +} + +function createUserMessage(text: string): ChatMessage { + return { + entity: ChatMessageEntity.USER, + text, + id: `test-msg-${Date.now()}`, + timestamp: new Date(), + } as ChatMessage; +} + +function createMockAgentArgs(args: { query: string; reasoning?: string }): { query: string; reasoning: string } { + return { + query: args.query, + reasoning: args.reasoning || '', + }; +} + +type MockLLMResponse = + | { type: 'tool_call'; toolName: string; toolArgs: any } + | { type: 'final_answer'; answer: string } + | { type: 'error'; error: string }; + +class MockLLMClient { + private responseQueue: MockLLMResponse[] = []; + private callCount = 0; + private defaultResponse: MockLLMResponse | null = null; + private originalGetInstance: any; + + queueResponse(response: MockLLMResponse): void { + this.responseQueue.push(response); + } + + setDefaultResponse(response: MockLLMResponse): void { + this.defaultResponse = response; + } + + install(): () => void { + this.originalGetInstance = (LLMClient as any).getInstance; + (LLMClient as any).getInstance = () => this.createFakeClient(); + return () => { + (LLMClient as any).getInstance = this.originalGetInstance; + }; + } + + assertCallCount(expected: number): void { + assert.strictEqual(this.callCount, expected, `Expected ${expected} LLM calls, got ${this.callCount}`); + } + + private createFakeClient() { + return { + call: async () => { + this.callCount++; + return { rawResponse: { callNumber: this.callCount } }; + }, + parseResponse: () => { + const response = this.responseQueue.shift() || this.defaultResponse; + if (!response) { + return { type: 'error', error: 'No more mock responses' }; + } + + if (response.type === 'tool_call') { + return { type: 'tool_call', name: response.toolName, args: response.toolArgs }; + } + if (response.type === 'final_answer') { + return { type: 'final_answer', answer: response.answer }; + } + return { type: 'error', error: response.error }; + }, + }; + } +} + +function setupMockLLMClient(responses: MockLLMResponse[]): { client: MockLLMClient; cleanup: () => void } { + const client = new MockLLMClient(); + for (const response of responses) { + client.queueResponse(response); + } + const cleanup = client.install(); + return { client, cleanup }; +} + +function createTestAbortController(): { controller: AbortController; signal: AbortSignal } { + const controller = new AbortController(); + return { controller, signal: controller.signal }; +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Assertion helpers +function assertSuccessResult(result: any): void { + assert.isTrue(result.success, `Expected success but got error: ${result.error}`); +} + +function assertErrorResult(result: any, expectedMessage?: string): void { + assert.isFalse(result.success, 'Expected error result but got success'); + if (expectedMessage) { + assert.include(result.error || '', expectedMessage); + } +} + +function assertTerminationReason(result: any, expected: string): void { + assert.strictEqual(result.terminationReason, expected); +} + +function assertToolCalled(steps: ChatMessage[], toolName: string): void { + const toolCall = steps.find( + (m) => m.entity === ChatMessageEntity.MODEL && (m as any).action === 'tool' && (m as any).toolName === toolName + ); + assert.isOk(toolCall, `Expected tool ${toolName} to be called`); +} + +function assertFinalAnswer(steps: ChatMessage[]): void { + const final = steps.find( + (m) => m.entity === ChatMessageEntity.MODEL && (m as any).action === 'final' + ); + assert.isOk(final, 'Expected final answer in steps'); +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('ai_chat: AgentRunner.core', () => { + let mockLLMCleanup: (() => void) | null = null; + + beforeEach(() => { + stubAIChatPanel(); + }); + + afterEach(() => { + if (mockLLMCleanup) { + mockLLMCleanup(); + mockLLMCleanup = null; + } + }); + + // ========================================================================== + // Basic Execution Flow Tests + // ========================================================================== + + describe('basic execution flow', () => { + it('executes single tool call and returns final answer', async () => { + const echoTool = createMockToolWithResult('echo_tool', { echoed: true }); + const { client, cleanup } = setupMockLLMClient([ + { type: 'tool_call', toolName: 'echo_tool', toolArgs: { message: 'hello' } }, + { type: 'final_answer', answer: 'Echo completed!' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([echoTool]); + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Please echo hello')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Echo hello' }), + config, + hooks, + null + ); + + assertSuccessResult(result); + assertTerminationReason(result, 'final_answer'); + assert.strictEqual(result.output, 'Echo completed!'); + client.assertCallCount(2); // One for tool call, one for final answer + }); + + it('executes multiple sequential tool calls before final answer', async () => { + const tool1 = createTrackedMockTool('tool_1', { step: 1 }); + const tool2 = createTrackedMockTool('tool_2', { step: 2 }); + const tool3 = createTrackedMockTool('tool_3', { step: 3 }); + + const { cleanup } = setupMockLLMClient([ + { type: 'tool_call', toolName: 'tool_1', toolArgs: { a: 1 } }, + { type: 'tool_call', toolName: 'tool_2', toolArgs: { b: 2 } }, + { type: 'tool_call', toolName: 'tool_3', toolArgs: { c: 3 } }, + { type: 'final_answer', answer: 'All three tools executed' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([tool1, tool2, tool3]); + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Run all tools')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Run all tools' }), + config, + hooks, + null + ); + + assertSuccessResult(result); + assert.strictEqual(tool1.calls.length, 1); + assert.strictEqual(tool2.calls.length, 1); + assert.strictEqual(tool3.calls.length, 1); + assert.deepStrictEqual(tool1.calls[0].args, { a: 1 }); + assert.deepStrictEqual(tool2.calls[0].args, { b: 2 }); + assert.deepStrictEqual(tool3.calls[0].args, { c: 3 }); + }); + + it('returns final answer immediately when no tools are called', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Direct answer without tools' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([]); + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('What is 2+2?')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'What is 2+2?' }), + config, + hooks, + null + ); + + assertSuccessResult(result); + assert.strictEqual(result.output, 'Direct answer without tools'); + assertTerminationReason(result, 'final_answer'); + }); + }); + + // ========================================================================== + // Tool Execution Error Handling + // ========================================================================== + + describe('tool execution error handling', () => { + it('handles tool execution errors gracefully and continues', async () => { + const failingTool = createMockToolWithError('failing_tool', 'Tool crashed!'); + const { cleanup } = setupMockLLMClient([ + { type: 'tool_call', toolName: 'failing_tool', toolArgs: {} }, + { type: 'final_answer', answer: 'Recovered from tool error' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([failingTool]); + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Try the tool')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Try the tool' }), + config, + hooks, + null + ); + + // Agent should recover and provide final answer + assertSuccessResult(result); + assert.strictEqual(result.output, 'Recovered from tool error'); + + // Check that error was captured in intermediate steps + const toolResultMessages = result.intermediateSteps?.filter( + (m) => m.entity === ChatMessageEntity.TOOL_RESULT + ) as ToolResultMessage[] || []; + + const errorResult = toolResultMessages.find(m => m.isError); + assert.isOk(errorResult, 'Should have an error tool result'); + assert.include(errorResult?.resultText || '', 'Tool crashed!'); + }); + + it('handles unknown tool gracefully', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'tool_call', toolName: 'nonexistent_tool', toolArgs: {} }, + { type: 'final_answer', answer: 'Recovered from unknown tool' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([]); // No tools registered + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Use unknown tool')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Use unknown tool' }), + config, + hooks, + null + ); + + // Agent should recover + assertSuccessResult(result); + }); + }); + + // ========================================================================== + // Iteration Limit Tests + // ========================================================================== + + describe('iteration limits', () => { + it('respects maxIterations limit', async () => { + // Keep returning tool calls forever + const client = new MockLLMClient(); + client.setDefaultResponse({ type: 'tool_call', toolName: 'infinite_tool', toolArgs: {} }); + mockLLMCleanup = client.install(); + + const infiniteTool = createMockToolWithResult('infinite_tool', { done: false }); + const config = createDefaultConfig([infiniteTool]); + config.maxIterations = 3; // Limit to 3 iterations + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Loop forever')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Loop forever' }), + config, + hooks, + null + ); + + // Should fail with max_iterations + assertErrorResult(result); + assertTerminationReason(result, 'max_iterations'); + assert.include(result.error || '', 'maximum iterations'); + }); + + it('tracks iteration count correctly in session', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'tool_call', toolName: 'tool1', toolArgs: {} }, + { type: 'tool_call', toolName: 'tool1', toolArgs: {} }, + { type: 'final_answer', answer: 'Done after 2 tool calls' }, + ]); + mockLLMCleanup = cleanup; + + const tool = createMockToolWithResult('tool1', { ok: true }); + const config = createDefaultConfig([tool]); + config.maxIterations = 10; + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Run twice')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Run twice' }), + config, + hooks, + null + ); + + assertSuccessResult(result); + // Session should track iterations (3 iterations: 2 tool calls + 1 final) + assert.strictEqual(result.agentSession.iterationCount, 3); + }); + }); + + // ========================================================================== + // Abort Signal Tests + // ========================================================================== + + describe('abort signal handling', () => { + it('handles abort signal during execution', async () => { + const { controller, signal } = createTestAbortController(); + + // Create a slow tool that gives us time to abort + const slowTool = createMockTool('slow_tool', async () => { + await delay(100); + return { completed: true }; + }); + + // Queue tool call then final answer + const { cleanup } = setupMockLLMClient([ + { type: 'tool_call', toolName: 'slow_tool', toolArgs: {} }, + { type: 'final_answer', answer: 'Should not reach here' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([slowTool]); + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Run slow tool')]; + + // Start execution + const runPromise = AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Run slow tool' }), + config, + hooks, + null, + undefined, + undefined, + signal + ); + + // Abort after a short delay + await delay(20); + controller.abort(); + + const result = await runPromise; + + // Should be aborted + assertErrorResult(result, 'cancelled'); + assertTerminationReason(result, 'error'); + }); + + it('handles pre-aborted signal', async () => { + const { controller, signal } = createTestAbortController(); + controller.abort(); // Pre-abort + + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Should not execute' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([]); + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Test')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Test' }), + config, + hooks, + null, + undefined, + undefined, + signal + ); + + assertErrorResult(result, 'cancelled'); + }); + }); + + // ========================================================================== + // Session Metadata Tests + // ========================================================================== + + describe('session metadata', () => { + it('creates session with correct agent name', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([]); + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Test')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Test' }), + config, + hooks, + null // No executing agent, should use 'Unknown' + ); + + assert.strictEqual(result.agentSession.agentName, 'Unknown'); + assert.isOk(result.agentSession.sessionId); + assert.strictEqual(result.agentSession.status, 'completed'); + }); + + it('tracks start and end time', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([]); + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Test')]; + + const startTime = new Date(); + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Test' }), + config, + hooks, + null + ); + const endTime = new Date(); + + assert.isOk(result.agentSession.startTime); + assert.isOk(result.agentSession.endTime); + assert.isTrue(result.agentSession.startTime >= startTime); + assert.isTrue(result.agentSession.endTime! <= endTime); + }); + + it('records model used in session', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([]); + config.modelName = 'custom-model-v1'; + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Test')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Test' }), + config, + hooks, + null + ); + + assert.strictEqual(result.agentSession.modelUsed, 'custom-model-v1'); + }); + + it('records tools available in session', async () => { + const tool1 = createMockToolWithResult('tool_a', {}); + const tool2 = createMockToolWithResult('tool_b', {}); + + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([tool1, tool2]); + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Test')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Test' }), + config, + hooks, + null + ); + + assert.deepStrictEqual(result.agentSession.tools, ['tool_a', 'tool_b']); + }); + }); + + // ========================================================================== + // Termination Reason Tests + // ========================================================================== + + describe('termination reasons', () => { + it('terminates with final_answer on normal completion', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Task completed' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([]); + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Complete the task')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Complete the task' }), + config, + hooks, + null + ); + + assertSuccessResult(result); + assertTerminationReason(result, 'final_answer'); + assert.strictEqual(result.agentSession.terminationReason, 'final_answer'); + }); + + it('terminates with max_iterations at limit', async () => { + const client = new MockLLMClient(); + client.setDefaultResponse({ type: 'tool_call', toolName: 'loop_tool', toolArgs: {} }); + mockLLMCleanup = client.install(); + + const tool = createMockToolWithResult('loop_tool', { ok: true }); + const config = createDefaultConfig([tool]); + config.maxIterations = 2; + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Loop')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Loop' }), + config, + hooks, + null + ); + + assertErrorResult(result); + assertTerminationReason(result, 'max_iterations'); + assert.strictEqual(result.agentSession.terminationReason, 'max_iterations'); + }); + + it('terminates with error on LLM failure', async () => { + // Make LLM throw an error + (LLMClient as any).getInstance = () => ({ + call: async () => { throw new Error('LLM service unavailable'); }, + parseResponse: () => ({ type: 'error', error: 'n/a' }), + }); + mockLLMCleanup = () => {}; + + const config = createDefaultConfig([]); + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Test')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Test' }), + config, + hooks, + null + ); + + assertErrorResult(result, 'LLM call failed'); + assertTerminationReason(result, 'error'); + assert.strictEqual(result.agentSession.terminationReason, 'error'); + }); + }); + + // ========================================================================== + // Intermediate Steps Tests + // ========================================================================== + + describe('intermediate steps', () => { + it('includes intermediate steps in result', async () => { + const tool = createMockToolWithResult('step_tool', { data: 'step_result' }); + + const { cleanup } = setupMockLLMClient([ + { type: 'tool_call', toolName: 'step_tool', toolArgs: { input: 'test' } }, + { type: 'final_answer', answer: 'Final result' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([tool]); + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Run with steps')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Run with steps' }), + config, + hooks, + null + ); + + assertSuccessResult(result); + assert.isArray(result.intermediateSteps); + + // Should have: user message, tool call, tool result, final answer + assert.isAtLeast(result.intermediateSteps!.length, 3); + + // Verify tool call is in steps + assertToolCalled(result.intermediateSteps!, 'step_tool'); + + // Verify final answer is in steps + assertFinalAnswer(result.intermediateSteps!); + }); + }); + + // ========================================================================== + // Image Data Handling Tests + // ========================================================================== + + describe('image data handling', () => { + it('handles tool results with only image data for non-vision models', async () => { + const imageTool = createMockToolWithResult('screenshot_tool', { + imageData: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + }); + + const { cleanup } = setupMockLLMClient([ + { type: 'tool_call', toolName: 'screenshot_tool', toolArgs: {} }, + { type: 'final_answer', answer: 'Screenshot taken' }, + ]); + mockLLMCleanup = cleanup; + + const config = createDefaultConfig([imageTool]); + config.getVisionCapability = async () => false; // Non-vision model + const hooks = createDefaultHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Take a screenshot')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Take a screenshot' }), + config, + hooks, + null + ); + + assertSuccessResult(result); + + // Find the tool result message + const toolResult = result.intermediateSteps?.find( + (m) => m.entity === ChatMessageEntity.TOOL_RESULT && (m as ToolResultMessage).toolName === 'screenshot_tool' + ) as ToolResultMessage; + + assert.isOk(toolResult); + // For non-vision models, image-only results should get placeholder text + assert.strictEqual(toolResult.resultText, 'Image omitted (model lacks vision).'); + }); + }); + + // ========================================================================== + // computeToolResultText Tests + // ========================================================================== + + describe('computeToolResultText static method', () => { + it('returns string results as-is', () => { + const result = AgentRunner.computeToolResultText('Hello world'); + assert.strictEqual(result, 'Hello world'); + }); + + it('returns JSON for object results without image', () => { + const result = AgentRunner.computeToolResultText({ key: 'value', count: 42 }); + const parsed = JSON.parse(result); + assert.deepStrictEqual(parsed, { key: 'value', count: 42 }); + }); + + it('returns placeholder for image-only results', () => { + const result = AgentRunner.computeToolResultText( + { imageData: 'base64...' }, + 'base64...' + ); + assert.strictEqual(result, 'Image omitted (model lacks vision).'); + }); + + it('includes non-image fields even when image is present', () => { + const result = AgentRunner.computeToolResultText( + { imageData: 'base64...', width: 100, height: 200 }, + 'base64...' + ); + const parsed = JSON.parse(result); + assert.deepStrictEqual(parsed, { width: 100, height: 200 }); + }); + }); +}); diff --git a/front_end/panels/ai_chat/agent_framework/__tests__/AgentRunner.handoff.test.ts b/front_end/panels/ai_chat/agent_framework/__tests__/AgentRunner.handoff.test.ts new file mode 100644 index 0000000000..ac8063c54b --- /dev/null +++ b/front_end/panels/ai_chat/agent_framework/__tests__/AgentRunner.handoff.test.ts @@ -0,0 +1,681 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Tests for AgentRunner handoff functionality. + * Tests LLM-triggered handoffs, max_iterations handoffs, + * message filtering, nested sessions, and error handling. + */ + +import { AgentRunner } from '../AgentRunner.js'; +import type { AgentRunnerConfig, AgentRunnerHooks } from '../AgentRunner.js'; +import { ConfigurableAgentTool, ToolRegistry, type AgentToolConfig } from '../ConfigurableAgentTool.js'; +import { ChatMessageEntity, type ChatMessage } from '../../models/ChatTypes.js'; +import { AIChatPanel } from '../../ui/AIChatPanel.js'; +import { LLMClient } from '../../LLM/LLMClient.js'; +import type { Tool } from '../../tools/Tools.js'; + +// ============================================================================ +// Test Helper Functions +// ============================================================================ + +function createMockAgentToolConfig(overrides: Partial = {}): AgentToolConfig { + return { + name: overrides.name || 'test_agent', + description: overrides.description || 'Test agent for unit tests', + systemPrompt: overrides.systemPrompt || 'You are a test agent.', + tools: overrides.tools || [], + schema: overrides.schema || { + type: 'object', + properties: { + query: { type: 'string' }, + reasoning: { type: 'string' }, + }, + required: ['query'], + }, + maxIterations: overrides.maxIterations || 10, + temperature: overrides.temperature || 0, + ...overrides, + }; +} + +function createMockAgentRunnerConfig(overrides: Partial = {}): AgentRunnerConfig { + return { + apiKey: overrides.apiKey ?? 'test-api-key', + modelName: overrides.modelName ?? 'gpt-4.1-2025-04-14', + systemPrompt: overrides.systemPrompt ?? 'You are a helpful assistant.', + tools: overrides.tools ?? [], + maxIterations: overrides.maxIterations ?? 10, + temperature: overrides.temperature ?? 0, + provider: overrides.provider ?? 'openai', + ...overrides, + }; +} + +function createMockAgentRunnerHooks(): AgentRunnerHooks { + return { + prepareInitialMessages: undefined, + createSuccessResult: (output, steps, reason) => ({ + success: true, + output, + terminationReason: reason, + intermediateSteps: steps, + }), + createErrorResult: (error, steps, reason) => ({ + success: false, + error, + terminationReason: reason, + intermediateSteps: steps, + }), + }; +} + +function createMockTool(name: string, executeFn: (args: T) => Promise): Tool { + return { + name, + description: `Mock tool ${name}`, + schema: { type: 'object', properties: {} }, + execute: executeFn, + }; +} + +function createMockToolWithResult(name: string, result: any): Tool { + return createMockTool(name, async () => result); +} + +function createUserMessage(text: string): ChatMessage { + return { + entity: ChatMessageEntity.USER, + text, + id: `test-msg-${Date.now()}`, + timestamp: new Date(), + } as ChatMessage; +} + +function createMockAgentArgs(args: Partial<{ query: string; reasoning: string }> = {}): { query: string; reasoning: string } { + return { + query: args.query || 'Test query', + reasoning: args.reasoning || '', + }; +} + +type MockLLMResponse = + | { type: 'tool_call'; toolName: string; toolArgs: any } + | { type: 'final_answer'; answer: string } + | { type: 'error'; error: string }; + +class MockLLMClient { + private responseQueue: MockLLMResponse[] = []; + private callCount = 0; + private defaultResponse: MockLLMResponse | null = null; + private originalGetInstance: any; + + queueResponse(response: MockLLMResponse): void { + this.responseQueue.push(response); + } + + setDefaultResponse(response: MockLLMResponse): void { + this.defaultResponse = response; + } + + install(): () => void { + this.originalGetInstance = (LLMClient as any).getInstance; + (LLMClient as any).getInstance = () => this.createFakeClient(); + return () => { + (LLMClient as any).getInstance = this.originalGetInstance; + }; + } + + private createFakeClient() { + return { + call: async () => { + this.callCount++; + return { rawResponse: { callNumber: this.callCount } }; + }, + parseResponse: () => { + const response = this.responseQueue.shift() || this.defaultResponse; + if (!response) { + return { type: 'error', error: 'No more mock responses' }; + } + + if (response.type === 'tool_call') { + return { type: 'tool_call', name: response.toolName, args: response.toolArgs }; + } + if (response.type === 'final_answer') { + return { type: 'final_answer', answer: response.answer }; + } + return { type: 'error', error: response.error }; + }, + }; + } +} + +function assertSuccessResult(result: any): void { + assert.isTrue(result.success, `Expected success but got error: ${result.error}`); +} + +function assertErrorResult(result: any, expectedMessage?: string): void { + assert.isFalse(result.success, 'Expected error result but got success'); + if (expectedMessage) { + assert.include(result.error || '', expectedMessage); + } +} + +function assertTerminationReason(result: any, expected: string): void { + assert.strictEqual(result.terminationReason, expected); +} + +function stubAIChatPanel(): void { + (AIChatPanel as any).getProviderForModel = (_model: string) => 'openai'; + (AIChatPanel as any).isVisionCapable = async (_model: string) => false; +} + +function resetToolRegistry(): void { + (ToolRegistry as any).toolFactories = new Map(); + (ToolRegistry as any).registeredTools = new Map(); +} + +function createAndRegisterAgent(config: AgentToolConfig): ConfigurableAgentTool { + const agent = new ConfigurableAgentTool(config); + ToolRegistry.registerToolFactory(config.name, () => agent); + return agent; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('ai_chat: AgentRunner.handoff', () => { + let mockLLMCleanup: (() => void) | null = null; + + beforeEach(() => { + stubAIChatPanel(); + resetToolRegistry(); + }); + + afterEach(() => { + if (mockLLMCleanup) { + mockLLMCleanup(); + mockLLMCleanup = null; + } + resetToolRegistry(); + }); + + // ========================================================================== + // LLM-Triggered Handoff Tests + // ========================================================================== + + describe('LLM-triggered handoffs', () => { + it('executes handoff when LLM calls handoff tool', async () => { + // 1. Create and register target agent + const targetConfig = createMockAgentToolConfig({ + name: 'target_agent', + description: 'Target agent for handoff', + systemPrompt: 'You are the target agent.', + }); + createAndRegisterAgent(targetConfig); + + // 2. Create parent agent with handoff config pointing to target + const parentConfig = createMockAgentToolConfig({ + name: 'parent_agent', + description: 'Parent agent that can handoff', + systemPrompt: 'You are the parent agent.', + handoffs: [ + { + targetAgentName: 'target_agent', + trigger: 'llm_tool_call', + }, + ], + }); + const parentAgent = createAndRegisterAgent(parentConfig); + + // 3. Mock LLM with inline pattern that works: + // - Parent's first call returns handoff tool call + // - Target's call returns final answer + let callCount = 0; + (LLMClient as any).getInstance = () => ({ + call: async () => { + callCount++; + if (callCount === 1) { + // Parent calls handoff + return { + rawResponse: { + choices: [{ + message: { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { + name: 'handoff_to_target_agent', + arguments: JSON.stringify({ query: 'Continue task', reasoning: 'Need specialized help' }), + }, + }], + }, + finish_reason: 'tool_calls', + }], + }, + }; + } + // Target returns final answer + return { + rawResponse: { + choices: [{ + message: { role: 'assistant', content: 'Target completed the task!' }, + finish_reason: 'stop', + }], + }, + }; + }, + parseResponse: (response: any) => { + const message = response.rawResponse?.choices?.[0]?.message; + if (message?.tool_calls) { + const toolCall = message.tool_calls[0]; + return { + type: 'tool_call', + name: toolCall.function.name, + args: JSON.parse(toolCall.function.arguments), + }; + } + return { type: 'final_answer', answer: message?.content || '' }; + }, + }); + mockLLMCleanup = () => {}; + + const config = createMockAgentRunnerConfig(); + const hooks = createMockAgentRunnerHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Start task')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Start task' }), + config, + hooks, + parentAgent + ); + + assertSuccessResult(result); + assertTerminationReason(result, 'handed_off'); + }); + + it('continues without handoff tool when target does not exist', async () => { + // When handoff target doesn't exist, AgentRunner: + // 1. Logs a warning: "Configured LLM handoff target 'nonexistent_agent' not found" + // 2. Does NOT add the handoff_to_nonexistent_agent tool to toolSchemas + // 3. Agent continues normally without the handoff option + + // Create parent agent with handoff to nonexistent target + const parentConfig = createMockAgentToolConfig({ + name: 'parent_with_bad_handoff', + systemPrompt: 'Parent agent', + handoffs: [ + { + targetAgentName: 'nonexistent_agent', + trigger: 'llm_tool_call', + }, + ], + }); + const parentAgent = createAndRegisterAgent(parentConfig); + + // Since handoff tool won't be available, LLM just returns final answer + const client = new MockLLMClient(); + client.queueResponse({ type: 'final_answer', answer: 'Completed without handoff' }); + mockLLMCleanup = client.install(); + + const config = createMockAgentRunnerConfig(); + const hooks = createMockAgentRunnerHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Do something')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Do something' }), + config, + hooks, + parentAgent + ); + + // Should succeed normally (handoff tool was never available to LLM) + assertSuccessResult(result); + assertTerminationReason(result, 'final_answer'); + }); + }); + + // ========================================================================== + // Max Iterations Handoff Tests + // ========================================================================== + + describe('max_iterations handoffs', () => { + it('executes handoff when max iterations reached', async () => { + // 1. Register target agent (continuation_agent) for max_iterations handoff + const targetConfig = createMockAgentToolConfig({ + name: 'continuation_agent', + description: 'Continues work after max iterations', + systemPrompt: 'You continue work.', + }); + createAndRegisterAgent(targetConfig); + ToolRegistry.getRegisteredTool('continuation_agent'); // Ensure cached + + // 2. Create parent with max_iterations handoff trigger + const parentConfig = createMockAgentToolConfig({ + name: 'limited_agent', + systemPrompt: 'Limited iteration agent', + maxIterations: 2, + tools: ['do_work'], + handoffs: [ + { + targetAgentName: 'continuation_agent', + trigger: 'max_iterations', + }, + ], + }); + const parentAgent = createAndRegisterAgent(parentConfig); + + // 3. Register a tool for the parent to use + const workTool = createMockToolWithResult('do_work', { worked: true }); + ToolRegistry.registerToolFactory('do_work', () => workTool); + + // 4. Mock LLM: + // - Parent's first 2 calls: call do_work tool (will hit max_iterations) + // - After handoff, target's call: return final answer + const client = new MockLLMClient(); + // Parent iteration 1 + client.queueResponse({ type: 'tool_call', toolName: 'do_work', toolArgs: {} }); + // Parent iteration 2 - after this, max_iterations is reached, handoff triggers + client.queueResponse({ type: 'tool_call', toolName: 'do_work', toolArgs: {} }); + // Target (continuation_agent) returns final answer + client.queueResponse({ type: 'final_answer', answer: 'Work continued and completed!' }); + mockLLMCleanup = client.install(); + + const runnerConfig = createMockAgentRunnerConfig({ tools: [workTool], maxIterations: 2 }); + const hooks = createMockAgentRunnerHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Do lots of work')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Do lots of work' }), + runnerConfig, + hooks, + parentAgent + ); + + // Should succeed via handoff + assertSuccessResult(result); + assertTerminationReason(result, 'handed_off'); + }); + + it('returns max_iterations error when no handoff configured', async () => { + // Create agent without max_iterations handoff + const config = createMockAgentToolConfig({ + name: 'no_handoff_agent', + systemPrompt: 'No handoff configured', + maxIterations: 2, + // No handoffs configured + }); + const agent = createAndRegisterAgent(config); + + const tool = createMockToolWithResult('loop_tool', { looped: true }); + ToolRegistry.registerToolFactory('loop_tool', () => tool); + + // Mock LLM to keep calling tool forever + const client = new MockLLMClient(); + client.setDefaultResponse({ type: 'tool_call', toolName: 'loop_tool', toolArgs: {} }); + mockLLMCleanup = client.install(); + + const runnerConfig = createMockAgentRunnerConfig({ tools: [tool], maxIterations: 2 }); + const hooks = createMockAgentRunnerHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Loop forever')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Loop forever' }), + runnerConfig, + hooks, + agent + ); + + assertErrorResult(result, 'maximum iterations'); + assertTerminationReason(result, 'max_iterations'); + }); + }); + + // ========================================================================== + // Message Filtering Tests + // ========================================================================== + + describe('message filtering', () => { + it('filters messages based on includeToolResults', async () => { + // This test verifies that when includeToolResults is specified, + // only those tool results are passed to the target agent + + // Create target agent + const targetConfig = createMockAgentToolConfig({ + name: 'filtered_target', + systemPrompt: 'Target with filtered messages', + }); + createAndRegisterAgent(targetConfig); + + // Create parent with filtered handoff + const parentConfig = createMockAgentToolConfig({ + name: 'filtering_parent', + systemPrompt: 'Parent that filters', + tools: ['tool_a', 'tool_b'], + handoffs: [ + { + targetAgentName: 'filtered_target', + trigger: 'llm_tool_call', + includeToolResults: ['tool_a'], // Only include results from tool_a + }, + ], + }); + const parentAgent = createAndRegisterAgent(parentConfig); + + // Register tools + const toolA = createMockToolWithResult('tool_a', { from: 'a' }); + const toolB = createMockToolWithResult('tool_b', { from: 'b' }); + ToolRegistry.registerToolFactory('tool_a', () => toolA); + ToolRegistry.registerToolFactory('tool_b', () => toolB); + + // Mock LLM sequence: call tool_a, call tool_b, then handoff + let callCount = 0; + (LLMClient as any).getInstance = () => ({ + call: async () => { + callCount++; + if (callCount === 1) { + return { + rawResponse: { + choices: [{ + message: { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { name: 'tool_a', arguments: '{}' }, + }], + }, + finish_reason: 'tool_calls', + }], + }, + }; + } + if (callCount === 2) { + return { + rawResponse: { + choices: [{ + message: { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_2', + type: 'function', + function: { name: 'tool_b', arguments: '{}' }, + }], + }, + finish_reason: 'tool_calls', + }], + }, + }; + } + if (callCount === 3) { + // Call handoff + return { + rawResponse: { + choices: [{ + message: { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_3', + type: 'function', + function: { + name: 'handoff_to_filtered_target', + arguments: JSON.stringify({ query: 'Continue', reasoning: 'Done' }), + }, + }], + }, + finish_reason: 'tool_calls', + }], + }, + }; + } + // Target returns final + return { + rawResponse: { + choices: [{ + message: { role: 'assistant', content: 'Filtered target done!' }, + finish_reason: 'stop', + }], + }, + }; + }, + parseResponse: (response: any) => { + const message = response.rawResponse?.choices?.[0]?.message; + if (message?.tool_calls) { + const toolCall = message.tool_calls[0]; + return { + type: 'tool_call', + name: toolCall.function.name, + args: JSON.parse(toolCall.function.arguments), + }; + } + return { type: 'final_answer', answer: message?.content || '' }; + }, + }); + mockLLMCleanup = () => {}; + + const runnerConfig = createMockAgentRunnerConfig({ tools: [toolA, toolB], maxIterations: 10 }); + const hooks = createMockAgentRunnerHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Use both tools then handoff')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Use both tools then handoff' }), + runnerConfig, + hooks, + parentAgent + ); + + assertSuccessResult(result); + }); + }); + + // ========================================================================== + // Nested Session Tests + // ========================================================================== + + describe('nested sessions', () => { + it('creates nested session for handoff target', async () => { + // Create target agent + const targetConfig = createMockAgentToolConfig({ + name: 'nested_target', + systemPrompt: 'Nested target', + }); + createAndRegisterAgent(targetConfig); + + // Create parent with handoff + const parentConfig = createMockAgentToolConfig({ + name: 'nesting_parent', + systemPrompt: 'Parent that nests', + handoffs: [ + { + targetAgentName: 'nested_target', + trigger: 'llm_tool_call', + }, + ], + }); + const parentAgent = createAndRegisterAgent(parentConfig); + + // Mock LLM + let callCount = 0; + (LLMClient as any).getInstance = () => ({ + call: async () => { + callCount++; + if (callCount === 1) { + return { + rawResponse: { + choices: [{ + message: { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { + name: 'handoff_to_nested_target', + arguments: JSON.stringify({ query: 'Nested', reasoning: 'Nesting' }), + }, + }], + }, + finish_reason: 'tool_calls', + }], + }, + }; + } + return { + rawResponse: { + choices: [{ + message: { role: 'assistant', content: 'Nested done!' }, + finish_reason: 'stop', + }], + }, + }; + }, + parseResponse: (response: any) => { + const message = response.rawResponse?.choices?.[0]?.message; + if (message?.tool_calls) { + const toolCall = message.tool_calls[0]; + return { + type: 'tool_call', + name: toolCall.function.name, + args: JSON.parse(toolCall.function.arguments), + }; + } + return { type: 'final_answer', answer: message?.content || '' }; + }, + }); + mockLLMCleanup = () => {}; + + const runnerConfig = createMockAgentRunnerConfig(); + const hooks = createMockAgentRunnerHooks(); + const initialMessages: ChatMessage[] = [createUserMessage('Nest please')]; + + const result = await AgentRunner.run( + initialMessages, + createMockAgentArgs({ query: 'Nest please' }), + runnerConfig, + hooks, + parentAgent + ); + + assertSuccessResult(result); + + // Check that session has nested sessions + const parentSession = result.agentSession; + assert.isOk(parentSession); + // The nested session should be present + assert.isAtLeast(parentSession.nestedSessions.length, 1); + }); + }); +}); diff --git a/front_end/panels/ai_chat/agent_framework/__tests__/AgentRunnerEventBus.test.ts b/front_end/panels/ai_chat/agent_framework/__tests__/AgentRunnerEventBus.test.ts new file mode 100644 index 0000000000..913815363c --- /dev/null +++ b/front_end/panels/ai_chat/agent_framework/__tests__/AgentRunnerEventBus.test.ts @@ -0,0 +1,429 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Tests for AgentRunnerEventBus class. + * Tests singleton behavior, event emission, listener registration, + * and event data structure validation. + */ + +import { AgentRunnerEventBus, type AgentRunnerProgressEvent } from '../AgentRunnerEventBus.js'; +import type { EventTargetEvent } from '../../../../core/common/EventTarget.js'; + +// Type alias for the event bus events map +type EventBusEvents = { 'agent-progress': AgentRunnerProgressEvent }; + +// ============================================================================ +// Tests +// ============================================================================ + +describe('ai_chat: AgentRunnerEventBus', () => { + // ========================================================================== + // Singleton Behavior Tests + // ========================================================================== + + describe('singleton pattern', () => { + it('returns same instance on multiple calls', () => { + const instance1 = AgentRunnerEventBus.getInstance(); + const instance2 = AgentRunnerEventBus.getInstance(); + + assert.strictEqual(instance1, instance2); + }); + + it('instance is consistent across test runs', () => { + const instance = AgentRunnerEventBus.getInstance(); + assert.isOk(instance); + assert.isFunction(instance.emitProgress); + }); + }); + + // ========================================================================== + // Event Emission Tests + // ========================================================================== + + describe('event emission', () => { + it('emits session_started event', (done) => { + const eventBus = AgentRunnerEventBus.getInstance(); + + const event: AgentRunnerProgressEvent = { + type: 'session_started', + sessionId: 'test-session-1', + agentName: 'test_agent', + timestamp: new Date(), + data: { session: { agentName: 'test_agent' } }, + }; + + const listener = (e: EventTargetEvent): void => { + const receivedEvent = e.data; + assert.strictEqual(receivedEvent.type, 'session_started'); + assert.strictEqual(receivedEvent.sessionId, 'test-session-1'); + assert.strictEqual(receivedEvent.agentName, 'test_agent'); + eventBus.removeEventListener('agent-progress', listener); + done(); + }; + + eventBus.addEventListener('agent-progress', listener); + eventBus.emitProgress(event); + }); + + it('emits tool_started event', (done) => { + const eventBus = AgentRunnerEventBus.getInstance(); + + const event: AgentRunnerProgressEvent = { + type: 'tool_started', + sessionId: 'test-session-2', + agentName: 'test_agent', + timestamp: new Date(), + data: { + session: { agentName: 'test_agent' }, + toolCall: { toolName: 'test_tool', toolArgs: {} }, + }, + }; + + const listener = (e: EventTargetEvent): void => { + const receivedEvent = e.data; + assert.strictEqual(receivedEvent.type, 'tool_started'); + assert.strictEqual(receivedEvent.data.toolCall.toolName, 'test_tool'); + eventBus.removeEventListener('agent-progress', listener); + done(); + }; + + eventBus.addEventListener('agent-progress', listener); + eventBus.emitProgress(event); + }); + + it('emits tool_completed event', (done) => { + const eventBus = AgentRunnerEventBus.getInstance(); + + const event: AgentRunnerProgressEvent = { + type: 'tool_completed', + sessionId: 'test-session-3', + agentName: 'test_agent', + timestamp: new Date(), + data: { + session: { agentName: 'test_agent' }, + toolResult: { success: true, result: { data: 'result' } }, + }, + }; + + const listener = (e: EventTargetEvent): void => { + const receivedEvent = e.data; + assert.strictEqual(receivedEvent.type, 'tool_completed'); + assert.isTrue(receivedEvent.data.toolResult.success); + eventBus.removeEventListener('agent-progress', listener); + done(); + }; + + eventBus.addEventListener('agent-progress', listener); + eventBus.emitProgress(event); + }); + + it('emits session_completed event', (done) => { + const eventBus = AgentRunnerEventBus.getInstance(); + + const event: AgentRunnerProgressEvent = { + type: 'session_completed', + sessionId: 'test-session-4', + agentName: 'test_agent', + timestamp: new Date(), + data: { + session: { agentName: 'test_agent', status: 'completed' }, + reason: 'final_answer', + }, + }; + + const listener = (e: EventTargetEvent): void => { + const receivedEvent = e.data; + assert.strictEqual(receivedEvent.type, 'session_completed'); + assert.strictEqual(receivedEvent.data.reason, 'final_answer'); + eventBus.removeEventListener('agent-progress', listener); + done(); + }; + + eventBus.addEventListener('agent-progress', listener); + eventBus.emitProgress(event); + }); + + it('emits child_agent_started event', (done) => { + const eventBus = AgentRunnerEventBus.getInstance(); + + const event: AgentRunnerProgressEvent = { + type: 'child_agent_started', + sessionId: 'parent-session', + parentSessionId: undefined, + agentName: 'parent_agent', + timestamp: new Date(), + data: { + parentSession: { agentName: 'parent_agent' }, + childAgentName: 'child_agent', + childSessionId: 'child-session', + }, + }; + + const listener = (e: EventTargetEvent): void => { + const receivedEvent = e.data; + assert.strictEqual(receivedEvent.type, 'child_agent_started'); + assert.strictEqual(receivedEvent.data.childAgentName, 'child_agent'); + assert.strictEqual(receivedEvent.data.childSessionId, 'child-session'); + eventBus.removeEventListener('agent-progress', listener); + done(); + }; + + eventBus.addEventListener('agent-progress', listener); + eventBus.emitProgress(event); + }); + + it('emits session_updated event', (done) => { + const eventBus = AgentRunnerEventBus.getInstance(); + + const event: AgentRunnerProgressEvent = { + type: 'session_updated', + sessionId: 'test-session-5', + agentName: 'test_agent', + timestamp: new Date(), + data: { + session: { agentName: 'test_agent', iterationCount: 3 }, + }, + }; + + const listener = (e: EventTargetEvent): void => { + const receivedEvent = e.data; + assert.strictEqual(receivedEvent.type, 'session_updated'); + eventBus.removeEventListener('agent-progress', listener); + done(); + }; + + eventBus.addEventListener('agent-progress', listener); + eventBus.emitProgress(event); + }); + }); + + // ========================================================================== + // Event Data Structure Tests + // ========================================================================== + + describe('event data structure', () => { + it('includes all required fields', (done) => { + const eventBus = AgentRunnerEventBus.getInstance(); + const timestamp = new Date(); + + const event: AgentRunnerProgressEvent = { + type: 'session_started', + sessionId: 'struct-test-session', + parentSessionId: 'parent-session-id', + agentName: 'structure_test_agent', + timestamp, + data: { test: true }, + }; + + const listener = (e: EventTargetEvent): void => { + const receivedEvent = e.data; + // Verify all fields are present + assert.isOk(receivedEvent.type); + assert.isOk(receivedEvent.sessionId); + assert.isOk(receivedEvent.agentName); + assert.isOk(receivedEvent.timestamp); + assert.isOk(receivedEvent.data); + + // Verify optional fields + assert.strictEqual(receivedEvent.parentSessionId, 'parent-session-id'); + + eventBus.removeEventListener('agent-progress', listener); + done(); + }; + + eventBus.addEventListener('agent-progress', listener); + eventBus.emitProgress(event); + }); + + it('preserves timestamp as Date object', (done) => { + const eventBus = AgentRunnerEventBus.getInstance(); + const timestamp = new Date('2025-01-15T12:00:00Z'); + + const event: AgentRunnerProgressEvent = { + type: 'session_started', + sessionId: 'timestamp-test', + agentName: 'test_agent', + timestamp, + data: {}, + }; + + const listener = (e: EventTargetEvent): void => { + const receivedEvent = e.data; + assert.instanceOf(receivedEvent.timestamp, Date); + assert.strictEqual(receivedEvent.timestamp.toISOString(), timestamp.toISOString()); + eventBus.removeEventListener('agent-progress', listener); + done(); + }; + + eventBus.addEventListener('agent-progress', listener); + eventBus.emitProgress(event); + }); + + it('preserves complex data objects', (done) => { + const eventBus = AgentRunnerEventBus.getInstance(); + + const complexData = { + session: { + agentName: 'complex_agent', + messages: [ + { id: '1', type: 'tool_call' }, + { id: '2', type: 'tool_result' }, + ], + nestedSessions: [ + { sessionId: 'nested-1', agentName: 'nested_agent' }, + ], + }, + toolCall: { + toolName: 'complex_tool', + toolArgs: { + nested: { + deeply: { + value: 42, + }, + }, + }, + }, + }; + + const event: AgentRunnerProgressEvent = { + type: 'tool_started', + sessionId: 'complex-data-test', + agentName: 'complex_agent', + timestamp: new Date(), + data: complexData, + }; + + const listener = (e: EventTargetEvent): void => { + const receivedEvent = e.data; + assert.deepStrictEqual(receivedEvent.data, complexData); + assert.strictEqual(receivedEvent.data.toolCall.toolArgs.nested.deeply.value, 42); + eventBus.removeEventListener('agent-progress', listener); + done(); + }; + + eventBus.addEventListener('agent-progress', listener); + eventBus.emitProgress(event); + }); + }); + + // ========================================================================== + // Multiple Listener Tests + // ========================================================================== + + describe('multiple listeners', () => { + it('notifies all registered listeners', (done) => { + const eventBus = AgentRunnerEventBus.getInstance(); + let listener1Called = false; + let listener2Called = false; + + const event: AgentRunnerProgressEvent = { + type: 'session_started', + sessionId: 'multi-listener-test', + agentName: 'test_agent', + timestamp: new Date(), + data: {}, + }; + + const checkComplete = (): void => { + if (listener1Called && listener2Called) { + done(); + } + }; + + const listener1 = (e: EventTargetEvent): void => { + listener1Called = true; + eventBus.removeEventListener('agent-progress', listener1); + checkComplete(); + }; + + const listener2 = (e: EventTargetEvent): void => { + listener2Called = true; + eventBus.removeEventListener('agent-progress', listener2); + checkComplete(); + }; + + eventBus.addEventListener('agent-progress', listener1); + eventBus.addEventListener('agent-progress', listener2); + eventBus.emitProgress(event); + }); + + it('allows removing specific listeners', (done) => { + const eventBus = AgentRunnerEventBus.getInstance(); + let removedListenerCalled = false; + let activeListenerCalled = false; + + const event: AgentRunnerProgressEvent = { + type: 'session_started', + sessionId: 'remove-listener-test', + agentName: 'test_agent', + timestamp: new Date(), + data: {}, + }; + + const removedListener = (): void => { + removedListenerCalled = true; + }; + + const activeListener = (e: EventTargetEvent): void => { + activeListenerCalled = true; + eventBus.removeEventListener('agent-progress', activeListener); + + // Give some time for removed listener to potentially be called + setTimeout(() => { + assert.isFalse(removedListenerCalled, 'Removed listener should not be called'); + assert.isTrue(activeListenerCalled, 'Active listener should be called'); + done(); + }, 10); + }; + + eventBus.addEventListener('agent-progress', removedListener); + eventBus.addEventListener('agent-progress', activeListener); + + // Remove the first listener before emitting + eventBus.removeEventListener('agent-progress', removedListener); + + eventBus.emitProgress(event); + }); + }); + + // ========================================================================== + // Event Type Validation Tests + // ========================================================================== + + describe('event types', () => { + const eventTypes: AgentRunnerProgressEvent['type'][] = [ + 'session_started', + 'tool_started', + 'tool_completed', + 'session_updated', + 'child_agent_started', + 'session_completed', + ]; + + eventTypes.forEach((eventType) => { + it(`handles ${eventType} event type`, (done) => { + const eventBus = AgentRunnerEventBus.getInstance(); + + const event: AgentRunnerProgressEvent = { + type: eventType, + sessionId: `${eventType}-test`, + agentName: 'test_agent', + timestamp: new Date(), + data: {}, + }; + + const listener = (e: EventTargetEvent): void => { + const receivedEvent = e.data; + assert.strictEqual(receivedEvent.type, eventType); + eventBus.removeEventListener('agent-progress', listener); + done(); + }; + + eventBus.addEventListener('agent-progress', listener); + eventBus.emitProgress(event); + }); + }); + }); +}); diff --git a/front_end/panels/ai_chat/agent_framework/__tests__/ConfigurableAgentTool.test.ts b/front_end/panels/ai_chat/agent_framework/__tests__/ConfigurableAgentTool.test.ts new file mode 100644 index 0000000000..9b702babd4 --- /dev/null +++ b/front_end/panels/ai_chat/agent_framework/__tests__/ConfigurableAgentTool.test.ts @@ -0,0 +1,695 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Tests for ConfigurableAgentTool class. + * Tests constructor validation, configuration handling, model name resolution, + * lifecycle hooks, and result creation. + */ + +import { ConfigurableAgentTool, ToolRegistry, type AgentToolConfig, type CallCtx } from '../ConfigurableAgentTool.js'; +import { MODEL_SENTINELS } from '../../core/Constants.js'; +import { AIChatPanel } from '../../ui/AIChatPanel.js'; +import { LLMClient } from '../../LLM/LLMClient.js'; +import { AgentDescriptorRegistry } from '../../core/AgentDescriptorRegistry.js'; +import type { Tool } from '../../tools/Tools.js'; + +// ============================================================================ +// Test Helper Functions +// ============================================================================ + +function createMockAgentToolConfig(overrides: Partial = {}): AgentToolConfig { + return { + name: overrides.name || 'test_agent', + description: overrides.description || 'Test agent for unit tests', + systemPrompt: overrides.systemPrompt || 'You are a test agent.', + tools: overrides.tools || [], + schema: overrides.schema || { + type: 'object', + properties: { + query: { type: 'string' }, + reasoning: { type: 'string' }, + }, + required: ['query'], + }, + maxIterations: overrides.maxIterations || 10, + temperature: overrides.temperature || 0, + ...overrides, + }; +} + +function createMockCallCtx(overrides: Partial = {}): CallCtx { + return { + apiKey: overrides.apiKey ?? 'test-api-key', + model: overrides.model ?? 'gpt-4.1-2025-04-14', + mainModel: overrides.mainModel ?? 'gpt-4.1-2025-04-14', + miniModel: overrides.miniModel ?? 'gpt-4.1-mini', + nanoModel: overrides.nanoModel, + provider: overrides.provider ?? 'openai', + ...overrides, + }; +} + +function createMockAgentArgs(args: Partial<{ query: string; reasoning: string }> = {}): { query: string; reasoning: string } { + return { + query: args.query || 'Test query', + reasoning: args.reasoning || '', + }; +} + +function createMockTool(name: string, executeFn: (args: T) => Promise): Tool { + return { + name, + description: `Mock tool ${name}`, + schema: { type: 'object', properties: {} }, + execute: executeFn, + }; +} + +function createMockToolWithResult(name: string, result: any): Tool { + return createMockTool(name, async () => result); +} + +type MockLLMResponse = + | { type: 'tool_call'; toolName: string; toolArgs: any } + | { type: 'final_answer'; answer: string } + | { type: 'error'; error: string }; + +function setupMockLLMClient(responses: MockLLMResponse[]): { cleanup: () => void } { + const responseQueue = [...responses]; + let callCount = 0; + const originalGetInstance = (LLMClient as any).getInstance; + + (LLMClient as any).getInstance = () => ({ + call: async () => { + callCount++; + return { rawResponse: { callNumber: callCount } }; + }, + parseResponse: () => { + const response = responseQueue.shift(); + if (!response) { + return { type: 'error', error: 'No more mock responses' }; + } + if (response.type === 'tool_call') { + return { type: 'tool_call', name: response.toolName, args: response.toolArgs }; + } + if (response.type === 'final_answer') { + return { type: 'final_answer', answer: response.answer }; + } + return { type: 'error', error: response.error }; + }, + }); + + return { + cleanup: () => { + (LLMClient as any).getInstance = originalGetInstance; + }, + }; +} + +function assertSuccessResult(result: any): void { + assert.isTrue(result.success, `Expected success but got error: ${result.error}`); +} + +function assertErrorResult(result: any, expectedMessage?: string): void { + assert.isFalse(result.success, 'Expected error result but got success'); + if (expectedMessage) { + assert.include(result.error || '', expectedMessage); + } +} + +function stubAIChatPanel(): void { + (AIChatPanel as any).getProviderForModel = (_model: string) => 'openai'; + (AIChatPanel as any).isVisionCapable = async (_model: string) => false; +} + +function resetToolRegistry(): void { + // Clear the registry for test isolation + (ToolRegistry as any).toolFactories = new Map(); + (ToolRegistry as any).registeredTools = new Map(); +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('ai_chat: ConfigurableAgentTool', () => { + let mockLLMCleanup: (() => void) | null = null; + + beforeEach(() => { + stubAIChatPanel(); + resetToolRegistry(); + }); + + afterEach(() => { + if (mockLLMCleanup) { + mockLLMCleanup(); + mockLLMCleanup = null; + } + resetToolRegistry(); + }); + + // ========================================================================== + // Constructor Tests + // ========================================================================== + + describe('constructor', () => { + it('creates agent with minimal required config', () => { + const config = createMockAgentToolConfig({ + name: 'minimal_agent', + description: 'A minimal test agent', + systemPrompt: 'You are minimal.', + }); + + const agent = new ConfigurableAgentTool(config); + + assert.strictEqual(agent.name, 'minimal_agent'); + assert.strictEqual(agent.description, 'A minimal test agent'); + assert.deepStrictEqual(agent.schema, config.schema); + }); + + it('creates agent with full config including optional fields', () => { + const config = createMockAgentToolConfig({ + name: 'full_agent', + description: 'A fully configured agent', + systemPrompt: 'You are fully configured.', + tools: ['tool_a', 'tool_b'], + maxIterations: 15, + temperature: 0.5, + version: '2025-01-01', + ui: { + displayName: 'Full Agent', + avatar: '🤖', + color: '#ff0000', + }, + }); + + const agent = new ConfigurableAgentTool(config); + + assert.strictEqual(agent.name, 'full_agent'); + assert.strictEqual(agent.config.maxIterations, 15); + assert.strictEqual(agent.config.temperature, 0.5); + assert.strictEqual(agent.config.version, '2025-01-01'); + assert.strictEqual(agent.config.ui?.displayName, 'Full Agent'); + }); + + it('throws error when systemPrompt is missing', () => { + const config = { + name: 'no_prompt_agent', + description: 'Missing system prompt', + systemPrompt: '', // Empty string + tools: [], + schema: { type: 'object', properties: {} }, + }; + + assert.throws(() => { + new ConfigurableAgentTool(config as AgentToolConfig); + }, /systemPrompt is required/); + }); + + it('calls init hook if provided', () => { + let initCalled = false; + let initAgent: ConfigurableAgentTool | null = null; + + const config = createMockAgentToolConfig({ + name: 'init_agent', + init: (agent) => { + initCalled = true; + initAgent = agent; + }, + }); + + const agent = new ConfigurableAgentTool(config); + + assert.isTrue(initCalled); + assert.strictEqual(initAgent, agent); + }); + + it('registers agent descriptor on creation', async () => { + const config = createMockAgentToolConfig({ + name: 'descriptor_agent', + version: '1.0.0', + }); + + new ConfigurableAgentTool(config); + + // Wait for async registration + await new Promise(resolve => setTimeout(resolve, 10)); + + const descriptor = await AgentDescriptorRegistry.getDescriptor('descriptor_agent'); + // Descriptor registration is async, may or may not be available immediately + // This test verifies the pattern is followed + }); + }); + + // ========================================================================== + // Model Name Resolution Tests + // ========================================================================== + + describe('model name resolution', () => { + it('uses string modelName directly', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done with custom model' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'string_model_agent', + modelName: 'custom-model-v1', + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx(); + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assertSuccessResult(result); + }); + + it('resolves modelName from function', async () => { + let functionCalled = false; + + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done with function model' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'function_model_agent', + modelName: () => { + functionCalled = true; + return 'dynamic-model-v2'; + }, + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx(); + + await agent.execute(createMockAgentArgs(), ctx); + + assert.isTrue(functionCalled); + }); + + it('resolves USE_MINI sentinel to miniModel from context', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done with mini model' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'mini_model_agent', + modelName: MODEL_SENTINELS.USE_MINI, + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx({ + miniModel: 'gpt-4.1-mini', + mainModel: 'gpt-4.1', + }); + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assertSuccessResult(result); + }); + + it('resolves USE_NANO sentinel to nanoModel from context', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done with nano model' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'nano_model_agent', + modelName: MODEL_SENTINELS.USE_NANO, + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx({ + nanoModel: 'gpt-4.1-nano', + miniModel: 'gpt-4.1-mini', + mainModel: 'gpt-4.1', + }); + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assertSuccessResult(result); + }); + + it('falls back to mainModel when miniModel not provided for USE_MINI', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done with fallback' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'fallback_agent', + modelName: MODEL_SENTINELS.USE_MINI, + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx({ + mainModel: 'gpt-4.1-main', + // miniModel intentionally not set + }); + delete ctx.miniModel; + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assertSuccessResult(result); + }); + + it('uses context model when no modelName in config', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done with context model' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'context_model_agent', + }); + delete config.modelName; // No model specified in config + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx({ + model: 'context-provided-model', + mainModel: 'main-model', + }); + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assertSuccessResult(result); + }); + }); + + // ========================================================================== + // API Key Handling Tests + // ========================================================================== + + describe('API key handling', () => { + it('returns error when API key missing for OpenAI provider', async () => { + const config = createMockAgentToolConfig({ + name: 'no_key_agent', + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx({ + provider: 'openai', + }); + delete ctx.apiKey; // Remove API key + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assertErrorResult(result, 'API key not configured'); + }); + + it('allows execution without API key for LiteLLM provider', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done without API key' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'litellm_agent', + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx({ + provider: 'litellm', + }); + delete ctx.apiKey; // No API key needed for LiteLLM + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assertSuccessResult(result); + }); + + it('allows execution without API key for BrowserOperator provider', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done without API key' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'browseroperator_agent', + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx({ + provider: 'browseroperator', + }); + delete ctx.apiKey; + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assertSuccessResult(result); + }); + }); + + // ========================================================================== + // Lifecycle Hooks Tests + // ========================================================================== + + describe('lifecycle hooks', () => { + it('calls beforeExecute hook before execution', async () => { + let hookCalled = false; + let hookCtx: CallCtx | null = null; + + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'before_hook_agent', + beforeExecute: async (ctx) => { + hookCalled = true; + hookCtx = ctx; + }, + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx(); + + await agent.execute(createMockAgentArgs(), ctx); + + assert.isTrue(hookCalled); + assert.isOk(hookCtx); + }); + + it('continues execution even if beforeExecute throws', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done despite hook error' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'failing_before_hook_agent', + beforeExecute: async () => { + throw new Error('Hook failed!'); + }, + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx(); + + const result = await agent.execute(createMockAgentArgs(), ctx); + + // Should still succeed despite hook failure + assertSuccessResult(result); + }); + + it('calls afterExecute hook after successful execution', async () => { + let hookCalled = false; + let hookResult: any = null; + + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Success' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'after_hook_agent', + afterExecute: async (result, session, ctx) => { + hookCalled = true; + hookResult = result; + }, + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx(); + + await agent.execute(createMockAgentArgs(), ctx); + + assert.isTrue(hookCalled); + assert.isOk(hookResult); + assert.isTrue(hookResult.success); + }); + }); + + // ========================================================================== + // Result Creation Tests + // ========================================================================== + + describe('result creation', () => { + it('includes intermediate steps when includeIntermediateStepsOnReturn is true', async () => { + const tool = createMockToolWithResult('step_tool', { ok: true }); + ToolRegistry.registerToolFactory('step_tool', () => tool); + + const { cleanup } = setupMockLLMClient([ + { type: 'tool_call', toolName: 'step_tool', toolArgs: {} }, + { type: 'final_answer', answer: 'Done with steps' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'steps_agent', + tools: ['step_tool'], + includeIntermediateStepsOnReturn: true, + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx(); + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assertSuccessResult(result); + assert.isArray(result.intermediateSteps); + assert.isAtLeast(result.intermediateSteps!.length, 1); + }); + + it('excludes intermediate steps when includeIntermediateStepsOnReturn is false', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done without steps' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'no_steps_agent', + includeIntermediateStepsOnReturn: false, // Explicitly false + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx(); + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assertSuccessResult(result); + // Steps should not be included + assert.isUndefined(result.intermediateSteps); + }); + + it('uses custom createSuccessResult when provided', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Custom success' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'custom_success_agent', + createSuccessResult: (output, steps, reason, config) => ({ + success: true, + output: `CUSTOM: ${output}`, + terminationReason: reason, + intermediateSteps: steps, + }), + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx(); + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assert.isTrue(result.success); + assert.include(result.output!, 'CUSTOM:'); + }); + + it('uses custom createErrorResult when provided', async () => { + // Make LLM fail + (LLMClient as any).getInstance = () => ({ + call: async () => { throw new Error('LLM error'); }, + parseResponse: () => ({ type: 'error', error: 'failed' }), + }); + mockLLMCleanup = () => {}; + + const config = createMockAgentToolConfig({ + name: 'custom_error_agent', + createErrorResult: (error, steps, reason, config) => ({ + success: false, + error: `CUSTOM ERROR: ${error}`, + terminationReason: reason, + intermediateSteps: steps, + }), + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx(); + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assert.isFalse(result.success); + assert.include(result.error!, 'CUSTOM ERROR:'); + }); + }); + + // ========================================================================== + // Custom Message Preparation Tests + // ========================================================================== + + describe('custom message preparation', () => { + it('uses custom prepareMessages when provided', async () => { + let prepareMessagesCalled = false; + + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done with custom messages' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'custom_messages_agent', + prepareMessages: (args, config) => { + prepareMessagesCalled = true; + return [ + { + entity: 1, // USER + text: `Custom message for: ${args.query}`, + } as any, + ]; + }, + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx(); + + await agent.execute(createMockAgentArgs({ query: 'Test query' }), ctx); + + assert.isTrue(prepareMessagesCalled); + }); + }); + + // ========================================================================== + // Agent Session Return Tests + // ========================================================================== + + describe('agent session', () => { + it('returns agentSession with result', async () => { + const { cleanup } = setupMockLLMClient([ + { type: 'final_answer', answer: 'Done' }, + ]); + mockLLMCleanup = cleanup; + + const config = createMockAgentToolConfig({ + name: 'session_agent', + }); + + const agent = new ConfigurableAgentTool(config); + const ctx = createMockCallCtx(); + + const result = await agent.execute(createMockAgentArgs(), ctx); + + assert.isOk(result.agentSession); + assert.strictEqual(result.agentSession.agentName, 'session_agent'); + assert.isOk(result.agentSession.sessionId); + }); + }); +}); diff --git a/front_end/panels/ai_chat/agent_framework/__tests__/ToolRegistry.test.ts b/front_end/panels/ai_chat/agent_framework/__tests__/ToolRegistry.test.ts new file mode 100644 index 0000000000..31b204031b --- /dev/null +++ b/front_end/panels/ai_chat/agent_framework/__tests__/ToolRegistry.test.ts @@ -0,0 +1,335 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Tests for ToolRegistry class. + * Tests tool registration, factory management, instance caching, + * and error handling. + */ + +import { ToolRegistry, ConfigurableAgentTool, type AgentToolConfig } from '../ConfigurableAgentTool.js'; +import type { Tool } from '../../tools/Tools.js'; + +// ============================================================================ +// Test Helper Functions +// ============================================================================ + +function createMockTool( + name: string, + executeFn: (args: TInput) => Promise, + options?: { schema?: any; description?: string } +): Tool { + return { + name, + description: options?.description || `Mock tool ${name}`, + schema: options?.schema || { type: 'object', properties: {} }, + execute: executeFn, + }; +} + +function createMockToolWithResult(name: string, result: any): Tool { + return createMockTool(name, async () => result); +} + +function createMockAgentToolConfig(overrides: Partial = {}): AgentToolConfig { + return { + name: overrides.name || 'test_agent', + description: overrides.description || 'Test agent for unit tests', + systemPrompt: overrides.systemPrompt || 'You are a test agent.', + tools: overrides.tools || [], + schema: overrides.schema || { + type: 'object', + properties: { + query: { type: 'string' }, + reasoning: { type: 'string' }, + }, + required: ['query'], + }, + maxIterations: overrides.maxIterations || 10, + temperature: overrides.temperature || 0, + ...overrides, + }; +} + +function resetToolRegistry(): void { + // Clear the registry for test isolation + (ToolRegistry as any).toolFactories = new Map(); + (ToolRegistry as any).registeredTools = new Map(); +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('ai_chat: ToolRegistry', () => { + beforeEach(() => { + resetToolRegistry(); + }); + + afterEach(() => { + resetToolRegistry(); + }); + + // ========================================================================== + // Registration Tests + // ========================================================================== + + describe('registerToolFactory', () => { + it('registers and retrieves a tool factory', () => { + const mockTool = createMockToolWithResult('test_tool', { registered: true }); + + ToolRegistry.registerToolFactory('test_tool', () => mockTool); + + const instance = ToolRegistry.getToolInstance('test_tool'); + assert.isOk(instance); + assert.strictEqual(instance?.name, 'test_tool'); + }); + + it('registers multiple distinct tools', () => { + const tool1 = createMockToolWithResult('tool_1', { id: 1 }); + const tool2 = createMockToolWithResult('tool_2', { id: 2 }); + const tool3 = createMockToolWithResult('tool_3', { id: 3 }); + + ToolRegistry.registerToolFactory('tool_1', () => tool1); + ToolRegistry.registerToolFactory('tool_2', () => tool2); + ToolRegistry.registerToolFactory('tool_3', () => tool3); + + assert.isOk(ToolRegistry.getToolInstance('tool_1')); + assert.isOk(ToolRegistry.getToolInstance('tool_2')); + assert.isOk(ToolRegistry.getToolInstance('tool_3')); + + assert.strictEqual(ToolRegistry.getToolInstance('tool_1')?.name, 'tool_1'); + assert.strictEqual(ToolRegistry.getToolInstance('tool_2')?.name, 'tool_2'); + assert.strictEqual(ToolRegistry.getToolInstance('tool_3')?.name, 'tool_3'); + }); + + it('overwrites existing factory when registering duplicate', () => { + const originalTool = createMockToolWithResult('duplicate_tool', { version: 1 }); + const replacementTool = createMockToolWithResult('duplicate_tool', { version: 2 }); + + ToolRegistry.registerToolFactory('duplicate_tool', () => originalTool); + ToolRegistry.registerToolFactory('duplicate_tool', () => replacementTool); + + // Should return the replacement + const instance = ToolRegistry.getRegisteredTool('duplicate_tool'); + assert.isOk(instance); + }); + + it('creates and caches instance immediately upon registration', () => { + let factoryCallCount = 0; + const factory = () => { + factoryCallCount++; + return createMockToolWithResult('cached_tool', { call: factoryCallCount }); + }; + + // Registration should call factory once + ToolRegistry.registerToolFactory('cached_tool', factory); + assert.strictEqual(factoryCallCount, 1); + + // getRegisteredTool should return cached instance (no new factory call) + ToolRegistry.getRegisteredTool('cached_tool'); + assert.strictEqual(factoryCallCount, 1); + }); + + it('handles factory instantiation error gracefully', () => { + const failingFactory = () => { + throw new Error('Factory exploded!'); + }; + + // Should not throw, but tool should not be available + ToolRegistry.registerToolFactory('failing_tool', failingFactory); + + // Tool should not be registered due to instantiation failure + const instance = ToolRegistry.getRegisteredTool('failing_tool'); + assert.isNull(instance); + }); + }); + + // ========================================================================== + // Instance Retrieval Tests + // ========================================================================== + + describe('getToolInstance', () => { + it('creates new instance on each call', () => { + let instanceCount = 0; + const factory = () => { + instanceCount++; + return createMockToolWithResult('fresh_tool', { instance: instanceCount }); + }; + + ToolRegistry.registerToolFactory('fresh_tool', factory); + instanceCount = 0; // Reset after registration creates first instance + + // Each call should create new instance + const instance1 = ToolRegistry.getToolInstance('fresh_tool'); + const instance2 = ToolRegistry.getToolInstance('fresh_tool'); + const instance3 = ToolRegistry.getToolInstance('fresh_tool'); + + assert.strictEqual(instanceCount, 3); + }); + + it('returns null for unregistered tool', () => { + const instance = ToolRegistry.getToolInstance('nonexistent_tool'); + assert.isNull(instance); + }); + + it('returns functional tool instance', async () => { + const mockTool = createMockTool<{ value: number }, { doubled: number }>( + 'math_tool', + async (args) => ({ doubled: args.value * 2 }) + ); + + ToolRegistry.registerToolFactory('math_tool', () => mockTool); + + const instance = ToolRegistry.getToolInstance('math_tool'); + assert.isOk(instance); + + const result = await instance!.execute({ value: 5 }); + assert.deepStrictEqual(result, { doubled: 10 }); + }); + }); + + // ========================================================================== + // Registered Tool Retrieval Tests + // ========================================================================== + + describe('getRegisteredTool', () => { + it('returns cached instance', () => { + const mockTool = createMockToolWithResult('cached_tool', { cached: true }); + ToolRegistry.registerToolFactory('cached_tool', () => mockTool); + + const instance1 = ToolRegistry.getRegisteredTool('cached_tool'); + const instance2 = ToolRegistry.getRegisteredTool('cached_tool'); + + // Should be same instance + assert.strictEqual(instance1, instance2); + }); + + it('returns null for unregistered tool', () => { + const instance = ToolRegistry.getRegisteredTool('unregistered_tool'); + assert.isNull(instance); + }); + + it('returns null if factory failed during registration', () => { + ToolRegistry.registerToolFactory('bad_tool', () => { + throw new Error('Cannot instantiate'); + }); + + const instance = ToolRegistry.getRegisteredTool('bad_tool'); + assert.isNull(instance); + }); + }); + + // ========================================================================== + // ConfigurableAgentTool Detection Tests + // ========================================================================== + + describe('ConfigurableAgentTool instances', () => { + it('can store and retrieve ConfigurableAgentTool as a tool', () => { + const agentConfig = createMockAgentToolConfig({ + name: 'agent_as_tool', + description: 'An agent registered as a tool', + systemPrompt: 'You are an agent tool.', + }); + + ToolRegistry.registerToolFactory('agent_as_tool', () => new ConfigurableAgentTool(agentConfig)); + + const instance = ToolRegistry.getRegisteredTool('agent_as_tool'); + assert.isOk(instance); + assert.instanceOf(instance, ConfigurableAgentTool); + }); + + it('can distinguish ConfigurableAgentTool from regular tools', () => { + const regularTool = createMockToolWithResult('regular_tool', { type: 'regular' }); + const agentConfig = createMockAgentToolConfig({ + name: 'agent_tool', + systemPrompt: 'Agent prompt', + }); + + ToolRegistry.registerToolFactory('regular_tool', () => regularTool); + ToolRegistry.registerToolFactory('agent_tool', () => new ConfigurableAgentTool(agentConfig)); + + const regular = ToolRegistry.getRegisteredTool('regular_tool'); + const agent = ToolRegistry.getRegisteredTool('agent_tool'); + + assert.notInstanceOf(regular, ConfigurableAgentTool); + assert.instanceOf(agent, ConfigurableAgentTool); + }); + }); + + // ========================================================================== + // Edge Cases + // ========================================================================== + + describe('edge cases', () => { + it('handles tool with special characters in name', () => { + const specialTool = createMockToolWithResult('tool-with-dashes_and_underscores.v2', { special: true }); + + ToolRegistry.registerToolFactory('tool-with-dashes_and_underscores.v2', () => specialTool); + + const instance = ToolRegistry.getRegisteredTool('tool-with-dashes_and_underscores.v2'); + assert.isOk(instance); + assert.strictEqual(instance?.name, 'tool-with-dashes_and_underscores.v2'); + }); + + it('handles empty tool name', () => { + const emptyNameTool = createMockToolWithResult('', { empty: true }); + + ToolRegistry.registerToolFactory('', () => emptyNameTool); + + const instance = ToolRegistry.getToolInstance(''); + assert.isOk(instance); + }); + + it('handles tool with complex schema', () => { + const complexTool = createMockTool('complex_tool', async () => ({}), { + schema: { + type: 'object', + properties: { + nested: { + type: 'object', + properties: { + value: { type: 'string' }, + }, + }, + array: { + type: 'array', + items: { type: 'number' }, + }, + }, + required: ['nested'], + }, + }); + + ToolRegistry.registerToolFactory('complex_tool', () => complexTool); + + const instance = ToolRegistry.getToolInstance('complex_tool'); + assert.isOk(instance); + assert.isOk(instance?.schema.properties?.nested); + assert.isOk(instance?.schema.properties?.array); + }); + }); + + // ========================================================================== + // Isolation Tests + // ========================================================================== + + describe('test isolation', () => { + it('registry is clean after reset', () => { + // Register a tool + const tool = createMockToolWithResult('isolation_tool', {}); + ToolRegistry.registerToolFactory('isolation_tool', () => tool); + + // Verify it's registered + assert.isOk(ToolRegistry.getRegisteredTool('isolation_tool')); + + // Reset + resetToolRegistry(); + + // Should no longer exist + assert.isNull(ToolRegistry.getRegisteredTool('isolation_tool')); + assert.isNull(ToolRegistry.getToolInstance('isolation_tool')); + }); + }); +}); diff --git a/front_end/panels/ai_chat/agent_framework/implementation/ConfiguredAgents.ts b/front_end/panels/ai_chat/agent_framework/implementation/ConfiguredAgents.ts index 5d252e533f..76b35b5db9 100644 --- a/front_end/panels/ai_chat/agent_framework/implementation/ConfiguredAgents.ts +++ b/front_end/panels/ai_chat/agent_framework/implementation/ConfiguredAgents.ts @@ -15,6 +15,9 @@ import { HTMLToMarkdownTool } from '../../tools/HTMLToMarkdownTool.js'; import { ReadabilityExtractorTool } from '../../tools/ReadabilityExtractorTool.js'; import { ConfigurableAgentTool, ToolRegistry } from '../ConfigurableAgentTool.js'; import { ThinkingTool } from '../../tools/ThinkingTool.js'; +import { SaveResearchReportTool } from '../../tools/SaveResearchReportTool.js'; +import { SearchCustomAgentsTool } from '../../tools/SearchCustomAgentsTool.js'; +import { CallCustomAgentTool } from '../../tools/CallCustomAgentTool.js'; import { registerMCPMetaTools } from '../../mcp/MCPMetaTools.js'; import { createDirectURLNavigatorAgentConfig } from './agents/DirectURLNavigatorAgent.js'; import { createResearchAgentConfig } from './agents/ResearchAgent.js'; @@ -29,13 +32,18 @@ import { createScrollActionAgentConfig } from './agents/ScrollActionAgent.js'; import { createWebTaskAgentConfig } from './agents/WebTaskAgent.js'; import { createEcommerceProductInfoAgentConfig } from './agents/EcommerceProductInfoAgent.js'; import { createSearchAgentConfig } from './agents/SearchAgent.js'; +import { AgentStudioIntegration } from '../../core/AgentStudioIntegration.js'; +import { initializeMiniApps } from '../../mini_apps/MiniAppInitialization.js'; /** * Initialize all configured agents */ -export function initializeConfiguredAgents(): void { +export async function initializeConfiguredAgents(): Promise { // Ensure MCP meta-tools are available regardless of mode; selection logic decides if they are surfaced registerMCPMetaTools(); + + // Initialize mini app system (registers mini apps and mini app tools) + initializeMiniApps(); // Register core tools ToolRegistry.registerToolFactory('navigate_url', () => new NavigateURLTool()); ToolRegistry.registerToolFactory('navigate_back', () => new NavigateBackTool()); @@ -69,7 +77,14 @@ export function initializeConfiguredAgents(): void { // Register bookmark and document search tools ToolRegistry.registerToolFactory('bookmark_store', () => new BookmarkStoreTool()); ToolRegistry.registerToolFactory('document_search', () => new DocumentSearchTool()); - + + // Register research report tool + ToolRegistry.registerToolFactory('save_research_report', () => new SaveResearchReportTool()); + + // Register custom agent tools (for calling agents created in Agent Studio) + ToolRegistry.registerToolFactory('search_custom_agents', () => new SearchCustomAgentsTool()); + ToolRegistry.registerToolFactory('call_custom_agent', () => new CallCustomAgentTool()); + // Create and register Direct URL Navigator Agent const directURLNavigatorAgentConfig = createDirectURLNavigatorAgentConfig(); const directURLNavigatorAgent = new ConfigurableAgentTool(directURLNavigatorAgentConfig); @@ -131,4 +146,6 @@ export function initializeConfiguredAgents(): void { const ecommerceProductInfoAgent = new ConfigurableAgentTool(ecommerceProductInfoAgentConfig); ToolRegistry.registerToolFactory('ecommerce_product_info_fetcher_tool', () => ecommerceProductInfoAgent); + // Initialize custom agents from Agent Studio + await AgentStudioIntegration.initialize(); } diff --git a/front_end/panels/ai_chat/core/AgentStorageManager.ts b/front_end/panels/ai_chat/core/AgentStorageManager.ts new file mode 100644 index 0000000000..902f30b28e --- /dev/null +++ b/front_end/panels/ai_chat/core/AgentStorageManager.ts @@ -0,0 +1,421 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from './Logger.js'; + +const logger = createLogger('AgentStorageManager'); + +const DATABASE_NAME = 'agent_studio_db'; +const DATABASE_VERSION = 1; +const OBJECT_STORE_NAME = 'customAgents'; +const INDEX_NAME = 'name'; + +/** + * Schema property definition for agent input + */ +export interface SchemaProperty { + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + description?: string; + items?: { type: string }; + properties?: Record; +} + +/** + * UI configuration for displaying an agent + */ +export interface AgentUIConfig { + displayName: string; + avatar: string; + color: string; + backgroundColor: string; +} + +/** + * Stored agent configuration in IndexedDB + */ +export interface StoredAgentConfig { + id: string; + name: string; + description: string; + version: string; + systemPrompt: string; + tools: string[]; + maxIterations: number; + temperature: number; + modelName?: string; + schema: { + type: string; + properties: Record; + required?: string[]; + }; + ui: AgentUIConfig; + isBuiltIn: false; + createdAt: string; + updatedAt: string; +} + +/** + * Input for creating a new agent (without auto-generated fields) + */ +export type CreateAgentInput = Omit; + +/** + * Input for updating an existing agent + */ +export type UpdateAgentInput = Partial>; + +interface ValidationResult { + valid: boolean; + error?: string; +} + +/** + * Manages IndexedDB-backed storage for custom agent configurations. + * Follows singleton pattern for consistent state across the application. + */ +export class AgentStorageManager { + private static instance: AgentStorageManager | null = null; + + private db: IDBDatabase | null = null; + private dbInitializationPromise: Promise | null = null; + + private constructor() { + logger.info('Initialized AgentStorageManager'); + } + + static getInstance(): AgentStorageManager { + if (!AgentStorageManager.instance) { + AgentStorageManager.instance = new AgentStorageManager(); + } + return AgentStorageManager.instance; + } + + /** + * Create a new custom agent + */ + async createAgent(input: CreateAgentInput): Promise { + const validation = this.validateAgentConfig(input); + if (!validation.valid) { + throw new Error(validation.error || 'Invalid agent configuration'); + } + + const db = await this.ensureDatabase(); + + // Check for name conflicts + if (await this.agentNameExists(input.name)) { + throw new Error(`Agent with name "${input.name}" already exists.`); + } + + const now = new Date().toISOString(); + const agent: StoredAgentConfig = { + ...input, + id: this.generateUUID(), + isBuiltIn: false, + createdAt: now, + updatedAt: now, + }; + + const transaction = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + + await this.requestToPromise(store.add(agent)); + await this.transactionComplete(transaction); + + logger.info('Created custom agent', { name: agent.name, id: agent.id }); + return agent; + } + + /** + * Get an agent by ID + */ + async getAgent(id: string): Promise { + const db = await this.ensureDatabase(); + const transaction = db.transaction(OBJECT_STORE_NAME, 'readonly'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + + const agent = await this.requestToPromise(store.get(id)); + await this.transactionComplete(transaction); + + return agent || null; + } + + /** + * Get an agent by name + */ + async getAgentByName(name: string): Promise { + const db = await this.ensureDatabase(); + const transaction = db.transaction(OBJECT_STORE_NAME, 'readonly'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + const index = store.index(INDEX_NAME); + + const agent = await this.requestToPromise(index.get(name)); + await this.transactionComplete(transaction); + + return agent || null; + } + + /** + * Get all custom agents + */ + async getAllAgents(): Promise { + const db = await this.ensureDatabase(); + const transaction = db.transaction(OBJECT_STORE_NAME, 'readonly'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + + const agents = await this.requestToPromise(store.getAll()); + await this.transactionComplete(transaction); + + // Sort by creation date (newest first) + return (agents || []).sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + } + + /** + * Update an existing agent + */ + async updateAgent(id: string, updates: UpdateAgentInput): Promise { + const db = await this.ensureDatabase(); + const existing = await this.getAgent(id); + + if (!existing) { + throw new Error(`Agent with ID "${id}" not found.`); + } + + // If name is being changed, check for conflicts + if (updates.name && updates.name !== existing.name) { + if (await this.agentNameExists(updates.name)) { + throw new Error(`Agent with name "${updates.name}" already exists.`); + } + } + + const updated: StoredAgentConfig = { + ...existing, + ...updates, + id: existing.id, // Ensure ID cannot be changed + isBuiltIn: false, // Ensure isBuiltIn cannot be changed + createdAt: existing.createdAt, // Ensure createdAt cannot be changed + updatedAt: new Date().toISOString(), + }; + + const validation = this.validateAgentConfig(updated); + if (!validation.valid) { + throw new Error(validation.error || 'Invalid agent configuration'); + } + + const transaction = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + + await this.requestToPromise(store.put(updated)); + await this.transactionComplete(transaction); + + logger.info('Updated custom agent', { name: updated.name, id: updated.id }); + return updated; + } + + /** + * Delete an agent by ID + */ + async deleteAgent(id: string): Promise { + const db = await this.ensureDatabase(); + const existing = await this.getAgent(id); + + if (!existing) { + throw new Error(`Agent with ID "${id}" not found.`); + } + + const transaction = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + + await this.requestToPromise(store.delete(id)); + await this.transactionComplete(transaction); + + logger.info('Deleted custom agent', { name: existing.name, id }); + } + + /** + * Check if an agent name already exists + */ + async agentNameExists(name: string): Promise { + const agent = await this.getAgentByName(name); + return agent !== null; + } + + /** + * Export all agents as JSON array + */ + async exportAgents(): Promise { + return this.getAllAgents(); + } + + /** + * Import agents from JSON array + * Skips agents with conflicting names + */ + async importAgents(configs: StoredAgentConfig[]): Promise<{ imported: number; skipped: string[] }> { + const skipped: string[] = []; + let imported = 0; + + for (const config of configs) { + try { + // Create a new agent with the imported config (generates new ID) + const input: CreateAgentInput = { + name: config.name, + description: config.description, + version: config.version, + systemPrompt: config.systemPrompt, + tools: config.tools, + maxIterations: config.maxIterations, + temperature: config.temperature, + modelName: config.modelName, + schema: config.schema, + ui: config.ui, + }; + + await this.createAgent(input); + imported++; + } catch (error) { + logger.warn(`Skipped importing agent "${config.name}":`, error); + skipped.push(config.name); + } + } + + logger.info('Import complete', { imported, skipped: skipped.length }); + return { imported, skipped }; + } + + /** + * Validate agent configuration + */ + private validateAgentConfig(config: Partial): ValidationResult { + // Name validation + if (!config.name || !config.name.trim()) { + return { valid: false, error: 'Agent name cannot be empty.' }; + } + + // Name format: lowercase, hyphens, underscores only + if (!/^[a-z][a-z0-9_-]*$/.test(config.name)) { + return { + valid: false, + error: 'Agent name must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores.' + }; + } + + if (config.name.length > 64) { + return { valid: false, error: 'Agent name must be 64 characters or fewer.' }; + } + + // System prompt validation + if (!config.systemPrompt || !config.systemPrompt.trim()) { + return { valid: false, error: 'System prompt cannot be empty.' }; + } + + // Tools validation + if (!config.tools || config.tools.length === 0) { + return { valid: false, error: 'At least one tool must be selected.' }; + } + + // Max iterations validation + if (config.maxIterations !== undefined) { + if (!Number.isInteger(config.maxIterations) || config.maxIterations < 1 || config.maxIterations > 100) { + return { valid: false, error: 'Max iterations must be an integer between 1 and 100.' }; + } + } + + // Temperature validation + if (config.temperature !== undefined) { + if (typeof config.temperature !== 'number' || config.temperature < 0 || config.temperature > 2) { + return { valid: false, error: 'Temperature must be a number between 0 and 2.' }; + } + } + + // Schema validation + if (!config.schema || config.schema.type !== 'object') { + return { valid: false, error: 'Schema must have type "object".' }; + } + + return { valid: true }; + } + + private async ensureDatabase(): Promise { + if (this.db) { + return this.db; + } + + if (!('indexedDB' in globalThis)) { + throw new Error('IndexedDB is not supported in this environment.'); + } + + if (this.dbInitializationPromise) { + this.db = await this.dbInitializationPromise; + return this.db; + } + + this.dbInitializationPromise = this.openDatabase(); + + try { + this.db = await this.dbInitializationPromise; + return this.db; + } catch (error) { + this.dbInitializationPromise = null; + logger.error('Failed to open IndexedDB database', { error }); + throw error; + } + } + + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + logger.info('Initializing agent storage database'); + + if (!db.objectStoreNames.contains(OBJECT_STORE_NAME)) { + const store = db.createObjectStore(OBJECT_STORE_NAME, { keyPath: 'id' }); + store.createIndex(INDEX_NAME, 'name', { unique: true }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error('Failed to open IndexedDB')); + }; + + request.onblocked = () => { + logger.warn('Agent storage database open request was blocked.'); + }; + }); + } + + private requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('IndexedDB request failed')); + }); + } + + private transactionComplete(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error || new Error('IndexedDB transaction failed')); + transaction.onabort = () => reject(transaction.error || new Error('IndexedDB transaction aborted')); + }); + } + + private generateUUID(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + const template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'; + return template.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } +} diff --git a/front_end/panels/ai_chat/core/AgentStudioIntegration.ts b/front_end/panels/ai_chat/core/AgentStudioIntegration.ts new file mode 100644 index 0000000000..38092863d7 --- /dev/null +++ b/front_end/panels/ai_chat/core/AgentStudioIntegration.ts @@ -0,0 +1,296 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from './Logger.js'; +import { AgentStorageManager, type StoredAgentConfig } from './AgentStorageManager.js'; +import { ConfigurableAgentTool, ToolRegistry, type AgentToolConfig } from '../agent_framework/ConfigurableAgentTool.js'; + +const logger = createLogger('AgentStudioIntegration'); + +/** + * Display information for an agent (used in Agent Studio UI) + */ +export interface AgentDisplayInfo { + name: string; + displayName: string; + description: string; + avatar: string; + color: string; + backgroundColor: string; + isBuiltIn: boolean; + id?: string; // Only for custom agents + tools: string[]; + maxIterations: number; + temperature: number; + systemPrompt: string; + version: string; + schema: { + type: string; + properties: Record; + required?: string[]; + }; +} + +/** + * List of built-in agent names (registered in ConfiguredAgents.ts) + */ +const BUILT_IN_AGENTS = [ + 'direct_url_navigator_agent', + 'research_agent', + 'search_agent', + 'content_writer_agent', + 'action_agent', + 'action_verification_agent', + 'click_action_agent', + 'form_fill_action_agent', + 'keyboard_input_action_agent', + 'hover_action_agent', + 'scroll_action_agent', + 'web_task_agent', + 'ecommerce_product_info_fetcher_tool', +]; + +/** + * Tracks registered custom agents for cleanup + */ +const registeredCustomAgents = new Set(); + +/** + * Integrates custom agents from Agent Studio with the existing agent system. + * Handles registration, refresh, and provides unified access to all agents. + */ +export class AgentStudioIntegration { + private static initialized = false; + + /** + * Initialize custom agents into ToolRegistry on startup. + * Should be called from initializeConfiguredAgents(). + */ + static async initialize(): Promise { + if (this.initialized) { + logger.info('AgentStudioIntegration already initialized'); + return; + } + + try { + const storage = AgentStorageManager.getInstance(); + const customAgents = await storage.getAllAgents(); + + for (const agentConfig of customAgents) { + this.registerCustomAgent(agentConfig); + } + + this.initialized = true; + logger.info(`Initialized ${customAgents.length} custom agents from Agent Studio`); + } catch (error) { + logger.error('Failed to initialize custom agents:', error); + // Don't block startup if custom agents fail to load + this.initialized = true; + } + } + + /** + * Refresh custom agents after changes in Agent Studio. + * Unregisters removed agents and registers new/updated ones. + */ + static async refreshAgents(): Promise { + try { + const storage = AgentStorageManager.getInstance(); + const customAgents = await storage.getAllAgents(); + const currentNames = new Set(customAgents.map(a => a.name)); + + // Unregister agents that no longer exist + for (const name of registeredCustomAgents) { + if (!currentNames.has(name)) { + this.unregisterCustomAgent(name); + } + } + + // Register or update all current custom agents + for (const agentConfig of customAgents) { + this.registerCustomAgent(agentConfig); + } + + logger.info(`Refreshed custom agents: ${customAgents.length} active`); + } catch (error) { + logger.error('Failed to refresh custom agents:', error); + } + } + + /** + * Get all agents (built-in + custom) for display in Agent Studio UI. + */ + static async getAllAgentsForDisplay(): Promise { + const agents: AgentDisplayInfo[] = []; + + // Add built-in agents + for (const name of BUILT_IN_AGENTS) { + const tool = ToolRegistry.getRegisteredTool(name); + if (tool && tool instanceof ConfigurableAgentTool) { + const config = tool.config; + agents.push({ + name: config.name, + displayName: config.ui?.displayName || this.formatAgentName(config.name), + description: config.description, + avatar: config.ui?.avatar || '🤖', + color: config.ui?.color || '#00a4fe', + backgroundColor: config.ui?.backgroundColor || '#e2f3fb', + isBuiltIn: true, + tools: config.tools, + maxIterations: config.maxIterations || 10, + temperature: config.temperature || 0, + systemPrompt: config.systemPrompt, + version: config.version || '1.0.0', + schema: config.schema, + }); + } + } + + // Add custom agents + try { + const storage = AgentStorageManager.getInstance(); + const customAgents = await storage.getAllAgents(); + + for (const stored of customAgents) { + agents.push({ + name: stored.name, + displayName: stored.ui.displayName, + description: stored.description, + avatar: stored.ui.avatar, + color: stored.ui.color, + backgroundColor: stored.ui.backgroundColor, + isBuiltIn: false, + id: stored.id, + tools: stored.tools, + maxIterations: stored.maxIterations, + temperature: stored.temperature, + systemPrompt: stored.systemPrompt, + version: stored.version, + schema: stored.schema, + }); + } + } catch (error) { + logger.error('Failed to load custom agents for display:', error); + } + + return agents; + } + + /** + * Get list of all available tool names for tool selection UI. + */ + static getAvailableToolNames(): string[] { + const toolNames: string[] = []; + + // Core tools (non-agent tools) + const coreTools = [ + 'navigate_url', + 'navigate_back', + 'node_ids_to_urls', + 'fetcher_tool', + 'extract_data', + 'extract_schema_streamlined', + 'finalize_with_critique', + 'perform_action', + 'get_page_content', + 'search_content', + 'take_screenshot', + 'html_to_markdown', + 'readability_extractor', + 'scroll_page', + 'wait_for_page_load', + 'thinking', + 'create_file', + 'update_file', + 'delete_file', + 'read_file', + 'list_files', + 'update_todo', + 'execute_code', + 'render_webapp', + 'get_webapp_data', + 'remove_webapp', + 'bookmark_store', + 'document_search', + 'save_research_report', + ]; + + for (const name of coreTools) { + if (ToolRegistry.getRegisteredTool(name)) { + toolNames.push(name); + } + } + + return toolNames.sort(); + } + + /** + * Check if an agent name conflicts with built-in agents. + */ + static isBuiltInAgentName(name: string): boolean { + return BUILT_IN_AGENTS.includes(name); + } + + /** + * Convert StoredAgentConfig to AgentToolConfig for runtime use. + */ + static toAgentToolConfig(stored: StoredAgentConfig): AgentToolConfig { + return { + name: stored.name, + description: stored.description, + version: stored.version, + systemPrompt: stored.systemPrompt, + tools: stored.tools, + maxIterations: stored.maxIterations, + temperature: stored.temperature, + modelName: stored.modelName, + schema: stored.schema, + ui: stored.ui, + handoffs: [], // Custom agents don't support handoff editing + }; + } + + /** + * Register a custom agent with ToolRegistry. + */ + private static registerCustomAgent(stored: StoredAgentConfig): void { + try { + // Check for conflicts with built-in agents + if (this.isBuiltInAgentName(stored.name)) { + logger.warn(`Cannot register custom agent "${stored.name}" - conflicts with built-in agent`); + return; + } + + const config = this.toAgentToolConfig(stored); + const agent = new ConfigurableAgentTool(config); + + ToolRegistry.registerToolFactory(stored.name, () => agent); + registeredCustomAgents.add(stored.name); + + logger.info(`Registered custom agent: ${stored.name}`); + } catch (error) { + logger.error(`Failed to register custom agent "${stored.name}":`, error); + } + } + + /** + * Unregister a custom agent from ToolRegistry. + * Note: ToolRegistry doesn't support unregistration, so we just track it. + */ + private static unregisterCustomAgent(name: string): void { + registeredCustomAgents.delete(name); + logger.info(`Marked custom agent as unregistered: ${name}`); + // Note: Actual removal from ToolRegistry would require adding an unregister method + } + + /** + * Format agent name for display (snake_case to Title Case). + */ + private static formatAgentName(name: string): string { + return name + .split(/[_-]/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } +} diff --git a/front_end/panels/ai_chat/core/AgentTestRunner.ts b/front_end/panels/ai_chat/core/AgentTestRunner.ts new file mode 100644 index 0000000000..f3310d725c --- /dev/null +++ b/front_end/panels/ai_chat/core/AgentTestRunner.ts @@ -0,0 +1,458 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from './Logger.js'; +import { ConfigurableAgentTool, type AgentToolConfig, type ConfigurableAgentResult, type CallCtx } from '../agent_framework/ConfigurableAgentTool.js'; +import type { ChatMessage } from '../models/ChatTypes.js'; + +const logger = createLogger('AgentTestRunner'); + +/** + * Input for running an agent test + */ +export interface AgentTestInput { + agentConfig: AgentToolConfig; + userInput: string; + context?: Partial; +} + +/** + * Message in test execution history + */ +export interface TestMessage { + role: 'user' | 'assistant' | 'tool'; + content: string; + toolName?: string; + timestamp: number; +} + +/** + * Result from an agent test run + */ +export interface AgentTestResult { + success: boolean; + output?: string; + error?: string; + messages: TestMessage[]; + iterations: number; + duration: number; + terminationReason?: string; +} + +/** + * Test execution state + */ +interface TestExecutionState { + abortController: AbortController; + startTime: number; + messages: TestMessage[]; + iterations: number; +} + +const DEFAULT_TEST_TIMEOUT = 60000; // 60 seconds +const MAX_TEST_ITERATIONS = 20; + +/** + * AgentTestRunner - Executes agent tests for Agent Studio + * + * Provides manual test execution capabilities with: + * - Configurable timeout + * - Execution history capture + * - Cancellation support + */ +export class AgentTestRunner { + private currentTest: TestExecutionState | null = null; + + /** + * Run a test execution for an agent + */ + async runTest(input: AgentTestInput, timeout = DEFAULT_TEST_TIMEOUT): Promise { + logger.info('Starting agent test', { agentName: input.agentConfig.name }); + + // Initialize test state + const state: TestExecutionState = { + abortController: new AbortController(), + startTime: Date.now(), + messages: [], + iterations: 0, + }; + + this.currentTest = state; + + // Set up timeout + const timeoutId = setTimeout(() => { + state.abortController.abort(); + }, timeout); + + try { + // Create agent instance with limited iterations for testing + const testConfig: AgentToolConfig = { + ...input.agentConfig, + maxIterations: Math.min(input.agentConfig.maxIterations || 10, MAX_TEST_ITERATIONS), + // Include intermediate steps so we can see what happened + includeIntermediateStepsOnReturn: true, + }; + + const agent = new ConfigurableAgentTool(testConfig); + + // Add initial user message + state.messages.push({ + role: 'user', + content: input.userInput, + timestamp: Date.now(), + }); + + // Build execution context + const callCtx: CallCtx = { + ...input.context, + abortSignal: state.abortController.signal, + }; + + // Execute the agent + const result = await agent.execute( + { + query: input.userInput, + reasoning: 'Test execution from Agent Studio', + }, + callCtx + ); + + // Process result + const testResult = this.processResult(result, state); + + logger.info('Agent test completed', { + agentName: input.agentConfig.name, + success: testResult.success, + duration: testResult.duration, + }); + + return testResult; + } catch (error) { + const duration = Date.now() - state.startTime; + + if (state.abortController.signal.aborted) { + return { + success: false, + error: 'Test execution timed out', + messages: state.messages, + iterations: state.iterations, + duration, + terminationReason: 'timeout', + }; + } + + logger.error('Agent test failed:', error); + + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + messages: state.messages, + iterations: state.iterations, + duration, + terminationReason: 'error', + }; + } finally { + clearTimeout(timeoutId); + this.currentTest = null; + } + } + + /** + * Cancel the currently running test + */ + cancelTest(): boolean { + if (this.currentTest) { + this.currentTest.abortController.abort(); + logger.info('Test execution cancelled'); + return true; + } + return false; + } + + /** + * Check if a test is currently running + */ + isRunning(): boolean { + return this.currentTest !== null; + } + + /** + * Process agent result into test result + */ + private processResult(result: ConfigurableAgentResult & { agentSession?: any }, state: TestExecutionState): AgentTestResult { + const duration = Date.now() - state.startTime; + + // Extract messages from intermediate steps + if (result.intermediateSteps) { + for (const step of result.intermediateSteps) { + state.messages.push(this.convertChatMessage(step)); + state.iterations++; + } + } + + // Add final output + if (result.success && result.output) { + state.messages.push({ + role: 'assistant', + content: result.output, + timestamp: Date.now(), + }); + } + + return { + success: result.success, + output: result.output, + error: result.error, + messages: state.messages, + iterations: Math.ceil(state.iterations / 2), // Roughly estimate iterations + duration, + terminationReason: result.terminationReason, + }; + } + + /** + * Convert ChatMessage to TestMessage + */ + private convertChatMessage(msg: ChatMessage): TestMessage { + let content = ''; + let toolName: string | undefined; + + // Extract content based on message type + if (msg.entity === 'user') { + content = (msg as { text: string }).text || ''; + } else if (msg.entity === 'model') { + const modelMsg = msg as { answer?: string; toolName?: string }; + content = modelMsg.answer || ''; + toolName = modelMsg.toolName; + } else if (msg.entity === 'tool_result') { + const toolMsg = msg as { resultText: string; toolName: string }; + content = toolMsg.resultText || ''; + toolName = toolMsg.toolName; + } + + return { + role: this.mapEntity(msg.entity), + content, + toolName, + timestamp: Date.now(), + }; + } + + /** + * Map ChatMessageEntity to test message role + */ + private mapEntity(entity: string): 'user' | 'assistant' | 'tool' { + // ChatMessageEntity enum values: + // USER = 'user', MODEL = 'model', TOOL_RESULT = 'tool_result' + switch (entity) { + case 'user': + return 'user'; + case 'model': + return 'assistant'; + case 'tool_result': + return 'tool'; + default: + return 'assistant'; + } + } + + /** + * Format test result as HTML for display + */ + static formatResultAsHTML(result: AgentTestResult): string { + const statusClass = result.success ? 'success' : 'error'; + const statusIcon = result.success ? '✓' : '✗'; + + let html = ` +
+
+ ${statusIcon} + ${result.success ? 'Success' : 'Failed'} + + ${result.iterations} iterations • ${(result.duration / 1000).toFixed(1)}s + +
+ `; + + if (result.error) { + html += ` +
+ Error: ${escapeHTML(result.error)} +
+ `; + } + + if (result.output) { + html += ` +
+ Output: +
${escapeHTML(result.output)}
+
+ `; + } + + if (result.messages.length > 0) { + html += ` +
+ Execution History: +
+ `; + + for (const msg of result.messages) { + const roleClass = msg.role === 'tool' ? 'tool-message' : `${msg.role}-message`; + const roleLabel = msg.role === 'tool' ? `Tool: ${msg.toolName || 'unknown'}` : msg.role; + + html += ` +
+ ${roleLabel} + ${escapeHTML(msg.content.substring(0, 500))}${msg.content.length > 500 ? '...' : ''} +
+ `; + } + + html += ` +
+
+ `; + } + + html += '
'; + + return html; + } + + /** + * Get CSS for test result display + */ + static getResultCSS(): string { + return ` + .test-result { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + } + + .test-status { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid #e0e0e0; + } + + .status-icon { + font-size: 16px; + } + + .test-result.success .status-icon { + color: #4caf50; + } + + .test-result.error .status-icon { + color: #f44336; + } + + .status-text { + font-weight: 600; + } + + .test-result.success .status-text { + color: #4caf50; + } + + .test-result.error .status-text { + color: #f44336; + } + + .test-meta { + color: #666; + font-size: 12px; + margin-left: auto; + } + + .test-error { + background: #ffebee; + color: #c62828; + padding: 8px 12px; + border-radius: 4px; + margin-bottom: 12px; + } + + .test-output { + margin-bottom: 12px; + } + + .test-output pre { + background: #f5f5f5; + padding: 12px; + border-radius: 4px; + overflow-x: auto; + margin-top: 4px; + white-space: pre-wrap; + word-wrap: break-word; + } + + .test-messages { + margin-top: 12px; + } + + .messages-list { + max-height: 200px; + overflow-y: auto; + margin-top: 8px; + border: 1px solid #e0e0e0; + border-radius: 4px; + } + + .message { + padding: 8px 12px; + border-bottom: 1px solid #f0f0f0; + display: flex; + gap: 8px; + } + + .message:last-child { + border-bottom: none; + } + + .message-role { + font-weight: 500; + min-width: 80px; + color: #666; + text-transform: capitalize; + } + + .user-message .message-role { + color: #1976d2; + } + + .assistant-message .message-role { + color: #7b1fa2; + } + + .tool-message .message-role { + color: #388e3c; + } + + .message-content { + flex: 1; + color: #333; + overflow: hidden; + text-overflow: ellipsis; + } + `; + } +} + +/** + * Helper to escape HTML + */ +function escapeHTML(str: string): string { + return (str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/front_end/panels/ai_chat/core/BaseOrchestratorAgent.ts b/front_end/panels/ai_chat/core/BaseOrchestratorAgent.ts index 008e7aeeef..7f277cf397 100644 --- a/front_end/panels/ai_chat/core/BaseOrchestratorAgent.ts +++ b/front_end/panels/ai_chat/core/BaseOrchestratorAgent.ts @@ -35,10 +35,15 @@ import { ListFilesTool, type Tool } from '../tools/Tools.js'; +import { SaveResearchReportTool } from '../tools/SaveResearchReportTool.js'; +import { SearchCustomAgentsTool } from '../tools/SearchCustomAgentsTool.js'; +import { CallCustomAgentTool } from '../tools/CallCustomAgentTool.js'; // Imports from their own files -// Initialize configured agents -initializeConfiguredAgents(); +// Initialize configured agents (including custom agents from Agent Studio) +// Note: This is async but we don't await here to avoid blocking module load. +// Custom agents will be loaded asynchronously and available after initial load. +void initializeConfiguredAgents(); const logger = createLogger('BaseOrchestratorAgent'); const DEFAULT_ORCHESTRATOR_VERSION = '2025-09-17'; @@ -187,19 +192,14 @@ Present findings in a comprehensive, detailed markdown report with these expande - Maintain objectivity and distinguish facts from speculation - For multiple independent tasks, deploy research agents in parallel for efficiency -## CRITICAL: Final Output Format +## CRITICAL: Final Output -When calling 'finalize_with_critique', structure your response exactly as: +When your research is complete, use the 'save_research_report' tool to save and display your findings: +- **reasoning**: 2-3 sentences explaining your research approach, key insights, and how you organized the findings +- **report**: Your comprehensive markdown report (aim for 5000+ words for complex topics) +- **filename**: A descriptive filename like "topic_research_report.md" - -[2-3 sentences explaining your research approach, key insights, and organization method] - - - -[Your comprehensive markdown report - will be displayed in enhanced document viewer] - - -The markdown report will be extracted and shown via an enhanced document viewer button while only the reasoning appears in chat.`, +The report will be automatically saved to session files and displayed to the user in an enhanced document viewer. The reasoning text will appear in the chat.`, [BaseOrchestratorAgentType.SHOPPING]: `You are a **Shopping Browser Agent**. Your mission is to help users find and compare products tailored to their specific needs and budget, providing up-to-date, unbiased, and well-cited recommendations. @@ -323,6 +323,8 @@ export const AGENT_CONFIGS: {[key: string]: AgentConfig} = { new DeleteFileTool(), new ReadFileTool(), new ListFilesTool(), + new SearchCustomAgentsTool(), + new CallCustomAgentTool(), ] }, [BaseOrchestratorAgentType.DEEP_RESEARCH]: { @@ -338,7 +340,7 @@ export const AGENT_CONFIGS: {[key: string]: AgentConfig} = { ToolRegistry.getToolInstance('document_search') || (() => { throw new Error('document_search tool not found'); })(), ToolRegistry.getToolInstance('bookmark_store') || (() => { throw new Error('bookmark_store tool not found'); })(), ToolRegistry.getToolInstance('search_agent') || (() => { throw new Error('search_agent tool not found'); })(), - new FinalizeWithCritiqueTool(), + new SaveResearchReportTool(), new RenderWebAppTool(), new GetWebAppDataTool(), new RemoveWebAppTool(), @@ -347,6 +349,8 @@ export const AGENT_CONFIGS: {[key: string]: AgentConfig} = { new DeleteFileTool(), new ReadFileTool(), new ListFilesTool(), + new SearchCustomAgentsTool(), + new CallCustomAgentTool(), ] }, // [BaseOrchestratorAgentType.SHOPPING]: { @@ -540,6 +544,8 @@ export function getAgentTools(agentType: string): Array> { new DeleteFileTool(), new ReadFileTool(), new ListFilesTool(), + new SearchCustomAgentsTool(), + new CallCustomAgentTool(), ]; } diff --git a/front_end/panels/ai_chat/core/Constants.ts b/front_end/panels/ai_chat/core/Constants.ts index 84e97bf93a..3bac40cd9d 100644 --- a/front_end/panels/ai_chat/core/Constants.ts +++ b/front_end/panels/ai_chat/core/Constants.ts @@ -61,10 +61,6 @@ export const DEFAULTS = { // Regular expressions export const REGEX_PATTERNS = { - // XML parsing patterns - REASONING_TAG: /\s*([\s\S]*?)\s*<\/reasoning>/, - MARKDOWN_REPORT_TAG: /\s*([\s\S]*?)\s*<\/markdown_report>/, - // Markdown patterns HEADING: /^#{1,6}\s+.+$/gm, LIST_ITEM: /^[\*\-]\s+.+$/gm, diff --git a/front_end/panels/ai_chat/core/StateGraph.ts b/front_end/panels/ai_chat/core/StateGraph.ts index 4bcb32eedd..f51eac64c6 100644 --- a/front_end/panels/ai_chat/core/StateGraph.ts +++ b/front_end/panels/ai_chat/core/StateGraph.ts @@ -62,7 +62,7 @@ export class StateGraph { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// ============================================================================ +// Test Types +// ============================================================================ + +interface TestState { + messages: ChatMessage[]; + value: number; + path: string[]; + context?: { + tracingContext?: any; + }; +} + +// ============================================================================ +// Test Node Factories +// ============================================================================ + +function createTestNode( + name: string, + transform: (state: TestState) => TestState +): Runnable { + return { + invoke: async (state: TestState, signal?: AbortSignal): Promise => { + // Track the path through nodes + const newState = { ...state, path: [...state.path, name] }; + return transform(newState); + }, + }; +} + +function createDelayNode( + name: string, + delayMs: number +): Runnable { + return { + invoke: async (state: TestState, signal?: AbortSignal): Promise => { + await delay(delayMs); + return { ...state, path: [...state.path, name] }; + }, + }; +} + +function createErrorNode(name: string, errorMessage: string): Runnable { + return { + invoke: async (state: TestState, signal?: AbortSignal): Promise => { + throw new Error(errorMessage); + }, + }; +} + +function createCounterNode(name: string): Runnable { + return { + invoke: async (state: TestState, signal?: AbortSignal): Promise => { + return { + ...state, + value: state.value + 1, + path: [...state.path, name], + }; + }, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('ai_chat: StateGraph', () => { + // ========================================================================== + // Graph Construction Tests + // ========================================================================== + + describe('graph construction', () => { + it('creates a graph with a name', () => { + const graph = new StateGraph({ name: 'test_graph' }); + assert.isOk(graph); + }); + + it('adds nodes successfully', () => { + const graph = new StateGraph({ name: 'add_nodes_graph' }); + const node1 = createTestNode('node1', (s) => s); + const node2 = createTestNode('node2', (s) => s); + + // Should not throw + graph.addNode('node1', node1); + graph.addNode('node2', node2); + }); + + it('sets entry point correctly', () => { + const graph = new StateGraph({ name: 'entry_point_graph' }); + const startNode = createTestNode('start', (s) => s); + + graph.addNode('start', startNode); + graph.setEntryPoint('start'); + + // Should not throw + const compiled = graph.compile(); + assert.isOk(compiled); + }); + + it('throws error when setting entry point to unknown node', () => { + const graph = new StateGraph({ name: 'unknown_entry_graph' }); + + assert.throws(() => { + graph.setEntryPoint('nonexistent_node'); + }, /not found/); + }); + + it('adds conditional edges', () => { + const graph = new StateGraph({ name: 'conditional_graph' }); + const node1 = createTestNode('node1', (s) => s); + const node2 = createTestNode('node2', (s) => s); + const node3 = createTestNode('node3', (s) => s); + + graph.addNode('node1', node1); + graph.addNode('node2', node2); + graph.addNode('node3', node3); + + // Should not throw + graph.addConditionalEdges('node1', (state) => state.value > 5 ? 'high' : 'low', { + high: 'node2', + low: 'node3', + }); + }); + + it('compiles and returns self', () => { + const graph = new StateGraph({ name: 'compile_graph' }); + const node = createTestNode('start', (s) => s); + graph.addNode('start', node); + graph.setEntryPoint('start'); + + const compiled = graph.compile(); + assert.strictEqual(compiled, graph); + }); + }); + + // ========================================================================== + // Basic Execution Tests + // ========================================================================== + + describe('basic execution', () => { + it('executes single node and terminates', async () => { + const graph = new StateGraph({ name: 'single_node_graph' }); + const node = createTestNode('only_node', (s) => ({ ...s, value: 42 })); + + graph.addNode('only_node', node); + graph.setEntryPoint('only_node'); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + let finalState: TestState | null = null; + for await (const state of graph.invoke(initialState)) { + finalState = state; + } + + assert.isOk(finalState); + assert.strictEqual(finalState!.value, 42); + assert.deepStrictEqual(finalState!.path, ['only_node']); + }); + + it('executes linear sequence of nodes', async () => { + const graph = new StateGraph({ name: 'linear_graph' }); + + graph.addNode('step1', createCounterNode('step1')); + graph.addNode('step2', createCounterNode('step2')); + graph.addNode('step3', createCounterNode('step3')); + + graph.setEntryPoint('step1'); + graph.addConditionalEdges('step1', () => 'next', { next: 'step2' }); + graph.addConditionalEdges('step2', () => 'next', { next: 'step3' }); + graph.addConditionalEdges('step3', () => 'end', { end: END_NODE_MARKER }); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + let finalState: TestState | null = null; + for await (const state of graph.invoke(initialState)) { + finalState = state; + } + + assert.isOk(finalState); + assert.strictEqual(finalState!.value, 3); + assert.deepStrictEqual(finalState!.path, ['step1', 'step2', 'step3']); + }); + + it('terminates at END_NODE_MARKER', async () => { + const graph = new StateGraph({ name: 'end_marker_graph' }); + + graph.addNode('start', createTestNode('start', (s) => s)); + graph.setEntryPoint('start'); + graph.addConditionalEdges('start', () => 'stop', { stop: END_NODE_MARKER }); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + let yieldCount = 0; + for await (const state of graph.invoke(initialState)) { + yieldCount++; + } + + // Should yield once for the start node + assert.strictEqual(yieldCount, 1); + }); + }); + + // ========================================================================== + // Conditional Routing Tests + // ========================================================================== + + describe('conditional routing', () => { + it('routes based on state value', async () => { + const graph = new StateGraph({ name: 'routing_graph' }); + + graph.addNode('decision', createTestNode('decision', (s) => s)); + graph.addNode('path_a', createTestNode('path_a', (s) => ({ ...s, value: s.value + 100 }))); + graph.addNode('path_b', createTestNode('path_b', (s) => ({ ...s, value: s.value + 200 }))); + + graph.setEntryPoint('decision'); + graph.addConditionalEdges('decision', (state) => state.value > 5 ? 'a' : 'b', { + a: 'path_a', + b: 'path_b', + }); + graph.addConditionalEdges('path_a', () => 'end', { end: END_NODE_MARKER }); + graph.addConditionalEdges('path_b', () => 'end', { end: END_NODE_MARKER }); + + // Test path A (value > 5) + const stateA: TestState = { messages: [], value: 10, path: [] }; + let finalA: TestState | null = null; + for await (const state of graph.invoke(stateA)) { + finalA = state; + } + assert.strictEqual(finalA!.value, 110); + assert.deepStrictEqual(finalA!.path, ['decision', 'path_a']); + + // Test path B (value <= 5) + const stateB: TestState = { messages: [], value: 3, path: [] }; + let finalB: TestState | null = null; + for await (const state of graph.invoke(stateB)) { + finalB = state; + } + assert.strictEqual(finalB!.value, 203); + assert.deepStrictEqual(finalB!.path, ['decision', 'path_b']); + }); + + it('handles unknown routing key gracefully', async () => { + const graph = new StateGraph({ name: 'unknown_route_graph' }); + + graph.addNode('start', createTestNode('start', (s) => s)); + graph.setEntryPoint('start'); + graph.addConditionalEdges('start', () => 'unknown_key', { + known_key: 'start', // Only this key is mapped + }); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + // Should terminate gracefully when routing key doesn't match + let finalState: TestState | null = null; + for await (const state of graph.invoke(initialState)) { + finalState = state; + } + + assert.isOk(finalState); + assert.deepStrictEqual(finalState!.path, ['start']); + }); + }); + + // ========================================================================== + // Generator Behavior Tests + // ========================================================================== + + describe('generator behavior', () => { + it('yields intermediate states', async () => { + const graph = new StateGraph({ name: 'yield_graph' }); + + graph.addNode('step1', createCounterNode('step1')); + graph.addNode('step2', createCounterNode('step2')); + graph.addNode('step3', createCounterNode('step3')); + + graph.setEntryPoint('step1'); + graph.addConditionalEdges('step1', () => 'next', { next: 'step2' }); + graph.addConditionalEdges('step2', () => 'next', { next: 'step3' }); + graph.addConditionalEdges('step3', () => 'end', { end: END_NODE_MARKER }); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + const yieldedStates: TestState[] = []; + + for await (const state of graph.invoke(initialState)) { + yieldedStates.push(state); + } + + assert.strictEqual(yieldedStates.length, 3); + assert.strictEqual(yieldedStates[0].value, 1); + assert.strictEqual(yieldedStates[1].value, 2); + assert.strictEqual(yieldedStates[2].value, 3); + }); + + it('returns final state from generator', async () => { + const graph = new StateGraph({ name: 'return_graph' }); + + graph.addNode('start', createTestNode('start', (s) => ({ ...s, value: 999 }))); + graph.setEntryPoint('start'); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + const generator = graph.invoke(initialState); + + // Consume the generator + let result; + while (true) { + const { value, done } = await generator.next(); + if (done) { + result = value; + break; + } + } + + assert.isOk(result); + assert.strictEqual(result.value, 999); + }); + }); + + // ========================================================================== + // Safety Limit Tests + // ========================================================================== + + describe('safety limits', () => { + it('respects 50-step safety limit', async () => { + const graph = new StateGraph({ name: 'infinite_graph' }); + + // Create an infinite loop + graph.addNode('loop', createCounterNode('loop')); + graph.setEntryPoint('loop'); + graph.addConditionalEdges('loop', () => 'continue', { continue: 'loop' }); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + let stepCount = 0; + for await (const state of graph.invoke(initialState)) { + stepCount++; + } + + // Should stop at 50 steps (safety limit) + assert.isAtMost(stepCount, 51); // 50 + potential final step + }); + }); + + // ========================================================================== + // Abort Signal Tests + // ========================================================================== + + describe('abort signal handling', () => { + it('handles abort signal at step boundary', async () => { + const graph = new StateGraph({ name: 'abort_graph' }); + const { controller, signal } = createTestAbortController(); + + // Create nodes with delay to allow abort + graph.addNode('step1', createDelayNode('step1', 10)); + graph.addNode('step2', createDelayNode('step2', 10)); + graph.addNode('step3', createDelayNode('step3', 10)); + + graph.setEntryPoint('step1'); + graph.addConditionalEdges('step1', () => 'next', { next: 'step2' }); + graph.addConditionalEdges('step2', () => 'next', { next: 'step3' }); + graph.addConditionalEdges('step3', () => 'end', { end: END_NODE_MARKER }); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + // Start execution + const generator = graph.invoke(initialState, signal); + + // Execute first step + await generator.next(); + + // Abort + controller.abort(); + + // Try to continue - should throw + let abortError: Error | null = null; + try { + await generator.next(); + } catch (error) { + abortError = error as Error; + } + + assert.isOk(abortError); + assert.include(abortError!.message.toLowerCase(), 'abort'); + }); + + it('handles pre-aborted signal', async () => { + const graph = new StateGraph({ name: 'pre_abort_graph' }); + const { controller, signal } = createTestAbortController(); + controller.abort(); // Pre-abort + + graph.addNode('start', createTestNode('start', (s) => s)); + graph.setEntryPoint('start'); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + let abortError: Error | null = null; + try { + for await (const state of graph.invoke(initialState, signal)) { + // Should not reach here + } + } catch (error) { + abortError = error as Error; + } + + assert.isOk(abortError); + assert.include(abortError!.message.toLowerCase(), 'abort'); + }); + }); + + // ========================================================================== + // Error Handling Tests + // ========================================================================== + + describe('error handling', () => { + it('propagates node execution errors', async () => { + const graph = new StateGraph({ name: 'error_graph' }); + + graph.addNode('error_node', createErrorNode('error_node', 'Node exploded!')); + graph.setEntryPoint('error_node'); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + let finalState: TestState | null = null; + for await (const state of graph.invoke(initialState)) { + finalState = state; + } + + // Graph should add error message to state + assert.isOk(finalState); + const errorMessage = finalState!.messages.find( + (m) => m.entity === ChatMessageEntity.MODEL && (m as ModelChatMessage).error + ) as ModelChatMessage | undefined; + + assert.isOk(errorMessage); + assert.include(errorMessage!.error!, 'Node exploded!'); + }); + + it('terminates execution after error', async () => { + const graph = new StateGraph({ name: 'terminate_error_graph' }); + + graph.addNode('before', createCounterNode('before')); + graph.addNode('error', createErrorNode('error', 'Boom!')); + graph.addNode('after', createCounterNode('after')); + + graph.setEntryPoint('before'); + graph.addConditionalEdges('before', () => 'next', { next: 'error' }); + graph.addConditionalEdges('error', () => 'next', { next: 'after' }); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + let finalState: TestState | null = null; + for await (const state of graph.invoke(initialState)) { + finalState = state; + } + + // Should have executed 'before' but not 'after' + assert.include(finalState!.path, 'before'); + assert.notInclude(finalState!.path, 'after'); + }); + }); + + // ========================================================================== + // State Management Tests + // ========================================================================== + + describe('state management', () => { + it('passes state between nodes', async () => { + const graph = new StateGraph({ name: 'state_pass_graph' }); + + graph.addNode('set_value', createTestNode('set_value', (s) => ({ ...s, value: 100 }))); + graph.addNode('double_value', createTestNode('double_value', (s) => ({ ...s, value: s.value * 2 }))); + graph.addNode('add_ten', createTestNode('add_ten', (s) => ({ ...s, value: s.value + 10 }))); + + graph.setEntryPoint('set_value'); + graph.addConditionalEdges('set_value', () => 'next', { next: 'double_value' }); + graph.addConditionalEdges('double_value', () => 'next', { next: 'add_ten' }); + graph.addConditionalEdges('add_ten', () => 'end', { end: END_NODE_MARKER }); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + let finalState: TestState | null = null; + for await (const state of graph.invoke(initialState)) { + finalState = state; + } + + // 0 -> 100 -> 200 -> 210 + assert.strictEqual(finalState!.value, 210); + }); + + it('accumulates message history', async () => { + const graph = new StateGraph({ name: 'messages_graph' }); + + graph.addNode('add_msg_1', createTestNode('add_msg_1', (s) => ({ + ...s, + messages: [...s.messages, { entity: ChatMessageEntity.USER, text: 'Message 1' } as ChatMessage], + }))); + graph.addNode('add_msg_2', createTestNode('add_msg_2', (s) => ({ + ...s, + messages: [...s.messages, { entity: ChatMessageEntity.USER, text: 'Message 2' } as ChatMessage], + }))); + + graph.setEntryPoint('add_msg_1'); + graph.addConditionalEdges('add_msg_1', () => 'next', { next: 'add_msg_2' }); + graph.addConditionalEdges('add_msg_2', () => 'end', { end: END_NODE_MARKER }); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + let finalState: TestState | null = null; + for await (const state of graph.invoke(initialState)) { + finalState = state; + } + + assert.strictEqual(finalState!.messages.length, 2); + }); + + it('maintains context through execution', async () => { + const graph = new StateGraph({ name: 'context_graph' }); + + graph.addNode('check_context', createTestNode('check_context', (s) => { + // Verify context is accessible + if (s.context?.tracingContext?.traceId) { + return { ...s, value: 1 }; + } + return { ...s, value: 0 }; + })); + + graph.setEntryPoint('check_context'); + + const initialState: TestState = { + messages: [], + value: 0, + path: [], + context: { + tracingContext: { + traceId: 'test-trace-id', + }, + }, + }; + + let finalState: TestState | null = null; + for await (const state of graph.invoke(initialState)) { + finalState = state; + } + + // Context should have been accessible + assert.strictEqual(finalState!.value, 1); + }); + }); + + // ========================================================================== + // Edge Cases + // ========================================================================== + + describe('edge cases', () => { + it('handles node with no outgoing edges', async () => { + const graph = new StateGraph({ name: 'no_edges_graph' }); + + graph.addNode('lonely_node', createTestNode('lonely_node', (s) => s)); + graph.setEntryPoint('lonely_node'); + // No conditional edges added + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + let finalState: TestState | null = null; + for await (const state of graph.invoke(initialState)) { + finalState = state; + } + + // Should execute the node and then terminate + assert.isOk(finalState); + assert.deepStrictEqual(finalState!.path, ['lonely_node']); + }); + + it('handles conditional edge to nonexistent node', async () => { + const graph = new StateGraph({ name: 'nonexistent_target_graph' }); + + graph.addNode('start', createTestNode('start', (s) => s)); + graph.setEntryPoint('start'); + graph.addConditionalEdges('start', () => 'go', { go: 'nonexistent' }); + + const initialState: TestState = { messages: [], value: 0, path: [] }; + + // Should terminate gracefully + let finalState: TestState | null = null; + for await (const state of graph.invoke(initialState)) { + finalState = state; + } + + assert.isOk(finalState); + }); + }); +}); diff --git a/front_end/panels/ai_chat/core/structured_response.ts b/front_end/panels/ai_chat/core/structured_response.ts deleted file mode 100644 index 52951650ba..0000000000 --- a/front_end/panels/ai_chat/core/structured_response.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright 2025 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import { CONTENT_THRESHOLDS, REGEX_PATTERNS } from '../core/Constants.js'; -import { createLogger } from '../core/Logger.js'; - -const logger = createLogger('structured_response'); - -export interface StructuredResponse { - reasoning: string; - markdownReport: string; -} - -// Parse and wrapped content from a model answer -export function parseStructuredResponse(text: string): StructuredResponse | null { - try { - const reasoningMatch = text.match(REGEX_PATTERNS.REASONING_TAG); - const reportMatch = text.match(REGEX_PATTERNS.MARKDOWN_REPORT_TAG); - if (reasoningMatch && reportMatch) { - const reasoning = reasoningMatch[1]?.trim() ?? ''; - const markdownReport = reportMatch[1]?.trim() ?? ''; - if (reasoning && markdownReport && markdownReport.length >= CONTENT_THRESHOLDS.MARKDOWN_REPORT_MIN_LENGTH) { - return { reasoning, markdownReport }; - } - } - } catch (error) { - logger.error('Failed to parse structured response', error); - } - return null; -} - -// Create a stable key for a structured response -export function getMessageStateKey(structuredResponse: StructuredResponse): string { - const content = structuredResponse.reasoning + structuredResponse.markdownReport; - const encoder = new TextEncoder(); - const bytes = encoder.encode(content); - let hash = 0; - for (let i = 0; i < bytes.length; i++) { - // eslint-disable-next-line no-bitwise - hash = ((hash << 5) - hash) + bytes[i]; - // eslint-disable-next-line no-bitwise - hash = hash & hash; - } - return Math.abs(hash).toString(16).padStart(8, '0'); -} - diff --git a/front_end/panels/ai_chat/mini_apps/GenericMiniAppBridge.ts b/front_end/panels/ai_chat/mini_apps/GenericMiniAppBridge.ts new file mode 100644 index 0000000000..284fd0001d --- /dev/null +++ b/front_end/panels/ai_chat/mini_apps/GenericMiniAppBridge.ts @@ -0,0 +1,240 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as SDK from '../../../core/sdk/sdk.js'; +import type * as Protocol from '../../../generated/protocol.js'; +import { createLogger } from '../core/Logger.js'; +import type { + MiniAppBridge, + MiniAppState, + SPAToDevToolsAction, + DevToolsToSPAAction, +} from './types/MiniAppTypes.js'; + +const logger = createLogger('GenericMiniAppBridge'); + +/** + * Callback type for handling SPA actions + */ +export type ActionHandler = (action: SPAToDevToolsAction) => void | Promise; + +/** + * GenericMiniAppBridge - CDP-based bidirectional communication for mini apps + * + * Uses unique binding names per app type to avoid conflicts: + * - Binding: __miniAppBridge_{appId} + * + * Communication: + * - SPA → DevTools: Runtime.addBinding (instant, event-driven) + * - DevTools → SPA: Runtime.evaluate calling window.miniApp.dispatch() + */ +export class GenericMiniAppBridge implements MiniAppBridge { + private readonly appId: string; + private readonly bindingName: string; + + private target: SDK.Target.Target | null = null; + private _webappId: string | null = null; + private bindingHandler: ((event: { data: Protocol.Runtime.BindingCalledEvent }) => void) | null = null; + private actionHandler: ActionHandler | null = null; + private _installed = false; + + constructor(appId: string) { + this.appId = appId; + this.bindingName = `__miniAppBridge_${appId}`; + } + + /** + * Register a handler for actions from the SPA + */ + onAction(handler: ActionHandler): void { + this.actionHandler = handler; + } + + /** + * Install the bridge - sets up Runtime.addBinding for SPA→DevTools communication + */ + async install(webappId: string): Promise { + if (this._installed) { + logger.warn(`Bridge for "${this.appId}" already installed`); + return; + } + + this._webappId = webappId; + this.target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); + + if (!this.target) { + throw new Error('No primary page target available'); + } + + const runtimeModel = this.target.model(SDK.RuntimeModel.RuntimeModel); + if (!runtimeModel) { + throw new Error('RuntimeModel not available'); + } + + // Create handler for binding calls + this.bindingHandler = this.handleBindingCalled.bind(this); + runtimeModel.addEventListener(SDK.RuntimeModel.Events.BindingCalled, this.bindingHandler); + + // Add the binding - this creates window.__miniAppBridge_{appId}() in the page + await this.target.runtimeAgent().invoke_addBinding({ + name: this.bindingName, + }); + + this._installed = true; + logger.info(`Bridge installed for "${this.appId}"`, { webappId, bindingName: this.bindingName }); + } + + /** + * Uninstall the bridge - removes binding and event listeners + */ + async uninstall(): Promise { + if (!this._installed || !this.target) { + return; + } + + const runtimeModel = this.target.model(SDK.RuntimeModel.RuntimeModel); + + // Remove event listener + if (runtimeModel && this.bindingHandler) { + runtimeModel.removeEventListener(SDK.RuntimeModel.Events.BindingCalled, this.bindingHandler); + } + + // Remove the binding + try { + await this.target.runtimeAgent().invoke_removeBinding({ + name: this.bindingName, + }); + } catch (error) { + logger.error(`Failed to remove binding for "${this.appId}":`, error); + } + + this.bindingHandler = null; + this.target = null; + this._webappId = null; + this._installed = false; + + logger.info(`Bridge uninstalled for "${this.appId}"`); + } + + /** + * Send an action to the SPA + */ + async sendToSPA(action: DevToolsToSPAAction): Promise { + if (!this.target || !this._webappId) { + logger.error(`Bridge for "${this.appId}" not installed, cannot send to SPA`); + return; + } + + try { + const runtimeAgent = this.target.runtimeAgent(); + + // Call window.miniApp.dispatch() in the iframe context + await runtimeAgent.invoke_evaluate({ + expression: ` + (() => { + const iframe = document.getElementById(${JSON.stringify(this._webappId)}); + if (!iframe || !iframe.contentWindow) { + console.error('[MiniAppBridge] Iframe not found: ${this._webappId}'); + return false; + } + if (typeof iframe.contentWindow.miniApp?.dispatch === 'function') { + iframe.contentWindow.miniApp.dispatch(${JSON.stringify(action)}); + return true; + } + console.error('[MiniAppBridge] miniApp.dispatch not found'); + return false; + })() + `, + returnByValue: true, + }); + } catch (error) { + logger.error(`Failed to send to SPA for "${this.appId}":`, error); + } + } + + /** + * Get the current state from the SPA + */ + async getState(): Promise { + if (!this.target || !this._webappId) { + logger.error(`Bridge for "${this.appId}" not installed, cannot get state`); + return {}; + } + + try { + const runtimeAgent = this.target.runtimeAgent(); + + const result = await runtimeAgent.invoke_evaluate({ + expression: ` + (() => { + const iframe = document.getElementById(${JSON.stringify(this._webappId)}); + if (!iframe || !iframe.contentWindow) { + console.error('[MiniAppBridge] Iframe not found: ${this._webappId}'); + return null; + } + if (typeof iframe.contentWindow.miniApp?.getState === 'function') { + return iframe.contentWindow.miniApp.getState(); + } + console.error('[MiniAppBridge] miniApp.getState not found'); + return null; + })() + `, + returnByValue: true, + }); + + if (result.exceptionDetails) { + logger.error(`Exception getting state for "${this.appId}":`, result.exceptionDetails.text); + return {}; + } + + return (result.result.value as MiniAppState) || {}; + } catch (error) { + logger.error(`Failed to get state for "${this.appId}":`, error); + return {}; + } + } + + /** + * Handle binding calls from the SPA + */ + private handleBindingCalled(event: { data: Protocol.Runtime.BindingCalledEvent }): void { + const { data } = event; + + // Only handle our binding + if (data.name !== this.bindingName) { + return; + } + + try { + const action = JSON.parse(data.payload) as SPAToDevToolsAction; + logger.info(`Received action from SPA "${this.appId}":`, action.type); + + if (this.actionHandler) { + // Handle async actions + const result = this.actionHandler(action); + if (result instanceof Promise) { + result.catch(error => { + logger.error(`Error handling action for "${this.appId}":`, error); + }); + } + } + } catch (error) { + logger.error(`Failed to parse SPA action for "${this.appId}":`, error); + } + } + + /** + * Check if bridge is installed + */ + get installed(): boolean { + return this._installed; + } + + /** + * Get the webapp ID + */ + get webappId(): string | null { + return this._webappId; + } +} diff --git a/front_end/panels/ai_chat/mini_apps/MiniAppEventBus.ts b/front_end/panels/ai_chat/mini_apps/MiniAppEventBus.ts new file mode 100644 index 0000000000..c5de0c1b7b --- /dev/null +++ b/front_end/panels/ai_chat/mini_apps/MiniAppEventBus.ts @@ -0,0 +1,234 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Common from '../../../core/common/common.js'; +import { createLogger } from '../core/Logger.js'; +import type { MiniAppEvent, MiniAppEventType } from './types/MiniAppTypes.js'; + +const logger = createLogger('MiniAppEventBus'); + +/** + * Event types for the MiniAppEventBus + */ +export const enum Events { + MiniAppEvent = 'MiniAppEvent', +} + +/** + * Type definition for event data + */ +export type EventTypes = { + [Events.MiniAppEvent]: MiniAppEvent; +}; + +/** + * MiniAppEventBus - Event propagation system for mini apps + * + * Allows components to subscribe to mini app lifecycle events: + * - app_launched: When a mini app is launched + * - app_closed: When a mini app is closed + * - state_changed: When a mini app's state changes + * - action_received: When an action is received from a mini app + * - action_executed: When an action is executed on a mini app + * - error: When an error occurs in a mini app + * + * Uses DevTools' Common.ObjectWrapper for event handling. + */ +export class MiniAppEventBus extends Common.ObjectWrapper.ObjectWrapper { + private static instance: MiniAppEventBus | null = null; + + private constructor() { + super(); + logger.info('Initialized MiniAppEventBus'); + } + + static getInstance(): MiniAppEventBus { + if (!MiniAppEventBus.instance) { + MiniAppEventBus.instance = new MiniAppEventBus(); + } + return MiniAppEventBus.instance; + } + + /** + * Emit a mini app event + */ + emit(event: MiniAppEvent): void { + logger.debug(`Emitting event: ${event.type} for app "${event.appId}"`); + this.dispatchEventToListeners(Events.MiniAppEvent, event); + } + + /** + * Subscribe to all mini app events + */ + subscribe(callback: (event: MiniAppEvent) => void): () => void { + const handler = (event: Common.EventTarget.EventTargetEvent) => { + callback(event.data); + }; + + this.addEventListener(Events.MiniAppEvent, handler); + + // Return unsubscribe function + return () => { + this.removeEventListener(Events.MiniAppEvent, handler); + }; + } + + /** + * Subscribe to events for a specific app + */ + subscribeToApp(appId: string, callback: (event: MiniAppEvent) => void): () => void { + const handler = (event: Common.EventTarget.EventTargetEvent) => { + if (event.data.appId === appId) { + callback(event.data); + } + }; + + this.addEventListener(Events.MiniAppEvent, handler); + + // Return unsubscribe function + return () => { + this.removeEventListener(Events.MiniAppEvent, handler); + }; + } + + /** + * Subscribe to a specific event type + */ + subscribeToType(eventType: MiniAppEventType, callback: (event: MiniAppEvent) => void): () => void { + const handler = (event: Common.EventTarget.EventTargetEvent) => { + if (event.data.type === eventType) { + callback(event.data); + } + }; + + this.addEventListener(Events.MiniAppEvent, handler); + + // Return unsubscribe function + return () => { + this.removeEventListener(Events.MiniAppEvent, handler); + }; + } + + /** + * Subscribe to a specific event type for a specific app + */ + subscribeToAppEvent( + appId: string, + eventType: MiniAppEventType, + callback: (event: MiniAppEvent) => void + ): () => void { + const handler = (event: Common.EventTarget.EventTargetEvent) => { + if (event.data.appId === appId && event.data.type === eventType) { + callback(event.data); + } + }; + + this.addEventListener(Events.MiniAppEvent, handler); + + // Return unsubscribe function + return () => { + this.removeEventListener(Events.MiniAppEvent, handler); + }; + } + + /** + * Wait for a specific event (Promise-based) + */ + waitForEvent( + appId: string, + eventType: MiniAppEventType, + timeout?: number + ): Promise { + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined; + + const unsubscribe = this.subscribeToAppEvent(appId, eventType, (event) => { + if (timeoutId) { + clearTimeout(timeoutId); + } + unsubscribe(); + resolve(event); + }); + + if (timeout) { + timeoutId = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timeout waiting for "${eventType}" event from "${appId}"`)); + }, timeout); + } + }); + } + + /** + * Helper: Create and emit an app_launched event + */ + emitLaunched(appId: string, data?: unknown): void { + this.emit({ + type: 'app_launched', + appId, + timestamp: new Date(), + data, + }); + } + + /** + * Helper: Create and emit an app_closed event + */ + emitClosed(appId: string, data?: unknown): void { + this.emit({ + type: 'app_closed', + appId, + timestamp: new Date(), + data, + }); + } + + /** + * Helper: Create and emit a state_changed event + */ + emitStateChanged(appId: string, state: unknown): void { + this.emit({ + type: 'state_changed', + appId, + timestamp: new Date(), + data: state, + }); + } + + /** + * Helper: Create and emit an action_received event + */ + emitActionReceived(appId: string, action: unknown): void { + this.emit({ + type: 'action_received', + appId, + timestamp: new Date(), + data: action, + }); + } + + /** + * Helper: Create and emit an action_executed event + */ + emitActionExecuted(appId: string, action: string, result: unknown): void { + this.emit({ + type: 'action_executed', + appId, + timestamp: new Date(), + data: { action, result }, + }); + } + + /** + * Helper: Create and emit an error event + */ + emitError(appId: string, error: Error | string): void { + this.emit({ + type: 'error', + appId, + timestamp: new Date(), + data: { error: error instanceof Error ? error.message : error }, + }); + } +} diff --git a/front_end/panels/ai_chat/mini_apps/MiniAppInitialization.ts b/front_end/panels/ai_chat/mini_apps/MiniAppInitialization.ts new file mode 100644 index 0000000000..84c6698f44 --- /dev/null +++ b/front_end/panels/ai_chat/mini_apps/MiniAppInitialization.ts @@ -0,0 +1,100 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../core/Logger.js'; +import { ToolRegistry } from '../agent_framework/ConfigurableAgentTool.js'; +import { MiniAppRegistry } from './MiniAppRegistry.js'; + +// Import mini apps +import { AgentStudioMiniApp } from './apps/agent_studio/AgentStudioMiniApp.js'; + +// Import mini app tools +import { ListMiniAppsTool } from '../tools/mini_app/ListMiniAppsTool.js'; +import { LaunchMiniAppTool } from '../tools/mini_app/LaunchMiniAppTool.js'; +import { GetMiniAppStateTool } from '../tools/mini_app/GetMiniAppStateTool.js'; +import { UpdateMiniAppStateTool } from '../tools/mini_app/UpdateMiniAppStateTool.js'; +import { ExecuteMiniAppActionTool } from '../tools/mini_app/ExecuteMiniAppActionTool.js'; +import { CloseMiniAppTool } from '../tools/mini_app/CloseMiniAppTool.js'; + +const logger = createLogger('MiniAppInitialization'); + +let initialized = false; + +/** + * Initialize the mini app system + * + * This registers all mini apps and their associated tools. + * Should be called once during application startup. + */ +export function initializeMiniApps(): void { + if (initialized) { + logger.warn('Mini app system already initialized'); + return; + } + + logger.info('Initializing mini app system...'); + + // Register mini apps + registerMiniApps(); + + // Register mini app tools + registerMiniAppTools(); + + initialized = true; + logger.info('Mini app system initialized successfully'); +} + +/** + * Register all mini apps + */ +function registerMiniApps(): void { + // Register Agent Studio as a mini app + MiniAppRegistry.register(new AgentStudioMiniApp()); + + // Future mini apps will be registered here: + // MiniAppRegistry.register(new DataVisualizerMiniApp()); + // MiniAppRegistry.register(new FormBuilderMiniApp()); + + logger.info(`Registered ${MiniAppRegistry.getAllApps().length} mini apps`); +} + +/** + * Register mini app tools in the ToolRegistry + * + * These tools allow AI agents to interact with mini apps. + */ +function registerMiniAppTools(): void { + ToolRegistry.registerToolFactory('list_mini_apps', () => new ListMiniAppsTool()); + ToolRegistry.registerToolFactory('launch_mini_app', () => new LaunchMiniAppTool()); + ToolRegistry.registerToolFactory('get_mini_app_state', () => new GetMiniAppStateTool()); + ToolRegistry.registerToolFactory('update_mini_app_state', () => new UpdateMiniAppStateTool()); + ToolRegistry.registerToolFactory('execute_mini_app_action', () => new ExecuteMiniAppActionTool()); + ToolRegistry.registerToolFactory('close_mini_app', () => new CloseMiniAppTool()); + + logger.info('Registered 6 mini app tools'); +} + +/** + * Check if mini app system is initialized + */ +export function isMiniAppSystemInitialized(): boolean { + return initialized; +} + +/** + * Reset the mini app system (for testing) + */ +export function resetMiniAppSystem(): void { + if (!initialized) { + return; + } + + // Close all running mini apps + MiniAppRegistry.closeAll().catch(error => { + logger.error('Error closing mini apps during reset:', error); + }); + + initialized = false; + logger.info('Mini app system reset'); +} diff --git a/front_end/panels/ai_chat/mini_apps/MiniAppRegistry.ts b/front_end/panels/ai_chat/mini_apps/MiniAppRegistry.ts new file mode 100644 index 0000000000..e59a4ce4ab --- /dev/null +++ b/front_end/panels/ai_chat/mini_apps/MiniAppRegistry.ts @@ -0,0 +1,364 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../core/Logger.js'; +import type { + MiniApp, + MiniAppInstance, + MiniAppBridge, + MiniAppController, +} from './types/MiniAppTypes.js'; +import { GenericMiniAppBridge } from './GenericMiniAppBridge.js'; +import { MiniAppEventBus } from './MiniAppEventBus.js'; +import { RenderWebAppTool } from '../tools/RenderWebAppTool.js'; +import { RemoveWebAppTool } from '../tools/RemoveWebAppTool.js'; + +const logger = createLogger('MiniAppRegistry'); + +/** + * MiniAppRegistry - Central registry for mini app lifecycle management + * + * Responsibilities: + * - Register mini app definitions + * - Launch mini apps (single instance per app type) + * - Track running instances + * - Close and cleanup mini apps + */ +export class MiniAppRegistry { + private static apps = new Map(); + private static instances = new Map(); + + /** + * Register a mini app definition + */ + static register(app: MiniApp): void { + if (this.apps.has(app.id)) { + logger.warn(`Mini app "${app.id}" is already registered, replacing...`); + } + this.apps.set(app.id, app); + logger.info(`Registered mini app: ${app.id}`); + } + + /** + * Unregister a mini app definition + */ + static unregister(appId: string): void { + if (this.instances.has(appId)) { + logger.warn(`Cannot unregister "${appId}" while it is running. Close it first.`); + return; + } + this.apps.delete(appId); + logger.info(`Unregistered mini app: ${appId}`); + } + + /** + * Get a registered mini app by ID + */ + static getApp(appId: string): MiniApp | undefined { + return this.apps.get(appId); + } + + /** + * Get all registered mini apps + */ + static getAllApps(): MiniApp[] { + return Array.from(this.apps.values()); + } + + /** + * Check if a mini app is currently running + */ + static isRunning(appId: string): boolean { + return this.instances.has(appId); + } + + /** + * Get a running instance by app ID + */ + static getRunningInstance(appId: string): MiniAppInstance | undefined { + return this.instances.get(appId); + } + + /** + * Get all running instances + */ + static getAllRunningInstances(): MiniAppInstance[] { + return Array.from(this.instances.values()); + } + + /** + * Launch a mini app + * + * If the app is already running, returns the existing instance. + * Only one instance per app type is allowed. + */ + static async launch(appId: string): Promise { + // Check if already running (single instance per app) + const existing = this.instances.get(appId); + if (existing) { + logger.info(`Mini app "${appId}" is already running, returning existing instance`); + return existing; + } + + // Get the app definition + const app = this.apps.get(appId); + if (!app) { + throw new Error(`Mini app "${appId}" is not registered`); + } + + logger.info(`Launching mini app: ${appId}`); + + try { + // Get SPA content + const spa = app.getSPA(); + + // Render the webapp using RenderWebAppTool + const renderTool = new RenderWebAppTool(); + const renderResult = await renderTool.execute({ + html: spa.html, + css: spa.css, + js: this.wrapSPAJavaScript(appId, spa.js), + reasoning: `Launching mini app: ${app.name}`, + }); + + if ('error' in renderResult) { + throw new Error(`Failed to render mini app: ${renderResult.error}`); + } + + const webappId = renderResult.webappId; + + // Create the bridge + const bridge: MiniAppBridge = new GenericMiniAppBridge(appId); + await bridge.install(webappId); + + // Create the controller + const controller: MiniAppController = app.createController(); + + // Create the instance + const instance: MiniAppInstance = { + app, + controller, + bridge, + webappId, + launchedAt: new Date(), + }; + + // Store the instance + this.instances.set(appId, instance); + + // Set up close handler + controller.onClose(async () => { + await this.close(appId); + }); + + // Initialize the controller with the bridge + await controller.initialize(bridge); + + // Emit launch event + MiniAppEventBus.getInstance().emit({ + type: 'app_launched', + appId, + timestamp: new Date(), + data: { webappId }, + }); + + logger.info(`Successfully launched mini app: ${appId}`, { webappId }); + return instance; + + } catch (error) { + logger.error(`Failed to launch mini app "${appId}":`, error); + throw error; + } + } + + /** + * Close a running mini app + */ + static async close(appId: string): Promise { + const instance = this.instances.get(appId); + if (!instance) { + logger.warn(`Mini app "${appId}" is not running`); + return; + } + + logger.info(`Closing mini app: ${appId}`); + + try { + // Clean up controller + await instance.controller.cleanup(); + + // Uninstall bridge + await instance.bridge.uninstall(); + + // Remove the webapp + const removeTool = new RemoveWebAppTool(); + await removeTool.execute({ webappId: instance.webappId, reasoning: `Closing mini app: ${appId}` }); + + // Remove from instances + this.instances.delete(appId); + + // Emit close event + MiniAppEventBus.getInstance().emit({ + type: 'app_closed', + appId, + timestamp: new Date(), + }); + + logger.info(`Successfully closed mini app: ${appId}`); + + } catch (error) { + logger.error(`Error closing mini app "${appId}":`, error); + // Still remove from instances to avoid stuck state + this.instances.delete(appId); + throw error; + } + } + + /** + * Close all running mini apps + */ + static async closeAll(): Promise { + const appIds = Array.from(this.instances.keys()); + logger.info(`Closing all mini apps: ${appIds.join(', ') || 'none running'}`); + + for (const appId of appIds) { + try { + await this.close(appId); + } catch (error) { + logger.error(`Error closing mini app "${appId}":`, error); + } + } + } + + /** + * Wrap SPA JavaScript with the standard mini app protocol + * + * This adds the window.miniApp interface that all SPAs must implement: + * - window.miniApp.dispatch(action) - receive actions from DevTools + * - window.miniApp.getState() - return current state to DevTools + */ + private static wrapSPAJavaScript(appId: string, originalJs: string): string { + const bindingName = `__miniAppBridge_${appId}`; + + const wrapper = ` +// ============================================================================ +// Mini App Protocol Wrapper (auto-injected) +// ============================================================================ +(function() { + // Internal state storage + let __miniAppState = {}; + + // Mini App interface (called by DevTools) + window.miniApp = { + // Dispatch an action from DevTools to the SPA + dispatch: function(action) { + if (typeof action === 'string') { + try { + action = JSON.parse(action); + } catch (e) { + console.error('[MiniApp] Failed to parse action:', e); + return; + } + } + + console.log('[MiniApp] Received action:', action.action); + + // Handle standard actions + switch (action.action) { + case 'get-state': + // State is returned via getState() method + break; + + case 'set-state': + __miniAppState = action.payload || {}; + if (typeof window.onMiniAppStateChange === 'function') { + window.onMiniAppStateChange(__miniAppState); + } + break; + + case 'update-state': + __miniAppState = { ...__miniAppState, ...(action.payload || {}) }; + if (typeof window.onMiniAppStateChange === 'function') { + window.onMiniAppStateChange(__miniAppState); + } + break; + + case 'execute': + if (typeof window.onMiniAppAction === 'function') { + const { actionName, args } = action.payload || {}; + window.onMiniAppAction(actionName, args); + } + break; + + default: + // Forward to custom handler if defined + if (typeof window.onMiniAppDispatch === 'function') { + window.onMiniAppDispatch(action); + } + } + }, + + // Get current state (called by DevTools) + getState: function() { + // Allow SPA to provide custom state getter + if (typeof window.getMiniAppState === 'function') { + return window.getMiniAppState(); + } + return __miniAppState; + }, + + // Update state from SPA code + setState: function(newState) { + __miniAppState = newState; + // Notify DevTools of state change + window.${bindingName}(JSON.stringify({ + type: 'state-changed', + state: __miniAppState + })); + }, + + // Update state partially from SPA code + updateState: function(updates) { + __miniAppState = { ...__miniAppState, ...updates }; + // Notify DevTools of state change + window.${bindingName}(JSON.stringify({ + type: 'state-changed', + state: __miniAppState + })); + }, + + // Send action to DevTools + sendAction: function(type, payload) { + window.${bindingName}(JSON.stringify({ type, payload })); + }, + + // Close the mini app + close: function() { + window.${bindingName}(JSON.stringify({ type: 'close' })); + } + }; + + // Signal that mini app is ready + window.addEventListener('DOMContentLoaded', function() { + setTimeout(function() { + window.${bindingName}(JSON.stringify({ type: 'ready' })); + }, 100); + }); + + // If DOM is already loaded, signal ready immediately + if (document.readyState !== 'loading') { + setTimeout(function() { + window.${bindingName}(JSON.stringify({ type: 'ready' })); + }, 100); + } +})(); +// ============================================================================ +// End Mini App Protocol Wrapper +// ============================================================================ + +`; + + return wrapper + '\n' + originalJs; + } +} diff --git a/front_end/panels/ai_chat/mini_apps/MiniAppStorageManager.ts b/front_end/panels/ai_chat/mini_apps/MiniAppStorageManager.ts new file mode 100644 index 0000000000..c1cd9b3c9d --- /dev/null +++ b/front_end/panels/ai_chat/mini_apps/MiniAppStorageManager.ts @@ -0,0 +1,300 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../core/Logger.js'; +import type { MiniAppStorageEntry } from './types/MiniAppTypes.js'; + +const logger = createLogger('MiniAppStorageManager'); + +const DATABASE_NAME = 'mini_apps_storage_db'; +const DATABASE_VERSION = 1; +const OBJECT_STORE_NAME = 'miniAppData'; + +/** + * MiniAppStorageManager - Unified IndexedDB storage for all mini apps + * + * Provides a scoped key-value store where each mini app's data is + * automatically namespaced by its appId. + * + * Storage schema: + * - Primary key: composite of (appId, key) + * - Index on appId for efficient app-scoped queries + */ +export class MiniAppStorageManager { + private static instance: MiniAppStorageManager | null = null; + + private db: IDBDatabase | null = null; + private dbInitializationPromise: Promise | null = null; + + private constructor() { + logger.info('Initialized MiniAppStorageManager'); + } + + static getInstance(): MiniAppStorageManager { + if (!MiniAppStorageManager.instance) { + MiniAppStorageManager.instance = new MiniAppStorageManager(); + } + return MiniAppStorageManager.instance; + } + + // ============================================================================ + // Public API + // ============================================================================ + + /** + * Get a value for a specific app and key + */ + async get(appId: string, key: string): Promise { + const db = await this.ensureDatabase(); + const transaction = db.transaction(OBJECT_STORE_NAME, 'readonly'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + + const compositeKey = this.makeCompositeKey(appId, key); + const entry = await this.requestToPromise(store.get(compositeKey)); + await this.transactionComplete(transaction); + + return entry?.value; + } + + /** + * Set a value for a specific app and key + */ + async set(appId: string, key: string, value: unknown): Promise { + const db = await this.ensureDatabase(); + const transaction = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + + const entry: MiniAppStorageEntry = { + appId, + key, + value, + updatedAt: new Date().toISOString(), + }; + + const compositeKey = this.makeCompositeKey(appId, key); + await this.requestToPromise(store.put({ ...entry, id: compositeKey })); + await this.transactionComplete(transaction); + + logger.debug(`Set storage for "${appId}": ${key}`); + } + + /** + * Delete a value for a specific app and key + */ + async delete(appId: string, key: string): Promise { + const db = await this.ensureDatabase(); + const transaction = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + + const compositeKey = this.makeCompositeKey(appId, key); + await this.requestToPromise(store.delete(compositeKey)); + await this.transactionComplete(transaction); + + logger.debug(`Deleted storage for "${appId}": ${key}`); + } + + /** + * Get all key-value pairs for a specific app + */ + async getAll(appId: string): Promise> { + const db = await this.ensureDatabase(); + const transaction = db.transaction(OBJECT_STORE_NAME, 'readonly'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + const index = store.index('appId'); + + const entries = await this.requestToPromise(index.getAll(appId)); + await this.transactionComplete(transaction); + + const result: Record = {}; + for (const entry of entries || []) { + result[entry.key] = entry.value; + } + + return result; + } + + /** + * Clear all data for a specific app + */ + async clear(appId: string): Promise { + const db = await this.ensureDatabase(); + const transaction = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + const index = store.index('appId'); + + // Get all keys for this app + const keysRequest = index.getAllKeys(appId); + const keys = await this.requestToPromise(keysRequest); + + // Delete each entry + for (const key of keys || []) { + await this.requestToPromise(store.delete(key)); + } + + await this.transactionComplete(transaction); + logger.info(`Cleared all storage for "${appId}"`); + } + + /** + * Check if a key exists for a specific app + */ + async has(appId: string, key: string): Promise { + const value = await this.get(appId, key); + return value !== undefined; + } + + /** + * Get all keys for a specific app + */ + async keys(appId: string): Promise { + const db = await this.ensureDatabase(); + const transaction = db.transaction(OBJECT_STORE_NAME, 'readonly'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + const index = store.index('appId'); + + const entries = await this.requestToPromise(index.getAll(appId)); + await this.transactionComplete(transaction); + + return (entries || []).map(e => e.key); + } + + /** + * Get the count of entries for a specific app + */ + async count(appId: string): Promise { + const db = await this.ensureDatabase(); + const transaction = db.transaction(OBJECT_STORE_NAME, 'readonly'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + const index = store.index('appId'); + + const count = await this.requestToPromise(index.count(appId)); + await this.transactionComplete(transaction); + + return count; + } + + // ============================================================================ + // Batch Operations + // ============================================================================ + + /** + * Set multiple values at once for a specific app + */ + async setMany(appId: string, entries: Record): Promise { + const db = await this.ensureDatabase(); + const transaction = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + + const now = new Date().toISOString(); + + for (const [key, value] of Object.entries(entries)) { + const entry: MiniAppStorageEntry = { + appId, + key, + value, + updatedAt: now, + }; + const compositeKey = this.makeCompositeKey(appId, key); + await this.requestToPromise(store.put({ ...entry, id: compositeKey })); + } + + await this.transactionComplete(transaction); + logger.debug(`Set ${Object.keys(entries).length} entries for "${appId}"`); + } + + /** + * Delete multiple keys at once for a specific app + */ + async deleteMany(appId: string, keys: string[]): Promise { + const db = await this.ensureDatabase(); + const transaction = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + const store = transaction.objectStore(OBJECT_STORE_NAME); + + for (const key of keys) { + const compositeKey = this.makeCompositeKey(appId, key); + await this.requestToPromise(store.delete(compositeKey)); + } + + await this.transactionComplete(transaction); + logger.debug(`Deleted ${keys.length} entries for "${appId}"`); + } + + // ============================================================================ + // Private Helpers + // ============================================================================ + + private makeCompositeKey(appId: string, key: string): string { + return `${appId}::${key}`; + } + + private async ensureDatabase(): Promise { + if (this.db) { + return this.db; + } + + if (!('indexedDB' in globalThis)) { + throw new Error('IndexedDB is not supported in this environment.'); + } + + if (this.dbInitializationPromise) { + this.db = await this.dbInitializationPromise; + return this.db; + } + + this.dbInitializationPromise = this.openDatabase(); + + try { + this.db = await this.dbInitializationPromise; + return this.db; + } catch (error) { + this.dbInitializationPromise = null; + logger.error('Failed to open IndexedDB database', { error }); + throw error; + } + } + + private openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + logger.info('Initializing mini apps storage database'); + + if (!db.objectStoreNames.contains(OBJECT_STORE_NAME)) { + const store = db.createObjectStore(OBJECT_STORE_NAME, { keyPath: 'id' }); + // Index for querying by appId + store.createIndex('appId', 'appId', { unique: false }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error || new Error('Failed to open IndexedDB')); + }; + + request.onblocked = () => { + logger.warn('Mini apps storage database open request was blocked.'); + }; + }); + } + + private requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error('IndexedDB request failed')); + }); + } + + private transactionComplete(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error || new Error('IndexedDB transaction failed')); + transaction.onabort = () => reject(transaction.error || new Error('IndexedDB transaction aborted')); + }); + } +} diff --git a/front_end/panels/ai_chat/mini_apps/__tests__/GenericMiniAppBridge.test.ts b/front_end/panels/ai_chat/mini_apps/__tests__/GenericMiniAppBridge.test.ts new file mode 100644 index 0000000000..a35f90439a --- /dev/null +++ b/front_end/panels/ai_chat/mini_apps/__tests__/GenericMiniAppBridge.test.ts @@ -0,0 +1,223 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as SDK from '../../../../core/sdk/sdk.js'; +import { createTarget, stubNoopSettings } from '../../../../testing/EnvironmentHelpers.js'; +import { describeWithMockConnection, setMockConnectionResponseHandler } from '../../../../testing/MockConnection.js'; + +import { GenericMiniAppBridge } from '../GenericMiniAppBridge.js'; +import type { DevToolsToSPAAction } from '../types/MiniAppTypes.js'; + +describe('GenericMiniAppBridge', () => { + describeWithMockConnection('bridge operations', () => { + let target: SDK.Target.Target; + + beforeEach(() => { + stubNoopSettings(); + target = createTarget(); + }); + + describe('install', () => { + it('installs binding via Runtime.addBinding', async () => { + let addBindingCalled = false; + let bindingName = ''; + + setMockConnectionResponseHandler('Runtime.addBinding', (params) => { + addBindingCalled = true; + bindingName = params.name || ''; + return {}; + }); + + const bridge = new GenericMiniAppBridge('test-app'); + await bridge.install('webapp-123'); + + assert.isTrue(addBindingCalled); + assert.strictEqual(bindingName, '__miniAppBridge_test-app'); + assert.isTrue(bridge.installed); + assert.strictEqual(bridge.webappId, 'webapp-123'); + }); + + it('skips installation if already installed', async () => { + let addBindingCallCount = 0; + + setMockConnectionResponseHandler('Runtime.addBinding', () => { + addBindingCallCount++; + return {}; + }); + + const bridge = new GenericMiniAppBridge('test-app'); + await bridge.install('webapp-123'); + await bridge.install('webapp-456'); // Second call should be no-op + + assert.strictEqual(addBindingCallCount, 1); + assert.strictEqual(bridge.webappId, 'webapp-123'); // Original webappId preserved + }); + }); + + describe('uninstall', () => { + it('uninstalls cleanly on close', async () => { + let removeBindingCalled = false; + let removedBindingName = ''; + + setMockConnectionResponseHandler('Runtime.addBinding', () => ({})); + setMockConnectionResponseHandler('Runtime.removeBinding', (params) => { + removeBindingCalled = true; + removedBindingName = params.name || ''; + return {}; + }); + + const bridge = new GenericMiniAppBridge('test-app'); + await bridge.install('webapp-123'); + await bridge.uninstall(); + + assert.isTrue(removeBindingCalled); + assert.strictEqual(removedBindingName, '__miniAppBridge_test-app'); + assert.isFalse(bridge.installed); + assert.isNull(bridge.webappId); + }); + + it('handles uninstall when not installed', async () => { + const bridge = new GenericMiniAppBridge('test-app'); + + // Should not throw + await bridge.uninstall(); + + assert.isFalse(bridge.installed); + }); + }); + + describe('sendToSPA', () => { + it('sends actions to SPA via Runtime.evaluate', async () => { + let evaluatedExpression = ''; + + setMockConnectionResponseHandler('Runtime.addBinding', () => ({})); + setMockConnectionResponseHandler('Runtime.evaluate', (params) => { + evaluatedExpression = params.expression || ''; + return { + result: { type: 'boolean', value: true }, + }; + }); + + const bridge = new GenericMiniAppBridge('test-app'); + await bridge.install('webapp-123'); + + const action: DevToolsToSPAAction = { + action: 'set-state', + payload: { key: 'value' }, + }; + await bridge.sendToSPA(action); + + assert.include(evaluatedExpression, 'webapp-123'); + assert.include(evaluatedExpression, 'miniApp.dispatch'); + assert.include(evaluatedExpression, 'set-state'); + }); + + it('handles send when not installed', async () => { + const bridge = new GenericMiniAppBridge('test-app'); + + // Should not throw, just log error + await bridge.sendToSPA({ action: 'test' }); + + assert.isFalse(bridge.installed); + }); + }); + + describe('getState', () => { + it('retrieves state from SPA via Runtime.evaluate', async () => { + const mockState = { key1: 'value1', count: 42 }; + + setMockConnectionResponseHandler('Runtime.addBinding', () => ({})); + setMockConnectionResponseHandler('Runtime.evaluate', () => ({ + result: { + type: 'object', + value: mockState, + }, + })); + + const bridge = new GenericMiniAppBridge('test-app'); + await bridge.install('webapp-123'); + + const state = await bridge.getState(); + + assert.deepEqual(state, mockState); + }); + + it('returns empty object when not installed', async () => { + const bridge = new GenericMiniAppBridge('test-app'); + + const state = await bridge.getState(); + + assert.deepEqual(state, {}); + }); + + it('handles evaluation errors gracefully', async () => { + setMockConnectionResponseHandler('Runtime.addBinding', () => ({})); + setMockConnectionResponseHandler('Runtime.evaluate', () => ({ + result: { type: 'undefined' }, + exceptionDetails: { + text: 'Evaluation failed', + lineNumber: 0, + columnNumber: 0, + }, + })); + + const bridge = new GenericMiniAppBridge('test-app'); + await bridge.install('webapp-123'); + + const state = await bridge.getState(); + + assert.deepEqual(state, {}); + }); + }); + + describe('onAction', () => { + it('registers action handler for SPA actions', async () => { + setMockConnectionResponseHandler('Runtime.addBinding', () => ({})); + + const bridge = new GenericMiniAppBridge('test-app'); + const actionHandler = sinon.stub(); + + bridge.onAction(actionHandler); + await bridge.install('webapp-123'); + + // The handler is registered but we can't easily simulate + // BindingCalled events in tests without more complex mocking + // This test verifies the handler can be set without error + assert.isTrue(bridge.installed); + }); + }); + + describe('properties', () => { + it('reports correct installed state', async () => { + setMockConnectionResponseHandler('Runtime.addBinding', () => ({})); + setMockConnectionResponseHandler('Runtime.removeBinding', () => ({})); + + const bridge = new GenericMiniAppBridge('test-app'); + + assert.isFalse(bridge.installed); + + await bridge.install('webapp-123'); + assert.isTrue(bridge.installed); + + await bridge.uninstall(); + assert.isFalse(bridge.installed); + }); + + it('tracks webappId correctly', async () => { + setMockConnectionResponseHandler('Runtime.addBinding', () => ({})); + setMockConnectionResponseHandler('Runtime.removeBinding', () => ({})); + + const bridge = new GenericMiniAppBridge('test-app'); + + assert.isNull(bridge.webappId); + + await bridge.install('webapp-123'); + assert.strictEqual(bridge.webappId, 'webapp-123'); + + await bridge.uninstall(); + assert.isNull(bridge.webappId); + }); + }); + }); +}); diff --git a/front_end/panels/ai_chat/mini_apps/__tests__/MiniAppEventBus.test.ts b/front_end/panels/ai_chat/mini_apps/__tests__/MiniAppEventBus.test.ts new file mode 100644 index 0000000000..00fe820cbb --- /dev/null +++ b/front_end/panels/ai_chat/mini_apps/__tests__/MiniAppEventBus.test.ts @@ -0,0 +1,295 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { MiniAppEventBus } from '../MiniAppEventBus.js'; +import type { MiniAppEvent } from '../types/MiniAppTypes.js'; + +describe('MiniAppEventBus', () => { + let eventBus: MiniAppEventBus; + + beforeEach(() => { + // Get the singleton instance + eventBus = MiniAppEventBus.getInstance(); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('singleton', () => { + it('returns the same instance', () => { + const instance1 = MiniAppEventBus.getInstance(); + const instance2 = MiniAppEventBus.getInstance(); + + assert.strictEqual(instance1, instance2); + }); + }); + + describe('emit and subscribe', () => { + it('emits and receives events', () => { + const callback = sinon.stub(); + const unsubscribe = eventBus.subscribe(callback); + + const event: MiniAppEvent = { + type: 'app_launched', + appId: 'test-app', + timestamp: new Date(), + data: { webappId: 'webapp-123' }, + }; + + eventBus.emit(event); + + sinon.assert.calledOnce(callback); + sinon.assert.calledWith(callback, event); + + unsubscribe(); + }); + + it('unsubscribe stops receiving events', () => { + const callback = sinon.stub(); + const unsubscribe = eventBus.subscribe(callback); + + eventBus.emit({ + type: 'app_launched', + appId: 'test-app', + timestamp: new Date(), + }); + + sinon.assert.calledOnce(callback); + + unsubscribe(); + + eventBus.emit({ + type: 'app_closed', + appId: 'test-app', + timestamp: new Date(), + }); + + // Should still be called only once (from before unsubscribe) + sinon.assert.calledOnce(callback); + }); + }); + + describe('subscribeToApp', () => { + it('filters by app ID', () => { + const callback = sinon.stub(); + const unsubscribe = eventBus.subscribeToApp('target-app', callback); + + // Event for different app - should not trigger + eventBus.emit({ + type: 'app_launched', + appId: 'other-app', + timestamp: new Date(), + }); + + sinon.assert.notCalled(callback); + + // Event for target app - should trigger + eventBus.emit({ + type: 'app_launched', + appId: 'target-app', + timestamp: new Date(), + }); + + sinon.assert.calledOnce(callback); + + unsubscribe(); + }); + }); + + describe('subscribeToType', () => { + it('filters by event type', () => { + const callback = sinon.stub(); + const unsubscribe = eventBus.subscribeToType('state_changed', callback); + + // Different event type - should not trigger + eventBus.emit({ + type: 'app_launched', + appId: 'test-app', + timestamp: new Date(), + }); + + sinon.assert.notCalled(callback); + + // Target event type - should trigger + eventBus.emit({ + type: 'state_changed', + appId: 'test-app', + timestamp: new Date(), + data: { key: 'value' }, + }); + + sinon.assert.calledOnce(callback); + + unsubscribe(); + }); + }); + + describe('subscribeToAppEvent', () => { + it('filters by both app ID and event type', () => { + const callback = sinon.stub(); + const unsubscribe = eventBus.subscribeToAppEvent('target-app', 'state_changed', callback); + + // Different app - should not trigger + eventBus.emit({ + type: 'state_changed', + appId: 'other-app', + timestamp: new Date(), + }); + + // Different event type - should not trigger + eventBus.emit({ + type: 'app_launched', + appId: 'target-app', + timestamp: new Date(), + }); + + sinon.assert.notCalled(callback); + + // Matching app and event type - should trigger + eventBus.emit({ + type: 'state_changed', + appId: 'target-app', + timestamp: new Date(), + data: { key: 'value' }, + }); + + sinon.assert.calledOnce(callback); + + unsubscribe(); + }); + }); + + describe('waitForEvent', () => { + it('resolves on matching event', async () => { + const promise = eventBus.waitForEvent('test-app', 'app_launched'); + + // Emit the event after a small delay + setTimeout(() => { + eventBus.emit({ + type: 'app_launched', + appId: 'test-app', + timestamp: new Date(), + data: { webappId: 'webapp-123' }, + }); + }, 10); + + const event = await promise; + + assert.strictEqual(event.type, 'app_launched'); + assert.strictEqual(event.appId, 'test-app'); + }); + + it('rejects on timeout', async () => { + const promise = eventBus.waitForEvent('test-app', 'app_launched', 50); + + try { + await promise; + assert.fail('Should have thrown timeout error'); + } catch (error) { + assert.match((error as Error).message, /Timeout/); + } + }); + + it('ignores non-matching events', async () => { + const promise = eventBus.waitForEvent('test-app', 'app_closed', 100); + + // Emit wrong event type + setTimeout(() => { + eventBus.emit({ + type: 'app_launched', + appId: 'test-app', + timestamp: new Date(), + }); + }, 10); + + // Emit correct event + setTimeout(() => { + eventBus.emit({ + type: 'app_closed', + appId: 'test-app', + timestamp: new Date(), + }); + }, 30); + + const event = await promise; + + assert.strictEqual(event.type, 'app_closed'); + }); + }); + + describe('helper emitters', () => { + it('emitLaunched creates app_launched event', () => { + const callback = sinon.stub(); + const unsubscribe = eventBus.subscribe(callback); + + eventBus.emitLaunched('test-app', { webappId: 'webapp-123' }); + + sinon.assert.calledOnce(callback); + const event = callback.firstCall.args[0] as MiniAppEvent; + assert.strictEqual(event.type, 'app_launched'); + assert.strictEqual(event.appId, 'test-app'); + assert.deepEqual(event.data, { webappId: 'webapp-123' }); + + unsubscribe(); + }); + + it('emitClosed creates app_closed event', () => { + const callback = sinon.stub(); + const unsubscribe = eventBus.subscribe(callback); + + eventBus.emitClosed('test-app'); + + sinon.assert.calledOnce(callback); + const event = callback.firstCall.args[0] as MiniAppEvent; + assert.strictEqual(event.type, 'app_closed'); + assert.strictEqual(event.appId, 'test-app'); + + unsubscribe(); + }); + + it('emitStateChanged creates state_changed event', () => { + const callback = sinon.stub(); + const unsubscribe = eventBus.subscribe(callback); + + const state = { key1: 'value1', count: 42 }; + eventBus.emitStateChanged('test-app', state); + + sinon.assert.calledOnce(callback); + const event = callback.firstCall.args[0] as MiniAppEvent; + assert.strictEqual(event.type, 'state_changed'); + assert.deepEqual(event.data, state); + + unsubscribe(); + }); + + it('emitError creates error event with Error object', () => { + const callback = sinon.stub(); + const unsubscribe = eventBus.subscribe(callback); + + eventBus.emitError('test-app', new Error('Something went wrong')); + + sinon.assert.calledOnce(callback); + const event = callback.firstCall.args[0] as MiniAppEvent; + assert.strictEqual(event.type, 'error'); + assert.deepEqual(event.data, { error: 'Something went wrong' }); + + unsubscribe(); + }); + + it('emitError creates error event with string', () => { + const callback = sinon.stub(); + const unsubscribe = eventBus.subscribe(callback); + + eventBus.emitError('test-app', 'Error message'); + + sinon.assert.calledOnce(callback); + const event = callback.firstCall.args[0] as MiniAppEvent; + assert.strictEqual(event.type, 'error'); + assert.deepEqual(event.data, { error: 'Error message' }); + + unsubscribe(); + }); + }); +}); diff --git a/front_end/panels/ai_chat/mini_apps/__tests__/MiniAppRegistry.test.ts b/front_end/panels/ai_chat/mini_apps/__tests__/MiniAppRegistry.test.ts new file mode 100644 index 0000000000..47fb4cf9d0 --- /dev/null +++ b/front_end/panels/ai_chat/mini_apps/__tests__/MiniAppRegistry.test.ts @@ -0,0 +1,322 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { MiniAppRegistry } from '../MiniAppRegistry.js'; +import { MiniAppEventBus } from '../MiniAppEventBus.js'; +import { RenderWebAppTool } from '../../tools/RenderWebAppTool.js'; +import { RemoveWebAppTool } from '../../tools/RemoveWebAppTool.js'; +import { GenericMiniAppBridge } from '../GenericMiniAppBridge.js'; +import type { + MiniApp, + MiniAppController, + MiniAppActionSchema, + MiniAppStateSchema, +} from '../types/MiniAppTypes.js'; + +// ============================================================================ +// Mock Factories +// ============================================================================ + +function createMockController(overrides?: Partial): MiniAppController { + return { + initialize: sinon.stub().resolves(), + getState: sinon.stub().resolves({ testKey: 'testValue' }), + setState: sinon.stub().resolves(), + updateState: sinon.stub().resolves(), + executeAction: sinon.stub().resolves({ result: 'action-result' }), + cleanup: sinon.stub().resolves(), + onClose: sinon.stub(), + ...overrides, + }; +} + +function createMockMiniApp(id: string, overrides?: Partial): MiniApp { + const supportedActions: MiniAppActionSchema[] = [ + { + name: 'test-action', + description: 'A test action', + schema: { type: 'object', properties: {} }, + }, + ]; + + const stateSchema: MiniAppStateSchema = { + type: 'object', + properties: { + testKey: { type: 'string', description: 'A test key' }, + }, + }; + + return { + id, + name: `Test App ${id}`, + description: `Test mini app ${id}`, + icon: '🧪', + getSPA: () => ({ html: '
test
', css: '.test {}', js: 'console.log("test");' }), + getSupportedActions: () => supportedActions, + getStateSchema: () => stateSchema, + createController: () => createMockController(), + ...overrides, + }; +} + +// ============================================================================ +// Test Utilities +// ============================================================================ + +function resetRegistry(): void { + // Access private static fields to reset state between tests + (MiniAppRegistry as any).apps = new Map(); + (MiniAppRegistry as any).instances = new Map(); +} + +// ============================================================================ +// MiniAppRegistry Tests +// ============================================================================ + +describe('MiniAppRegistry', () => { + beforeEach(() => { + resetRegistry(); + }); + + afterEach(() => { + sinon.restore(); + resetRegistry(); + }); + + describe('register and getApp', () => { + it('registers and retrieves mini app definitions', () => { + const app = createMockMiniApp('test-app'); + + MiniAppRegistry.register(app); + + const retrieved = MiniAppRegistry.getApp('test-app'); + assert.isDefined(retrieved); + assert.strictEqual(retrieved?.id, 'test-app'); + assert.strictEqual(retrieved?.name, 'Test App test-app'); + }); + + it('returns undefined for unregistered app', () => { + const retrieved = MiniAppRegistry.getApp('non-existent'); + assert.isUndefined(retrieved); + }); + + it('replaces existing app when re-registering with same id', () => { + const app1 = createMockMiniApp('test-app', { name: 'First App' }); + const app2 = createMockMiniApp('test-app', { name: 'Second App' }); + + MiniAppRegistry.register(app1); + MiniAppRegistry.register(app2); + + const retrieved = MiniAppRegistry.getApp('test-app'); + assert.strictEqual(retrieved?.name, 'Second App'); + }); + }); + + describe('getAllApps', () => { + it('returns all registered apps', () => { + MiniAppRegistry.register(createMockMiniApp('app1')); + MiniAppRegistry.register(createMockMiniApp('app2')); + MiniAppRegistry.register(createMockMiniApp('app3')); + + const apps = MiniAppRegistry.getAllApps(); + assert.strictEqual(apps.length, 3); + }); + + it('returns empty array when no apps registered', () => { + const apps = MiniAppRegistry.getAllApps(); + assert.deepEqual(apps, []); + }); + }); + + describe('launch', () => { + let renderToolStub: sinon.SinonStub; + let bridgeInstallStub: sinon.SinonStub; + let eventBusEmitStub: sinon.SinonStub; + + beforeEach(() => { + // Mock RenderWebAppTool + renderToolStub = sinon.stub(RenderWebAppTool.prototype, 'execute').resolves({ + success: true, + webappId: 'webapp-123', + message: 'Rendered', + }); + + // Mock GenericMiniAppBridge + bridgeInstallStub = sinon.stub(GenericMiniAppBridge.prototype, 'install').resolves(); + + // Mock EventBus + const mockEventBus = { + emit: sinon.stub(), + }; + eventBusEmitStub = mockEventBus.emit; + sinon.stub(MiniAppEventBus, 'getInstance').returns(mockEventBus as any); + }); + + it('launches app with full lifecycle (bridge + controller init)', async () => { + const controller = createMockController(); + const app = createMockMiniApp('test-app', { + createController: () => controller, + }); + MiniAppRegistry.register(app); + + const instance = await MiniAppRegistry.launch('test-app'); + + assert.strictEqual(instance.app.id, 'test-app'); + assert.strictEqual(instance.webappId, 'webapp-123'); + assert.isDefined(instance.launchedAt); + sinon.assert.calledOnce(renderToolStub); + sinon.assert.calledOnce(bridgeInstallStub); + sinon.assert.calledOnce(controller.initialize as sinon.SinonStub); + sinon.assert.calledOnce(controller.onClose as sinon.SinonStub); + }); + + it('enforces single instance per app type', async () => { + const app = createMockMiniApp('test-app'); + MiniAppRegistry.register(app); + + const instance1 = await MiniAppRegistry.launch('test-app'); + const instance2 = await MiniAppRegistry.launch('test-app'); + + assert.strictEqual(instance1, instance2); + // RenderWebAppTool should only be called once + sinon.assert.calledOnce(renderToolStub); + }); + + it('emits app_launched event on launch', async () => { + const app = createMockMiniApp('test-app'); + MiniAppRegistry.register(app); + + await MiniAppRegistry.launch('test-app'); + + sinon.assert.calledOnce(eventBusEmitStub); + const emittedEvent = eventBusEmitStub.firstCall.args[0]; + assert.strictEqual(emittedEvent.type, 'app_launched'); + assert.strictEqual(emittedEvent.appId, 'test-app'); + }); + + it('throws error when app not registered', async () => { + try { + await MiniAppRegistry.launch('unknown-app'); + assert.fail('Should have thrown'); + } catch (error) { + assert.match((error as Error).message, /not registered/); + } + }); + + it('wraps SPA JavaScript with mini app protocol', async () => { + const app = createMockMiniApp('test-app'); + MiniAppRegistry.register(app); + + await MiniAppRegistry.launch('test-app'); + + const renderCall = renderToolStub.firstCall.args[0]; + assert.include(renderCall.js, 'window.miniApp'); + assert.include(renderCall.js, '__miniAppBridge_test-app'); + assert.include(renderCall.js, 'dispatch'); + assert.include(renderCall.js, 'getState'); + }); + }); + + describe('close', () => { + let renderToolStub: sinon.SinonStub; + let removeToolStub: sinon.SinonStub; + let bridgeUninstallStub: sinon.SinonStub; + let eventBusEmitStub: sinon.SinonStub; + + beforeEach(() => { + renderToolStub = sinon.stub(RenderWebAppTool.prototype, 'execute').resolves({ + success: true, + webappId: 'webapp-123', + message: 'Rendered', + }); + + removeToolStub = sinon.stub(RemoveWebAppTool.prototype, 'execute').resolves({ + success: true, + removed: ['webapp-123'], + message: 'Removed', + }); + + bridgeUninstallStub = sinon.stub(GenericMiniAppBridge.prototype, 'uninstall').resolves(); + sinon.stub(GenericMiniAppBridge.prototype, 'install').resolves(); + + const mockEventBus = { + emit: sinon.stub(), + }; + eventBusEmitStub = mockEventBus.emit; + sinon.stub(MiniAppEventBus, 'getInstance').returns(mockEventBus as any); + }); + + it('closes app with proper cleanup', async () => { + const controller = createMockController(); + const app = createMockMiniApp('test-app', { + createController: () => controller, + }); + MiniAppRegistry.register(app); + + await MiniAppRegistry.launch('test-app'); + await MiniAppRegistry.close('test-app'); + + sinon.assert.calledOnce(controller.cleanup as sinon.SinonStub); + sinon.assert.calledOnce(bridgeUninstallStub); + sinon.assert.calledOnce(removeToolStub); + assert.isFalse(MiniAppRegistry.isRunning('test-app')); + }); + + it('emits app_closed event on close', async () => { + const app = createMockMiniApp('test-app'); + MiniAppRegistry.register(app); + + await MiniAppRegistry.launch('test-app'); + eventBusEmitStub.resetHistory(); + await MiniAppRegistry.close('test-app'); + + sinon.assert.calledOnce(eventBusEmitStub); + const emittedEvent = eventBusEmitStub.firstCall.args[0]; + assert.strictEqual(emittedEvent.type, 'app_closed'); + assert.strictEqual(emittedEvent.appId, 'test-app'); + }); + + it('handles close of non-running app gracefully', async () => { + // Should not throw + await MiniAppRegistry.close('non-running'); + assert.isFalse(MiniAppRegistry.isRunning('non-running')); + }); + }); + + describe('isRunning and getRunningInstance', () => { + beforeEach(() => { + sinon.stub(RenderWebAppTool.prototype, 'execute').resolves({ + success: true, + webappId: 'webapp-123', + message: 'Rendered', + }); + sinon.stub(GenericMiniAppBridge.prototype, 'install').resolves(); + sinon.stub(MiniAppEventBus, 'getInstance').returns({ emit: sinon.stub() } as any); + }); + + it('correctly reports running status', async () => { + const app = createMockMiniApp('test-app'); + MiniAppRegistry.register(app); + + assert.isFalse(MiniAppRegistry.isRunning('test-app')); + + await MiniAppRegistry.launch('test-app'); + + assert.isTrue(MiniAppRegistry.isRunning('test-app')); + }); + + it('returns running instance', async () => { + const app = createMockMiniApp('test-app'); + MiniAppRegistry.register(app); + + assert.isUndefined(MiniAppRegistry.getRunningInstance('test-app')); + + await MiniAppRegistry.launch('test-app'); + + const instance = MiniAppRegistry.getRunningInstance('test-app'); + assert.isDefined(instance); + assert.strictEqual(instance?.app.id, 'test-app'); + }); + }); +}); diff --git a/front_end/panels/ai_chat/mini_apps/apps/agent_studio/AgentStudioMiniApp.ts b/front_end/panels/ai_chat/mini_apps/apps/agent_studio/AgentStudioMiniApp.ts new file mode 100644 index 0000000000..1bb58260c4 --- /dev/null +++ b/front_end/panels/ai_chat/mini_apps/apps/agent_studio/AgentStudioMiniApp.ts @@ -0,0 +1,675 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../../../core/Logger.js'; +import { AgentStorageManager, type CreateAgentInput, type SchemaProperty } from '../../../core/AgentStorageManager.js'; +import { AgentStudioIntegration, type AgentDisplayInfo } from '../../../core/AgentStudioIntegration.js'; +import { ToolRegistry } from '../../../agent_framework/ConfigurableAgentTool.js'; +import type { + MiniApp, + MiniAppSPA, + MiniAppController, + MiniAppBridge, + MiniAppState, + MiniAppActionSchema, + MiniAppStateSchema, + SPAToDevToolsAction, +} from '../../types/MiniAppTypes.js'; +import { MiniAppEventBus } from '../../MiniAppEventBus.js'; +import { AgentStudioSPA } from '../../../ui/agent_studio/AgentStudioSPA.js'; + +const logger = createLogger('AgentStudioMiniApp'); + +/** + * Agent info for the SPA + */ +interface AgentInfo { + id?: string; + name: string; + displayName: string; + description: string; + avatar: string; + color: string; + backgroundColor: string; + isBuiltIn: boolean; + tools: string[]; + maxIterations: number; + temperature: number; + systemPrompt: string; + version: string; + schema: object; +} + +/** + * Tool info for the SPA + */ +interface ToolInfo { + name: string; + description: string; +} + +/** + * Form data for saving agents + */ +interface AgentFormData { + name: string; + displayName: string; + description: string; + avatar: string; + color: string; + systemPrompt: string; + tools: string[]; + maxIterations: number; + temperature: number; + schema: object; +} + +/** + * AgentStudioMiniApp - Wrapper for Agent Studio as a mini app + * + * This wraps the existing AgentStudio SPA to work with the mini app system + * while maintaining backward compatibility with the existing implementation. + */ +export class AgentStudioMiniApp implements MiniApp { + id = 'agent_studio'; + name = 'Agent Studio'; + description = 'Create and manage custom AI agents with configurable tools, prompts, and behaviors. View built-in agents or create custom ones.'; + icon = '🤖'; + + getSPA(): MiniAppSPA { + return { + html: AgentStudioSPA.html, + css: AgentStudioSPA.css, + // We need to use the existing JS since it already has the agentStudio binding + // The MiniAppRegistry will wrap this with the miniApp protocol + js: AgentStudioSPA.js, + }; + } + + getSupportedActions(): MiniAppActionSchema[] { + return [ + { + name: 'select-agent', + description: 'Select an agent by name to view or edit', + schema: { + type: 'object', + properties: { + name: { type: 'string', description: 'The agent name to select' }, + }, + required: ['name'], + }, + }, + { + name: 'create-agent', + description: 'Start creating a new custom agent', + schema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'save-agent', + description: 'Save the current agent configuration', + schema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Agent name (lowercase, hyphens/underscores)' }, + displayName: { type: 'string', description: 'Human-readable display name' }, + description: { type: 'string', description: 'Agent description' }, + systemPrompt: { type: 'string', description: 'System prompt for the agent' }, + tools: { type: 'array', description: 'List of tool names the agent can use' }, + maxIterations: { type: 'number', description: 'Maximum iterations (1-100)' }, + temperature: { type: 'number', description: 'LLM temperature (0-2)' }, + }, + required: ['name', 'systemPrompt', 'tools'], + }, + }, + { + name: 'delete-agent', + description: 'Delete the currently selected custom agent', + schema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'list-agents', + description: 'Get a list of all agents (built-in and custom)', + schema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'list-tools', + description: 'Get a list of all available tools', + schema: { + type: 'object', + properties: {}, + }, + }, + ]; + } + + getStateSchema(): MiniAppStateSchema { + return { + type: 'object', + properties: { + agents: { + type: 'array', + description: 'List of all agents (built-in and custom)', + }, + tools: { + type: 'array', + description: 'List of available tools', + }, + selectedAgent: { + type: 'object', + description: 'Currently selected agent or null', + }, + isCreatingNew: { + type: 'boolean', + description: 'Whether a new agent is being created', + }, + }, + }; + } + + createController(): MiniAppController { + return new AgentStudioMiniAppController(); + } +} + +/** + * Controller for Agent Studio mini app + * + * Handles business logic and bridges between the mini app system + * and the existing Agent Studio infrastructure. + */ +class AgentStudioMiniAppController implements MiniAppController { + private bridge: MiniAppBridge | null = null; + private closeCallback: (() => void | Promise) | null = null; + + // State + private selectedAgentId: string | null = null; + private selectedAgentName: string | null = null; + private isCreatingNew = false; + + async initialize(bridge: MiniAppBridge): Promise { + this.bridge = bridge; + bridge.onAction(this.handleAction.bind(this)); + logger.info('AgentStudioMiniAppController initialized'); + } + + async cleanup(): Promise { + this.bridge = null; + this.selectedAgentId = null; + this.selectedAgentName = null; + this.isCreatingNew = false; + logger.info('AgentStudioMiniAppController cleaned up'); + } + + onClose(callback: () => void | Promise): void { + this.closeCallback = callback; + } + + async getState(): Promise { + const { agents, tools } = await this.loadAllData(); + + let selectedAgent: AgentInfo | null = null; + if (this.selectedAgentName) { + const allAgents = await AgentStudioIntegration.getAllAgentsForDisplay(); + const agent = allAgents.find(a => a.name === this.selectedAgentName); + if (agent) { + selectedAgent = this.toAgentInfo(agent); + } + } + + return { + agents, + tools, + selectedAgent, + isCreatingNew: this.isCreatingNew, + }; + } + + async setState(state: MiniAppState): Promise { + // Agent Studio state is mostly read-only from external perspective + // State changes happen through actions + if (state.selectedAgentName) { + this.selectedAgentName = state.selectedAgentName as string; + } + if (state.isCreatingNew !== undefined) { + this.isCreatingNew = state.isCreatingNew as boolean; + } + } + + async updateState(updates: Partial): Promise { + if (updates.selectedAgentName) { + this.selectedAgentName = updates.selectedAgentName as string; + } + if (updates.isCreatingNew !== undefined) { + this.isCreatingNew = updates.isCreatingNew as boolean; + } + } + + async executeAction(actionName: string, args: unknown): Promise { + const argsObj = args as Record; + + switch (actionName) { + case 'select-agent': + return this.handleSelectAgentAction(argsObj.name as string); + + case 'create-agent': + return this.handleNewAgentAction(); + + case 'save-agent': + return this.handleSaveAgentAction(argsObj as unknown as AgentFormData); + + case 'delete-agent': + return this.handleDeleteAgentAction(); + + case 'list-agents': + return this.handleListAgentsAction(); + + case 'list-tools': + return this.handleListToolsAction(); + + default: + throw new Error(`Unknown action: ${actionName}`); + } + } + + // ============================================================================ + // SPA Action Handlers (from the SPA via bridge) + // ============================================================================ + + async handleAction(action: SPAToDevToolsAction): Promise { + logger.info('Handling SPA action:', action.type); + + switch (action.type) { + case 'ready': + await this.pushInitialState(); + break; + + case 'select-agent': { + // SPA sends: { type: 'select-agent', name, id, isBuiltIn } + const actionData = action as SPAToDevToolsAction & { name: string; id: string | null; isBuiltIn: boolean }; + await this.handleSelectAgent(actionData.name, actionData.id, actionData.isBuiltIn); + break; + } + + case 'new-agent': + await this.handleNewAgent(); + break; + + case 'save-agent': { + // SPA sends: { type: 'save-agent', data: AgentFormData } + const actionData = action as SPAToDevToolsAction & { data: AgentFormData }; + await this.handleSaveAgent(actionData.data); + break; + } + + case 'delete-agent': + await this.handleDeleteAgent(); + break; + + case 'clone-agent': + await this.handleCloneAgent(); + break; + + case 'run-test': { + // SPA sends: { type: 'run-test', query: string } + const actionData = action as SPAToDevToolsAction & { query: string }; + await this.handleRunTest(actionData.query); + break; + } + + case 'close': + if (this.closeCallback) { + await this.closeCallback(); + } + break; + + case 'state-changed': + // State changed from SPA + MiniAppEventBus.getInstance().emitStateChanged('agent_studio', action.payload); + break; + + default: + logger.warn('Unknown SPA action type:', action.type); + } + } + + // ============================================================================ + // Action Implementations (for executeAction) + // ============================================================================ + + private async handleSelectAgentAction(name: string): Promise<{ success: boolean; agent?: AgentInfo }> { + const allAgents = await AgentStudioIntegration.getAllAgentsForDisplay(); + const agent = allAgents.find(a => a.name === name); + + if (!agent) { + return { success: false }; + } + + this.selectedAgentName = name; + this.selectedAgentId = agent.id || null; + this.isCreatingNew = false; + + // Update SPA + await this.bridge?.sendToSPA({ + action: 'agent-selected', + payload: { agent: this.toAgentInfo(agent) }, + }); + + return { success: true, agent: this.toAgentInfo(agent) }; + } + + private async handleNewAgentAction(): Promise<{ success: boolean }> { + this.isCreatingNew = true; + this.selectedAgentId = null; + this.selectedAgentName = null; + + await this.bridge?.sendToSPA({ + action: 'agent-selected', + payload: { agent: this.createEmptyAgent() }, + }); + + return { success: true }; + } + + private async handleSaveAgentAction(data: AgentFormData): Promise<{ success: boolean; agent?: AgentInfo; error?: string }> { + try { + const storageManager = AgentStorageManager.getInstance(); + + const input: CreateAgentInput = { + name: data.name, + description: data.description || '', + version: '1.0.0', + systemPrompt: data.systemPrompt, + tools: data.tools, + maxIterations: data.maxIterations || 10, + temperature: data.temperature || 0.7, + schema: (data.schema as { type: string; properties: Record; required?: string[] }) || { + type: 'object', + properties: { + query: { type: 'string', description: 'The user query' }, + }, + required: ['query'], + }, + ui: { + displayName: data.displayName || data.name, + avatar: data.avatar || '🤖', + color: data.color || '#3b82f6', + backgroundColor: '#e0e7ff', + }, + }; + + let savedAgent; + if (this.isCreatingNew) { + savedAgent = await storageManager.createAgent(input); + } else if (this.selectedAgentId) { + savedAgent = await storageManager.updateAgent(this.selectedAgentId, input); + } else { + return { success: false, error: 'No agent selected to update' }; + } + + // Refresh the tool registry + await AgentStudioIntegration.refreshAgents(); + + // Update state + this.selectedAgentId = savedAgent.id; + this.selectedAgentName = savedAgent.name; + this.isCreatingNew = false; + + return { success: true, agent: this.toAgentInfo(savedAgent as unknown as AgentDisplayInfo) }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + return { success: false, error: errorMsg }; + } + } + + private async handleDeleteAgentAction(): Promise<{ success: boolean; error?: string }> { + if (!this.selectedAgentId) { + return { success: false, error: 'No agent selected' }; + } + + try { + const storageManager = AgentStorageManager.getInstance(); + await storageManager.deleteAgent(this.selectedAgentId); + + // Refresh the tool registry + await AgentStudioIntegration.refreshAgents(); + + // Clear selection + this.selectedAgentId = null; + this.selectedAgentName = null; + + return { success: true }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + return { success: false, error: errorMsg }; + } + } + + private async handleListAgentsAction(): Promise<{ agents: AgentInfo[] }> { + const { agents } = await this.loadAllData(); + return { agents }; + } + + private async handleListToolsAction(): Promise<{ tools: ToolInfo[] }> { + const { tools } = await this.loadAllData(); + return { tools }; + } + + // ============================================================================ + // SPA-Triggered Handlers (legacy compatibility) + // ============================================================================ + + private async pushInitialState(): Promise { + const { agents, tools } = await this.loadAllData(); + + await this.bridge?.sendToSPA({ + action: 'init', + payload: { + agents, + tools, + selectedAgent: undefined, + }, + }); + + logger.info('Initial state pushed to SPA'); + } + + private async handleSelectAgent(name: string, id: string | null, _isBuiltIn: boolean): Promise { + this.isCreatingNew = false; + this.selectedAgentName = name; + this.selectedAgentId = id; + + const allAgents = await AgentStudioIntegration.getAllAgentsForDisplay(); + const agent = allAgents.find(a => a.name === name); + + if (agent) { + await this.bridge?.sendToSPA({ + action: 'agent-selected', + payload: { agent: this.toAgentInfo(agent) }, + }); + } + } + + private async handleNewAgent(): Promise { + this.isCreatingNew = true; + this.selectedAgentId = null; + this.selectedAgentName = null; + + await this.bridge?.sendToSPA({ + action: 'agent-selected', + payload: { agent: this.createEmptyAgent() }, + }); + } + + private async handleSaveAgent(data: AgentFormData): Promise { + const result = await this.handleSaveAgentAction(data); + + if (result.success) { + // Reload agents list + const { agents } = await this.loadAllData(); + await this.bridge?.sendToSPA({ + action: 'agents-updated', + payload: { agents }, + }); + + await this.bridge?.sendToSPA({ + action: 'notification', + payload: { message: 'Agent saved successfully!', type: 'success' }, + }); + } else { + await this.bridge?.sendToSPA({ + action: 'notification', + payload: { message: result.error || 'Failed to save agent', type: 'error' }, + }); + } + } + + private async handleDeleteAgent(): Promise { + const result = await this.handleDeleteAgentAction(); + + if (result.success) { + const { agents } = await this.loadAllData(); + await this.bridge?.sendToSPA({ + action: 'agents-updated', + payload: { agents }, + }); + + await this.bridge?.sendToSPA({ + action: 'notification', + payload: { message: 'Agent deleted successfully!', type: 'success' }, + }); + } else { + await this.bridge?.sendToSPA({ + action: 'notification', + payload: { message: result.error || 'Failed to delete agent', type: 'error' }, + }); + } + } + + private async handleCloneAgent(): Promise { + if (!this.selectedAgentName) { + await this.bridge?.sendToSPA({ + action: 'notification', + payload: { message: 'No agent selected to clone', type: 'error' }, + }); + return; + } + + const allAgents = await AgentStudioIntegration.getAllAgentsForDisplay(); + const agent = allAgents.find(a => a.name === this.selectedAgentName); + + if (!agent) { + await this.bridge?.sendToSPA({ + action: 'notification', + payload: { message: 'Agent not found', type: 'error' }, + }); + return; + } + + // Create a clone with modified name + const cloneName = `${agent.name}_copy`; + const cloneAgent: AgentInfo = { + ...this.toAgentInfo(agent), + id: undefined, + name: cloneName, + displayName: `${agent.displayName} (Copy)`, + isBuiltIn: false, + }; + + this.isCreatingNew = true; + this.selectedAgentId = null; + this.selectedAgentName = null; + + await this.bridge?.sendToSPA({ + action: 'agent-selected', + payload: { agent: cloneAgent }, + }); + + await this.bridge?.sendToSPA({ + action: 'notification', + payload: { message: 'Agent cloned. Save to create a new custom agent.', type: 'success' }, + }); + } + + private async handleRunTest(query: string): Promise { + // TODO: Implement agent testing + logger.info('Running test with query:', query); + + await this.bridge?.sendToSPA({ + action: 'test-result', + payload: { html: '

Agent testing is not yet implemented.

' }, + }); + } + + // ============================================================================ + // Helper Methods + // ============================================================================ + + private async loadAllData(): Promise<{ agents: AgentInfo[]; tools: ToolInfo[] }> { + const allAgents = await AgentStudioIntegration.getAllAgentsForDisplay(); + const agents: AgentInfo[] = allAgents.map(a => this.toAgentInfo(a)); + + const toolNames = AgentStudioIntegration.getAvailableToolNames(); + const tools: ToolInfo[] = toolNames.map(name => { + const tool = ToolRegistry.getRegisteredTool(name); + return { + name, + description: tool?.description || 'No description available', + }; + }); + + return { agents, tools }; + } + + private toAgentInfo(agent: AgentDisplayInfo): AgentInfo { + return { + id: agent.id, + name: agent.name, + displayName: agent.displayName, + description: agent.description, + avatar: agent.avatar, + color: agent.color, + backgroundColor: agent.backgroundColor, + isBuiltIn: agent.isBuiltIn, + tools: agent.tools, + maxIterations: agent.maxIterations, + temperature: agent.temperature, + systemPrompt: agent.systemPrompt, + version: agent.version, + schema: agent.schema, + }; + } + + private createEmptyAgent(): AgentInfo { + return { + name: '', + displayName: '', + description: '', + avatar: '🤖', + color: '#3b82f6', + backgroundColor: '#e0e7ff', + isBuiltIn: false, + tools: [], + maxIterations: 10, + temperature: 0.7, + systemPrompt: '', + version: '1.0.0', + schema: { + type: 'object', + properties: { + query: { type: 'string', description: 'The user query' }, + }, + required: ['query'], + }, + }; + } +} diff --git a/front_end/panels/ai_chat/mini_apps/apps/agent_studio/index.ts b/front_end/panels/ai_chat/mini_apps/apps/agent_studio/index.ts new file mode 100644 index 0000000000..f1e67c4676 --- /dev/null +++ b/front_end/panels/ai_chat/mini_apps/apps/agent_studio/index.ts @@ -0,0 +1,5 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export { AgentStudioMiniApp } from './AgentStudioMiniApp.js'; diff --git a/front_end/panels/ai_chat/mini_apps/index.ts b/front_end/panels/ai_chat/mini_apps/index.ts new file mode 100644 index 0000000000..c68b3eb08a --- /dev/null +++ b/front_end/panels/ai_chat/mini_apps/index.ts @@ -0,0 +1,58 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Mini Apps System + * + * A generic system for rendering self-contained UI applications as full-screen + * iframes with full AI read/write control over their state. + * + * Usage: + * + * 1. Define a mini app by implementing the MiniApp interface: + * + * class MyMiniApp implements MiniApp { + * id = 'my_app'; + * name = 'My App'; + * description = 'Does something cool'; + * icon = '🎯'; + * + * getSPA(): MiniAppSPA { ... } + * getSupportedActions(): MiniAppActionSchema[] { ... } + * getStateSchema(): MiniAppStateSchema { ... } + * createController(): MiniAppController { ... } + * } + * + * 2. Register the app: + * + * MiniAppRegistry.register(new MyMiniApp()); + * + * 3. Launch the app: + * + * const instance = await MiniAppRegistry.launch('my_app'); + * + * 4. Interact with the app: + * + * const state = await instance.controller.getState(); + * await instance.controller.executeAction('do-something', { arg: 'value' }); + * + * 5. Close the app: + * + * await MiniAppRegistry.close('my_app'); + */ + +// Types +export * from './types/MiniAppTypes.js'; + +// Core +export { MiniAppRegistry } from './MiniAppRegistry.js'; +export { GenericMiniAppBridge } from './GenericMiniAppBridge.js'; +export { MiniAppStorageManager } from './MiniAppStorageManager.js'; +export { MiniAppEventBus, Events as MiniAppEvents } from './MiniAppEventBus.js'; + +// Initialization +export { initializeMiniApps, isMiniAppSystemInitialized, resetMiniAppSystem } from './MiniAppInitialization.js'; + +// Apps +export { AgentStudioMiniApp } from './apps/agent_studio/AgentStudioMiniApp.js'; diff --git a/front_end/panels/ai_chat/mini_apps/types/MiniAppTypes.ts b/front_end/panels/ai_chat/mini_apps/types/MiniAppTypes.ts new file mode 100644 index 0000000000..999269a167 --- /dev/null +++ b/front_end/panels/ai_chat/mini_apps/types/MiniAppTypes.ts @@ -0,0 +1,271 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * MiniApp Types - Core interfaces for the Mini Apps system + * + * This system enables multiple self-contained UI applications to be rendered + * as full-screen iframes with full AI read/write control over their state. + */ + +// ============================================================================ +// SPA Content Types +// ============================================================================ + +/** + * The HTML, CSS, and JS content that makes up a mini app's UI + */ +export interface MiniAppSPA { + html: string; + css: string; + js: string; +} + +// ============================================================================ +// Schema Types (for AI tool integration) +// ============================================================================ + +/** + * Schema for an action that AI agents can invoke on a mini app + */ +export interface MiniAppActionSchema { + /** Action name (used in ExecuteMiniAppActionTool) */ + name: string; + /** Description for AI agents */ + description: string; + /** JSON Schema for action arguments */ + schema: { + type: string; + properties: Record; + required?: string[]; + }; +} + +/** + * Schema describing the state structure a mini app exposes + */ +export interface MiniAppStateSchema { + type: string; + properties: Record; + }>; +} + +// ============================================================================ +// State Types +// ============================================================================ + +/** + * Generic mini app state - a key-value store + */ +export interface MiniAppState { + [key: string]: unknown; +} + +/** + * Snapshot of mini app state at a point in time + */ +export interface MiniAppStateSnapshot { + appId: string; + timestamp: Date; + state: MiniAppState; +} + +// ============================================================================ +// Action Types (Communication Protocol) +// ============================================================================ + +/** + * Base action from SPA to DevTools + */ +export interface SPAToDevToolsAction { + type: string; + payload?: unknown; +} + +/** + * Base action from DevTools to SPA + */ +export interface DevToolsToSPAAction { + action: string; + payload?: unknown; +} + +/** + * Standard lifecycle actions all mini apps must support (SPA → DevTools) + */ +export type StandardSPAAction = + | { type: 'ready' } + | { type: 'close' } + | { type: 'state-changed'; state: MiniAppState } + | { type: 'error'; error: string }; + +/** + * Standard actions all mini apps receive (DevTools → SPA) + */ +export type StandardDevToolsAction = + | { action: 'init'; payload: unknown } + | { action: 'get-state' } + | { action: 'set-state'; payload: MiniAppState } + | { action: 'update-state'; payload: Partial } + | { action: 'execute'; payload: { actionName: string; args: unknown } }; + +// ============================================================================ +// Bridge Interface +// ============================================================================ + +/** + * Handles bidirectional communication between DevTools and a mini app SPA + */ +export interface MiniAppBridge { + /** Install the bridge for a specific webapp instance */ + install(webappId: string): Promise; + + /** Uninstall the bridge and clean up resources */ + uninstall(): Promise; + + /** Send an action to the SPA */ + sendToSPA(action: DevToolsToSPAAction): Promise; + + /** Register a handler for actions from the SPA */ + onAction(handler: (action: SPAToDevToolsAction) => void | Promise): void; + + /** Get the current state from the SPA */ + getState(): Promise; + + /** Check if the bridge is installed */ + readonly installed: boolean; + + /** The webapp ID this bridge is connected to */ + readonly webappId: string | null; +} + +// ============================================================================ +// Controller Interface +// ============================================================================ + +/** + * Handles business logic for a mini app + */ +export interface MiniAppController { + /** Initialize the controller with a bridge */ + initialize(bridge: MiniAppBridge): Promise; + + /** Get the current state */ + getState(): Promise; + + /** Set the entire state */ + setState(state: MiniAppState): Promise; + + /** Update state partially */ + updateState(updates: Partial): Promise; + + /** Execute a named action with arguments */ + executeAction(actionName: string, args: unknown): Promise; + + /** Clean up resources */ + cleanup(): Promise; + + /** Register a callback for when the app closes */ + onClose(callback: () => void | Promise): void; +} + +// ============================================================================ +// MiniApp Interface (Main Contract) +// ============================================================================ + +/** + * Core interface that all mini apps must implement + */ +export interface MiniApp { + /** Unique identifier for this app type */ + id: string; + + /** Human-readable display name */ + name: string; + + /** Description for AI agents to understand what this app does */ + description: string; + + /** Icon for the app (emoji or icon class) */ + icon: string; + + /** Get the SPA content (HTML, CSS, JS) */ + getSPA(): MiniAppSPA; + + /** Get the actions this app supports (for AI tooling) */ + getSupportedActions(): MiniAppActionSchema[]; + + /** Get the state schema this app exposes (for AI reading) */ + getStateSchema(): MiniAppStateSchema; + + /** Create a controller instance for this app */ + createController(): MiniAppController; +} + +// ============================================================================ +// Instance Types (for Registry) +// ============================================================================ + +/** + * A running instance of a mini app + */ +export interface MiniAppInstance { + /** The app definition */ + app: MiniApp; + + /** The controller handling business logic */ + controller: MiniAppController; + + /** The bridge handling communication */ + bridge: MiniAppBridge; + + /** The webapp ID (iframe ID) */ + webappId: string; + + /** When this instance was launched */ + launchedAt: Date; +} + +// ============================================================================ +// Event Types +// ============================================================================ + +/** + * Events emitted by the mini app system + */ +export type MiniAppEventType = + | 'app_launched' + | 'app_closed' + | 'state_changed' + | 'action_received' + | 'action_executed' + | 'error'; + +/** + * Event payload for mini app events + */ +export interface MiniAppEvent { + type: MiniAppEventType; + appId: string; + timestamp: Date; + data?: unknown; +} + +// ============================================================================ +// Storage Types +// ============================================================================ + +/** + * Storage entry for mini app data + */ +export interface MiniAppStorageEntry { + appId: string; + key: string; + value: unknown; + updatedAt: string; +} diff --git a/front_end/panels/ai_chat/testing/BUILD.gn b/front_end/panels/ai_chat/testing/BUILD.gn index c8b471bedd..c8d815777a 100644 --- a/front_end/panels/ai_chat/testing/BUILD.gn +++ b/front_end/panels/ai_chat/testing/BUILD.gn @@ -7,42 +7,14 @@ import("../../../../scripts/build/typescript/typescript.gni") devtools_module("testing") { sources = [ - # Framework files - "framework/TestRunner.ts", - "framework/TestSuite.ts", - "framework/SnapshotManager.ts", - "framework/ResultCollector.ts", - "framework/Types.ts", - - # Adapter files - "adapters/LLMAdapter.ts", - "adapters/MockLLMAdapter.ts", - "adapters/OpenAIAdapter.ts", - - # Test case files will be added here as they are created + # Test utilities + "TestUtilities.ts", + "MockLLMClient.ts", ] deps = [ "../:ai_chat", "../../../core/common:bundle", "../../../core/sdk:bundle", - "../../../testing:bundle", - ] -} - -# Test target for running the evaluation framework tests -ts_library("unittests") { - testonly = true - - sources = [ - "framework/TestRunner.test.ts", - "framework/SnapshotManager.test.ts", - "adapters/MockLLMAdapter.test.ts", - ] - - deps = [ - ":testing", - "../:ai_chat", - "../../../testing:bundle", ] } \ No newline at end of file diff --git a/front_end/panels/ai_chat/testing/MockLLMClient.ts b/front_end/panels/ai_chat/testing/MockLLMClient.ts new file mode 100644 index 0000000000..8ffcf879b4 --- /dev/null +++ b/front_end/panels/ai_chat/testing/MockLLMClient.ts @@ -0,0 +1,306 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Mock LLM Client for testing agent execution. + * Provides a queueable response system for deterministic testing. + */ + +import type { LLMResponse, LLMMessage } from '../LLM/LLMTypes.js'; +import { LLMClient } from '../LLM/LLMClient.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export type MockLLMResponseType = 'tool_call' | 'final_answer' | 'error'; + +export interface MockLLMResponseConfig { + type: MockLLMResponseType; + toolName?: string; + toolArgs?: Record; + answer?: string; + errorMessage?: string; + reasoning?: string; +} + +export interface LLMCallRecord { + messages: LLMMessage[]; + tools: unknown[]; + systemPrompt?: string; + model?: string; + timestamp: Date; +} + +// ============================================================================ +// MockLLMClient +// ============================================================================ + +/** + * Mock LLM Client with queueable responses for testing. + */ +export class MockLLMClient { + private responseQueue: MockLLMResponseConfig[] = []; + private callHistory: LLMCallRecord[] = []; + private defaultResponse: MockLLMResponseConfig = { type: 'final_answer', answer: 'Default response' }; + private callIndex = 0; + + /** + * Queue a response to be returned on the next LLM call. + */ + queueResponse(config: MockLLMResponseConfig): this { + this.responseQueue.push(config); + return this; + } + + /** + * Queue multiple responses. + */ + queueResponses(configs: MockLLMResponseConfig[]): this { + this.responseQueue.push(...configs); + return this; + } + + /** + * Queue a tool call response. + */ + queueToolCall(toolName: string, toolArgs: Record = {}, reasoning?: string): this { + return this.queueResponse({ type: 'tool_call', toolName, toolArgs, reasoning }); + } + + /** + * Queue a final answer response. + */ + queueFinalAnswer(answer: string, reasoning?: string): this { + return this.queueResponse({ type: 'final_answer', answer, reasoning }); + } + + /** + * Queue an error response. + */ + queueError(errorMessage: string): this { + return this.queueResponse({ type: 'error', errorMessage }); + } + + /** + * Set the default response when queue is empty. + */ + setDefaultResponse(config: MockLLMResponseConfig): this { + this.defaultResponse = config; + return this; + } + + /** + * Get the call history. + */ + getCallHistory(): LLMCallRecord[] { + return [...this.callHistory]; + } + + /** + * Get the number of calls made. + */ + getCallCount(): number { + return this.callHistory.length; + } + + /** + * Assert the number of calls made. + */ + assertCallCount(expected: number): void { + if (this.callHistory.length !== expected) { + throw new Error(`Expected ${expected} LLM calls, but got ${this.callHistory.length}`); + } + } + + /** + * Reset the mock client state. + */ + reset(): void { + this.responseQueue = []; + this.callHistory = []; + this.callIndex = 0; + } + + /** + * Get the next response from the queue (or default). + */ + private getNextResponse(): MockLLMResponseConfig { + if (this.responseQueue.length > 0) { + return this.responseQueue.shift()!; + } + return this.defaultResponse; + } + + /** + * Create a mock LLM call implementation. + */ + createCallImplementation(): (params: any) => Promise { + return async (params: any): Promise => { + this.callHistory.push({ + messages: params.messages || [], + tools: params.tools || [], + systemPrompt: params.systemPrompt, + model: params.model, + timestamp: new Date(), + }); + + const responseConfig = this.getNextResponse(); + + // Build the raw response based on config + const rawResponse: any = { + id: `chatcmpl-mock-${Date.now()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: params.model || 'mock-model', + choices: [ + { + index: 0, + message: this.buildMessage(responseConfig), + finish_reason: responseConfig.type === 'tool_call' ? 'tool_calls' : 'stop', + }, + ], + usage: { + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + }, + }; + + return { + rawResponse, + reasoning: responseConfig.reasoning ? { summary: [responseConfig.reasoning] } : undefined, + }; + }; + } + + /** + * Create a mock parseResponse implementation. + */ + createParseResponseImplementation(): (response: LLMResponse) => { type: string; name?: string; args?: Record; answer?: string; error?: string } { + return (response: LLMResponse) => { + const message = response.rawResponse?.choices?.[0]?.message; + + if (message?.tool_calls && message.tool_calls.length > 0) { + const toolCall = message.tool_calls[0]; + return { + type: 'tool_call', + name: toolCall.function?.name, + args: toolCall.function?.arguments ? JSON.parse(toolCall.function.arguments) : {}, + }; + } + + if (message?.content?.startsWith('ERROR:')) { + return { + type: 'error', + error: message.content.replace('ERROR:', '').trim(), + }; + } + + return { + type: 'final_answer', + answer: message?.content || '', + }; + }; + } + + /** + * Build a message object from response config. + */ + private buildMessage(config: MockLLMResponseConfig): any { + if (config.type === 'tool_call' && config.toolName) { + return { + role: 'assistant', + content: null, + tool_calls: [ + { + id: `call_mock_${Date.now()}`, + type: 'function', + function: { + name: config.toolName, + arguments: JSON.stringify(config.toolArgs || {}), + }, + }, + ], + }; + } + + if (config.type === 'error') { + return { + role: 'assistant', + content: `ERROR: ${config.errorMessage || 'Unknown error'}`, + }; + } + + return { + role: 'assistant', + content: config.answer || '', + }; + } + + /** + * Install this mock client as the LLMClient singleton. + * Returns a cleanup function to restore the original. + */ + install(): () => void { + const originalGetInstance = LLMClient.getInstance; + const mockClient = { + call: this.createCallImplementation(), + parseResponse: this.createParseResponseImplementation(), + }; + + (LLMClient as any).getInstance = () => mockClient; + + return () => { + (LLMClient as any).getInstance = originalGetInstance; + }; + } +} + +// ============================================================================ +// Convenience Functions +// ============================================================================ + +/** + * Creates and installs a mock LLM client with a predefined sequence. + */ +export function setupMockLLMClient(sequence: MockLLMResponseConfig[]): { + client: MockLLMClient; + cleanup: () => void; +} { + const client = new MockLLMClient(); + client.queueResponses(sequence); + const cleanup = client.install(); + return { client, cleanup }; +} + +/** + * Creates a simple tool call -> final answer sequence. + */ +export function createToolThenAnswerSequence( + toolName: string, + toolArgs: Record, + answer: string +): MockLLMResponseConfig[] { + return [ + { type: 'tool_call', toolName, toolArgs }, + { type: 'final_answer', answer }, + ]; +} + +/** + * Creates a multi-tool sequence ending with a final answer. + */ +export function createMultiToolSequence( + tools: Array<{ toolName: string; toolArgs?: Record }>, + answer: string +): MockLLMResponseConfig[] { + const sequence: MockLLMResponseConfig[] = tools.map(t => ({ + type: 'tool_call' as const, + toolName: t.toolName, + toolArgs: t.toolArgs || {}, + })); + sequence.push({ type: 'final_answer', answer }); + return sequence; +} diff --git a/front_end/panels/ai_chat/testing/TestUtilities.ts b/front_end/panels/ai_chat/testing/TestUtilities.ts new file mode 100644 index 0000000000..e0b169f14d --- /dev/null +++ b/front_end/panels/ai_chat/testing/TestUtilities.ts @@ -0,0 +1,418 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Shared test utilities for the AI Chat agent framework. + * Provides mock factories, state builders, and assertion helpers. + */ + +import type { Tool } from '../tools/Tools.js'; +import type { AgentToolConfig, CallCtx, ConfigurableAgentArgs, ConfigurableAgentResult } from '../agent_framework/ConfigurableAgentTool.js'; +import type { AgentRunnerConfig, AgentRunnerHooks } from '../agent_framework/AgentRunner.js'; +import type { AgentSession, AgentMessage } from '../agent_framework/AgentSessionTypes.js'; +import { ChatMessageEntity, type ChatMessage, type ModelChatMessage, type ToolResultMessage } from '../models/ChatTypes.js'; +import type { LLMProvider } from '../LLM/LLMTypes.js'; + +// ============================================================================ +// Mock Tool Factory +// ============================================================================ + +/** + * Creates a minimal mock tool for testing. + */ +export function createMockTool, TResult = unknown>( + name: string, + executeImpl?: (args: TArgs, ctx?: unknown) => Promise, + options?: { + description?: string; + schema?: { type: string; properties: Record; required?: string[] }; + } +): Tool { + return { + name, + description: options?.description ?? `Test tool: ${name}`, + schema: options?.schema ?? { type: 'object', properties: {} }, + execute: executeImpl ?? (async () => ({ success: true } as unknown as TResult)), + }; +} + +/** + * Creates a mock tool that returns a specific result. + */ +export function createMockToolWithResult(name: string, result: T): Tool, T> { + return createMockTool(name, async () => result); +} + +/** + * Creates a mock tool that throws an error. + */ +export function createMockToolWithError(name: string, errorMessage: string): Tool, never> { + return createMockTool(name, async () => { + throw new Error(errorMessage); + }); +} + +/** + * Creates a mock tool that tracks calls. + */ +export function createTrackedMockTool, TResult = unknown>( + name: string, + result: TResult +): Tool & { calls: Array<{ args: TArgs; ctx?: unknown }> } { + const calls: Array<{ args: TArgs; ctx?: unknown }> = []; + const tool = createMockTool(name, async (args, ctx) => { + calls.push({ args, ctx }); + return result; + }); + return Object.assign(tool, { calls }); +} + +// ============================================================================ +// Mock CallCtx Factory +// ============================================================================ + +/** + * Creates a mock CallCtx for testing. + */ +export function createMockCallCtx(overrides?: Partial): CallCtx { + return { + apiKey: 'test-api-key', + provider: 'openai' as LLMProvider, + model: 'gpt-4.1-2025-04-14', + miniModel: 'gpt-4.1-mini-2025-04-14', + nanoModel: 'gpt-4.1-nano-2025-04-14', + mainModel: 'gpt-4.1-2025-04-14', + getVisionCapability: async () => false, + ...overrides, + }; +} + +// ============================================================================ +// Mock AgentToolConfig Factory +// ============================================================================ + +/** + * Creates a minimal mock AgentToolConfig for testing. + */ +export function createMockAgentToolConfig(overrides?: Partial): AgentToolConfig { + const name = overrides?.name ?? 'test_agent'; + return { + name, + description: `Test agent: ${name}`, + systemPrompt: 'You are a test agent.', + tools: [], + schema: { + type: 'object', + properties: { + query: { type: 'string', description: 'The task to perform' }, + reasoning: { type: 'string', description: 'Why this agent was invoked' }, + }, + required: ['query', 'reasoning'], + }, + maxIterations: 5, + temperature: 0, + ...overrides, + }; +} + +// ============================================================================ +// Mock AgentRunnerConfig Factory +// ============================================================================ + +/** + * Creates a mock AgentRunnerConfig for testing. + */ +export function createMockAgentRunnerConfig(overrides?: Partial): AgentRunnerConfig { + return { + apiKey: 'test-api-key', + modelName: 'gpt-4.1-2025-04-14', + systemPrompt: 'You are a test agent.', + tools: [], + maxIterations: 5, + temperature: 0, + provider: 'openai' as LLMProvider, + getVisionCapability: async () => false, + ...overrides, + }; +} + +// ============================================================================ +// Mock AgentRunnerHooks Factory +// ============================================================================ + +/** + * Creates default AgentRunnerHooks for testing. + */ +export function createMockAgentRunnerHooks(overrides?: Partial): AgentRunnerHooks { + return { + prepareInitialMessages: undefined, + createSuccessResult: (output, intermediateSteps, reason) => ({ + success: true, + output, + intermediateSteps, + terminationReason: reason, + }), + createErrorResult: (error, intermediateSteps, reason) => ({ + success: false, + error, + intermediateSteps, + terminationReason: reason, + }), + ...overrides, + }; +} + +// ============================================================================ +// Message Builders +// ============================================================================ + +/** + * Creates a user message. + */ +export function createUserMessage(text: string): ChatMessage { + return { + entity: ChatMessageEntity.USER, + text, + } as ChatMessage; +} + +/** + * Creates a model message with a tool call. + */ +export function createToolCallMessage( + toolName: string, + toolArgs: Record, + options?: { + toolCallId?: string; + reasoning?: string; + } +): ModelChatMessage { + return { + entity: ChatMessageEntity.MODEL, + action: 'tool', + toolName, + toolArgs, + toolCallId: options?.toolCallId ?? crypto.randomUUID(), + isFinalAnswer: false, + reasoning: options?.reasoning ? [options.reasoning] : undefined, + }; +} + +/** + * Creates a tool result message. + */ +export function createToolResultMessage( + toolName: string, + result: unknown, + options?: { + toolCallId?: string; + isError?: boolean; + imageData?: string; + } +): ToolResultMessage { + const isError = options?.isError ?? false; + const resultText = typeof result === 'string' ? result : JSON.stringify(result); + + return { + entity: ChatMessageEntity.TOOL_RESULT, + toolName, + toolCallId: options?.toolCallId ?? crypto.randomUUID(), + resultText, + isError, + ...(isError && { error: resultText }), + ...(options?.imageData && { imageData: options.imageData }), + }; +} + +/** + * Creates a model message with a final answer. + */ +export function createFinalAnswerMessage( + answer: string, + options?: { + reasoning?: string; + } +): ModelChatMessage { + return { + entity: ChatMessageEntity.MODEL, + action: 'final', + answer, + isFinalAnswer: true, + reasoning: options?.reasoning ? [options.reasoning] : undefined, + }; +} + +// ============================================================================ +// Mock ConfigurableAgentArgs +// ============================================================================ + +/** + * Creates mock ConfigurableAgentArgs for testing. + */ +export function createMockAgentArgs(overrides?: Partial): ConfigurableAgentArgs { + return { + query: 'Test query', + reasoning: 'Test reasoning', + ...overrides, + }; +} + +// ============================================================================ +// Mock AgentSession Factory +// ============================================================================ + +/** + * Creates a minimal mock AgentSession for testing. + */ +export function createMockAgentSession(overrides?: Partial): AgentSession { + return { + agentName: 'test_agent', + sessionId: crypto.randomUUID(), + status: 'running', + startTime: new Date(), + messages: [], + nestedSessions: [], + tools: [], + ...overrides, + }; +} + +// ============================================================================ +// Assertion Helpers +// ============================================================================ + +/** + * Asserts that a specific tool was called in the messages. + */ +export function assertToolCalled( + messages: ChatMessage[], + toolName: string, + times?: number +): void { + const toolCalls = messages.filter( + (m) => m.entity === ChatMessageEntity.MODEL && (m as ModelChatMessage).action === 'tool' && (m as ModelChatMessage).toolName === toolName + ); + + if (times !== undefined) { + if (toolCalls.length !== times) { + throw new Error(`Expected tool "${toolName}" to be called ${times} times, but was called ${toolCalls.length} times`); + } + } else if (toolCalls.length === 0) { + throw new Error(`Expected tool "${toolName}" to be called at least once`); + } +} + +/** + * Asserts that a final answer was provided. + */ +export function assertFinalAnswer( + messages: ChatMessage[], + contains?: string +): void { + const finalAnswer = messages.find( + (m) => m.entity === ChatMessageEntity.MODEL && (m as ModelChatMessage).action === 'final' + ) as ModelChatMessage | undefined; + + if (!finalAnswer) { + throw new Error('Expected a final answer message'); + } + + if (contains && !finalAnswer.answer?.includes(contains)) { + throw new Error(`Expected final answer to contain "${contains}", but got: ${finalAnswer.answer}`); + } +} + +/** + * Asserts that a tool result contains specific content. + */ +export function assertToolResult( + messages: ChatMessage[], + toolName: string, + options?: { + isError?: boolean; + contains?: string; + } +): void { + const toolResults = messages.filter( + (m) => m.entity === ChatMessageEntity.TOOL_RESULT && (m as ToolResultMessage).toolName === toolName + ) as ToolResultMessage[]; + + if (toolResults.length === 0) { + throw new Error(`Expected a tool result for "${toolName}"`); + } + + const lastResult = toolResults[toolResults.length - 1]; + + if (options?.isError !== undefined && lastResult.isError !== options.isError) { + throw new Error(`Expected tool result isError to be ${options.isError}, but got ${lastResult.isError}`); + } + + if (options?.contains && !lastResult.resultText?.includes(options.contains)) { + throw new Error(`Expected tool result to contain "${options.contains}", but got: ${lastResult.resultText}`); + } +} + +/** + * Asserts that the result indicates a successful execution. + */ +export function assertSuccessResult(result: ConfigurableAgentResult): void { + if (!result.success) { + throw new Error(`Expected success result, but got error: ${result.error}`); + } +} + +/** + * Asserts that the result indicates an error. + */ +export function assertErrorResult( + result: ConfigurableAgentResult, + contains?: string +): void { + if (result.success) { + throw new Error(`Expected error result, but got success: ${result.output}`); + } + + if (contains && !result.error?.includes(contains)) { + throw new Error(`Expected error to contain "${contains}", but got: ${result.error}`); + } +} + +/** + * Asserts the termination reason. + */ +export function assertTerminationReason( + result: ConfigurableAgentResult, + expected: string +): void { + if (result.terminationReason !== expected) { + throw new Error(`Expected termination reason "${expected}", but got: ${result.terminationReason}`); + } +} + +// ============================================================================ +// Wait Utilities +// ============================================================================ + +/** + * Creates a promise that resolves after a delay. + */ +export function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Creates an abort controller with automatic cleanup. + */ +export function createTestAbortController(): { + controller: AbortController; + signal: AbortSignal; + abort: () => void; +} { + const controller = new AbortController(); + return { + controller, + signal: controller.signal, + abort: () => controller.abort(), + }; +} diff --git a/front_end/panels/ai_chat/tools/CallCustomAgentTool.ts b/front_end/panels/ai_chat/tools/CallCustomAgentTool.ts new file mode 100644 index 0000000000..f0d65eb480 --- /dev/null +++ b/front_end/panels/ai_chat/tools/CallCustomAgentTool.ts @@ -0,0 +1,97 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import type { Tool, LLMContext } from './Tools.js'; +import { ToolRegistry } from '../agent_framework/ConfigurableAgentTool.js'; +import { AgentStudioIntegration } from '../core/AgentStudioIntegration.js'; +import { createLogger } from '../core/Logger.js'; + +const logger = createLogger('CallCustomAgentTool'); + +/** + * Input for calling a custom agent + */ +export interface CallCustomAgentInput { + agent_name: string; + args: Record; +} + +/** + * Result from calling a custom agent + */ +export interface CallCustomAgentResult { + result?: unknown; + error?: string; +} + +/** + * Tool to execute a custom agent by name with dynamic arguments. + * + * This tool passes through any arguments to the target agent, allowing + * custom agents to define their own input schemas. The caller should + * first use search_custom_agents to discover available agents and their + * schemas before calling this tool. + * + * Combined with SearchCustomAgentsTool, this implements Anthropic's + * "Tool Search Tool" pattern for context-efficient agent discovery. + */ +export class CallCustomAgentTool implements Tool { + name = 'call_custom_agent'; + description = 'Execute a custom agent by name with arguments matching its schema. Use search_custom_agents first to discover available agents and their required input schemas.'; + + schema = { + type: 'object', + properties: { + agent_name: { + type: 'string', + description: 'The name of the custom agent to call (from search_custom_agents results)' + }, + args: { + type: 'object', + description: 'Arguments to pass to the agent, matching the schema returned by search_custom_agents' + } + }, + required: ['agent_name', 'args'] + }; + + async execute(input: CallCustomAgentInput, ctx?: LLMContext): Promise { + try { + logger.info('Calling custom agent', { agent: input.agent_name, args: input.args }); + + // Validate this is a custom agent (not built-in) + if (AgentStudioIntegration.isBuiltInAgentName(input.agent_name)) { + return { + error: `'${input.agent_name}' is a built-in agent. Use the appropriate built-in tool instead.` + }; + } + + // Get the agent tool from registry + const tool = ToolRegistry.getToolInstance(input.agent_name); + + if (!tool) { + // Get available custom agents for helpful error message + const allAgents = await AgentStudioIntegration.getAllAgentsForDisplay(); + const customAgents = allAgents.filter(a => !a.isBuiltIn); + const availableNames = customAgents.map(a => a.name); + + return { + error: `Custom agent '${input.agent_name}' not found. Use search_custom_agents to find available agents. Available: ${availableNames.length > 0 ? availableNames.join(', ') : 'none'}` + }; + } + + // Execute the agent with passed-through args + // The agent itself validates its own schema + const result = await tool.execute(input.args, ctx); + + logger.info('Custom agent execution complete', { agent: input.agent_name }); + + return { result }; + } catch (error) { + logger.error('Custom agent execution failed:', error); + return { + error: `Execution failed: ${error instanceof Error ? error.message : String(error)}` + }; + } + } +} diff --git a/front_end/panels/ai_chat/tools/SaveResearchReportTool.ts b/front_end/panels/ai_chat/tools/SaveResearchReportTool.ts new file mode 100644 index 0000000000..51271aa467 --- /dev/null +++ b/front_end/panels/ai_chat/tools/SaveResearchReportTool.ts @@ -0,0 +1,112 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../core/Logger.js'; +import type { Tool, LLMContext } from './Tools.js'; +import { FileStorageManager } from './FileStorageManager.js'; +import { FileContentViewer } from '../ui/FileContentViewer.js'; + +const logger = createLogger('Tool:SaveResearchReport'); + +export interface SaveResearchReportArgs { + reasoning: string; + report: string; + filename: string; +} + +export interface SaveResearchReportResult { + success: boolean; + reasoning?: string; + fileName?: string; + message?: string; + error?: string; +} + +/** + * SaveResearchReportTool - Saves a research report to file storage and displays it + * + * This tool is used by the Deep Research agent to save and display the final + * research report. It: + * 1. Saves the markdown report to FileStorageManager + * 2. Automatically opens the report in FileContentViewer + * 3. Returns the reasoning text to be displayed in chat + */ +export class SaveResearchReportTool implements Tool { + name = 'save_research_report'; + description = 'Save the final research report and display it in the viewer. Use this when your research is complete to present findings to the user.'; + + schema = { + type: 'object', + properties: { + reasoning: { + type: 'string', + description: '2-3 sentences explaining your research approach, key insights, and how you organized the findings' + }, + report: { + type: 'string', + description: 'The full markdown research report (aim for 5000+ words for comprehensive topics). Include executive summary, detailed findings, source citations, and conclusions.' + }, + filename: { + type: 'string', + description: 'Descriptive filename for the report (e.g., "ai_market_trends_research_report.md"). Must end with .md' + } + }, + required: ['reasoning', 'report', 'filename'] + }; + + async execute(args: SaveResearchReportArgs, _ctx?: LLMContext): Promise { + logger.info('Executing save research report', { filename: args.filename }); + + // Validate filename ends with .md + let filename = args.filename; + if (!filename.toLowerCase().endsWith('.md')) { + filename = `${filename}.md`; + } + + const manager = FileStorageManager.getInstance(); + + try { + // Check if file already exists and update or create accordingly + const existingFile = await manager.readFile(filename); + + if (existingFile) { + // Update existing file + await manager.updateFile(filename, args.report); + logger.info('Updated existing research report', { filename }); + } else { + // Create new file + await manager.createFile(filename, args.report, 'text/markdown'); + logger.info('Created new research report', { filename }); + } + + // Get the file info for the viewer + const files = await manager.listFiles(); + const file = files.find(f => f.fileName === filename); + + if (file) { + // Auto-open in FileContentViewer + try { + await FileContentViewer.show(file, args.report); + logger.info('Research report opened in viewer', { filename }); + } catch (viewerError) { + logger.warn('Failed to auto-open viewer, file still saved', { filename, error: viewerError }); + } + } + + // Return reasoning to display in chat + return { + success: true, + reasoning: args.reasoning, + fileName: filename, + message: `Research report saved as "${filename}" and opened in viewer.` + }; + } catch (error: any) { + logger.error('Failed to save research report', { filename, error: error?.message }); + return { + success: false, + error: error?.message || 'Failed to save research report.' + }; + } + } +} diff --git a/front_end/panels/ai_chat/tools/SearchCustomAgentsTool.ts b/front_end/panels/ai_chat/tools/SearchCustomAgentsTool.ts new file mode 100644 index 0000000000..b30db351b8 --- /dev/null +++ b/front_end/panels/ai_chat/tools/SearchCustomAgentsTool.ts @@ -0,0 +1,227 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import type { Tool, LLMContext } from './Tools.js'; +import { AgentStudioIntegration, type AgentDisplayInfo } from '../core/AgentStudioIntegration.js'; +import { callLLMWithTracing } from './LLMTracingWrapper.js'; +import { createLogger } from '../core/Logger.js'; + +const logger = createLogger('SearchCustomAgentsTool'); + +/** + * Input for searching custom agents + */ +export interface SearchCustomAgentsInput { + query: string; +} + +/** + * A matched agent with its schema + */ +export interface MatchedAgent { + name: string; + displayName: string; + description: string; + schema: object; +} + +/** + * Result from searching custom agents + */ +export interface SearchCustomAgentsResult { + agents: MatchedAgent[]; + message?: string; +} + +/** + * Tool to search for custom agents using LLM-based semantic matching. + * + * Based on Anthropic's "Tool Search Tool" pattern for context-efficient + * dynamic tool discovery. Instead of loading all custom agent definitions + * into context, this tool: + * + * 1. Sends only agent names + descriptions to a fast LLM for routing + * 2. Returns full schemas only for the top matching agents (max 3) + * + * This keeps context size constant regardless of how many custom agents exist. + */ +export class SearchCustomAgentsTool implements Tool { + name = 'search_custom_agents'; + description = 'Search for custom agents that can help with a task. Returns matching agents with their input schemas. Use this to discover what custom agents are available before calling them with call_custom_agent.'; + + schema = { + type: 'object', + properties: { + query: { + type: 'string', + description: 'What you want to accomplish (e.g., "check prices", "analyze data", "summarize content")' + } + }, + required: ['query'] + }; + + async execute(input: SearchCustomAgentsInput, ctx?: LLMContext): Promise { + try { + logger.info('Searching custom agents', { query: input.query }); + + // Get all agents and filter to custom only + const allAgents = await AgentStudioIntegration.getAllAgentsForDisplay(); + const customAgents = allAgents.filter(a => !a.isBuiltIn); + + if (customAgents.length === 0) { + return { + agents: [], + message: 'No custom agents available. Create custom agents in Agent Studio first.' + }; + } + + // Build compact catalog for LLM (name + description only) + const catalog = customAgents.map(a => ({ + name: a.name, + description: a.description + })); + + // Use LLM to find relevant agents + const matchingNames = await this.findRelevantAgents(input.query, catalog, ctx); + + // Return full info for matched agents only (limit to top 3) + const matches: MatchedAgent[] = matchingNames + .map(name => customAgents.find(a => a.name === name)) + .filter((a): a is AgentDisplayInfo => a !== undefined) + .slice(0, 3) + .map(a => ({ + name: a.name, + displayName: a.displayName, + description: a.description, + schema: a.schema + })); + + if (matches.length === 0) { + return { + agents: [], + message: `No custom agents found matching "${input.query}". Available agents: ${customAgents.map(a => a.name).join(', ')}` + }; + } + + logger.info('Found matching agents', { count: matches.length, names: matches.map(m => m.name) }); + + return { agents: matches }; + } catch (error) { + logger.error('Search custom agents failed:', error); + return { + agents: [], + message: `Search failed: ${error instanceof Error ? error.message : String(error)}` + }; + } + } + + /** + * Use LLM to semantically match user query to available agents + */ + private async findRelevantAgents( + query: string, + catalog: Array<{ name: string; description: string }>, + ctx?: LLMContext + ): Promise { + // If no LLM context, fall back to simple keyword matching + if (!ctx?.provider || !ctx.model) { + logger.warn('No LLM context available, falling back to keyword matching'); + return this.keywordMatch(query, catalog); + } + + const prompt = `You are a routing assistant. Given a user request, identify which custom agents could help. + +USER REQUEST: "${query}" + +AVAILABLE CUSTOM AGENTS: +${catalog.map(a => `- ${a.name}: ${a.description}`).join('\n')} + +Return a JSON array of agent names that could help with this request, most relevant first. +If no agents match, return an empty array []. +Only return the JSON array, nothing else. + +Example response: ["agent_name_1", "agent_name_2"]`; + + try { + const response = await callLLMWithTracing( + { + provider: ctx.provider, + model: ctx.model, + messages: [{ role: 'user', content: prompt }], + temperature: 0, + options: { retryConfig: { maxRetries: 2 } } + }, + { + toolName: this.name, + operationName: 'agent_routing', + context: 'custom_agent_discovery', + additionalMetadata: { + query, + catalogSize: catalog.length + } + } + ); + + if (!response.text) { + logger.warn('Empty LLM response, falling back to keyword matching'); + return this.keywordMatch(query, catalog); + } + + // Parse JSON array from response + const text = response.text.trim(); + // Handle potential markdown code blocks + const jsonMatch = text.match(/\[[\s\S]*\]/); + if (!jsonMatch) { + logger.warn('Could not find JSON array in response, falling back to keyword matching'); + return this.keywordMatch(query, catalog); + } + + const names = JSON.parse(jsonMatch[0]) as string[]; + + // Validate that returned names exist in catalog + const validNames = names.filter(name => + catalog.some(a => a.name === name) + ); + + return validNames; + } catch (error) { + logger.error('LLM routing failed, falling back to keyword matching:', error); + return this.keywordMatch(query, catalog); + } + } + + /** + * Simple keyword matching fallback when LLM is not available + */ + private keywordMatch( + query: string, + catalog: Array<{ name: string; description: string }> + ): string[] { + const queryLower = query.toLowerCase(); + const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2); + + const scored = catalog.map(agent => { + const nameLower = agent.name.toLowerCase(); + const descLower = agent.description.toLowerCase(); + let score = 0; + + // Exact query match in name or description + if (nameLower.includes(queryLower)) score += 10; + if (descLower.includes(queryLower)) score += 5; + + // Word overlap + for (const word of queryWords) { + if (nameLower.includes(word)) score += 3; + if (descLower.includes(word)) score += 1; + } + + return { name: agent.name, score }; + }); + + return scored + .filter(s => s.score > 0) + .sort((a, b) => b.score - a.score) + .map(s => s.name); + } +} diff --git a/front_end/panels/ai_chat/tools/mini_app/CloseMiniAppTool.ts b/front_end/panels/ai_chat/tools/mini_app/CloseMiniAppTool.ts new file mode 100644 index 0000000000..d461283a6f --- /dev/null +++ b/front_end/panels/ai_chat/tools/mini_app/CloseMiniAppTool.ts @@ -0,0 +1,83 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../../core/Logger.js'; +import { MiniAppRegistry } from '../../mini_apps/MiniAppRegistry.js'; +import type { Tool, LLMContext, ErrorResult } from '../Tools.js'; + +const logger = createLogger('CloseMiniAppTool'); + +/** + * Arguments for closing a mini app + */ +export interface CloseMiniAppArgs { + appId: string; +} + +/** + * Result of closing a mini app + */ +export interface CloseMiniAppResult { + success: boolean; + appId: string; + message: string; +} + +/** + * Tool for closing a running mini app + * + * Cleans up the mini app, removes its UI, and releases resources. + * The app can be launched again with launch_mini_app. + */ +export class CloseMiniAppTool implements Tool { + name = 'close_mini_app'; + description = 'Closes a running mini app. Removes the UI and cleans up resources. The app can be relaunched later with launch_mini_app. Any unsaved state will be lost unless the app persists it.'; + + async execute(args: CloseMiniAppArgs, _ctx?: LLMContext): Promise { + const { appId } = args; + + if (!appId) { + return { error: 'appId is required' }; + } + + logger.info('Closing mini app', { appId }); + + try { + // Check if app is running + if (!MiniAppRegistry.isRunning(appId)) { + return { + success: true, + appId, + message: `Mini app "${appId}" was not running`, + }; + } + + // Close the app + await MiniAppRegistry.close(appId); + + logger.info('Closed mini app', { appId }); + + return { + success: true, + appId, + message: `Mini app "${appId}" closed successfully`, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('Failed to close mini app:', errorMsg); + return { error: `Failed to close mini app: ${errorMsg}` }; + } + } + + schema = { + type: 'object', + properties: { + appId: { + type: 'string', + description: 'The unique identifier of the mini app to close', + }, + }, + required: ['appId'], + }; +} diff --git a/front_end/panels/ai_chat/tools/mini_app/ExecuteMiniAppActionTool.ts b/front_end/panels/ai_chat/tools/mini_app/ExecuteMiniAppActionTool.ts new file mode 100644 index 0000000000..e57cc1867e --- /dev/null +++ b/front_end/panels/ai_chat/tools/mini_app/ExecuteMiniAppActionTool.ts @@ -0,0 +1,108 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../../core/Logger.js'; +import { MiniAppRegistry } from '../../mini_apps/MiniAppRegistry.js'; +import type { Tool, LLMContext, ErrorResult } from '../Tools.js'; + +const logger = createLogger('ExecuteMiniAppActionTool'); + +/** + * Arguments for executing a mini app action + */ +export interface ExecuteMiniAppActionArgs { + appId: string; + actionName: string; + args?: Record; +} + +/** + * Result of executing a mini app action + */ +export interface ExecuteMiniAppActionResult { + success: boolean; + appId: string; + actionName: string; + result: unknown; + message: string; +} + +/** + * Tool for executing an action on a running mini app + * + * Each mini app exposes a set of supported actions that can be invoked. + * Use list_mini_apps to see what actions each app supports. + */ +export class ExecuteMiniAppActionTool implements Tool { + name = 'execute_mini_app_action'; + description = 'Executes a specific action on a running mini app. Each app supports different actions (see list_mini_apps for available actions). This is used for app-specific operations like adding items, submitting forms, or triggering behaviors.'; + + async execute(args: ExecuteMiniAppActionArgs, _ctx?: LLMContext): Promise { + const { appId, actionName, args: actionArgs } = args; + + if (!appId) { + return { error: 'appId is required' }; + } + + if (!actionName) { + return { error: 'actionName is required' }; + } + + logger.info('Executing mini app action', { appId, actionName, hasArgs: !!actionArgs }); + + try { + // Check if app is running + const instance = MiniAppRegistry.getRunningInstance(appId); + if (!instance) { + return { error: `Mini app "${appId}" is not running. Launch it first with launch_mini_app.` }; + } + + // Validate the action exists + const supportedActions = instance.app.getSupportedActions(); + const actionDef = supportedActions.find(a => a.name === actionName); + if (!actionDef) { + const availableActions = supportedActions.map(a => a.name).join(', '); + return { + error: `Action "${actionName}" is not supported by mini app "${appId}". Available actions: ${availableActions || 'none'}`, + }; + } + + // Execute the action + const result = await instance.controller.executeAction(actionName, actionArgs || {}); + + logger.info('Executed mini app action', { appId, actionName, result }); + + return { + success: true, + appId, + actionName, + result, + message: `Action "${actionName}" executed successfully`, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('Failed to execute mini app action:', errorMsg); + return { error: `Failed to execute action "${actionName}": ${errorMsg}` }; + } + } + + schema = { + type: 'object', + properties: { + appId: { + type: 'string', + description: 'The unique identifier of the mini app to execute the action on', + }, + actionName: { + type: 'string', + description: 'The name of the action to execute (see list_mini_apps for available actions)', + }, + args: { + type: 'object', + description: 'Optional arguments to pass to the action. The required args depend on the specific action.', + }, + }, + required: ['appId', 'actionName'], + }; +} diff --git a/front_end/panels/ai_chat/tools/mini_app/GetMiniAppStateTool.ts b/front_end/panels/ai_chat/tools/mini_app/GetMiniAppStateTool.ts new file mode 100644 index 0000000000..07c9212b71 --- /dev/null +++ b/front_end/panels/ai_chat/tools/mini_app/GetMiniAppStateTool.ts @@ -0,0 +1,84 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../../core/Logger.js'; +import { MiniAppRegistry } from '../../mini_apps/MiniAppRegistry.js'; +import type { MiniAppState } from '../../mini_apps/types/MiniAppTypes.js'; +import type { Tool, LLMContext, ErrorResult } from '../Tools.js'; + +const logger = createLogger('GetMiniAppStateTool'); + +/** + * Arguments for getting mini app state + */ +export interface GetMiniAppStateArgs { + appId: string; +} + +/** + * Result of getting mini app state + */ +export interface GetMiniAppStateResult { + appId: string; + state: MiniAppState; + stateSchema?: Record; +} + +/** + * Tool for reading the current state of a running mini app + * + * Returns the current state as a key-value object. The state structure + * depends on the specific mini app implementation. + */ +export class GetMiniAppStateTool implements Tool { + name = 'get_mini_app_state'; + description = 'Gets the current state of a running mini app. Returns the state as a key-value object. The app must be running (use launch_mini_app first). Use this to read data from the mini app UI.'; + + async execute(args: GetMiniAppStateArgs, _ctx?: LLMContext): Promise { + const { appId } = args; + + if (!appId) { + return { error: 'appId is required' }; + } + + logger.info('Getting mini app state', { appId }); + + try { + // Check if app is running + const instance = MiniAppRegistry.getRunningInstance(appId); + if (!instance) { + return { error: `Mini app "${appId}" is not running. Launch it first with launch_mini_app.` }; + } + + // Get the state + const state = await instance.controller.getState(); + + // Get the state schema for context + const stateSchema = instance.app.getStateSchema(); + + logger.info('Got mini app state', { appId, stateKeys: Object.keys(state) }); + + return { + appId, + state, + stateSchema: stateSchema as unknown as Record, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('Failed to get mini app state:', errorMsg); + return { error: `Failed to get mini app state: ${errorMsg}` }; + } + } + + schema = { + type: 'object', + properties: { + appId: { + type: 'string', + description: 'The unique identifier of the mini app to get state from', + }, + }, + required: ['appId'], + }; +} diff --git a/front_end/panels/ai_chat/tools/mini_app/LaunchMiniAppTool.ts b/front_end/panels/ai_chat/tools/mini_app/LaunchMiniAppTool.ts new file mode 100644 index 0000000000..5ccece1fd4 --- /dev/null +++ b/front_end/panels/ai_chat/tools/mini_app/LaunchMiniAppTool.ts @@ -0,0 +1,94 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../../core/Logger.js'; +import { MiniAppRegistry } from '../../mini_apps/MiniAppRegistry.js'; +import type { Tool, LLMContext, ErrorResult } from '../Tools.js'; + +const logger = createLogger('LaunchMiniAppTool'); + +/** + * Arguments for launching a mini app + */ +export interface LaunchMiniAppArgs { + appId: string; + initialState?: Record; +} + +/** + * Result of launching a mini app + */ +export interface LaunchMiniAppResult { + success: boolean; + appId: string; + name: string; + message: string; + wasAlreadyRunning: boolean; +} + +/** + * Tool for launching a mini app + * + * Renders a mini app as a full-screen iframe and returns success status. + * If the app is already running, returns the existing instance. + * Only one instance per app type is allowed. + */ +export class LaunchMiniAppTool implements Tool { + name = 'launch_mini_app'; + description = 'Launches a mini app by its ID. The app will be rendered as a full-screen interactive UI. Only one instance of each app can run at a time. If the app is already running, returns the existing instance. Use list_mini_apps first to see available apps.'; + + async execute(args: LaunchMiniAppArgs, _ctx?: LLMContext): Promise { + const { appId, initialState } = args; + + if (!appId) { + return { error: 'appId is required' }; + } + + logger.info('Launching mini app', { appId, hasInitialState: !!initialState }); + + try { + // Check if already running + const wasAlreadyRunning = MiniAppRegistry.isRunning(appId); + + // Launch the app (returns existing if already running) + const instance = await MiniAppRegistry.launch(appId); + + // Set initial state if provided and app wasn't already running + if (initialState && !wasAlreadyRunning) { + await instance.controller.setState(initialState); + } + + logger.info(`Mini app ${wasAlreadyRunning ? 'was already running' : 'launched'}`, { appId }); + + return { + success: true, + appId: instance.app.id, + name: instance.app.name, + message: wasAlreadyRunning + ? `Mini app "${instance.app.name}" was already running` + : `Successfully launched mini app "${instance.app.name}"`, + wasAlreadyRunning, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('Failed to launch mini app:', errorMsg); + return { error: `Failed to launch mini app: ${errorMsg}` }; + } + } + + schema = { + type: 'object', + properties: { + appId: { + type: 'string', + description: 'The unique identifier of the mini app to launch (e.g., "agent_studio", "data_visualizer")', + }, + initialState: { + type: 'object', + description: 'Optional initial state to set when launching the app. Ignored if app is already running.', + }, + }, + required: ['appId'], + }; +} diff --git a/front_end/panels/ai_chat/tools/mini_app/ListMiniAppsTool.ts b/front_end/panels/ai_chat/tools/mini_app/ListMiniAppsTool.ts new file mode 100644 index 0000000000..ceabe2c9ff --- /dev/null +++ b/front_end/panels/ai_chat/tools/mini_app/ListMiniAppsTool.ts @@ -0,0 +1,92 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../../core/Logger.js'; +import { MiniAppRegistry } from '../../mini_apps/MiniAppRegistry.js'; +import type { Tool, LLMContext, ErrorResult } from '../Tools.js'; + +const logger = createLogger('ListMiniAppsTool'); + +/** + * Arguments for listing mini apps + */ +export interface ListMiniAppsArgs { + includeRunning?: boolean; +} + +/** + * Info about a mini app + */ +export interface MiniAppInfo { + id: string; + name: string; + description: string; + icon: string; + isRunning: boolean; + supportedActions: string[]; +} + +/** + * Result of listing mini apps + */ +export interface ListMiniAppsResult { + apps: MiniAppInfo[]; + count: number; +} + +/** + * Tool for listing available mini apps + * + * Returns information about all registered mini apps, including their + * descriptions and supported actions for AI agents to understand + * what each app can do. + */ +export class ListMiniAppsTool implements Tool { + name = 'list_mini_apps'; + description = 'Lists all available mini apps that can be launched. Returns app IDs, names, descriptions, and supported actions. Use this to discover what mini apps are available before launching one.'; + + async execute(args: ListMiniAppsArgs, _ctx?: LLMContext): Promise { + logger.info('Listing mini apps', { includeRunning: args.includeRunning }); + + try { + const allApps = MiniAppRegistry.getAllApps(); + + const apps: MiniAppInfo[] = allApps.map(app => ({ + id: app.id, + name: app.name, + description: app.description, + icon: app.icon, + isRunning: MiniAppRegistry.isRunning(app.id), + supportedActions: app.getSupportedActions().map(a => `${a.name}: ${a.description}`), + })); + + // Filter to only running apps if requested + const result = args.includeRunning === false + ? apps.filter(app => !app.isRunning) + : apps; + + logger.info(`Found ${result.length} mini apps`); + + return { + apps: result, + count: result.length, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('Failed to list mini apps:', errorMsg); + return { error: `Failed to list mini apps: ${errorMsg}` }; + } + } + + schema = { + type: 'object', + properties: { + includeRunning: { + type: 'boolean', + description: 'If false, only return apps that are not currently running. Default: true (include all apps).', + }, + }, + required: [], + }; +} diff --git a/front_end/panels/ai_chat/tools/mini_app/UpdateMiniAppStateTool.ts b/front_end/panels/ai_chat/tools/mini_app/UpdateMiniAppStateTool.ts new file mode 100644 index 0000000000..027cc6c805 --- /dev/null +++ b/front_end/panels/ai_chat/tools/mini_app/UpdateMiniAppStateTool.ts @@ -0,0 +1,106 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../../core/Logger.js'; +import { MiniAppRegistry } from '../../mini_apps/MiniAppRegistry.js'; +import type { MiniAppState } from '../../mini_apps/types/MiniAppTypes.js'; +import type { Tool, LLMContext, ErrorResult } from '../Tools.js'; + +const logger = createLogger('UpdateMiniAppStateTool'); + +/** + * Arguments for updating mini app state + */ +export interface UpdateMiniAppStateArgs { + appId: string; + updates: Record; + replace?: boolean; +} + +/** + * Result of updating mini app state + */ +export interface UpdateMiniAppStateResult { + success: boolean; + appId: string; + message: string; + newState: MiniAppState; +} + +/** + * Tool for updating the state of a running mini app + * + * Can either merge updates into existing state (default) or replace + * the entire state. The UI will automatically reflect the changes. + */ +export class UpdateMiniAppStateTool implements Tool { + name = 'update_mini_app_state'; + description = 'Updates the state of a running mini app. By default, merges updates with existing state. Set replace=true to replace the entire state. The mini app UI will automatically reflect the changes.'; + + async execute(args: UpdateMiniAppStateArgs, _ctx?: LLMContext): Promise { + const { appId, updates, replace } = args; + + if (!appId) { + return { error: 'appId is required' }; + } + + if (!updates || typeof updates !== 'object') { + return { error: 'updates must be an object' }; + } + + logger.info('Updating mini app state', { appId, replace, updateKeys: Object.keys(updates) }); + + try { + // Check if app is running + const instance = MiniAppRegistry.getRunningInstance(appId); + if (!instance) { + return { error: `Mini app "${appId}" is not running. Launch it first with launch_mini_app.` }; + } + + // Update the state + if (replace) { + await instance.controller.setState(updates); + } else { + await instance.controller.updateState(updates); + } + + // Get the new state + const newState = await instance.controller.getState(); + + logger.info('Updated mini app state', { appId, newStateKeys: Object.keys(newState) }); + + return { + success: true, + appId, + message: replace + ? 'State replaced successfully' + : 'State updated successfully', + newState, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error('Failed to update mini app state:', errorMsg); + return { error: `Failed to update mini app state: ${errorMsg}` }; + } + } + + schema = { + type: 'object', + properties: { + appId: { + type: 'string', + description: 'The unique identifier of the mini app to update', + }, + updates: { + type: 'object', + description: 'The state updates to apply. Keys are state property names, values are the new values.', + }, + replace: { + type: 'boolean', + description: 'If true, replace the entire state with updates. If false (default), merge updates with existing state.', + }, + }, + required: ['appId', 'updates'], + }; +} diff --git a/front_end/panels/ai_chat/tools/mini_app/__tests__/MiniAppTools.test.ts b/front_end/panels/ai_chat/tools/mini_app/__tests__/MiniAppTools.test.ts new file mode 100644 index 0000000000..aa0316b87c --- /dev/null +++ b/front_end/panels/ai_chat/tools/mini_app/__tests__/MiniAppTools.test.ts @@ -0,0 +1,548 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { ListMiniAppsTool } from '../ListMiniAppsTool.js'; +import { LaunchMiniAppTool } from '../LaunchMiniAppTool.js'; +import { GetMiniAppStateTool } from '../GetMiniAppStateTool.js'; +import { UpdateMiniAppStateTool } from '../UpdateMiniAppStateTool.js'; +import { ExecuteMiniAppActionTool } from '../ExecuteMiniAppActionTool.js'; +import { CloseMiniAppTool } from '../CloseMiniAppTool.js'; +import { MiniAppRegistry } from '../../../mini_apps/MiniAppRegistry.js'; +import type { + MiniApp, + MiniAppController, + MiniAppBridge, + MiniAppInstance, + MiniAppActionSchema, + MiniAppStateSchema, +} from '../../../mini_apps/types/MiniAppTypes.js'; + +// ============================================================================ +// Mock Factories +// ============================================================================ + +function createMockController(overrides?: Partial): MiniAppController { + return { + initialize: sinon.stub().resolves(), + getState: sinon.stub().resolves({ testKey: 'testValue' }), + setState: sinon.stub().resolves(), + updateState: sinon.stub().resolves(), + executeAction: sinon.stub().resolves({ result: 'action-result' }), + cleanup: sinon.stub().resolves(), + onClose: sinon.stub(), + ...overrides, + }; +} + +function createMockBridge(webappId: string): MiniAppBridge { + return { + install: sinon.stub().resolves(), + uninstall: sinon.stub().resolves(), + sendToSPA: sinon.stub().resolves(), + onAction: sinon.stub(), + getState: sinon.stub().resolves({}), + installed: true, + webappId, + }; +} + +function createMockMiniApp(id: string, overrides?: Partial): MiniApp { + const supportedActions: MiniAppActionSchema[] = [ + { + name: 'test-action', + description: 'A test action', + schema: { type: 'object', properties: { arg1: { type: 'string' } } }, + }, + { + name: 'another-action', + description: 'Another test action', + schema: { type: 'object', properties: {} }, + }, + ]; + + const stateSchema: MiniAppStateSchema = { + type: 'object', + properties: { + testKey: { type: 'string', description: 'A test key' }, + }, + }; + + return { + id, + name: `Test App ${id}`, + description: `Test mini app ${id}`, + icon: '🧪', + getSPA: () => ({ html: '
test
', css: '', js: '' }), + getSupportedActions: () => supportedActions, + getStateSchema: () => stateSchema, + createController: () => createMockController(), + ...overrides, + }; +} + +function createMockInstance(appId: string, overrides?: Partial): MiniAppInstance { + const app = createMockMiniApp(appId); + return { + app, + controller: createMockController(), + bridge: createMockBridge(`webapp-${appId}`), + webappId: `webapp-${appId}`, + launchedAt: new Date(), + ...overrides, + }; +} + +// ============================================================================ +// ListMiniAppsTool Tests +// ============================================================================ + +describe('ListMiniAppsTool', () => { + afterEach(() => { + sinon.restore(); + }); + + it('returns all registered apps with metadata', async () => { + const mockApps = [ + createMockMiniApp('app1'), + createMockMiniApp('app2'), + ]; + + sinon.stub(MiniAppRegistry, 'getAllApps').returns(mockApps); + sinon.stub(MiniAppRegistry, 'isRunning').returns(false); + + const tool = new ListMiniAppsTool(); + const result = await tool.execute({}); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.strictEqual(result.count, 2); + assert.strictEqual(result.apps.length, 2); + assert.strictEqual(result.apps[0].id, 'app1'); + assert.strictEqual(result.apps[0].name, 'Test App app1'); + assert.strictEqual(result.apps[0].isRunning, false); + assert.isArray(result.apps[0].supportedActions); + } + }); + + it('includes running status for each app', async () => { + const mockApps = [ + createMockMiniApp('app1'), + createMockMiniApp('app2'), + ]; + + sinon.stub(MiniAppRegistry, 'getAllApps').returns(mockApps); + const isRunningStub = sinon.stub(MiniAppRegistry, 'isRunning'); + isRunningStub.withArgs('app1').returns(true); + isRunningStub.withArgs('app2').returns(false); + + const tool = new ListMiniAppsTool(); + const result = await tool.execute({}); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.strictEqual(result.apps[0].isRunning, true); + assert.strictEqual(result.apps[1].isRunning, false); + } + }); + + it('filters to only non-running apps when includeRunning is false', async () => { + const mockApps = [ + createMockMiniApp('app1'), + createMockMiniApp('app2'), + ]; + + sinon.stub(MiniAppRegistry, 'getAllApps').returns(mockApps); + const isRunningStub = sinon.stub(MiniAppRegistry, 'isRunning'); + isRunningStub.withArgs('app1').returns(true); + isRunningStub.withArgs('app2').returns(false); + + const tool = new ListMiniAppsTool(); + const result = await tool.execute({ includeRunning: false }); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.strictEqual(result.count, 1); + assert.strictEqual(result.apps[0].id, 'app2'); + } + }); + + it('returns empty array when no apps registered', async () => { + sinon.stub(MiniAppRegistry, 'getAllApps').returns([]); + + const tool = new ListMiniAppsTool(); + const result = await tool.execute({}); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.strictEqual(result.count, 0); + assert.deepEqual(result.apps, []); + } + }); +}); + +// ============================================================================ +// LaunchMiniAppTool Tests +// ============================================================================ + +describe('LaunchMiniAppTool', () => { + afterEach(() => { + sinon.restore(); + }); + + it('successfully launches app and returns instance info', async () => { + const mockInstance = createMockInstance('test-app'); + + sinon.stub(MiniAppRegistry, 'isRunning').returns(false); + sinon.stub(MiniAppRegistry, 'launch').resolves(mockInstance); + + const tool = new LaunchMiniAppTool(); + const result = await tool.execute({ appId: 'test-app' }); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.strictEqual(result.success, true); + assert.strictEqual(result.appId, 'test-app'); + assert.strictEqual(result.wasAlreadyRunning, false); + assert.match(result.message, /Successfully launched/); + } + }); + + it('returns existing instance when app already running (idempotent)', async () => { + const mockInstance = createMockInstance('test-app'); + + sinon.stub(MiniAppRegistry, 'isRunning').returns(true); + sinon.stub(MiniAppRegistry, 'launch').resolves(mockInstance); + + const tool = new LaunchMiniAppTool(); + const result = await tool.execute({ appId: 'test-app' }); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.strictEqual(result.success, true); + assert.strictEqual(result.wasAlreadyRunning, true); + assert.match(result.message, /already running/); + } + }); + + it('applies initial state when provided and app not already running', async () => { + const controller = createMockController(); + const mockInstance = createMockInstance('test-app', { controller }); + + sinon.stub(MiniAppRegistry, 'isRunning').returns(false); + sinon.stub(MiniAppRegistry, 'launch').resolves(mockInstance); + + const tool = new LaunchMiniAppTool(); + const initialState = { key1: 'value1', key2: 42 }; + await tool.execute({ appId: 'test-app', initialState }); + + sinon.assert.calledOnce(controller.setState as sinon.SinonStub); + sinon.assert.calledWith(controller.setState as sinon.SinonStub, initialState); + }); + + it('does not apply initial state when app already running', async () => { + const controller = createMockController(); + const mockInstance = createMockInstance('test-app', { controller }); + + sinon.stub(MiniAppRegistry, 'isRunning').returns(true); + sinon.stub(MiniAppRegistry, 'launch').resolves(mockInstance); + + const tool = new LaunchMiniAppTool(); + await tool.execute({ appId: 'test-app', initialState: { key: 'value' } }); + + sinon.assert.notCalled(controller.setState as sinon.SinonStub); + }); + + it('returns error when appId not provided', async () => { + const tool = new LaunchMiniAppTool(); + const result = await tool.execute({ appId: '' }); + + assert.strictEqual('error' in result, true); + if ('error' in result) { + assert.match(result.error, /appId is required/); + } + }); + + it('returns error when app ID not found', async () => { + sinon.stub(MiniAppRegistry, 'isRunning').returns(false); + sinon.stub(MiniAppRegistry, 'launch').rejects(new Error('Mini app "unknown-app" is not registered')); + + const tool = new LaunchMiniAppTool(); + const result = await tool.execute({ appId: 'unknown-app' }); + + assert.strictEqual('error' in result, true); + if ('error' in result) { + assert.match(result.error, /not registered/); + } + }); +}); + +// ============================================================================ +// GetMiniAppStateTool Tests +// ============================================================================ + +describe('GetMiniAppStateTool', () => { + afterEach(() => { + sinon.restore(); + }); + + it('returns current state from running app', async () => { + const expectedState = { key1: 'value1', key2: 123 }; + const controller = createMockController({ + getState: sinon.stub().resolves(expectedState), + }); + const mockInstance = createMockInstance('test-app', { controller }); + + sinon.stub(MiniAppRegistry, 'getRunningInstance').returns(mockInstance); + + const tool = new GetMiniAppStateTool(); + const result = await tool.execute({ appId: 'test-app' }); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.strictEqual(result.appId, 'test-app'); + assert.deepEqual(result.state, expectedState); + } + }); + + it('includes state schema in response', async () => { + const mockInstance = createMockInstance('test-app'); + sinon.stub(MiniAppRegistry, 'getRunningInstance').returns(mockInstance); + + const tool = new GetMiniAppStateTool(); + const result = await tool.execute({ appId: 'test-app' }); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.isDefined(result.stateSchema); + assert.strictEqual((result.stateSchema as any).type, 'object'); + } + }); + + it('returns error when app not running', async () => { + sinon.stub(MiniAppRegistry, 'getRunningInstance').returns(undefined); + + const tool = new GetMiniAppStateTool(); + const result = await tool.execute({ appId: 'not-running' }); + + assert.strictEqual('error' in result, true); + if ('error' in result) { + assert.match(result.error, /not running/); + } + }); + + it('handles empty state correctly', async () => { + const controller = createMockController({ + getState: sinon.stub().resolves({}), + }); + const mockInstance = createMockInstance('test-app', { controller }); + + sinon.stub(MiniAppRegistry, 'getRunningInstance').returns(mockInstance); + + const tool = new GetMiniAppStateTool(); + const result = await tool.execute({ appId: 'test-app' }); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.deepEqual(result.state, {}); + } + }); +}); + +// ============================================================================ +// UpdateMiniAppStateTool Tests +// ============================================================================ + +describe('UpdateMiniAppStateTool', () => { + afterEach(() => { + sinon.restore(); + }); + + it('merges partial state updates by default', async () => { + const controller = createMockController({ + getState: sinon.stub().resolves({ key1: 'new', key2: 'existing' }), + }); + const mockInstance = createMockInstance('test-app', { controller }); + + sinon.stub(MiniAppRegistry, 'getRunningInstance').returns(mockInstance); + + const tool = new UpdateMiniAppStateTool(); + const result = await tool.execute({ appId: 'test-app', updates: { key1: 'new' } }); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.strictEqual(result.success, true); + assert.match(result.message, /updated successfully/); + } + sinon.assert.calledOnce(controller.updateState as sinon.SinonStub); + sinon.assert.notCalled(controller.setState as sinon.SinonStub); + }); + + it('replaces full state when replace is true', async () => { + const newState = { newKey: 'newValue' }; + const controller = createMockController({ + getState: sinon.stub().resolves(newState), + }); + const mockInstance = createMockInstance('test-app', { controller }); + + sinon.stub(MiniAppRegistry, 'getRunningInstance').returns(mockInstance); + + const tool = new UpdateMiniAppStateTool(); + const result = await tool.execute({ appId: 'test-app', updates: newState, replace: true }); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.strictEqual(result.success, true); + assert.match(result.message, /replaced successfully/); + } + sinon.assert.calledOnce(controller.setState as sinon.SinonStub); + sinon.assert.notCalled(controller.updateState as sinon.SinonStub); + }); + + it('returns error when app not running', async () => { + sinon.stub(MiniAppRegistry, 'getRunningInstance').returns(undefined); + + const tool = new UpdateMiniAppStateTool(); + const result = await tool.execute({ appId: 'not-running', updates: { key: 'value' } }); + + assert.strictEqual('error' in result, true); + if ('error' in result) { + assert.match(result.error, /not running/); + } + }); + + it('returns error when updates is not an object', async () => { + const tool = new UpdateMiniAppStateTool(); + const result = await tool.execute({ appId: 'test-app', updates: null as any }); + + assert.strictEqual('error' in result, true); + if ('error' in result) { + assert.match(result.error, /updates must be an object/); + } + }); +}); + +// ============================================================================ +// ExecuteMiniAppActionTool Tests +// ============================================================================ + +describe('ExecuteMiniAppActionTool', () => { + afterEach(() => { + sinon.restore(); + }); + + it('executes valid action and returns result', async () => { + const actionResult = { data: 'action completed' }; + const controller = createMockController({ + executeAction: sinon.stub().resolves(actionResult), + }); + const mockInstance = createMockInstance('test-app', { controller }); + + sinon.stub(MiniAppRegistry, 'getRunningInstance').returns(mockInstance); + + const tool = new ExecuteMiniAppActionTool(); + const result = await tool.execute({ + appId: 'test-app', + actionName: 'test-action', + args: { arg1: 'value' }, + }); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.strictEqual(result.success, true); + assert.strictEqual(result.actionName, 'test-action'); + assert.deepEqual(result.result, actionResult); + } + sinon.assert.calledWith(controller.executeAction as sinon.SinonStub, 'test-action', { arg1: 'value' }); + }); + + it('passes empty object when args not provided', async () => { + const controller = createMockController(); + const mockInstance = createMockInstance('test-app', { controller }); + + sinon.stub(MiniAppRegistry, 'getRunningInstance').returns(mockInstance); + + const tool = new ExecuteMiniAppActionTool(); + await tool.execute({ appId: 'test-app', actionName: 'test-action' }); + + sinon.assert.calledWith(controller.executeAction as sinon.SinonStub, 'test-action', {}); + }); + + it('returns error when action name not supported', async () => { + const mockInstance = createMockInstance('test-app'); + sinon.stub(MiniAppRegistry, 'getRunningInstance').returns(mockInstance); + + const tool = new ExecuteMiniAppActionTool(); + const result = await tool.execute({ appId: 'test-app', actionName: 'unsupported-action' }); + + assert.strictEqual('error' in result, true); + if ('error' in result) { + assert.match(result.error, /not supported/); + assert.match(result.error, /Available actions/); + } + }); + + it('returns error when app not running', async () => { + sinon.stub(MiniAppRegistry, 'getRunningInstance').returns(undefined); + + const tool = new ExecuteMiniAppActionTool(); + const result = await tool.execute({ appId: 'not-running', actionName: 'test-action' }); + + assert.strictEqual('error' in result, true); + if ('error' in result) { + assert.match(result.error, /not running/); + } + }); +}); + +// ============================================================================ +// CloseMiniAppTool Tests +// ============================================================================ + +describe('CloseMiniAppTool', () => { + afterEach(() => { + sinon.restore(); + }); + + it('closes running app successfully', async () => { + sinon.stub(MiniAppRegistry, 'isRunning').returns(true); + const closeStub = sinon.stub(MiniAppRegistry, 'close').resolves(); + + const tool = new CloseMiniAppTool(); + const result = await tool.execute({ appId: 'test-app' }); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.strictEqual(result.success, true); + assert.match(result.message, /closed successfully/); + } + sinon.assert.calledOnce(closeStub); + sinon.assert.calledWith(closeStub, 'test-app'); + }); + + it('handles close of already-closed app gracefully', async () => { + sinon.stub(MiniAppRegistry, 'isRunning').returns(false); + + const tool = new CloseMiniAppTool(); + const result = await tool.execute({ appId: 'not-running' }); + + assert.strictEqual('error' in result, false); + if (!('error' in result)) { + assert.strictEqual(result.success, true); + assert.match(result.message, /was not running/); + } + }); + + it('returns error when close fails', async () => { + sinon.stub(MiniAppRegistry, 'isRunning').returns(true); + sinon.stub(MiniAppRegistry, 'close').rejects(new Error('Close failed')); + + const tool = new CloseMiniAppTool(); + const result = await tool.execute({ appId: 'test-app' }); + + assert.strictEqual('error' in result, true); + if ('error' in result) { + assert.match(result.error, /Close failed/); + } + }); +}); diff --git a/front_end/panels/ai_chat/tools/mini_app/index.ts b/front_end/panels/ai_chat/tools/mini_app/index.ts new file mode 100644 index 0000000000..cb4b35432d --- /dev/null +++ b/front_end/panels/ai_chat/tools/mini_app/index.ts @@ -0,0 +1,35 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Mini App Tools + * + * Tools for AI agents to interact with mini apps. + * + * These tools provide a complete interface for: + * - Discovering available mini apps (list_mini_apps) + * - Launching mini apps (launch_mini_app) + * - Reading mini app state (get_mini_app_state) + * - Updating mini app state (update_mini_app_state) + * - Executing app-specific actions (execute_mini_app_action) + * - Closing mini apps (close_mini_app) + */ + +export { ListMiniAppsTool } from './ListMiniAppsTool.js'; +export type { ListMiniAppsArgs, ListMiniAppsResult, MiniAppInfo } from './ListMiniAppsTool.js'; + +export { LaunchMiniAppTool } from './LaunchMiniAppTool.js'; +export type { LaunchMiniAppArgs, LaunchMiniAppResult } from './LaunchMiniAppTool.js'; + +export { GetMiniAppStateTool } from './GetMiniAppStateTool.js'; +export type { GetMiniAppStateArgs, GetMiniAppStateResult } from './GetMiniAppStateTool.js'; + +export { UpdateMiniAppStateTool } from './UpdateMiniAppStateTool.js'; +export type { UpdateMiniAppStateArgs, UpdateMiniAppStateResult } from './UpdateMiniAppStateTool.js'; + +export { ExecuteMiniAppActionTool } from './ExecuteMiniAppActionTool.js'; +export type { ExecuteMiniAppActionArgs, ExecuteMiniAppActionResult } from './ExecuteMiniAppActionTool.js'; + +export { CloseMiniAppTool } from './CloseMiniAppTool.js'; +export type { CloseMiniAppArgs, CloseMiniAppResult } from './CloseMiniAppTool.js'; diff --git a/front_end/panels/ai_chat/ui/AIChatPanel.ts b/front_end/panels/ai_chat/ui/AIChatPanel.ts index c2311e360b..f9539d9bf1 100644 --- a/front_end/panels/ai_chat/ui/AIChatPanel.ts +++ b/front_end/panels/ai_chat/ui/AIChatPanel.ts @@ -90,6 +90,8 @@ import { onMCPConfigChange } from '../mcp/MCPConfig.js'; import { MCPConnectorsCatalogDialog } from './mcp/MCPConnectorsCatalogDialog.js'; // Conversation history import { ConversationHistoryList } from './ConversationHistoryList.js'; +// Agent Studio +import { AgentStudioView } from './AgentStudioView.js'; // Model type definition @@ -665,6 +667,7 @@ export class AIChatPanel extends UI.Panel.Panel { #evaluationAgent: EvaluationAgent | null = null; // Evaluation agent for this tab #mcpUnsubscribe: (() => void) | null = null; #configManager: LLMConfigurationManager; + #agentStudioView: AgentStudioView | null = null; // Agent Studio view // Store bound event listeners to properly add/remove without duplications #boundOnMessagesChanged?: (e: Common.EventTarget.EventTargetEvent) => void; @@ -2028,6 +2031,11 @@ export class AIChatPanel extends UI.Panel.Panel { () => this.#onMCPConnectorsClick(), {jslogContext: 'connectors'} ); + contextMenu.defaultSection().appendItem( + 'Agent Studio', + () => this.#onAgentStudioClick(), + {jslogContext: 'agent-studio'} + ); }, true, // isIconDropdown true, // useSoftMenu @@ -2302,6 +2310,16 @@ export class AIChatPanel extends UI.Panel.Panel { }); } + /** + * Handles the Agent Studio button click event and shows the Agent Studio + */ + #onAgentStudioClick(): void { + if (!this.#agentStudioView) { + this.#agentStudioView = new AgentStudioView(); + } + void this.#agentStudioView.show(); + } + /** * Handles the settings button click event and shows the settings dialog */ diff --git a/front_end/panels/ai_chat/ui/AgentStudioBridge.ts b/front_end/panels/ai_chat/ui/AgentStudioBridge.ts new file mode 100644 index 0000000000..fdc5404351 --- /dev/null +++ b/front_end/panels/ai_chat/ui/AgentStudioBridge.ts @@ -0,0 +1,243 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as SDK from '../../../core/sdk/sdk.js'; +import type * as Protocol from '../../../generated/protocol.js'; +import { createLogger } from '../core/Logger.js'; + +const logger = createLogger('AgentStudioBridge'); + +const AGENT_STUDIO_BINDING_NAME = '__agentStudioBridge'; + +/** + * Action types from SPA to DevTools + */ +export type SPAAction = + | { type: 'select-agent'; name: string; id: string | null; isBuiltIn: boolean } + | { type: 'new-agent' } + | { type: 'save-agent'; data: AgentFormData } + | { type: 'delete-agent' } + | { type: 'clone-agent' } + | { type: 'run-test'; query: string } + | { type: 'close' } + | { type: 'ready' }; // SPA signals it's ready to receive data + +/** + * Form data for saving agents + */ +export interface AgentFormData { + name: string; + displayName: string; + description: string; + avatar: string; + color: string; + systemPrompt: string; + tools: string[]; + maxIterations: number; + temperature: number; + schema: object; +} + +/** + * Action types from DevTools to SPA + */ +export type DevToolsAction = + | { action: 'init'; payload: InitPayload } + | { action: 'agents-updated'; payload: { agents: AgentInfo[] } } + | { action: 'agent-selected'; payload: { agent: AgentInfo } } + | { action: 'agent-saved'; payload: { agent: AgentInfo } } + | { action: 'notification'; payload: { message: string; type: 'success' | 'error' | 'warning' } } + | { action: 'test-result'; payload: { html: string } }; + +export interface InitPayload { + agents: AgentInfo[]; + tools: ToolInfo[]; + selectedAgent?: AgentInfo; +} + +export interface AgentInfo { + id?: string; + name: string; + displayName: string; + description: string; + avatar: string; + color: string; + backgroundColor: string; + isBuiltIn: boolean; + tools: string[]; + maxIterations: number; + temperature: number; + systemPrompt: string; + version: string; + schema: object; +} + +export interface ToolInfo { + name: string; + description: string; +} + +/** + * Callback type for handling SPA actions + */ +export type ActionHandler = (action: SPAAction) => void | Promise; + +/** + * AgentStudioBridge - Handles bidirectional communication between DevTools and Agent Studio SPA + * + * Uses CDP Runtime.addBinding for instant SPA→DevTools communication (no polling) + * Uses CDP Runtime.evaluate for DevTools→SPA communication + */ +export class AgentStudioBridge { + private target: SDK.Target.Target | null = null; + private webappId: string | null = null; + private bindingHandler: ((event: { data: Protocol.Runtime.BindingCalledEvent }) => void) | null = null; + private actionHandler: ActionHandler | null = null; + private isInstalled = false; + + /** + * Set the action handler for SPA events + */ + onAction(handler: ActionHandler): void { + this.actionHandler = handler; + } + + /** + * Install the bridge - sets up Runtime.addBinding for SPA→DevTools communication + */ + async install(webappId: string): Promise { + if (this.isInstalled) { + logger.warn('Bridge already installed'); + return; + } + + this.webappId = webappId; + this.target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); + + if (!this.target) { + throw new Error('No primary page target available'); + } + + const runtimeModel = this.target.model(SDK.RuntimeModel.RuntimeModel); + if (!runtimeModel) { + throw new Error('RuntimeModel not available'); + } + + // Create handler for binding calls + this.bindingHandler = this.handleBindingCalled.bind(this); + runtimeModel.addEventListener(SDK.RuntimeModel.Events.BindingCalled, this.bindingHandler); + + // Add the binding - this creates window.__agentStudioBridge() in the page + await this.target.runtimeAgent().invoke_addBinding({ + name: AGENT_STUDIO_BINDING_NAME, + }); + + this.isInstalled = true; + logger.info('Bridge installed', { webappId }); + } + + /** + * Uninstall the bridge - removes binding and event listeners + */ + async uninstall(): Promise { + if (!this.isInstalled || !this.target) { + return; + } + + const runtimeModel = this.target.model(SDK.RuntimeModel.RuntimeModel); + + // Remove event listener + if (runtimeModel && this.bindingHandler) { + runtimeModel.removeEventListener(SDK.RuntimeModel.Events.BindingCalled, this.bindingHandler); + } + + // Remove the binding + try { + await this.target.runtimeAgent().invoke_removeBinding({ + name: AGENT_STUDIO_BINDING_NAME, + }); + } catch (error) { + logger.error('Failed to remove binding:', error); + } + + this.bindingHandler = null; + this.target = null; + this.webappId = null; + this.isInstalled = false; + + logger.info('Bridge uninstalled'); + } + + /** + * Send an action to the SPA + */ + async sendToSPA(action: DevToolsAction): Promise { + if (!this.target || !this.webappId) { + logger.error('Bridge not installed, cannot send to SPA'); + return; + } + + try { + const runtimeAgent = this.target.runtimeAgent(); + + // Call window.agentStudio.dispatch() in the iframe context + await runtimeAgent.invoke_evaluate({ + expression: ` + (() => { + const iframe = document.getElementById(${JSON.stringify(this.webappId)}); + if (!iframe || !iframe.contentWindow) { + console.error('Agent Studio iframe not found'); + return false; + } + if (typeof iframe.contentWindow.agentStudio?.dispatch === 'function') { + iframe.contentWindow.agentStudio.dispatch(${JSON.stringify(action)}); + return true; + } + console.error('agentStudio.dispatch not found'); + return false; + })() + `, + returnByValue: true, + }); + } catch (error) { + logger.error('Failed to send to SPA:', error); + } + } + + /** + * Handle binding calls from the SPA + */ + private handleBindingCalled(event: { data: Protocol.Runtime.BindingCalledEvent }): void { + const { data } = event; + + // Only handle our binding + if (data.name !== AGENT_STUDIO_BINDING_NAME) { + return; + } + + try { + const action = JSON.parse(data.payload) as SPAAction; + logger.info('Received action from SPA:', action.type); + + if (this.actionHandler) { + // Handle async actions + const result = this.actionHandler(action); + if (result instanceof Promise) { + result.catch(error => { + logger.error('Error handling action:', error); + }); + } + } + } catch (error) { + logger.error('Failed to parse SPA action:', error); + } + } + + /** + * Check if bridge is installed + */ + get installed(): boolean { + return this.isInstalled; + } +} diff --git a/front_end/panels/ai_chat/ui/AgentStudioController.ts b/front_end/panels/ai_chat/ui/AgentStudioController.ts new file mode 100644 index 0000000000..20bb011947 --- /dev/null +++ b/front_end/panels/ai_chat/ui/AgentStudioController.ts @@ -0,0 +1,486 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../core/Logger.js'; +import { AgentStorageManager, type CreateAgentInput, type SchemaProperty } from '../core/AgentStorageManager.js'; +import { AgentStudioIntegration, type AgentDisplayInfo } from '../core/AgentStudioIntegration.js'; +import { ToolRegistry } from '../agent_framework/ConfigurableAgentTool.js'; +import { + AgentStudioBridge, + type SPAAction, + type AgentFormData, + type AgentInfo, + type ToolInfo, +} from './AgentStudioBridge.js'; + +const logger = createLogger('AgentStudioController'); + +/** + * AgentStudioController - Manages state and business logic for Agent Studio + * + * Responsibilities: + * - Handles all SPA actions (select, save, delete, clone, etc.) + * - Manages agent state (selected agent, creating new) + * - Communicates with AgentStorageManager for persistence + * - Sends state updates to SPA via bridge + */ +export class AgentStudioController { + private bridge: AgentStudioBridge; + private selectedAgentId: string | null = null; + private selectedAgentName: string | null = null; + private isCreatingNew = false; + private closeCallback: (() => void | Promise) | null = null; + + constructor() { + this.bridge = new AgentStudioBridge(); + this.bridge.onAction(this.handleAction.bind(this)); + } + + /** + * Set callback for when close action is received + */ + onClose(callback: () => void | Promise): void { + this.closeCallback = callback; + } + + /** + * Initialize the controller and install the bridge + */ + async initialize(webappId: string): Promise { + await this.bridge.install(webappId); + logger.info('Controller initialized'); + } + + /** + * Cleanup - uninstall bridge and reset state + */ + async cleanup(): Promise { + await this.bridge.uninstall(); + this.selectedAgentId = null; + this.selectedAgentName = null; + this.isCreatingNew = false; + logger.info('Controller cleaned up'); + } + + /** + * Push initial state to the SPA + */ + async pushInitialState(): Promise { + const { agents, tools } = await this.loadAllData(); + + await this.bridge.sendToSPA({ + action: 'init', + payload: { + agents, + tools, + selectedAgent: undefined, + }, + }); + + logger.info('Initial state pushed to SPA'); + } + + /** + * Load all agents and tools + */ + private async loadAllData(): Promise<{ agents: AgentInfo[]; tools: ToolInfo[] }> { + const allAgents = await AgentStudioIntegration.getAllAgentsForDisplay(); + const agents: AgentInfo[] = allAgents.map(a => this.toAgentInfo(a)); + + const toolNames = AgentStudioIntegration.getAvailableToolNames(); + const tools: ToolInfo[] = toolNames.map(name => { + const tool = ToolRegistry.getRegisteredTool(name); + return { + name, + description: tool?.description || 'No description available', + }; + }); + + return { agents, tools }; + } + + /** + * Convert AgentDisplayInfo to AgentInfo + */ + private toAgentInfo(agent: AgentDisplayInfo): AgentInfo { + return { + id: agent.id, + name: agent.name, + displayName: agent.displayName, + description: agent.description, + avatar: agent.avatar, + color: agent.color, + backgroundColor: agent.backgroundColor, + isBuiltIn: agent.isBuiltIn, + tools: agent.tools, + maxIterations: agent.maxIterations, + temperature: agent.temperature, + systemPrompt: agent.systemPrompt, + version: agent.version, + schema: agent.schema, + }; + } + + /** + * Handle actions from the SPA + */ + private async handleAction(action: SPAAction): Promise { + logger.info('Handling action:', action.type); + + switch (action.type) { + case 'ready': + await this.pushInitialState(); + break; + + case 'select-agent': + await this.handleSelectAgent(action.name, action.id, action.isBuiltIn); + break; + + case 'new-agent': + await this.handleNewAgent(); + break; + + case 'save-agent': + await this.handleSaveAgent(action.data); + break; + + case 'delete-agent': + await this.handleDeleteAgent(); + break; + + case 'clone-agent': + await this.handleCloneAgent(); + break; + + case 'run-test': + await this.handleRunTest(action.query); + break; + + case 'close': + if (this.closeCallback) { + await this.closeCallback(); + } + break; + + default: + logger.warn('Unknown action type:', (action as SPAAction).type); + } + } + + /** + * Handle selecting an agent + */ + private async handleSelectAgent(name: string, id: string | null, isBuiltIn: boolean): Promise { + this.isCreatingNew = false; + this.selectedAgentName = name; + this.selectedAgentId = id; + + // Get full agent data + const allAgents = await AgentStudioIntegration.getAllAgentsForDisplay(); + const agent = allAgents.find(a => a.name === name); + + if (agent) { + await this.bridge.sendToSPA({ + action: 'agent-selected', + payload: { agent: this.toAgentInfo(agent) }, + }); + } + } + + /** + * Handle creating a new agent + */ + private async handleNewAgent(): Promise { + this.isCreatingNew = true; + this.selectedAgentId = null; + this.selectedAgentName = null; + + // Send empty agent template + const emptyAgent: AgentInfo = { + name: '', + displayName: '', + description: '', + avatar: '🤖', + color: '#00a4fe', + backgroundColor: '#e2f3fb', + isBuiltIn: false, + tools: [], + maxIterations: 10, + temperature: 0, + systemPrompt: '', + version: '1.0.0', + schema: { type: 'object', properties: {}, required: ['query', 'reasoning'] }, + }; + + await this.bridge.sendToSPA({ + action: 'agent-selected', + payload: { agent: emptyAgent }, + }); + } + + /** + * Handle saving an agent + */ + private async handleSaveAgent(data: AgentFormData): Promise { + try { + const storage = AgentStorageManager.getInstance(); + + if (this.isCreatingNew || !this.selectedAgentId) { + // Create new agent + const input: CreateAgentInput = { + name: data.name, + description: data.description || '', + version: '1.0.0', + systemPrompt: data.systemPrompt, + tools: data.tools || [], + maxIterations: data.maxIterations || 10, + temperature: data.temperature || 0, + schema: (data.schema && typeof data.schema === 'object' && 'type' in data.schema) + ? data.schema as { type: string; properties: Record; required?: string[] } + : { type: 'object', properties: {}, required: [] }, + ui: { + displayName: data.displayName || data.name, + avatar: data.avatar || '🤖', + color: data.color || '#00a4fe', + backgroundColor: '#e2f3fb', + }, + }; + + const created = await storage.createAgent(input); + this.selectedAgentId = created.id; + this.selectedAgentName = created.name; + this.isCreatingNew = false; + + logger.info('Created new agent:', created.name); + } else { + // Update existing agent + await storage.updateAgent(this.selectedAgentId, { + name: data.name, + description: data.description || '', + systemPrompt: data.systemPrompt, + tools: data.tools || [], + maxIterations: data.maxIterations || 10, + temperature: data.temperature || 0, + schema: (data.schema && typeof data.schema === 'object' && 'type' in data.schema) + ? data.schema as { type: string; properties: Record; required?: string[] } + : { type: 'object', properties: {}, required: [] }, + ui: { + displayName: data.displayName || data.name, + avatar: data.avatar || '🤖', + color: data.color || '#00a4fe', + backgroundColor: '#e2f3fb', + }, + }); + + this.selectedAgentName = data.name; + logger.info('Updated agent:', data.name); + } + + // Refresh agents in ToolRegistry + await AgentStudioIntegration.refreshAgents(); + + // Refresh agents list in SPA + const { agents, tools } = await this.loadAllData(); + await this.bridge.sendToSPA({ + action: 'agents-updated', + payload: { agents }, + }); + + // Send success notification + await this.bridge.sendToSPA({ + action: 'notification', + payload: { message: 'Agent saved successfully', type: 'success' }, + }); + } catch (error) { + logger.error('Failed to save agent:', error); + await this.bridge.sendToSPA({ + action: 'notification', + payload: { + message: `Failed to save agent: ${error instanceof Error ? error.message : 'Unknown error'}`, + type: 'error', + }, + }); + } + } + + /** + * Handle deleting an agent + */ + private async handleDeleteAgent(): Promise { + if (!this.selectedAgentId) { + logger.warn('No agent selected for deletion'); + return; + } + + try { + const storage = AgentStorageManager.getInstance(); + await storage.deleteAgent(this.selectedAgentId); + + // Refresh agents in ToolRegistry + await AgentStudioIntegration.refreshAgents(); + + // Clear selection + this.selectedAgentId = null; + this.selectedAgentName = null; + this.isCreatingNew = false; + + // Refresh agents list in SPA + const { agents } = await this.loadAllData(); + await this.bridge.sendToSPA({ + action: 'agents-updated', + payload: { agents }, + }); + + // Clear the form + await this.bridge.sendToSPA({ + action: 'agent-selected', + payload: { agent: null as unknown as AgentInfo }, + }); + + await this.bridge.sendToSPA({ + action: 'notification', + payload: { message: 'Agent deleted successfully', type: 'success' }, + }); + + logger.info('Agent deleted successfully'); + } catch (error) { + logger.error('Failed to delete agent:', error); + await this.bridge.sendToSPA({ + action: 'notification', + payload: { + message: `Failed to delete agent: ${error instanceof Error ? error.message : 'Unknown error'}`, + type: 'error', + }, + }); + } + } + + /** + * Handle cloning a built-in agent + */ + private async handleCloneAgent(): Promise { + if (!this.selectedAgentName) { + logger.warn('No agent selected for cloning'); + return; + } + + try { + // Get the agent's data + const allAgents = await AgentStudioIntegration.getAllAgentsForDisplay(); + const sourceAgent = allAgents.find(a => a.name === this.selectedAgentName); + + if (!sourceAgent) { + throw new Error('Agent not found'); + } + + // Generate unique name + let cloneName = `${sourceAgent.name}_custom`; + let counter = 1; + const storage = AgentStorageManager.getInstance(); + + while (await storage.agentNameExists(cloneName) || AgentStudioIntegration.isBuiltInAgentName(cloneName)) { + cloneName = `${sourceAgent.name}_custom_${counter}`; + counter++; + } + + // Create cloned agent + const input: CreateAgentInput = { + name: cloneName, + description: `Clone of ${sourceAgent.displayName}`, + version: '1.0.0', + systemPrompt: sourceAgent.systemPrompt, + tools: [...sourceAgent.tools], + maxIterations: sourceAgent.maxIterations, + temperature: sourceAgent.temperature, + schema: JSON.parse(JSON.stringify(sourceAgent.schema)), + ui: { + displayName: `${sourceAgent.displayName} (Custom)`, + avatar: sourceAgent.avatar, + color: sourceAgent.color, + backgroundColor: sourceAgent.backgroundColor, + }, + }; + + const created = await storage.createAgent(input); + + // Refresh agents in ToolRegistry + await AgentStudioIntegration.refreshAgents(); + + // Update state + this.selectedAgentId = created.id; + this.selectedAgentName = created.name; + this.isCreatingNew = false; + + // Refresh agents list + const { agents } = await this.loadAllData(); + await this.bridge.sendToSPA({ + action: 'agents-updated', + payload: { agents }, + }); + + // Select the new agent + const newAgent = agents.find(a => a.name === created.name); + if (newAgent) { + await this.bridge.sendToSPA({ + action: 'agent-selected', + payload: { agent: newAgent }, + }); + } + + await this.bridge.sendToSPA({ + action: 'notification', + payload: { message: `Agent cloned as "${created.name}"`, type: 'success' }, + }); + + logger.info('Cloned agent:', created.name); + } catch (error) { + logger.error('Failed to clone agent:', error); + await this.bridge.sendToSPA({ + action: 'notification', + payload: { + message: `Failed to clone agent: ${error instanceof Error ? error.message : 'Unknown error'}`, + type: 'error', + }, + }); + } + } + + /** + * Handle running a test + */ + private async handleRunTest(query: string): Promise { + // For now, just show a placeholder result + // Full implementation would use AgentTestRunner + const resultHTML = ` +
+
Test execution is not yet implemented.
+
Query: ${this.escapeHTML(query)}
+
+ `; + + await this.bridge.sendToSPA({ + action: 'test-result', + payload: { html: resultHTML }, + }); + } + + /** + * Get the bridge for external access (e.g., for close handling) + */ + getBridge(): AgentStudioBridge { + return this.bridge; + } + + /** + * Helper to escape HTML + */ + private escapeHTML(str: string): string { + return (str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} diff --git a/front_end/panels/ai_chat/ui/AgentStudioView.ts b/front_end/panels/ai_chat/ui/AgentStudioView.ts new file mode 100644 index 0000000000..6d37c18277 --- /dev/null +++ b/front_end/panels/ai_chat/ui/AgentStudioView.ts @@ -0,0 +1,194 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import { createLogger } from '../core/Logger.js'; +import { AgentStudioController } from './AgentStudioController.js'; +import { AgentStudioSPA } from './agent_studio/AgentStudioSPA.js'; +import { MiniAppRegistry } from '../mini_apps/MiniAppRegistry.js'; +import type { MiniAppInstance } from '../mini_apps/types/MiniAppTypes.js'; + +const logger = createLogger('AgentStudioView'); + +// Feature flag: use mini app system vs legacy implementation +const USE_MINI_APP_SYSTEM = true; + +/** + * AgentStudioView - Full-screen agent management UI + * + * Provides CRUD operations for custom agents with: + * - Agent list (built-in and custom) + * - Agent detail/edit form + * - Tool selection + * - Schema editor + * - Manual test runs + * + * Architecture: + * - When USE_MINI_APP_SYSTEM=true: Uses MiniAppRegistry for launching/managing + * - When USE_MINI_APP_SYSTEM=false: Uses legacy RenderWebAppTool directly + * - Two-way communication via Runtime.addBinding (SPA→DevTools) and Runtime.evaluate (DevTools→SPA) + * - Business logic handled by AgentStudioController or AgentStudioMiniAppController + */ +export class AgentStudioView { + private webappId: string | null = null; + private controller: AgentStudioController | null = null; + private closeCallback: (() => void) | null = null; + + // Mini app system support + private miniAppInstance: MiniAppInstance | null = null; + + /** + * Show the Agent Studio in full-screen + */ + async show(): Promise { + if (USE_MINI_APP_SYSTEM) { + await this.showViaMiniApp(); + } else { + await this.showLegacy(); + } + } + + /** + * Show via MiniAppRegistry (new system) + */ + private async showViaMiniApp(): Promise { + if (MiniAppRegistry.isRunning('agent_studio')) { + logger.info('Agent Studio already open via mini app system'); + return; + } + + try { + this.miniAppInstance = await MiniAppRegistry.launch('agent_studio'); + this.webappId = this.miniAppInstance.webappId; + + // Set up close handling + this.miniAppInstance.controller.onClose(async () => { + await this.hide(); + if (this.closeCallback) { + this.closeCallback(); + } + }); + + logger.info('Agent Studio opened via mini app system', { webappId: this.webappId }); + } catch (error) { + logger.error('Failed to open Agent Studio via mini app system:', error); + throw error; + } + } + + /** + * Show via legacy implementation (for backward compatibility) + */ + private async showLegacy(): Promise { + if (this.webappId) { + logger.info('Agent Studio already open'); + return; + } + + try { + // Import RenderWebAppTool dynamically + const { RenderWebAppTool } = await import('../tools/RenderWebAppTool.js'); + + // Render SPA in inspected page + const tool = new RenderWebAppTool(); + const result = await tool.execute({ + html: AgentStudioSPA.html, + css: AgentStudioSPA.css, + js: AgentStudioSPA.js, + reasoning: 'Display Agent Studio for managing custom agents', + }); + + if ('error' in result) { + throw new Error(result.error); + } + + this.webappId = result.webappId; + + // Create and initialize controller with bridge + this.controller = new AgentStudioController(); + await this.controller.initialize(this.webappId); + + // Set up close handling via controller callback (not bridge.onAction which would overwrite the controller's handler) + this.controller.onClose(async () => { + await this.hide(); + if (this.closeCallback) { + this.closeCallback(); + } + }); + + logger.info('Agent Studio opened', { webappId: this.webappId }); + } catch (error) { + logger.error('Failed to open Agent Studio:', error); + throw error; + } + } + + /** + * Hide the Agent Studio + */ + async hide(): Promise { + if (USE_MINI_APP_SYSTEM) { + await this.hideViaMiniApp(); + } else { + await this.hideLegacy(); + } + } + + /** + * Hide via MiniAppRegistry (new system) + */ + private async hideViaMiniApp(): Promise { + if (this.miniAppInstance || MiniAppRegistry.isRunning('agent_studio')) { + await MiniAppRegistry.close('agent_studio'); + this.miniAppInstance = null; + this.webappId = null; + logger.info('Agent Studio closed via mini app system'); + } + } + + /** + * Hide via legacy implementation + */ + private async hideLegacy(): Promise { + // Cleanup controller + if (this.controller) { + await this.controller.cleanup(); + this.controller = null; + } + + // Remove webapp + if (this.webappId) { + try { + const { RemoveWebAppTool } = await import('../tools/RemoveWebAppTool.js'); + const tool = new RemoveWebAppTool(); + await tool.execute({ + webappId: this.webappId, + reasoning: 'Closing Agent Studio', + }); + } catch (error) { + logger.error('Failed to remove webapp:', error); + } + + this.webappId = null; + } + + logger.info('Agent Studio closed'); + } + + /** + * Set callback for when studio is closed + */ + onClose(callback: () => void): void { + this.closeCallback = callback; + } + + /** + * Check if Agent Studio is visible + */ + isVisible(): boolean { + if (USE_MINI_APP_SYSTEM) { + return MiniAppRegistry.isRunning('agent_studio'); + } + return this.webappId !== null; + } +} diff --git a/front_end/panels/ai_chat/ui/ChatView.ts b/front_end/panels/ai_chat/ui/ChatView.ts index a06d980e13..bfda928102 100644 --- a/front_end/panels/ai_chat/ui/ChatView.ts +++ b/front_end/panels/ai_chat/ui/ChatView.ts @@ -15,7 +15,6 @@ import { getAgentUIConfig } from '../agent_framework/AgentSessionTypes.js'; import { VersionChecker, type VersionInfo } from '../core/VersionChecker.js'; import { LiveAgentSessionComponent } from './LiveAgentSessionComponent.js'; import { MarkdownRenderer, renderMarkdown } from './markdown/MarkdownRenderers.js'; -import { parseStructuredResponse } from '../core/structured_response.js'; import { ToolDescriptionFormatter } from './ToolDescriptionFormatter.js'; import './message/MessageList.js'; import { renderUserMessage } from './message/UserMessage.js'; @@ -23,13 +22,11 @@ import { renderModelMessage } from './message/ModelMessage.js'; import { renderToolResultMessage } from './message/ToolResultMessage.js'; import './version/VersionBanner.js'; import { renderGlobalActionsRow } from './message/GlobalActionsRow.js'; -import { renderStructuredResponse as renderStructuredResponseUI } from './message/StructuredResponseRender.js'; import './oauth/OAuthConnectPanel.js'; import './input/ChatInput.js'; import './input/InputBar.js'; import './model_selector/ModelSelector.js'; import { combineMessages } from './message/MessageCombiner.js'; -import { StructuredResponseController } from './message/StructuredResponseController.js'; import './TodoListDisplay.js'; import './FileListDisplay.js'; @@ -217,11 +214,7 @@ export class ChatView extends HTMLElement { // Add OAuth login properties #showOAuthLogin = false; #onOAuthLogin?: () => void; - - // Structured response auto-open controller - #structuredController = new StructuredResponseController(() => { - void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender); - }); + // Combined messages cache for this render pass #combinedMessagesCache: CombinedMessage[] = []; // Track agent session IDs that are nested inside other sessions to avoid duplicate top-level rendering @@ -279,7 +272,6 @@ export class ChatView extends HTMLElement { setAgentViewMode(mode: 'simplified' | 'enhanced'): void { this.#agentViewMode = mode; void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender); - this.#structuredController.resetLastProcessed(); } /** @@ -297,22 +289,6 @@ export class ChatView extends HTMLElement { // Scroll behavior handled by - #isLastStructuredMessage(currentCombinedIndex: number): boolean { - const combined = this.#combinedMessagesCache.length ? this.#combinedMessagesCache : combineMessages(this.#messages); - let lastStructuredIndex = -1; - for (let i = 0; i < combined.length; i++) { - const m = combined[i]; - if (m.entity === ChatMessageEntity.MODEL && (m as any).action === 'final') { - const sr = parseStructuredResponse(((m as any).answer || '') as string); - if (sr) { - lastStructuredIndex = i; - } - } - } - return lastStructuredIndex === currentCombinedIndex; - } - - // Update the prompt button click handler when props/state changes #updatePromptButtonClickHandler(): void { this.#handlePromptButtonClickBound = BaseOrchestratorAgent.createAgentTypeSelectionHandler( @@ -381,15 +357,8 @@ export class ChatView extends HTMLElement { } set data(data: Props) { - const previousMessageCount = this.#messages?.length || 0; - const willHaveMoreMessages = data.messages?.length > previousMessageCount; const wasInputDisabled = this.#isInputDisabled; - // Inform structured response controller of new messages - if (willHaveMoreMessages) { - this.#structuredController.handleNewMessages(this.#messages, data.messages); - } - this.#messages = data.messages; this.#state = data.state; this.#imageInput = data.imageInput; @@ -624,15 +593,7 @@ export class ChatView extends HTMLElement { // --- Render Final Answer --- if (isFinal) { - // Check if this is a structured response with REASONING and MARKDOWN_REPORT sections - const structuredResponse = parseStructuredResponse(modelMessage.answer || ''); - - if (structuredResponse) { - return this.#renderStructuredResponse(structuredResponse, combinedIndex); - } else { - // Regular final answer -> delegate to renderer - return renderModelMessage(modelMessage as any, this.#markdownRenderer); - } + return renderModelMessage(modelMessage as any, this.#markdownRenderer); } // --- Render Tool Call with Timeline Design --- @@ -1303,21 +1264,6 @@ export class ChatView extends HTMLElement { >`; } - // Method to parse structured response with reasoning and markdown_report XML tags - // parseStructuredResponse moved to core/structured_response.ts - - // Render structured response with last-message-only auto-processing - #renderStructuredResponse(structuredResponse: {reasoning: string, markdownReport: string}, combinedIndex?: number): Lit.TemplateResult { - const { aiState, isLastMessage } = this.#structuredController.computeStateAndMaybeOpen( - structuredResponse, - combinedIndex || 0, - this.#combinedMessagesCache as any - ); - return renderStructuredResponseUI(structuredResponse, { aiState, isLastMessage }, this.#markdownRenderer); - } - - // Presentational structured response handled by StructuredResponseRender - // Auto-open behavior delegated to StructuredResponseController /** * Toggle between simplified and enhanced agent view */ diff --git a/front_end/panels/ai_chat/ui/SchemaEditor.ts b/front_end/panels/ai_chat/ui/SchemaEditor.ts new file mode 100644 index 0000000000..f461f7c188 --- /dev/null +++ b/front_end/panels/ai_chat/ui/SchemaEditor.ts @@ -0,0 +1,482 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * Visual JSON Schema Editor for Agent Studio + * Generates HTML/CSS/JS for editing agent input schemas + */ + +export interface SchemaProperty { + name: string; + type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + description: string; + required: boolean; + items?: { type: string }; // For array types +} + +export interface SchemaEditorData { + properties: SchemaProperty[]; +} + +/** + * Convert SchemaEditorData to JSON Schema format + */ +export function schemaEditorDataToJSONSchema(data: SchemaEditorData): { + type: string; + properties: Record; + required?: string[]; +} { + const properties: Record = {}; + const required: string[] = []; + + for (const prop of data.properties) { + const propDef: Record = { + type: prop.type, + description: prop.description, + }; + + if (prop.type === 'array' && prop.items) { + propDef.items = prop.items; + } + + properties[prop.name] = propDef; + + if (prop.required) { + required.push(prop.name); + } + } + + const schema: { + type: string; + properties: Record; + required?: string[]; + } = { + type: 'object', + properties, + }; + + if (required.length > 0) { + schema.required = required; + } + + return schema; +} + +/** + * Convert JSON Schema to SchemaEditorData format + */ +export function jsonSchemaToEditorData(schema: { + type: string; + properties: Record; + required?: string[]; +}): SchemaEditorData { + const properties: SchemaProperty[] = []; + const requiredFields = new Set(schema.required || []); + + for (const [name, propDef] of Object.entries(schema.properties || {})) { + const def = propDef as Record; + const prop: SchemaProperty = { + name, + type: (def.type as SchemaProperty['type']) || 'string', + description: (def.description as string) || '', + required: requiredFields.has(name), + }; + + if (prop.type === 'array' && def.items) { + prop.items = def.items as { type: string }; + } + + properties.push(prop); + } + + return { properties }; +} + +/** + * Generate CSS for the Schema Editor + */ +export function generateSchemaEditorCSS(): string { + return ` + .schema-editor { + border: 1px solid var(--sys-color-divider, #e0e0e0); + border-radius: 8px; + padding: 16px; + background: var(--sys-color-surface, #fff); + } + + .schema-editor-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .schema-editor-title { + font-weight: 600; + font-size: 14px; + color: var(--sys-color-on-surface, #333); + } + + .schema-add-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background: var(--sys-color-primary, #00a4fe); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: background 0.2s; + } + + .schema-add-btn:hover { + background: var(--sys-color-primary-hover, #0093e0); + } + + .schema-properties-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .schema-property { + display: grid; + grid-template-columns: 1fr 120px 2fr auto auto; + gap: 8px; + align-items: center; + padding: 12px; + background: var(--sys-color-surface-variant, #f5f5f5); + border-radius: 6px; + border: 1px solid var(--sys-color-outline-variant, #e0e0e0); + } + + .schema-property-input { + padding: 8px 10px; + border: 1px solid var(--sys-color-outline, #ccc); + border-radius: 4px; + font-size: 13px; + background: var(--sys-color-surface, #fff); + color: var(--sys-color-on-surface, #333); + } + + .schema-property-input:focus { + outline: none; + border-color: var(--sys-color-primary, #00a4fe); + box-shadow: 0 0 0 2px rgba(0, 164, 254, 0.2); + } + + .schema-property-select { + padding: 8px 10px; + border: 1px solid var(--sys-color-outline, #ccc); + border-radius: 4px; + font-size: 13px; + background: var(--sys-color-surface, #fff); + color: var(--sys-color-on-surface, #333); + cursor: pointer; + } + + .schema-property-checkbox { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--sys-color-on-surface-variant, #666); + } + + .schema-property-checkbox input { + width: 16px; + height: 16px; + cursor: pointer; + } + + .schema-delete-btn { + padding: 6px 8px; + background: transparent; + border: none; + color: var(--sys-color-error, #dc3545); + cursor: pointer; + border-radius: 4px; + transition: background 0.2s; + } + + .schema-delete-btn:hover { + background: rgba(220, 53, 69, 0.1); + } + + .schema-empty { + text-align: center; + padding: 24px; + color: var(--sys-color-on-surface-variant, #666); + font-size: 13px; + } + + .schema-property-name { + font-weight: 500; + } + + @media (max-width: 800px) { + .schema-property { + grid-template-columns: 1fr; + gap: 8px; + } + + .schema-property-checkbox { + justify-content: flex-start; + } + } + `; +} + +/** + * Generate HTML for the Schema Editor + */ +export function generateSchemaEditorHTML(data: SchemaEditorData): string { + const propertiesHTML = data.properties.length === 0 + ? '
No properties defined. Click "Add Property" to create input fields for your agent.
' + : data.properties.map((prop, index) => ` +
+ + + + + +
+ `).join(''); + + return ` +
+
+ Input Schema + +
+
+ ${propertiesHTML} +
+
+ `; +} + +/** + * Generate JavaScript for the Schema Editor + */ +export function generateSchemaEditorJS(): string { + return ` + // Schema Editor state + window.schemaEditorData = window.schemaEditorData || { properties: [] }; + + function initSchemaEditor() { + const addBtn = document.getElementById('schema-add-property'); + const propertiesList = document.getElementById('schema-properties-list'); + + if (addBtn) { + addBtn.addEventListener('click', addProperty); + } + + if (propertiesList) { + propertiesList.addEventListener('click', handlePropertyAction); + propertiesList.addEventListener('input', handlePropertyChange); + propertiesList.addEventListener('change', handlePropertyChange); + } + } + + function addProperty() { + const newProp = { + name: 'new_property', + type: 'string', + description: '', + required: false + }; + + window.schemaEditorData.properties.push(newProp); + renderProperties(); + notifySchemaChange(); + } + + function handlePropertyAction(event) { + const target = event.target; + if (target.dataset.action === 'delete') { + const propertyEl = target.closest('.schema-property'); + if (propertyEl) { + const index = parseInt(propertyEl.dataset.index, 10); + window.schemaEditorData.properties.splice(index, 1); + renderProperties(); + notifySchemaChange(); + } + } + } + + function handlePropertyChange(event) { + const target = event.target; + const field = target.dataset.field; + if (!field) return; + + const propertyEl = target.closest('.schema-property'); + if (!propertyEl) return; + + const index = parseInt(propertyEl.dataset.index, 10); + const prop = window.schemaEditorData.properties[index]; + if (!prop) return; + + if (field === 'required') { + prop.required = target.checked; + } else if (field === 'name') { + // Sanitize name: lowercase, underscores only + prop.name = target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'); + target.value = prop.name; + } else { + prop[field] = target.value; + } + + notifySchemaChange(); + } + + function renderProperties() { + const propertiesList = document.getElementById('schema-properties-list'); + if (!propertiesList) return; + + if (window.schemaEditorData.properties.length === 0) { + propertiesList.innerHTML = '
No properties defined. Click "Add Property" to create input fields for your agent.
'; + return; + } + + propertiesList.innerHTML = window.schemaEditorData.properties.map((prop, index) => \` +
+ + + + + +
+ \`).join(''); + } + + function escapeHTMLJS(str) { + const div = document.createElement('div'); + div.textContent = str || ''; + return div.innerHTML; + } + + function notifySchemaChange() { + // Convert editor data to JSON schema format + const schema = { + type: 'object', + properties: {}, + required: [] + }; + + for (const prop of window.schemaEditorData.properties) { + schema.properties[prop.name] = { + type: prop.type, + description: prop.description + }; + if (prop.required) { + schema.required.push(prop.name); + } + } + + if (schema.required.length === 0) { + delete schema.required; + } + + // Send to parent + window.parent.postMessage({ + type: 'schema-change', + schema: schema + }, '*'); + } + + function setSchemaEditorData(data) { + window.schemaEditorData = data; + renderProperties(); + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initSchemaEditor); + } else { + initSchemaEditor(); + } + `; +} + +/** + * Helper to escape HTML + */ +function escapeHTML(str: string): string { + const div = document.createElement('div'); + div.textContent = str || ''; + return div.innerHTML; +} + +/** + * Create default schema with query and reasoning fields + */ +export function createDefaultSchema(): SchemaEditorData { + return { + properties: [ + { + name: 'query', + type: 'string', + description: 'The user query or task to execute', + required: true, + }, + { + name: 'reasoning', + type: 'string', + description: 'Reasoning for why this agent was invoked', + required: true, + }, + ], + }; +} diff --git a/front_end/panels/ai_chat/ui/__tests__/AgentSessionHeaderComponent.test.ts b/front_end/panels/ai_chat/ui/__tests__/AgentSessionHeaderComponent.test.ts new file mode 100644 index 0000000000..65d89754fa --- /dev/null +++ b/front_end/panels/ai_chat/ui/__tests__/AgentSessionHeaderComponent.test.ts @@ -0,0 +1,381 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../AgentSessionHeaderComponent.js'; +import {raf} from '../../../../testing/DOMHelpers.js'; +import type {AgentSessionHeaderComponent, SessionStatus} from '../AgentSessionHeaderComponent.js'; + +type AgentSession = { + agentName: string; + sessionId: string; + status: 'running' | 'completed' | 'error'; + startTime: Date; + endTime?: Date; + messages: any[]; + nestedSessions: any[]; + parentSessionId?: string; + config?: any; +}; + +function makeSession(sessionId: string, opts: Partial = {}): AgentSession { + return { + agentName: opts.agentName || 'test_agent', + sessionId, + status: opts.status || 'running', + startTime: opts.startTime || new Date(), + endTime: opts.endTime, + messages: opts.messages || [], + nestedSessions: opts.nestedSessions || [], + parentSessionId: opts.parentSessionId, + config: opts.config || {ui: {displayName: 'Test Agent'}}, + }; +} + +describe('AgentSessionHeaderComponent', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + function createComponent(): AgentSessionHeaderComponent { + const el = document.createElement('agent-session-header') as AgentSessionHeaderComponent; + container.appendChild(el); + return el; + } + + function getShadowRoot(el: AgentSessionHeaderComponent): ShadowRoot { + return el.shadowRoot!; + } + + describe('Basic Rendering', () => { + it('renders empty when no session set', async () => { + const el = createComponent(); + await raf(); + + const sroot = getShadowRoot(el); + const header = sroot.querySelector('.agent-header'); + assert.isNull(header, 'Should not render without session'); + }); + + it('renders header with display name', async () => { + const el = createComponent(); + el.setSession(makeSession('s1', {config: {ui: {displayName: 'Research Agent'}}}) as any); + await raf(); + + const sroot = getShadowRoot(el); + const header = sroot.querySelector('.agent-header'); + assert.isNotNull(header); + + const title = sroot.querySelector('.agent-title'); + assert.include(title!.textContent, 'Research Agent'); + }); + + it('renders expand icon', async () => { + const el = createComponent(); + el.setSession(makeSession('s1') as any); + await raf(); + + const sroot = getShadowRoot(el); + const expandIcon = sroot.querySelector('.expand-icon'); + assert.isNotNull(expandIcon); + assert.isTrue(expandIcon!.classList.contains('expanded')); + }); + }); + + describe('Session Status', () => { + it('shows LIVE badge for running sessions', async () => { + const el = createComponent(); + el.setSession(makeSession('s1', {status: 'running'}) as any); + await raf(); + + const sroot = getShadowRoot(el); + const statusBadge = sroot.querySelector('.status-badge'); + assert.isTrue(statusBadge!.classList.contains('live')); + assert.include(statusBadge!.textContent, 'LIVE'); + + const liveIndicator = sroot.querySelector('.live-indicator'); + assert.isNotNull(liveIndicator); + }); + + it('shows COMPLETED badge for completed sessions', async () => { + const el = createComponent(); + el.setSession(makeSession('s1', {status: 'completed'}) as any); + await raf(); + + const sroot = getShadowRoot(el); + const statusBadge = sroot.querySelector('.status-badge'); + assert.isTrue(statusBadge!.classList.contains('completed')); + assert.include(statusBadge!.textContent!.toUpperCase(), 'COMPLETED'); + assert.include(statusBadge!.textContent, '✓'); + }); + + it('shows ERROR badge for error sessions', async () => { + const el = createComponent(); + el.setSession(makeSession('s1', {status: 'error'}) as any); + await raf(); + + const sroot = getShadowRoot(el); + const statusBadge = sroot.querySelector('.status-badge'); + assert.isTrue(statusBadge!.classList.contains('error')); + assert.include(statusBadge!.textContent!.toUpperCase(), 'ERROR'); + assert.include(statusBadge!.textContent, '❌'); + }); + + it('applies status class to header element', async () => { + const el = createComponent(); + el.setSession(makeSession('s1', {status: 'completed'}) as any); + await raf(); + + const sroot = getShadowRoot(el); + const header = sroot.querySelector('.agent-header'); + assert.isTrue(header!.classList.contains('completed')); + }); + }); + + describe('Level Badge', () => { + it('shows Top Level badge for root sessions', async () => { + const el = createComponent(); + el.setSession(makeSession('s1', {parentSessionId: undefined}) as any); + await raf(); + + const sroot = getShadowRoot(el); + const levelBadge = sroot.querySelector('.level-badge'); + assert.isTrue(levelBadge!.classList.contains('top-level')); + assert.include(levelBadge!.textContent, 'Top Level'); + }); + + it('shows Nested badge for child sessions', async () => { + const el = createComponent(); + el.setSession(makeSession('child1', {parentSessionId: 'parent1'}) as any); + await raf(); + + const sroot = getShadowRoot(el); + const levelBadge = sroot.querySelector('.level-badge'); + assert.isTrue(levelBadge!.classList.contains('nested')); + assert.include(levelBadge!.textContent, 'Nested'); + }); + }); + + describe('Duration Display', () => { + it('shows duration in seconds for short sessions', async () => { + const startTime = new Date(); + startTime.setSeconds(startTime.getSeconds() - 45); + + const el = createComponent(); + el.setSession(makeSession('s1', { + startTime, + endTime: new Date(), + status: 'completed', + }) as any); + await raf(); + + const sroot = getShadowRoot(el); + const duration = sroot.querySelector('.duration'); + assert.match(duration!.textContent!.trim(), /^\d+s$/); + }); + + it('shows duration in minutes and seconds for longer sessions', async () => { + const startTime = new Date(); + startTime.setMinutes(startTime.getMinutes() - 2); + startTime.setSeconds(startTime.getSeconds() - 30); + + const el = createComponent(); + el.setSession(makeSession('s1', { + startTime, + endTime: new Date(), + status: 'completed', + }) as any); + await raf(); + + const sroot = getShadowRoot(el); + const duration = sroot.querySelector('.duration'); + assert.match(duration!.textContent!.trim(), /^\d+m \d+s$/); + }); + + it('shows 0s for sessions with no start time', async () => { + const el = createComponent(); + const session = makeSession('s1', {status: 'completed'}); + // @ts-ignore - testing edge case + session.startTime = null; + el.setSession(session as any); + await raf(); + + const sroot = getShadowRoot(el); + const duration = sroot.querySelector('.duration'); + assert.include(duration!.textContent, '0s'); + }); + }); + + describe('Toggle Expand/Collapse', () => { + it('starts expanded by default', async () => { + const el = createComponent(); + el.setSession(makeSession('s1') as any); + await raf(); + + const sroot = getShadowRoot(el); + const expandIcon = sroot.querySelector('.expand-icon'); + assert.isTrue(expandIcon!.classList.contains('expanded')); + }); + + it('collapses on toggleExpanded call', async () => { + const el = createComponent(); + el.setSession(makeSession('s1') as any); + await raf(); + + el.toggleExpanded(); + await raf(); + + const sroot = getShadowRoot(el); + const expandIcon = sroot.querySelector('.expand-icon'); + assert.isFalse(expandIcon!.classList.contains('expanded')); + }); + + it('expands again on second toggleExpanded call', async () => { + const el = createComponent(); + el.setSession(makeSession('s1') as any); + await raf(); + + el.toggleExpanded(); + await raf(); + el.toggleExpanded(); + await raf(); + + const sroot = getShadowRoot(el); + const expandIcon = sroot.querySelector('.expand-icon'); + assert.isTrue(expandIcon!.classList.contains('expanded')); + }); + + it('dispatches toggle-expanded event', async () => { + const el = createComponent(); + el.setSession(makeSession('s1') as any); + await raf(); + + let eventReceived = false; + let isExpanded = true; + el.addEventListener('toggle-expanded', (e: Event) => { + eventReceived = true; + isExpanded = (e as CustomEvent).detail.isExpanded; + }); + + el.toggleExpanded(); + await raf(); + + assert.isTrue(eventReceived); + assert.isFalse(isExpanded); + }); + + it('toggles on header click', async () => { + const el = createComponent(); + el.setSession(makeSession('s1') as any); + await raf(); + + const sroot = getShadowRoot(el); + const header = sroot.querySelector('.agent-header') as HTMLElement; + header.click(); + await raf(); + + const expandIcon = getShadowRoot(el).querySelector('.expand-icon'); + assert.isFalse(expandIcon!.classList.contains('expanded')); + }); + }); + + describe('Session Updates', () => { + it('updates when session status changes', async () => { + const el = createComponent(); + el.setSession(makeSession('s1', {status: 'running'}) as any); + await raf(); + + let sroot = getShadowRoot(el); + assert.isNotNull(sroot.querySelector('.status-badge.live')); + + el.setSession(makeSession('s1', {status: 'completed'}) as any); + await raf(); + + sroot = getShadowRoot(el); + assert.isNotNull(sroot.querySelector('.status-badge.completed')); + }); + + it('sets endTime when status changes from running', async () => { + const el = createComponent(); + const startTime = new Date(); + startTime.setSeconds(startTime.getSeconds() - 10); + + el.setSession(makeSession('s1', { + status: 'running', + startTime, + }) as any); + await raf(); + + el.setSession(makeSession('s1', { + status: 'completed', + startTime, + // No explicit endTime - should be set automatically + }) as any); + await raf(); + + const sroot = getShadowRoot(el); + const duration = sroot.querySelector('.duration'); + // Duration should be around 10s, not 0s + assert.match(duration!.textContent!.trim(), /\d+s/); + }); + }); + + describe('Edge Cases', () => { + it('handles session with unknown status', async () => { + const el = createComponent(); + const session = makeSession('s1'); + // @ts-ignore - testing edge case + session.status = 'unknown_status'; + el.setSession(session as any); + await raf(); + + const sroot = getShadowRoot(el); + const header = sroot.querySelector('.agent-header'); + assert.isNotNull(header); + }); + + it('handles session with missing config', async () => { + const el = createComponent(); + const session = makeSession('s1'); + // @ts-ignore - testing edge case + session.config = undefined; + el.setSession(session as any); + await raf(); + + const sroot = getShadowRoot(el); + const header = sroot.querySelector('.agent-header'); + assert.isNotNull(header); + }); + + it('handles very long agent names', async () => { + const el = createComponent(); + el.setSession(makeSession('s1', { + config: {ui: {displayName: 'A Very Long Agent Name That Might Overflow'}}, + }) as any); + await raf(); + + const sroot = getShadowRoot(el); + const title = sroot.querySelector('.agent-title'); + assert.include(title!.textContent, 'A Very Long Agent Name'); + }); + + it('cleans up timer on disconnect', async () => { + const el = createComponent(); + el.setSession(makeSession('s1', {status: 'running'}) as any); + await raf(); + + // Remove from DOM + container.removeChild(el); + + // No assertion needed - just verifying no errors occur + // The disconnectedCallback should clean up the timer + }); + }); +}); diff --git a/front_end/panels/ai_chat/ui/__tests__/ConversationHistoryList.test.ts b/front_end/panels/ai_chat/ui/__tests__/ConversationHistoryList.test.ts new file mode 100644 index 0000000000..677aa72084 --- /dev/null +++ b/front_end/panels/ai_chat/ui/__tests__/ConversationHistoryList.test.ts @@ -0,0 +1,469 @@ +// Copyright 2025 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import '../ConversationHistoryList.js'; +import {raf} from '../../../../testing/DOMHelpers.js'; +import type {ConversationHistoryList} from '../ConversationHistoryList.js'; + +type ConversationMetadata = { + id: string; + title: string; + preview?: string; + messageCount: number; + createdAt: number; + updatedAt: number; +}; + +function makeConversation(id: string, opts: Partial = {}): ConversationMetadata { + return { + id, + title: opts.title || `Conversation ${id}`, + preview: opts.preview, + messageCount: opts.messageCount ?? 5, + createdAt: opts.createdAt ?? Date.now() - 3600000, + updatedAt: opts.updatedAt ?? Date.now(), + }; +} + +describe('ConversationHistoryList Component', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + function createComponent(): ConversationHistoryList { + const el = document.createElement('ai-conversation-history-list') as ConversationHistoryList; + container.appendChild(el); + return el; + } + + function getShadowRoot(el: ConversationHistoryList): ShadowRoot { + return el.shadowRoot!; + } + + describe('Basic Rendering', () => { + it('renders header with title', async () => { + const el = createComponent(); + await raf(); + + const sroot = getShadowRoot(el); + const title = sroot.querySelector('.history-title'); + assert.isNotNull(title); + assert.include(title!.textContent, 'Chat History'); + }); + + it('renders close button', async () => { + const el = createComponent(); + await raf(); + + const sroot = getShadowRoot(el); + const closeButton = sroot.querySelector('.history-close-button'); + assert.isNotNull(closeButton); + }); + + it('shows empty state when no conversations', async () => { + const el = createComponent(); + el.conversations = []; + await raf(); + + const sroot = getShadowRoot(el); + const emptyState = sroot.querySelector('.history-empty-state'); + assert.isNotNull(emptyState); + assert.include(emptyState!.textContent, 'No saved conversations yet'); + }); + }); + + describe('Conversation Display', () => { + it('renders conversation items', async () => { + const el = createComponent(); + el.conversations = [ + makeConversation('c1', {title: 'First Chat'}), + makeConversation('c2', {title: 'Second Chat'}), + ]; + await raf(); + + const sroot = getShadowRoot(el); + const items = sroot.querySelectorAll('.history-conversation-item'); + assert.strictEqual(items.length, 2); + }); + + it('displays conversation title', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1', {title: 'Test Conversation'})]; + await raf(); + + const sroot = getShadowRoot(el); + const title = sroot.querySelector('.history-conversation-title'); + assert.include(title!.textContent, 'Test Conversation'); + }); + + it('displays conversation preview when available', async () => { + const el = createComponent(); + el.conversations = [ + makeConversation('c1', {preview: 'This is a preview of the conversation...'}), + ]; + await raf(); + + const sroot = getShadowRoot(el); + const preview = sroot.querySelector('.history-conversation-preview'); + assert.isNotNull(preview); + assert.include(preview!.textContent, 'This is a preview'); + }); + + it('hides preview when not available', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1', {preview: undefined})]; + await raf(); + + const sroot = getShadowRoot(el); + const preview = sroot.querySelector('.history-conversation-preview'); + assert.isNull(preview); + }); + + it('displays message count', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1', {messageCount: 42})]; + await raf(); + + const sroot = getShadowRoot(el); + const metadata = sroot.querySelector('.history-conversation-metadata'); + assert.include(metadata!.textContent, '42 messages'); + }); + + it('displays delete button for each conversation', async () => { + const el = createComponent(); + el.conversations = [ + makeConversation('c1'), + makeConversation('c2'), + ]; + await raf(); + + const sroot = getShadowRoot(el); + const deleteButtons = sroot.querySelectorAll('.history-delete-button'); + assert.strictEqual(deleteButtons.length, 2); + }); + }); + + describe('Current Conversation Highlighting', () => { + it('marks current conversation as active', async () => { + const el = createComponent(); + el.conversations = [ + makeConversation('c1'), + makeConversation('c2'), + makeConversation('c3'), + ]; + el.currentConversationId = 'c2'; + await raf(); + + const sroot = getShadowRoot(el); + const items = sroot.querySelectorAll('.history-conversation-item'); + const activeItems = sroot.querySelectorAll('.history-conversation-item.active'); + + assert.strictEqual(items.length, 3); + assert.strictEqual(activeItems.length, 1); + }); + + it('updates active state when currentConversationId changes', async () => { + const el = createComponent(); + el.conversations = [ + makeConversation('c1'), + makeConversation('c2'), + ]; + el.currentConversationId = 'c1'; + await raf(); + + let sroot = getShadowRoot(el); + let firstItem = sroot.querySelectorAll('.history-conversation-item')[0]; + assert.isTrue(firstItem.classList.contains('active')); + + el.currentConversationId = 'c2'; + await raf(); + + sroot = getShadowRoot(el); + const items = sroot.querySelectorAll('.history-conversation-item'); + assert.isFalse(items[0].classList.contains('active')); + assert.isTrue(items[1].classList.contains('active')); + }); + }); + + describe('Event Callbacks', () => { + it('calls onClose when close button clicked', async () => { + const el = createComponent(); + el.conversations = []; + + let closeCalled = false; + el.onClose = () => { + closeCalled = true; + }; + await raf(); + + const sroot = getShadowRoot(el); + const closeButton = sroot.querySelector('.history-close-button') as HTMLButtonElement; + closeButton.click(); + + assert.isTrue(closeCalled); + }); + + it('calls onConversationSelected when conversation clicked', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1')]; + el.currentConversationId = null; + + let selectedId = ''; + el.onConversationSelected = (id) => { + selectedId = id; + }; + await raf(); + + const sroot = getShadowRoot(el); + const item = sroot.querySelector('.history-conversation-item') as HTMLElement; + item.click(); + + assert.strictEqual(selectedId, 'c1'); + }); + + it('does not call onConversationSelected when clicking current conversation', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1')]; + el.currentConversationId = 'c1'; + + let callCount = 0; + el.onConversationSelected = () => { + callCount++; + }; + await raf(); + + const sroot = getShadowRoot(el); + const item = sroot.querySelector('.history-conversation-item') as HTMLElement; + item.click(); + + assert.strictEqual(callCount, 0); + }); + + it('calls onDeleteConversation when delete button clicked', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1')]; + + let deletedId = ''; + el.onDeleteConversation = (id) => { + deletedId = id; + }; + await raf(); + + const sroot = getShadowRoot(el); + const deleteButton = sroot.querySelector('.history-delete-button') as HTMLButtonElement; + deleteButton.click(); + + assert.strictEqual(deletedId, 'c1'); + }); + + it('stops propagation when delete button clicked', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1')]; + el.currentConversationId = null; + + let selectedCalled = false; + let deletedCalled = false; + + el.onConversationSelected = () => { + selectedCalled = true; + }; + el.onDeleteConversation = () => { + deletedCalled = true; + }; + await raf(); + + const sroot = getShadowRoot(el); + const deleteButton = sroot.querySelector('.history-delete-button') as HTMLButtonElement; + deleteButton.click(); + + assert.isTrue(deletedCalled); + assert.isFalse(selectedCalled); + }); + + it('closes after selecting conversation', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1')]; + el.currentConversationId = null; + + let closeCalled = false; + el.onClose = () => { + closeCalled = true; + }; + el.onConversationSelected = () => {}; + await raf(); + + const sroot = getShadowRoot(el); + const item = sroot.querySelector('.history-conversation-item') as HTMLElement; + item.click(); + + assert.isTrue(closeCalled); + }); + + it('closes after deleting conversation', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1')]; + + let closeCalled = false; + el.onClose = () => { + closeCalled = true; + }; + el.onDeleteConversation = () => {}; + await raf(); + + const sroot = getShadowRoot(el); + const deleteButton = sroot.querySelector('.history-delete-button') as HTMLButtonElement; + deleteButton.click(); + + assert.isTrue(closeCalled); + }); + }); + + describe('Date Formatting', () => { + it('shows "Just now" for very recent conversations', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1', {updatedAt: Date.now() - 30000})]; // 30 seconds ago + await raf(); + + const sroot = getShadowRoot(el); + const metadata = sroot.querySelector('.history-conversation-metadata'); + assert.include(metadata!.textContent, 'Just now'); + }); + + it('shows minutes ago for recent conversations', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1', {updatedAt: Date.now() - 300000})]; // 5 minutes ago + await raf(); + + const sroot = getShadowRoot(el); + const metadata = sroot.querySelector('.history-conversation-metadata'); + assert.include(metadata!.textContent, '5m ago'); + }); + + it('shows hours ago for today conversations', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1', {updatedAt: Date.now() - 7200000})]; // 2 hours ago + await raf(); + + const sroot = getShadowRoot(el); + const metadata = sroot.querySelector('.history-conversation-metadata'); + assert.include(metadata!.textContent, '2h ago'); + }); + + it('shows days ago for recent past conversations', async () => { + const el = createComponent(); + el.conversations = [makeConversation('c1', {updatedAt: Date.now() - 172800000})]; // 2 days ago + await raf(); + + const sroot = getShadowRoot(el); + const metadata = sroot.querySelector('.history-conversation-metadata'); + assert.include(metadata!.textContent, '2d ago'); + }); + }); + + describe('Property Getters/Setters', () => { + it('gets and sets conversations', async () => { + const el = createComponent(); + const conversations = [makeConversation('c1')]; + + el.conversations = conversations; + assert.strictEqual(el.conversations, conversations); + }); + + it('gets and sets currentConversationId', async () => { + const el = createComponent(); + + el.currentConversationId = 'test-id'; + assert.strictEqual(el.currentConversationId, 'test-id'); + + el.currentConversationId = null; + assert.isNull(el.currentConversationId); + }); + + it('gets and sets onConversationSelected', async () => { + const el = createComponent(); + const callback = () => {}; + + el.onConversationSelected = callback; + assert.strictEqual(el.onConversationSelected, callback); + }); + + it('gets and sets onDeleteConversation', async () => { + const el = createComponent(); + const callback = () => {}; + + el.onDeleteConversation = callback; + assert.strictEqual(el.onDeleteConversation, callback); + }); + + it('gets and sets onClose', async () => { + const el = createComponent(); + const callback = () => {}; + + el.onClose = callback; + assert.strictEqual(el.onClose, callback); + }); + }); + + describe('Edge Cases', () => { + it('handles many conversations', async () => { + const el = createComponent(); + el.conversations = Array.from({length: 50}, (_, i) => + makeConversation(`c${i}`, {title: `Conversation ${i}`}), + ); + await raf(); + + const sroot = getShadowRoot(el); + const items = sroot.querySelectorAll('.history-conversation-item'); + assert.strictEqual(items.length, 50); + }); + + it('handles long conversation titles', async () => { + const el = createComponent(); + el.conversations = [ + makeConversation('c1', { + title: 'This is a very long conversation title that might overflow the container and need to be truncated', + }), + ]; + await raf(); + + const sroot = getShadowRoot(el); + const title = sroot.querySelector('.history-conversation-title'); + assert.isNotNull(title); + }); + + it('handles special characters in titles', async () => { + const el = createComponent(); + el.conversations = [ + makeConversation('c1', {title: ''}), + ]; + await raf(); + + const sroot = getShadowRoot(el); + const title = sroot.querySelector('.history-conversation-title'); + // Lit should escape HTML automatically + assert.notInclude(title!.innerHTML, '', + unicode: '🎉', + }); + assert.include(result, ''); + renderToContainer(msg); + await raf(); + + const resultEl = container.querySelector('.tool-result-raw'); + assert.isNotNull(resultEl); + // Content should be displayed safely (as text, not executed) + assert.include(resultEl!.textContent, ''); + renderToContainer(msg); + await raf(); + + const textEl = container.querySelector('.message-text'); + assert.isNotNull(textEl); + // Content should be escaped or rendered safely + assert.include(textEl!.textContent, '