From dba1acf2b4e9fa9fe7e547e19233cbe6808b7181 Mon Sep 17 00:00:00 2001 From: arvinxx Date: Mon, 29 Dec 2025 15:58:32 +0800 Subject: [PATCH] :sparkles: feat: support exec async sub agent task --- locales/en-US/plugin.json | 3 + locales/zh-CN/plugin.json | 3 + .../src/agents/GeneralChatAgent.ts | 68 +- .../agents/__tests__/GeneralChatAgent.test.ts | 158 ++++ .../agent-runtime/src/types/generalAgent.ts | 2 + .../agent-runtime/src/types/instruction.ts | 86 +++ .../src/agents/inbox/systemRole.ts | 44 -- .../src/client/Inspector/ExecTask/index.tsx | 61 ++ .../src/client/Inspector/ExecTasks/index.tsx | 62 ++ .../src/client/Inspector/index.ts | 4 + .../src/client/Streaming/ExecTask/index.tsx | 47 ++ .../src/client/Streaming/ExecTasks/index.tsx | 56 ++ .../src/client/Streaming/index.ts | 18 + packages/builtin-tool-gtd/src/client/index.ts | 3 + .../builtin-tool-gtd/src/executor/index.ts | 97 ++- packages/builtin-tool-gtd/src/manifest.ts | 80 ++- packages/builtin-tool-gtd/src/systemRole.ts | 85 ++- packages/builtin-tool-gtd/src/types.ts | 71 ++ .../Messages/AssistantGroup/Tool/index.tsx | 6 + src/locales/default/plugin.ts | 3 + .../createAgentExecutors/exec-task.test.ts | 479 +++++++++++++ .../createAgentExecutors/exec-tasks.test.ts | 598 +++++++++++++++ .../fixtures/mockInstructions.ts | 50 ++ .../fixtures/mockStore.ts | 2 + src/store/chat/agents/createAgentExecutors.ts | 678 +++++++++++++++++- .../chat/slices/plugin/actions/pluginTypes.ts | 3 +- src/tools/streamings.ts | 2 + vitest.config.mts | 1 + 28 files changed, 2699 insertions(+), 71 deletions(-) create mode 100644 packages/builtin-tool-gtd/src/client/Inspector/ExecTask/index.tsx create mode 100644 packages/builtin-tool-gtd/src/client/Inspector/ExecTasks/index.tsx create mode 100644 packages/builtin-tool-gtd/src/client/Streaming/ExecTask/index.tsx create mode 100644 packages/builtin-tool-gtd/src/client/Streaming/ExecTasks/index.tsx create mode 100644 packages/builtin-tool-gtd/src/client/Streaming/index.ts create mode 100644 src/store/chat/agents/__tests__/createAgentExecutors/exec-task.test.ts create mode 100644 src/store/chat/agents/__tests__/createAgentExecutors/exec-tasks.test.ts diff --git a/locales/en-US/plugin.json b/locales/en-US/plugin.json index 9eca1c835b..71cc3f1144 100644 --- a/locales/en-US/plugin.json +++ b/locales/en-US/plugin.json @@ -60,6 +60,9 @@ "builtins.lobe-gtd.apiName.createPlan": "Create plan", "builtins.lobe-gtd.apiName.createPlan.result": "Create plan: {{goal}}", "builtins.lobe-gtd.apiName.createTodos": "Create todos", + "builtins.lobe-gtd.apiName.execTask": "Execute task", + "builtins.lobe-gtd.apiName.execTask.result": "Execute: {{description}}", + "builtins.lobe-gtd.apiName.execTasks": "Execute tasks", "builtins.lobe-gtd.apiName.removeTodos": "Delete todos", "builtins.lobe-gtd.apiName.updatePlan": "Update plan", "builtins.lobe-gtd.apiName.updatePlan.completed": "Completed", diff --git a/locales/zh-CN/plugin.json b/locales/zh-CN/plugin.json index 0eb9680a7a..3bc34d0df3 100644 --- a/locales/zh-CN/plugin.json +++ b/locales/zh-CN/plugin.json @@ -60,6 +60,9 @@ "builtins.lobe-gtd.apiName.createPlan": "创建计划", "builtins.lobe-gtd.apiName.createPlan.result": "创建计划:{{goal}}", "builtins.lobe-gtd.apiName.createTodos": "创建待办", + "builtins.lobe-gtd.apiName.execTask": "执行任务", + "builtins.lobe-gtd.apiName.execTask.result": "执行:{{description}}", + "builtins.lobe-gtd.apiName.execTasks": "执行多个任务", "builtins.lobe-gtd.apiName.removeTodos": "删除待办", "builtins.lobe-gtd.apiName.updatePlan": "更新计划", "builtins.lobe-gtd.apiName.updatePlan.completed": "已完成", diff --git a/packages/agent-runtime/src/agents/GeneralChatAgent.ts b/packages/agent-runtime/src/agents/GeneralChatAgent.ts index 7f14d00958..0a52db4acf 100644 --- a/packages/agent-runtime/src/agents/GeneralChatAgent.ts +++ b/packages/agent-runtime/src/agents/GeneralChatAgent.ts @@ -13,6 +13,8 @@ import { GeneralAgentCallingToolInstructionPayload, GeneralAgentConfig, HumanAbortPayload, + TaskResultPayload, + TasksBatchResultPayload, } from '../types'; /** @@ -315,7 +317,37 @@ export class GeneralChatAgent implements Agent { } case 'tool_result': { - const { parentMessageId } = context.payload as GeneralAgentCallToolResultPayload; + const { data, parentMessageId, stop } = + context.payload as GeneralAgentCallToolResultPayload; + + // Check if this is a GTD async task request (only execTask/execTasks are passed here with stop=true) + if (stop && data?.state) { + const stateType = data.state.type; + + // GTD async task (single) + if (stateType === 'execTask') { + const { parentMessageId: execParentId, task } = data.state as { + parentMessageId: string; + task: any; + }; + return { + payload: { parentMessageId: execParentId, task }, + type: 'exec_task', + }; + } + + // GTD async tasks (multiple) + if (stateType === 'execTasks') { + const { parentMessageId: execParentId, tasks } = data.state as { + parentMessageId: string; + tasks: any[]; + }; + return { + payload: { parentMessageId: execParentId, tasks }, + type: 'exec_tasks', + }; + } + } // Check if there are still pending tool messages waiting for approval const pendingToolMessages = state.messages.filter( @@ -380,6 +412,40 @@ export class GeneralChatAgent implements Agent { }; } + case 'task_result': { + // Single async task completed, continue to call LLM with result + const { parentMessageId } = context.payload as TaskResultPayload; + + // Continue to call LLM with updated messages (task message is already in state) + return { + payload: { + messages: state.messages, + model: this.config.modelRuntimeConfig?.model, + parentMessageId, + provider: this.config.modelRuntimeConfig?.provider, + tools: state.tools, + } as GeneralAgentCallLLMInstructionPayload, + type: 'call_llm', + }; + } + + case 'tasks_batch_result': { + // Async tasks batch completed, continue to call LLM with results + const { parentMessageId } = context.payload as TasksBatchResultPayload; + + // Continue to call LLM with updated messages (task messages are already in state) + return { + payload: { + messages: state.messages, + model: this.config.modelRuntimeConfig?.model, + parentMessageId, + provider: this.config.modelRuntimeConfig?.provider, + tools: state.tools, + } as GeneralAgentCallLLMInstructionPayload, + type: 'call_llm', + }; + } + case 'human_abort': { // User aborted the operation const { hasToolsCalling, parentMessageId, toolsCalling, reason } = diff --git a/packages/agent-runtime/src/agents/__tests__/GeneralChatAgent.test.ts b/packages/agent-runtime/src/agents/__tests__/GeneralChatAgent.test.ts index 2846773cf3..60cbc2bd9b 100644 --- a/packages/agent-runtime/src/agents/__tests__/GeneralChatAgent.test.ts +++ b/packages/agent-runtime/src/agents/__tests__/GeneralChatAgent.test.ts @@ -893,6 +893,164 @@ describe('GeneralChatAgent', () => { }); }); + describe('task_result phase (single task)', () => { + it('should return call_llm when task completed', async () => { + const agent = new GeneralChatAgent({ + agentConfig: { maxSteps: 100 }, + operationId: 'test-session', + modelRuntimeConfig: mockModelRuntimeConfig, + }); + + const state = createMockState({ + messages: [ + { role: 'user', content: 'Execute task' }, + { role: 'assistant', content: '' }, + { role: 'task', content: 'Task result', metadata: { instruction: 'Do task' } }, + ] as any, + }); + + const context = createMockContext('task_result', { + parentMessageId: 'task-parent-msg', + result: { success: true, taskMessageId: 'task-1', threadId: 'thread-1', result: 'Task result' }, + }); + + const result = await agent.runner(context, state); + + expect(result).toEqual({ + type: 'call_llm', + payload: { + messages: state.messages, + model: 'gpt-4o-mini', + parentMessageId: 'task-parent-msg', + provider: 'openai', + tools: undefined, + }, + }); + }); + + it('should return call_llm even when task failed', async () => { + const agent = new GeneralChatAgent({ + agentConfig: { maxSteps: 100 }, + operationId: 'test-session', + modelRuntimeConfig: mockModelRuntimeConfig, + }); + + const state = createMockState({ + messages: [ + { role: 'user', content: 'Execute task' }, + { role: 'assistant', content: '' }, + { role: 'task', content: 'Task failed: timeout', metadata: { instruction: 'Do task' } }, + ] as any, + }); + + const context = createMockContext('task_result', { + parentMessageId: 'task-parent-msg', + result: { + success: false, + taskMessageId: 'task-1', + threadId: 'thread-1', + error: 'Task timeout after 1800000ms', + }, + }); + + const result = await agent.runner(context, state); + + expect(result).toEqual({ + type: 'call_llm', + payload: { + messages: state.messages, + model: 'gpt-4o-mini', + parentMessageId: 'task-parent-msg', + provider: 'openai', + tools: undefined, + }, + }); + }); + }); + + describe('tasks_batch_result phase (multiple tasks)', () => { + it('should return call_llm when tasks completed', async () => { + const agent = new GeneralChatAgent({ + agentConfig: { maxSteps: 100 }, + operationId: 'test-session', + modelRuntimeConfig: mockModelRuntimeConfig, + }); + + const state = createMockState({ + messages: [ + { role: 'user', content: 'Execute tasks' }, + { role: 'assistant', content: '' }, + { role: 'task', content: 'Task 1 result', metadata: { instruction: 'Do task 1' } }, + { role: 'task', content: 'Task 2 result', metadata: { instruction: 'Do task 2' } }, + ] as any, + }); + + const context = createMockContext('tasks_batch_result', { + parentMessageId: 'task-parent-msg', + results: [ + { success: true, taskMessageId: 'task-1', threadId: 'thread-1', result: 'Task 1 result' }, + { success: true, taskMessageId: 'task-2', threadId: 'thread-2', result: 'Task 2 result' }, + ], + }); + + const result = await agent.runner(context, state); + + expect(result).toEqual({ + type: 'call_llm', + payload: { + messages: state.messages, + model: 'gpt-4o-mini', + parentMessageId: 'task-parent-msg', + provider: 'openai', + tools: undefined, + }, + }); + }); + + it('should return call_llm even when some tasks failed', async () => { + const agent = new GeneralChatAgent({ + agentConfig: { maxSteps: 100 }, + operationId: 'test-session', + modelRuntimeConfig: mockModelRuntimeConfig, + }); + + const state = createMockState({ + messages: [ + { role: 'user', content: 'Execute tasks' }, + { role: 'assistant', content: '' }, + { role: 'task', content: 'Task 1 result', metadata: { instruction: 'Do task 1' } }, + { role: 'task', content: 'Task failed: timeout', metadata: { instruction: 'Do task 2' } }, + ] as any, + }); + + const context = createMockContext('tasks_batch_result', { + parentMessageId: 'task-parent-msg', + results: [ + { success: true, taskMessageId: 'task-1', threadId: 'thread-1', result: 'Task 1 result' }, + { + success: false, + taskMessageId: 'task-2', + threadId: 'thread-2', + error: 'Task timeout after 1800000ms', + }, + ], + }); + + const result = await agent.runner(context, state); + + expect(result).toEqual({ + type: 'call_llm', + payload: { + messages: state.messages, + model: 'gpt-4o-mini', + parentMessageId: 'task-parent-msg', + provider: 'openai', + tools: undefined, + }, + }); + }); + }); + describe('unknown phase', () => { it('should return finish instruction for unknown phase', async () => { const agent = new GeneralChatAgent({ diff --git a/packages/agent-runtime/src/types/generalAgent.ts b/packages/agent-runtime/src/types/generalAgent.ts index 72d2ddaf27..8cc0fd9256 100644 --- a/packages/agent-runtime/src/types/generalAgent.ts +++ b/packages/agent-runtime/src/types/generalAgent.ts @@ -27,6 +27,8 @@ export interface GeneralAgentCallToolResultPayload { executionTime: number; isSuccess: boolean; parentMessageId: string; + /** Whether tool requested to stop execution (e.g., group management speak/delegate, GTD async tasks) */ + stop?: boolean; toolCall: ChatToolPayload; toolCallId: string; } diff --git a/packages/agent-runtime/src/types/instruction.ts b/packages/agent-runtime/src/types/instruction.ts index ed90e49881..34238bb17a 100644 --- a/packages/agent-runtime/src/types/instruction.ts +++ b/packages/agent-runtime/src/types/instruction.ts @@ -35,6 +35,8 @@ export interface AgentRuntimeContext { | 'llm_result' | 'tool_result' | 'tools_batch_result' + | 'task_result' + | 'tasks_batch_result' | 'human_response' | 'human_approved_tool' | 'human_abort' @@ -224,6 +226,88 @@ export interface AgentInstructionCompressContext { type: 'compress_context'; } +/** + * Task definition for exec_tasks instruction + */ +export interface ExecTaskItem { + /** Brief description of what this task does (shown in UI) */ + description: string; + /** Whether to inherit context messages from parent conversation */ + inheritMessages?: boolean; + /** Detailed instruction/prompt for the task execution */ + instruction: string; + /** Timeout in milliseconds (optional, default 30 minutes) */ + timeout?: number; +} + +/** + * Instruction to execute a single async task + */ +export interface AgentInstructionExecTask { + payload: { + /** Parent message ID (tool message that triggered the task) */ + parentMessageId: string; + /** Task to execute */ + task: ExecTaskItem; + }; + type: 'exec_task'; +} + +/** + * Instruction to execute multiple async tasks in parallel + */ +export interface AgentInstructionExecTasks { + payload: { + /** Parent message ID (tool message that triggered the tasks) */ + parentMessageId: string; + /** Array of tasks to execute */ + tasks: ExecTaskItem[]; + }; + type: 'exec_tasks'; +} + +/** + * Payload for task_result phase (single task) + */ +export interface TaskResultPayload { + /** Parent message ID */ + parentMessageId: string; + /** Result from executed task */ + result: { + /** Error message if task failed */ + error?: string; + /** Task result content */ + result?: string; + /** Whether the task completed successfully */ + success: boolean; + /** Task message ID */ + taskMessageId: string; + /** Thread ID where the task was executed */ + threadId: string; + }; +} + +/** + * Payload for tasks_batch_result phase (multiple tasks) + */ +export interface TasksBatchResultPayload { + /** Parent message ID */ + parentMessageId: string; + /** Results from executed tasks */ + results: Array<{ + /** Error message if task failed */ + error?: string; + /** Task result content */ + result?: string; + /** Whether the task completed successfully */ + success: boolean; + /** Task message ID */ + taskMessageId: string; + /** Thread ID where the task was executed */ + threadId: string; + }>; +} + /** * A serializable instruction object that the "Agent" (Brain) returns * to the "AgentRuntime" (Engine) to execute. @@ -232,6 +316,8 @@ export type AgentInstruction = | AgentInstructionCallLlm | AgentInstructionCallTool | AgentInstructionCallToolsBatch + | AgentInstructionExecTask + | AgentInstructionExecTasks | AgentInstructionRequestHumanPrompt | AgentInstructionRequestHumanSelect | AgentInstructionRequestHumanApprove diff --git a/packages/builtin-agents/src/agents/inbox/systemRole.ts b/packages/builtin-agents/src/agents/inbox/systemRole.ts index 9a36a50d6c..ba7aaeaf64 100644 --- a/packages/builtin-agents/src/agents/inbox/systemRole.ts +++ b/packages/builtin-agents/src/agents/inbox/systemRole.ts @@ -14,48 +14,4 @@ Your role is to: - Provide clear and concise explanations - Be friendly and professional in your responses - -You have access to two built-in tools: **Notebook** for content creation and **GTD** for task management. - -## Notebook Tool (createDocument) -Use Notebook to create documents when: -- User requests relatively long content (articles, reports, analyses, tutorials, guides) -- User explicitly asks to "write", "draft", "create", "generate" substantial content -- Output would exceed ~500 words or benefit from structured formatting -- Content should be preserved for future reference or editing -- Creating deliverables: blog posts, documentation, summaries, research notes - -**When to respond directly in chat instead**: -- Short answers, explanations, or clarifications -- Quick Q&A interactions -- Code snippets or brief examples -- Conversational exchanges - -## GTD Tool (createPlan, createTodos) -**ONLY use GTD when user explicitly requests task/project management**: -- User explicitly asks to "create a plan", "make a todo list", "track tasks" -- User says "help me plan [project]", "organize my tasks", "remind me to..." -- User provides a list of things they need to do and wants to track them - -**When NOT to use GTD** (respond in chat instead): -- Answering questions (even if about "what to do" or "steps to take") -- Providing advice, analysis, or opinions -- Code review or technical consultations -- Explaining concepts or procedures -- Any question that starts with "Is...", "Can...", "Should...", "Would...", "What if..." -- Security assessments or risk analysis - -**Key principle**: GTD is for ACTION TRACKING, not for answering questions. If the user is asking a question (even about tasks or plans), just answer it directly. - -## Choosing the Right Tool -- "Write me an article about..." → Notebook -- "Help me plan my project" → GTD (plan + todos) -- "Create a to-do list for..." → GTD (todos) -- "Draft a report on..." → Notebook -- "What are the steps to..." → Chat (just explain) -- "Is this code secure?" → Chat (just answer) -- "Should I do X or Y?" → Chat (just advise) -- "Remember to..." / "Add to my list..." → GTD (todos) - - Respond in the same language the user is using.`; diff --git a/packages/builtin-tool-gtd/src/client/Inspector/ExecTask/index.tsx b/packages/builtin-tool-gtd/src/client/Inspector/ExecTask/index.tsx new file mode 100644 index 0000000000..c9c96a6e4f --- /dev/null +++ b/packages/builtin-tool-gtd/src/client/Inspector/ExecTask/index.tsx @@ -0,0 +1,61 @@ +'use client'; + +import type { BuiltinInspectorProps } from '@lobechat/types'; +import { createStaticStyles, cx } from 'antd-style'; +import { memo } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { shinyTextStyles } from '@/styles'; + +import type { ExecTaskParams, ExecTaskState } from '../../../types'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + description: css` + padding-block-end: 1px; + color: ${cssVar.colorText}; + background: linear-gradient(to top, ${cssVar.colorInfoBg} 40%, transparent 40%); + `, + root: css` + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + + color: ${cssVar.colorTextSecondary}; + `, +})); + +export const ExecTaskInspector = memo>( + ({ args, partialArgs, isArgumentsStreaming }) => { + const { t } = useTranslation('plugin'); + + const description = args?.description || partialArgs?.description; + + if (isArgumentsStreaming && !description) { + return ( +
+ {t('builtins.lobe-gtd.apiName.execTask')} +
+ ); + } + + return ( +
+ {description ? ( + }} + i18nKey="builtins.lobe-gtd.apiName.execTask.result" + ns="plugin" + values={{ description }} + /> + ) : ( + {t('builtins.lobe-gtd.apiName.execTask')} + )} +
+ ); + }, +); + +ExecTaskInspector.displayName = 'ExecTaskInspector'; + +export default ExecTaskInspector; diff --git a/packages/builtin-tool-gtd/src/client/Inspector/ExecTasks/index.tsx b/packages/builtin-tool-gtd/src/client/Inspector/ExecTasks/index.tsx new file mode 100644 index 0000000000..9366899dcc --- /dev/null +++ b/packages/builtin-tool-gtd/src/client/Inspector/ExecTasks/index.tsx @@ -0,0 +1,62 @@ +'use client'; + +import type { BuiltinInspectorProps } from '@lobechat/types'; +import { Icon } from '@lobehub/ui'; +import { createStaticStyles, cx } from 'antd-style'; +import { ListTodo } from 'lucide-react'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { shinyTextStyles } from '@/styles'; + +import type { ExecTasksParams, ExecTasksState } from '../../../types'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + count: css` + font-family: ${cssVar.fontFamilyCode}; + color: ${cssVar.colorInfo}; + `, + root: css` + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + `, + title: css` + margin-inline-end: 8px; + color: ${cssVar.colorText}; + `, +})); + +export const ExecTasksInspector = memo>( + ({ args, partialArgs, isArgumentsStreaming }) => { + const { t } = useTranslation('plugin'); + + const tasks = args?.tasks || partialArgs?.tasks || []; + const count = tasks.length; + + if (isArgumentsStreaming && count === 0) { + return ( +
+ {t('builtins.lobe-gtd.apiName.execTasks')} +
+ ); + } + + return ( +
+ {t('builtins.lobe-gtd.apiName.execTasks')} + {count > 0 && ( + + + {count} + + )} +
+ ); + }, +); + +ExecTasksInspector.displayName = 'ExecTasksInspector'; + +export default ExecTasksInspector; diff --git a/packages/builtin-tool-gtd/src/client/Inspector/index.ts b/packages/builtin-tool-gtd/src/client/Inspector/index.ts index 5073dc68f2..5b8913c813 100644 --- a/packages/builtin-tool-gtd/src/client/Inspector/index.ts +++ b/packages/builtin-tool-gtd/src/client/Inspector/index.ts @@ -5,6 +5,8 @@ import { ClearTodosInspector } from './ClearTodos'; import { CompleteTodosInspector } from './CompleteTodos'; import { CreatePlanInspector } from './CreatePlan'; import { CreateTodosInspector } from './CreateTodos'; +import { ExecTaskInspector } from './ExecTask'; +import { ExecTasksInspector } from './ExecTasks'; import { RemoveTodosInspector } from './RemoveTodos'; import { UpdatePlanInspector } from './UpdatePlan'; import { UpdateTodosInspector } from './UpdateTodos'; @@ -20,6 +22,8 @@ export const GTDInspectors: Record = { [GTDApiName.completeTodos]: CompleteTodosInspector as BuiltinInspector, [GTDApiName.createPlan]: CreatePlanInspector as BuiltinInspector, [GTDApiName.createTodos]: CreateTodosInspector as BuiltinInspector, + [GTDApiName.execTask]: ExecTaskInspector as BuiltinInspector, + [GTDApiName.execTasks]: ExecTasksInspector as BuiltinInspector, [GTDApiName.removeTodos]: RemoveTodosInspector as BuiltinInspector, [GTDApiName.updatePlan]: UpdatePlanInspector as BuiltinInspector, [GTDApiName.updateTodos]: UpdateTodosInspector as BuiltinInspector, diff --git a/packages/builtin-tool-gtd/src/client/Streaming/ExecTask/index.tsx b/packages/builtin-tool-gtd/src/client/Streaming/ExecTask/index.tsx new file mode 100644 index 0000000000..a34c1891bf --- /dev/null +++ b/packages/builtin-tool-gtd/src/client/Streaming/ExecTask/index.tsx @@ -0,0 +1,47 @@ +'use client'; + +import type { BuiltinStreamingProps } from '@lobechat/types'; +import { Markdown } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { memo } from 'react'; + +import type { ExecTaskParams } from '../../../types'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + container: css` + padding: 12px; + border-radius: 8px; + background: ${cssVar.colorFillQuaternary}; + `, + description: css` + margin-bottom: 8px; + font-weight: 500; + color: ${cssVar.colorText}; + `, + instruction: css` + font-size: 13px; + color: ${cssVar.colorTextSecondary}; + `, +})); + +export const ExecTaskStreaming = memo>(({ args }) => { + const { instruction } = args || {}; + + if (!instruction) return null; + + return ( +
+ {instruction && ( +
+ + {instruction} + +
+ )} +
+ ); +}); + +ExecTaskStreaming.displayName = 'ExecTaskStreaming'; + +export default ExecTaskStreaming; diff --git a/packages/builtin-tool-gtd/src/client/Streaming/ExecTasks/index.tsx b/packages/builtin-tool-gtd/src/client/Streaming/ExecTasks/index.tsx new file mode 100644 index 0000000000..493a7a2cdf --- /dev/null +++ b/packages/builtin-tool-gtd/src/client/Streaming/ExecTasks/index.tsx @@ -0,0 +1,56 @@ +'use client'; + +import type { BuiltinStreamingProps } from '@lobechat/types'; +import { Markdown } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { memo } from 'react'; + +import type { ExecTasksParams } from '../../../types'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + container: css` + display: flex; + flex-direction: column; + gap: 8px; + `, + description: css` + font-weight: 500; + color: ${cssVar.colorText}; + `, + instruction: css` + font-size: 13px; + color: ${cssVar.colorTextSecondary}; + `, + taskItem: css` + padding: 12px; + border-radius: 8px; + background: ${cssVar.colorFillQuaternary}; + `, +})); + +export const ExecTasksStreaming = memo>(({ args }) => { + const { tasks } = args || {}; + + if (!tasks || tasks.length === 0) return null; + + return ( +
+ {tasks.map((task, index) => ( +
+ {task.description &&
{task.description}
} + {task.instruction && ( +
+ + {task.instruction} + +
+ )} +
+ ))} +
+ ); +}); + +ExecTasksStreaming.displayName = 'ExecTasksStreaming'; + +export default ExecTasksStreaming; diff --git a/packages/builtin-tool-gtd/src/client/Streaming/index.ts b/packages/builtin-tool-gtd/src/client/Streaming/index.ts new file mode 100644 index 0000000000..8f56c47378 --- /dev/null +++ b/packages/builtin-tool-gtd/src/client/Streaming/index.ts @@ -0,0 +1,18 @@ +import type { BuiltinStreaming } from '@lobechat/types'; + +import { GTDApiName } from '../../types'; +import { ExecTaskStreaming } from './ExecTask'; +import { ExecTasksStreaming } from './ExecTasks'; + +/** + * GTD Streaming Components Registry + * + * Streaming components render tool calls while they are + * still executing, allowing real-time feedback to users. + */ +export const GTDStreamings: Record = { + [GTDApiName.execTask]: ExecTaskStreaming as BuiltinStreaming, + [GTDApiName.execTasks]: ExecTasksStreaming as BuiltinStreaming, +}; + +export { ExecTaskStreaming, ExecTasksStreaming }; diff --git a/packages/builtin-tool-gtd/src/client/index.ts b/packages/builtin-tool-gtd/src/client/index.ts index a31b1b5b22..98a27abd9c 100644 --- a/packages/builtin-tool-gtd/src/client/index.ts +++ b/packages/builtin-tool-gtd/src/client/index.ts @@ -5,6 +5,9 @@ export { GTDInspectors } from './Inspector'; export type { TodoListRenderState } from './Render'; export { GTDRenders, TodoListRender, TodoListUI } from './Render'; +// Streaming components (real-time tool execution feedback) +export { ExecTaskStreaming, ExecTasksStreaming, GTDStreamings } from './Streaming'; + // Intervention components (interactive editing) export { AddTodoIntervention, ClearTodosIntervention, GTDInterventions } from './Intervention'; diff --git a/packages/builtin-tool-gtd/src/executor/index.ts b/packages/builtin-tool-gtd/src/executor/index.ts index b724d602c8..241d8e6f18 100644 --- a/packages/builtin-tool-gtd/src/executor/index.ts +++ b/packages/builtin-tool-gtd/src/executor/index.ts @@ -10,6 +10,8 @@ import { type CompleteTodosParams, type CreatePlanParams, type CreateTodosParams, + type ExecTaskParams, + type ExecTasksParams, GTDApiName, type Plan, type RemoveTodosParams, @@ -19,12 +21,13 @@ import { } from '../types'; import { getTodosFromContext } from './helper'; -// API enum for MVP (Todo + Plan) -const GTDApiNameMVP = { +const GTDApiNameEnum = { clearTodos: GTDApiName.clearTodos, completeTodos: GTDApiName.completeTodos, createPlan: GTDApiName.createPlan, createTodos: GTDApiName.createTodos, + execTask: GTDApiName.execTask, + execTasks: GTDApiName.execTasks, removeTodos: GTDApiName.removeTodos, updatePlan: GTDApiName.updatePlan, updateTodos: GTDApiName.updateTodos, @@ -33,9 +36,9 @@ const GTDApiNameMVP = { /** * GTD Tool Executor */ -class GTDExecutor extends BaseExecutor { +class GTDExecutor extends BaseExecutor { readonly identifier = GTDIdentifier; - protected readonly apiEnum = GTDApiNameMVP; + protected readonly apiEnum = GTDApiNameEnum; // ==================== Todo APIs ==================== @@ -477,6 +480,92 @@ class GTDExecutor extends BaseExecutor { }; } }; + + // ==================== Async Tasks API ==================== + + /** + * Execute a single async task + * + * This method triggers async task execution by returning a special state. + * The AgentRuntime's executor will recognize this state and trigger the exec_task instruction. + * + * Flow: + * 1. GTD tool returns stop: true with state.type = 'execTask' + * 2. AgentRuntime executor recognizes the state and triggers exec_task instruction + * 3. exec_task executor creates task message and polls for completion + */ + execTask = async ( + params: ExecTaskParams, + ctx: BuiltinToolContext, + ): Promise => { + const { description, instruction, inheritMessages, timeout } = params; + + if (!description || !instruction) { + return { + content: 'Task description and instruction are required.', + success: false, + }; + } + + // Return stop: true with special state that AgentRuntime will recognize + // The exec_task executor will be triggered by the runtime when it sees this state + return { + content: `🚀 Triggered async task for execution:\n- ${description}`, + state: { + parentMessageId: ctx.messageId, + task: { + description, + inheritMessages, + instruction, + timeout, + }, + type: 'execTask', + }, + stop: true, + success: true, + }; + }; + + /** + * Execute one or more async tasks + * + * This method triggers async task execution by returning a special state. + * The AgentRuntime's executor will recognize this state and trigger the exec_tasks instruction. + * + * Flow: + * 1. GTD tool returns stop: true with state.type = 'execTasks' + * 2. AgentRuntime executor recognizes the state and triggers exec_tasks instruction + * 3. exec_tasks executor creates task messages and polls for completion + */ + execTasks = async ( + params: ExecTasksParams, + ctx: BuiltinToolContext, + ): Promise => { + const { tasks } = params; + + if (!tasks || tasks.length === 0) { + return { + content: 'No tasks provided to execute.', + success: false, + }; + } + + const taskCount = tasks.length; + const taskList = tasks.map((t, i) => `${i + 1}. ${t.description}`).join('\n'); + + // Return stop: true with special state that AgentRuntime will recognize + // The exec_tasks executor will be triggered by the runtime when it sees this state + return { + content: `🚀 Triggered ${taskCount} async task${taskCount > 1 ? 's' : ''} for execution:\n${taskList}`, + state: { + parentMessageId: ctx.messageId, + tasks, + type: 'execTasks', + }, + stop: true, + success: true, + }; + }; } // Export the executor instance for registration diff --git a/packages/builtin-tool-gtd/src/manifest.ts b/packages/builtin-tool-gtd/src/manifest.ts index 6c956bdafa..dc7ed7b85c 100644 --- a/packages/builtin-tool-gtd/src/manifest.ts +++ b/packages/builtin-tool-gtd/src/manifest.ts @@ -37,12 +37,13 @@ export const GTDManifest: BuiltinToolManifest = { }, { description: - 'Update an existing plan document. Use this to modify the goal, description, context, or mark the plan as completed.', + 'Update an existing plan document. Only use this when the goal fundamentally changes. Plans should remain stable once created - do not update plans just because details change.', name: GTDApiName.updatePlan, parameters: { properties: { planId: { - description: 'The ID of the plan to update.', + description: + 'The document ID of the plan to update (e.g., "docs_xxx"). This ID is returned in the createPlan response. Do NOT use the goal text as planId.', type: 'string', }, goal: { @@ -57,10 +58,6 @@ export const GTDManifest: BuiltinToolManifest = { description: 'Updated detailed context.', type: 'string', }, - completed: { - description: 'Mark the plan as completed.', - type: 'boolean', - }, }, required: ['planId'], type: 'object', @@ -71,8 +68,7 @@ export const GTDManifest: BuiltinToolManifest = { { description: 'Create new todo items. Pass an array of text strings.', name: GTDApiName.createTodos, - humanIntervention: 'always', - renderDisplayControl: 'expand', + humanIntervention: 'required', parameters: { properties: { adds: { @@ -179,6 +175,74 @@ export const GTDManifest: BuiltinToolManifest = { type: 'object', }, }, + + // ==================== Async Tasks ==================== + { + description: + 'Execute a single long-running async task. The task runs in an isolated context and can take significant time to complete. Use this for a single complex operation that requires extended processing.', + name: GTDApiName.execTask, + parameters: { + properties: { + description: { + description: 'Brief description of what this task does (shown in UI).', + type: 'string', + }, + instruction: { + description: 'Detailed instruction/prompt for the task execution.', + type: 'string', + }, + inheritMessages: { + description: + 'Whether to inherit context messages from the parent conversation. Default is false.', + type: 'boolean', + }, + timeout: { + description: 'Optional timeout in milliseconds. Default is 30 minutes.', + type: 'number', + }, + }, + required: ['description', 'instruction'], + type: 'object', + }, + }, + { + description: + 'Execute one or more long-running async tasks. Each task runs in an isolated context and can take significant time to complete. Use this for complex operations that require extended processing.', + name: GTDApiName.execTasks, + parameters: { + properties: { + tasks: { + description: 'Array of tasks to execute asynchronously.', + items: { + properties: { + description: { + description: 'Brief description of what this task does (shown in UI).', + type: 'string', + }, + instruction: { + description: 'Detailed instruction/prompt for the task execution.', + type: 'string', + }, + inheritMessages: { + description: + 'Whether to inherit context messages from the parent conversation. Default is false.', + type: 'boolean', + }, + timeout: { + description: 'Optional timeout in milliseconds. Default is 30 minutes.', + type: 'number', + }, + }, + required: ['description', 'instruction'], + type: 'object', + }, + type: 'array', + }, + }, + required: ['tasks'], + type: 'object', + }, + }, ], identifier: GTDIdentifier, meta: { diff --git a/packages/builtin-tool-gtd/src/systemRole.ts b/packages/builtin-tool-gtd/src/systemRole.ts index 359d18c6e6..1aa2570333 100644 --- a/packages/builtin-tool-gtd/src/systemRole.ts +++ b/packages/builtin-tool-gtd/src/systemRole.ts @@ -1,12 +1,13 @@ -export const systemPrompt = `You have GTD (Getting Things Done) tools to help manage plans and todos effectively. These tools support two levels of task management: +export const systemPrompt = `You have GTD (Getting Things Done) tools to help manage plans, todos and tasks effectively. These tools support three levels of task management: -- **Plan**: A high-level strategic document describing goals, context, and overall direction. Plans do NOT contain actionable steps - they define the "what" and "why". -- **Todo**: The concrete execution list with actionable items. Todos define the "how" - specific tasks to accomplish the plan. +- **Plan**: A high-level strategic document describing goals, context, and overall direction. Plans do NOT contain actionable steps - they define the "what" and "why". **Plans should be stable once created** - they represent the overarching objective that rarely changes. +- **Todo**: The concrete execution list with actionable items. Todos define the "how" - specific tasks to accomplish the plan. **Todos are dynamic** - they can be added, updated, completed, and removed as work progresses. +- **Task**: Long-running async operations that execute in isolated contexts. Tasks are for complex, multi-step operations that require extended processing time. **Tasks run independently** - they can inherit context but execute separately from the main conversation. **Planning Tools** - For high-level goal documentation: - \`createPlan\`: Create a strategic plan document with goal and context -- \`updatePlan\`: Update plan details or mark as completed +- \`updatePlan\`: Update plan details **Todo Tools** - For actionable execution items: - \`createTodos\`: Create new todo items from text array @@ -14,11 +15,14 @@ export const systemPrompt = `You have GTD (Getting Things Done) tools to help ma - \`completeTodos\`: Mark items as done by indices - \`removeTodos\`: Remove items by indices - \`clearTodos\`: Clear completed or all items + +**Async Task Tools** - For long-running background tasks: +- \`execTask\`: Execute a single async task in isolated context +- \`execTasks\`: Execute multiple async tasks in parallel **IMPORTANT: Always create a Plan first, then Todos.** - When a user asks you to help with a task, goal, or project: 1. **First**, use \`createPlan\` to document the goal and relevant context 2. **Then**, use \`createTodos\` to break down the plan into actionable steps @@ -47,6 +51,26 @@ This "Plan-First" approach ensures: - User explicitly requests only action items - Capturing quick, simple tasks that don't need planning - Tracking progress on concrete deliverables + +**Use Async Tasks when:** +- **The request requires gathering external information**: User wants you to research, investigate, or find information that you don't already know. This requires web searches, reading multiple sources, and synthesizing information. +- **The task involves multiple steps**: The request cannot be answered in one simple response - it requires searching, reading, analyzing, and summarizing. +- **Quality depends on thorough investigation**: A superficial answer would be insufficient; the user expects comprehensive, well-researched results. +- **Independent execution is beneficial**: The task can run separately while freeing up the main conversation. + +**How to identify async task scenarios:** +Ask yourself: "Can I answer this well from my existing knowledge, or does this require actively gathering new information?" +- If you need to search the web, read articles, or investigate → Use async task +- If you can answer directly from knowledge → Just respond + +Use \`execTask\` for a single task, \`execTasks\` for multiple parallel tasks. + +**Example scenarios:** +- User asks about best restaurants in a city → execTask (needs current info from reviews, searches) +- User wants research on a topic → execTask (multi-step: search, read, analyze, summarize) +- User asks to compare products/services → execTask (needs to gather data from multiple sources) +- User asks a factual question you know → Just answer directly +- User wants multiple independent analyses → execTasks (parallel execution) @@ -58,6 +82,57 @@ This "Plan-First" approach ensures: - **Track progress**: Use todo completion to measure plan progress + +**IMPORTANT: Keep todos focused on major stages, not detailed sub-tasks.** + +- **Limit to 5-10 items**: A todo list should contain around 5-10 major milestones or stages, not 20+ detailed tasks. +- **Think in phases**: Group related tasks into higher-level stages (e.g., "Plan itinerary" instead of listing every city separately). +- **Use hierarchical numbering** when more detail is needed: Use "1.", "2.", "2.1", "2.2", "3." format to show parent-child relationships. + +**Good example** (Japan trip - 7 items, stage-focused): +- 1. Determine travel dates and duration +- 2. Handle visa and documentation +- 3. Book flights and accommodation +- 4. Plan city itineraries +- 5. Arrange local transportation +- 6. Prepare for departure +- 7. Final confirmation before trip + +**Bad example** (20+ detailed items): +- Book Tokyo hotel +- Book Kyoto hotel +- Book Osaka hotel +- Buy Suica card +- Download Google Maps +- Download translation app +- ... (too granular!) + +**When user needs more detail**, use hierarchical numbering: +- 1. Determine travel dates +- 2. Plan itinerary +- 2.1 Tokyo attractions (3 days) +- 2.2 Kyoto attractions (2 days) +- 2.3 Osaka attractions (2 days) +- 3. Handle bookings +- 3.1 Flights +- 3.2 Hotels +- 3.3 JR Pass +- 4. Departure preparation + + + +**IMPORTANT: Plans should remain stable once created. Each conversation has only ONE plan.** + +- **Do NOT update plans** when details change (dates, locations, preferences). Instead, update the todos to reflect new information. +- **Only use updatePlan** when the user's goal fundamentally changes (e.g., destination changes from Japan to Korea). +- When user provides more specific information (like exact dates or preferences), **update or add todos** - not the plan. + +Example: +- User: "Plan a trip to Japan" → Create plan with goal "Japan Trip" +- User: "I want to go in February" → Update todos to include February-specific tasks, NOT update the plan +- User: "Actually I want to go to Korea instead" → Use updatePlan to change the goal to "Korea Trip" (fundamental goal change) + + When working with GTD tools: - Confirm actions: "Created plan: [goal]" or "Added [n] todo items" diff --git a/packages/builtin-tool-gtd/src/types.ts b/packages/builtin-tool-gtd/src/types.ts index b264560bc0..f78d2e798e 100644 --- a/packages/builtin-tool-gtd/src/types.ts +++ b/packages/builtin-tool-gtd/src/types.ts @@ -24,6 +24,13 @@ export const GTDApiName = { /** Create new todo items */ createTodos: 'createTodos', + // ==================== Async Tasks ==================== + /** Execute a single async task */ + execTask: 'execTask', + + /** Execute one or more async tasks */ + execTasks: 'execTasks', + /** Remove todo items by indices */ removeTodos: 'removeTodos', @@ -229,3 +236,67 @@ export interface UpdatePlanState { /** The updated plan document */ plan: Plan; } + +// ==================== Async Tasks Types ==================== + +/** + * Single task item for execution + */ +export interface ExecTaskItem { + /** Brief description of what this task does (shown in UI) */ + description: string; + /** Whether to inherit context messages from parent conversation */ + inheritMessages?: boolean; + /** Detailed instruction/prompt for the task execution */ + instruction: string; + /** Timeout in milliseconds (optional, default 30 minutes) */ + timeout?: number; +} + +/** + * Parameters for execTask API + * Execute a single async task + */ +export interface ExecTaskParams { + /** Brief description of what this task does (shown in UI) */ + description: string; + /** Whether to inherit context messages from parent conversation */ + inheritMessages?: boolean; + /** Detailed instruction/prompt for the task execution */ + instruction: string; + /** Timeout in milliseconds (optional, default 30 minutes) */ + timeout?: number; +} + +/** + * Parameters for execTasks API + * Execute one or more async tasks + */ +export interface ExecTasksParams { + /** Array of tasks to execute */ + tasks: ExecTaskItem[]; +} + +/** + * State returned after triggering exec_task + */ +export interface ExecTaskState { + /** Parent message ID (tool message) */ + parentMessageId: string; + /** The task definition that was triggered */ + task: ExecTaskItem; + /** Type identifier for render component */ + type: 'execTask'; +} + +/** + * State returned after triggering exec_tasks + */ +export interface ExecTasksState { + /** Parent message ID (tool message) */ + parentMessageId: string; + /** Array of task definitions that were triggered */ + tasks: ExecTaskItem[]; + /** Type identifier for render component */ + type: 'execTasks'; +} diff --git a/src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx b/src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx index 2e4ef232f3..f5f490aa0c 100644 --- a/src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/Tool/index.tsx @@ -7,6 +7,7 @@ import { memo, useEffect, useState } from 'react'; import Actions from '@/features/Conversation/Messages/AssistantGroup/Tool/Actions'; import { useToolStore } from '@/store/tool'; import { toolSelectors } from '@/store/tool/selectors'; +import { getBuiltinRender } from '@/tools/renders'; import { getBuiltinStreaming } from '@/tools/streamings'; import Inspectors from './Inspector'; @@ -79,6 +80,10 @@ const Tool = memo( const hasStreamingRenderer = !!getBuiltinStreaming(identifier, apiName); const forceShowStreamingRender = isArgumentsStreaming && hasStreamingRenderer; + // Check if tool has custom render - if not, disable expand + // Custom render exists when: builtin render exists OR plugin type is not 'default' + const hasCustomRender = !!getBuiltinRender(identifier, apiName) || (!!type && type !== 'default'); + // Wrap handleExpand to prevent collapsing when alwaysExpand is set const wrappedHandleExpand = (expand?: boolean) => { // Block collapse action when alwaysExpand is set @@ -117,6 +122,7 @@ const Tool = memo( showPluginRender={showPluginRender} /> } + allowExpand={hasCustomRender} itemKey={id} paddingBlock={4} paddingInline={4} diff --git a/src/locales/default/plugin.ts b/src/locales/default/plugin.ts index 084da3b235..629e72a367 100644 --- a/src/locales/default/plugin.ts +++ b/src/locales/default/plugin.ts @@ -60,6 +60,9 @@ export default { 'builtins.lobe-gtd.apiName.createPlan': 'Create plan', 'builtins.lobe-gtd.apiName.createPlan.result': 'Create plan: {{goal}}', 'builtins.lobe-gtd.apiName.createTodos': 'Create todos', + 'builtins.lobe-gtd.apiName.execTask': 'Execute task', + 'builtins.lobe-gtd.apiName.execTask.result': 'Execute: {{description}}', + 'builtins.lobe-gtd.apiName.execTasks': 'Execute tasks', 'builtins.lobe-gtd.apiName.removeTodos': 'Delete todos', 'builtins.lobe-gtd.apiName.updatePlan': 'Update plan', 'builtins.lobe-gtd.apiName.updatePlan.completed': 'Completed', diff --git a/src/store/chat/agents/__tests__/createAgentExecutors/exec-task.test.ts b/src/store/chat/agents/__tests__/createAgentExecutors/exec-task.test.ts new file mode 100644 index 0000000000..57b8fa8d60 --- /dev/null +++ b/src/store/chat/agents/__tests__/createAgentExecutors/exec-task.test.ts @@ -0,0 +1,479 @@ +import type { AgentRuntimeContext, TaskResultPayload } from '@lobechat/agent-runtime'; +import type { Mock } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +import { aiAgentService } from '@/services/aiAgent'; + +import { createExecTaskInstruction } from './fixtures'; +import { createMockStore } from './fixtures/mockStore'; +import { createInitialState, createTestContext, executeWithMockContext } from './helpers'; + +// Mock aiAgentService +vi.mock('@/services/aiAgent', () => ({ + aiAgentService: { + execSubAgentTask: vi.fn(), + getSubAgentTaskStatus: vi.fn(), + }, +})); + +// Helper to get typed mocks +const mockExecSubAgentTask = aiAgentService.execSubAgentTask as Mock; +const mockGetSubAgentTaskStatus = aiAgentService.getSubAgentTaskStatus as Mock; + +describe('exec_task executor', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Basic Behavior', () => { + it('should execute single task successfully', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTaskInstruction( + { description: 'Test task', instruction: 'Do something' }, + 'msg_parent', + ); + const state = createInitialState({ operationId: 'test-op' }); + + // Mock task message creation + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + // Mock task execution + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + // Mock task status polling - completed on first poll + mockGetSubAgentTaskStatus.mockResolvedValueOnce({ + result: 'Task completed successfully', + status: 'completed', + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_task', + instruction, + mockStore, + state, + }); + + // Then + expect(result.nextContext).toBeDefined(); + expect((result.nextContext as AgentRuntimeContext).phase).toBe('task_result'); + + const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload; + expect(payload.result).toBeDefined(); + expect(payload.result.success).toBe(true); + expect(payload.result.threadId).toBe('thread_1'); + expect(payload.result.taskMessageId).toBe('task_msg_1'); + }); + }); + + describe('Error Handling', () => { + it('should return error when no context available', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext({ agentId: undefined, topicId: null }); + const instruction = createExecTaskInstruction(); + const state = createInitialState({ operationId: 'test-op' }); + + // Override operation context to have no agentId/topicId + mockStore.operations[context.operationId] = { + abortController: new AbortController(), + childOperationIds: [], + context: { + agentId: undefined, + topicId: undefined, + }, + id: context.operationId, + metadata: { startTime: Date.now() }, + status: 'running', + type: 'execAgentRuntime', + }; + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_task', + instruction, + mockStore, + state, + }); + + // Then + expect(result.nextContext).toBeDefined(); + const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload; + expect(payload.result.success).toBe(false); + expect(payload.result.error).toBe('No valid context available'); + }); + + it('should handle task message creation failure', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTaskInstruction(); + const state = createInitialState({ operationId: 'test-op' }); + + // Mock task message creation failure + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce(null); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_task', + instruction, + mockStore, + state, + }); + + // Then + const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload; + expect(payload.result.success).toBe(false); + expect(payload.result.error).toBe('Failed to create task message'); + }); + + it('should handle task creation API failure', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTaskInstruction(); + const state = createInitialState({ operationId: 'test-op' }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: '', + error: 'API error', + operationId: '', + success: false, + threadId: '', + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_task', + instruction, + mockStore, + state, + }); + + // Then + const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload; + expect(payload.result.success).toBe(false); + expect(payload.result.error).toBe('API error'); + expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith( + 'task_msg_1', + 'Task creation failed: API error', + undefined, + { operationId: 'test-op' }, + ); + }); + + it('should handle task execution failure', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTaskInstruction(); + const state = createInitialState({ operationId: 'test-op' }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + mockGetSubAgentTaskStatus.mockResolvedValueOnce({ + error: 'Execution error', + status: 'failed', + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_task', + instruction, + mockStore, + state, + }); + + // Then + const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload; + expect(payload.result.success).toBe(false); + expect(payload.result.error).toBe('Execution error'); + }); + }); + + describe('Task Status Polling', () => { + it('should update task message with taskDetail when completed', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTaskInstruction(); + const state = createInitialState({ operationId: 'test-op' }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + // Return completed with taskDetail on first poll + mockGetSubAgentTaskStatus.mockResolvedValueOnce({ + result: 'Done', + status: 'completed', + taskDetail: { status: 'completed' }, + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_task', + instruction, + mockStore, + state, + }); + + // Then + expect(mockStore.internal_dispatchMessage).toHaveBeenCalledWith( + { + id: 'task_msg_1', + type: 'updateMessage', + value: { taskDetail: { status: 'completed' } }, + }, + { operationId: 'test-op' }, + ); + const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload; + expect(payload.result.success).toBe(true); + }); + + it('should handle cancelled task status', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTaskInstruction(); + const state = createInitialState({ operationId: 'test-op' }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + // Use mockImplementationOnce to ensure fresh mock behavior + mockGetSubAgentTaskStatus.mockImplementationOnce(async () => ({ + status: 'cancel', + })); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_task', + instruction, + mockStore, + state, + }); + + // Then + const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload; + expect(payload.result.success).toBe(false); + expect(payload.result.error).toBe('Task was cancelled'); + expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith( + 'task_msg_1', + 'Task was cancelled', + undefined, + { operationId: 'test-op' }, + ); + }); + }); + + describe('Operation Cancellation', () => { + it('should stop polling when operation is cancelled before poll', async () => { + // Given + const mockStore = createMockStore(); + // Use same operationId for both context and state + const operationId = 'test-op'; + const context = createTestContext({ operationId }); + const instruction = createExecTaskInstruction(); + const state = createInitialState({ operationId }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + // Mock execSubAgentTask to mark operation as cancelled after it's called + // This simulates cancellation happening right after task creation but before polling + mockExecSubAgentTask.mockImplementation(async () => { + // After task creation API is called, mark operation as cancelled + // This simulates cancellation happening right after task creation + mockStore.operations[operationId].status = 'cancelled'; + return { + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }; + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_task', + instruction, + mockStore, + state, + }); + + // Then + const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload; + expect(payload.result.success).toBe(false); + expect(payload.result.error).toBe('Operation cancelled'); + // getSubAgentTaskStatus should not be called since operation was cancelled before poll + expect(mockGetSubAgentTaskStatus).not.toHaveBeenCalled(); + }); + }); + + describe('Result Phase', () => { + it('should return task_result phase with correct session info', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTaskInstruction( + { description: 'Test', instruction: 'Test instruction' }, + 'msg_parent', + ); + const state = createInitialState({ operationId: 'test-op', stepCount: 5 }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + mockGetSubAgentTaskStatus.mockResolvedValueOnce({ + result: 'Done', + status: 'completed', + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_task', + instruction, + mockStore, + state, + }); + + // Then + expect(result.nextContext).toBeDefined(); + const nextContext = result.nextContext as AgentRuntimeContext; + expect(nextContext.phase).toBe('task_result'); + expect(nextContext.session?.stepCount).toBe(6); + expect(nextContext.session?.status).toBe('running'); + + const payload = nextContext.payload as TaskResultPayload; + expect(payload.parentMessageId).toBe('msg_parent'); + }); + + it('should update messages in newState from store', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTaskInstruction(); + const state = createInitialState({ messages: [], operationId: 'test-op' }); + + const updatedMessages = [{ content: 'test', id: 'msg_1', role: 'user' }]; + mockStore.dbMessagesMap[context.messageKey] = updatedMessages as any; + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + mockGetSubAgentTaskStatus.mockResolvedValueOnce({ + result: 'Done', + status: 'completed', + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_task', + instruction, + mockStore, + state, + }); + + // Then + expect(result.newState.messages).toEqual(updatedMessages); + }); + }); + + describe('Task Message Creation', () => { + it('should create task message with correct parameters', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext({ agentId: 'agent_1', topicId: 'topic_1' }); + const instruction = createExecTaskInstruction( + { description: 'Test task', instruction: 'Do something important' }, + 'msg_parent', + ); + const state = createInitialState({ operationId: 'test-op' }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + mockGetSubAgentTaskStatus.mockResolvedValueOnce({ + result: 'Done', + status: 'completed', + }); + + // When + await executeWithMockContext({ + context, + executor: 'exec_task', + instruction, + mockStore, + state, + }); + + // Then + expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith( + { + agentId: 'agent_1', + content: '', + metadata: { instruction: 'Do something important' }, + parentId: 'msg_parent', + role: 'task', + topicId: 'topic_1', + }, + { operationId: 'test-op' }, + ); + }); + }); +}); diff --git a/src/store/chat/agents/__tests__/createAgentExecutors/exec-tasks.test.ts b/src/store/chat/agents/__tests__/createAgentExecutors/exec-tasks.test.ts new file mode 100644 index 0000000000..976acc7a6b --- /dev/null +++ b/src/store/chat/agents/__tests__/createAgentExecutors/exec-tasks.test.ts @@ -0,0 +1,598 @@ +import type { AgentRuntimeContext, TasksBatchResultPayload } from '@lobechat/agent-runtime'; +import type { Mock } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +import { aiAgentService } from '@/services/aiAgent'; + +import { createExecTasksInstruction } from './fixtures'; +import { createMockStore } from './fixtures/mockStore'; +import { createInitialState, createTestContext, executeWithMockContext } from './helpers'; + +// Mock aiAgentService +vi.mock('@/services/aiAgent', () => ({ + aiAgentService: { + execSubAgentTask: vi.fn(), + getSubAgentTaskStatus: vi.fn(), + }, +})); + +// Helper to get typed mocks +const mockExecSubAgentTask = aiAgentService.execSubAgentTask as Mock; +const mockGetSubAgentTaskStatus = aiAgentService.getSubAgentTaskStatus as Mock; + +describe('exec_tasks executor', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Basic Behavior', () => { + it('should execute single task successfully', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTasksInstruction( + [{ description: 'Test task 1', instruction: 'Do something' }], + 'msg_parent', + ); + const state = createInitialState({ operationId: 'test-op' }); + + // Mock task message creation + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + // Mock task execution + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + // Mock task status polling - completed on first poll + mockGetSubAgentTaskStatus.mockResolvedValueOnce({ + result: 'Task completed successfully', + status: 'completed', + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + expect(result.nextContext).toBeDefined(); + expect((result.nextContext as AgentRuntimeContext).phase).toBe('tasks_batch_result'); + + const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload; + expect(payload.results).toHaveLength(1); + expect(payload.results[0].success).toBe(true); + expect(payload.results[0].threadId).toBe('thread_1'); + expect(payload.results[0].taskMessageId).toBe('task_msg_1'); + }); + + it('should execute multiple tasks in parallel', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTasksInstruction( + [ + { description: 'Task 1', instruction: 'Do task 1' }, + { description: 'Task 2', instruction: 'Do task 2' }, + { description: 'Task 3', instruction: 'Do task 3' }, + ], + 'msg_parent', + ); + const state = createInitialState({ operationId: 'test-op' }); + + // Mock task message creation for each task + (mockStore.optimisticCreateMessage as Mock) + .mockResolvedValueOnce({ id: 'task_msg_1' }) + .mockResolvedValueOnce({ id: 'task_msg_2' }) + .mockResolvedValueOnce({ id: 'task_msg_3' }); + + // Mock task execution for each task + mockExecSubAgentTask + .mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }) + .mockResolvedValueOnce({ + assistantMessageId: 'asst_2', + operationId: 'op_2', + success: true, + threadId: 'thread_2', + }) + .mockResolvedValueOnce({ + assistantMessageId: 'asst_3', + operationId: 'op_3', + success: true, + threadId: 'thread_3', + }); + + // Mock task status polling - all completed + mockGetSubAgentTaskStatus + .mockResolvedValueOnce({ result: 'Result 1', status: 'completed' }) + .mockResolvedValueOnce({ result: 'Result 2', status: 'completed' }) + .mockResolvedValueOnce({ result: 'Result 3', status: 'completed' }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + expect(result.nextContext).toBeDefined(); + const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload; + expect(payload.results).toHaveLength(3); + expect(payload.results.every((r) => r.success)).toBe(true); + }); + }); + + describe('Error Handling', () => { + it('should return error when no context available', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext({ agentId: undefined, topicId: null }); + const instruction = createExecTasksInstruction(); + const state = createInitialState({ operationId: 'test-op' }); + + // Override operation context to have no agentId/topicId + mockStore.operations[context.operationId] = { + abortController: new AbortController(), + childOperationIds: [], + context: { + agentId: undefined, + topicId: undefined, + }, + id: context.operationId, + metadata: { startTime: Date.now() }, + status: 'running', + type: 'execAgentRuntime', + }; + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + expect(result.nextContext).toBeDefined(); + const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload; + expect(payload.results).toHaveLength(1); + expect(payload.results[0].success).toBe(false); + expect(payload.results[0].error).toBe('No valid context available'); + }); + + it('should handle task message creation failure', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTasksInstruction(); + const state = createInitialState({ operationId: 'test-op' }); + + // Mock task message creation failure + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce(null); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload; + expect(payload.results[0].success).toBe(false); + expect(payload.results[0].error).toBe('Failed to create task message'); + }); + + it('should handle task creation API failure', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTasksInstruction(); + const state = createInitialState({ operationId: 'test-op' }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: '', + error: 'API error', + operationId: '', + success: false, + threadId: '', + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload; + expect(payload.results[0].success).toBe(false); + expect(payload.results[0].error).toBe('API error'); + expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith( + 'task_msg_1', + 'Task creation failed: API error', + undefined, + { operationId: 'test-op' }, + ); + }); + + it('should handle task execution failure', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTasksInstruction(); + const state = createInitialState({ operationId: 'test-op' }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + mockGetSubAgentTaskStatus.mockResolvedValueOnce({ + error: 'Execution error', + status: 'failed', + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload; + expect(payload.results[0].success).toBe(false); + expect(payload.results[0].error).toBe('Execution error'); + }); + }); + + describe('Task Status Polling', () => { + it('should update task message with taskDetail when completed', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTasksInstruction(); + const state = createInitialState({ operationId: 'test-op' }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + // Return completed with taskDetail on first poll + mockGetSubAgentTaskStatus.mockResolvedValueOnce({ + result: 'Done', + status: 'completed', + taskDetail: { status: 'completed' }, + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + expect(mockStore.internal_dispatchMessage).toHaveBeenCalledWith( + { + id: 'task_msg_1', + type: 'updateMessage', + value: { taskDetail: { status: 'completed' } }, + }, + { operationId: 'test-op' }, + ); + const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload; + expect(payload.results[0].success).toBe(true); + }); + + it('should handle cancelled task status', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTasksInstruction(); + const state = createInitialState({ operationId: 'test-op' }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + // Use mockImplementationOnce to ensure fresh mock behavior + mockGetSubAgentTaskStatus.mockImplementationOnce(async () => ({ + status: 'cancel', + })); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload; + expect(payload.results[0].success).toBe(false); + expect(payload.results[0].error).toBe('Task was cancelled'); + expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith( + 'task_msg_1', + 'Task was cancelled', + undefined, + { operationId: 'test-op' }, + ); + }); + }); + + describe('Operation Cancellation', () => { + it('should stop polling when operation is cancelled before poll', async () => { + // Given + const mockStore = createMockStore(); + // Use same operationId for both context and state + const operationId = 'test-op'; + const context = createTestContext({ operationId }); + const instruction = createExecTasksInstruction(); + const state = createInitialState({ operationId }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + // Mock execSubAgentTask to mark operation as cancelled after it's called + // This simulates cancellation happening right after task creation but before polling + mockExecSubAgentTask.mockImplementation(async () => { + // After task creation API is called, mark operation as cancelled + // This simulates cancellation happening right after task creation + // Note: state.operationId is used in the polling loop for cancellation check + mockStore.operations[operationId].status = 'cancelled'; + return { + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }; + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload; + expect(payload.results[0].success).toBe(false); + expect(payload.results[0].error).toBe('Operation cancelled'); + // getSubAgentTaskStatus should not be called since operation was cancelled before poll + expect(mockGetSubAgentTaskStatus).not.toHaveBeenCalled(); + }); + }); + + describe('Result Phase', () => { + it('should return tasks_result phase with correct session info', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTasksInstruction( + [{ description: 'Test', instruction: 'Test instruction' }], + 'msg_parent', + ); + const state = createInitialState({ operationId: 'test-op', stepCount: 5 }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + mockGetSubAgentTaskStatus.mockResolvedValueOnce({ + result: 'Done', + status: 'completed', + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + expect(result.nextContext).toBeDefined(); + const nextContext = result.nextContext as AgentRuntimeContext; + expect(nextContext.phase).toBe('tasks_batch_result'); + expect(nextContext.session?.stepCount).toBe(6); + expect(nextContext.session?.status).toBe('running'); + + const payload = nextContext.payload as TasksBatchResultPayload; + expect(payload.parentMessageId).toBe('msg_parent'); + }); + + it('should update messages in newState from store', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTasksInstruction(); + const state = createInitialState({ messages: [], operationId: 'test-op' }); + + const updatedMessages = [{ content: 'test', id: 'msg_1', role: 'user' }]; + mockStore.dbMessagesMap[context.messageKey] = updatedMessages as any; + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + mockGetSubAgentTaskStatus.mockResolvedValueOnce({ + result: 'Done', + status: 'completed', + }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + expect(result.newState.messages).toEqual(updatedMessages); + }); + }); + + describe('Task Message Creation', () => { + it('should create task message with correct parameters', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext({ agentId: 'agent_1', topicId: 'topic_1' }); + const instruction = createExecTasksInstruction( + [{ description: 'Test task', instruction: 'Do something important' }], + 'msg_parent', + ); + const state = createInitialState({ operationId: 'test-op' }); + + (mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' }); + + mockExecSubAgentTask.mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }); + + mockGetSubAgentTaskStatus.mockResolvedValueOnce({ + result: 'Done', + status: 'completed', + }); + + // When + await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith( + { + agentId: 'agent_1', + content: '', + metadata: { instruction: 'Do something important' }, + parentId: 'msg_parent', + role: 'task', + topicId: 'topic_1', + }, + { operationId: 'test-op' }, + ); + }); + }); + + describe('Mixed Results', () => { + it('should handle mix of successful and failed tasks', async () => { + // Given + const mockStore = createMockStore(); + const context = createTestContext(); + const instruction = createExecTasksInstruction( + [ + { description: 'Task 1', instruction: 'Success task' }, + { description: 'Task 2', instruction: 'Fail task' }, + ], + 'msg_parent', + ); + const state = createInitialState({ operationId: 'test-op' }); + + (mockStore.optimisticCreateMessage as Mock) + .mockResolvedValueOnce({ id: 'task_msg_1' }) + .mockResolvedValueOnce({ id: 'task_msg_2' }); + + mockExecSubAgentTask + .mockResolvedValueOnce({ + assistantMessageId: 'asst_1', + operationId: 'op_1', + success: true, + threadId: 'thread_1', + }) + .mockResolvedValueOnce({ + assistantMessageId: 'asst_2', + operationId: 'op_2', + success: true, + threadId: 'thread_2', + }); + + mockGetSubAgentTaskStatus + .mockResolvedValueOnce({ result: 'Success', status: 'completed' }) + .mockResolvedValueOnce({ error: 'Task failed', status: 'failed' }); + + // When + const result = await executeWithMockContext({ + context, + executor: 'exec_tasks', + instruction, + mockStore, + state, + }); + + // Then + const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload; + expect(payload.results).toHaveLength(2); + expect(payload.results[0].success).toBe(true); + expect(payload.results[1].success).toBe(false); + expect(payload.results[1].error).toBe('Task failed'); + }); + }); +}); diff --git a/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockInstructions.ts b/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockInstructions.ts index 05a0111d64..369a7c6ecc 100644 --- a/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockInstructions.ts +++ b/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockInstructions.ts @@ -2,6 +2,9 @@ import type { AgentInstruction, AgentInstructionCallLlm, AgentInstructionCallTool, + AgentInstructionExecTask, + AgentInstructionExecTasks, + ExecTaskItem, GeneralAgentCallLLMInstructionPayload, GeneralAgentCallingToolInstructionPayload, } from '@lobechat/agent-runtime'; @@ -124,3 +127,50 @@ export const createFinishInstruction = ( type: 'finish', } as AgentInstruction; }; + +/** + * Create a mock exec_task instruction (single task) + */ +export const createExecTaskInstruction = ( + task?: Partial, + parentMessageId?: string, +): AgentInstructionExecTask => { + const defaultTask: ExecTaskItem = { + description: 'Test task', + instruction: 'Execute test task', + ...task, + }; + + return { + payload: { + parentMessageId: parentMessageId || `msg_${nanoid()}`, + task: defaultTask, + }, + type: 'exec_task', + }; +}; + +/** + * Create a mock exec_tasks instruction (multiple tasks) + */ +export const createExecTasksInstruction = ( + tasks: ExecTaskItem[] = [], + parentMessageId?: string, +): AgentInstructionExecTasks => { + const defaultTasks: ExecTaskItem[] = tasks.length + ? tasks + : [ + { + description: 'Test task', + instruction: 'Execute test task', + }, + ]; + + return { + payload: { + parentMessageId: parentMessageId || `msg_${nanoid()}`, + tasks: defaultTasks, + }, + type: 'exec_tasks', + }; +}; diff --git a/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockStore.ts b/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockStore.ts index eb5ea293cf..204478f259 100644 --- a/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockStore.ts +++ b/src/store/chat/agents/__tests__/createAgentExecutors/fixtures/mockStore.ts @@ -56,6 +56,8 @@ export const createMockStore = (overrides: Partial = {}): ChatStore = }), // AI chat methods + internal_dispatchMessage: vi.fn(), + internal_fetchAIChatMessage: vi.fn().mockResolvedValue(undefined), internal_invokeDifferentTypePlugin: vi.fn().mockResolvedValue({ error: null }), diff --git a/src/store/chat/agents/createAgentExecutors.ts b/src/store/chat/agents/createAgentExecutors.ts index e738c0da9f..73a662e7d8 100644 --- a/src/store/chat/agents/createAgentExecutors.ts +++ b/src/store/chat/agents/createAgentExecutors.ts @@ -3,12 +3,16 @@ import { type AgentInstruction, type AgentInstructionCallLlm, type AgentInstructionCallTool, + type AgentInstructionExecTask, + type AgentInstructionExecTasks, type AgentRuntimeContext, type GeneralAgentCallLLMInstructionPayload, type GeneralAgentCallLLMResultPayload, type GeneralAgentCallToolResultPayload, type GeneralAgentCallingToolInstructionPayload, type InstructionExecutor, + type TaskResultPayload, + type TasksBatchResultPayload, UsageCounter, } from '@lobechat/agent-runtime'; import type { ChatToolPayload, CreateMessageParams } from '@lobechat/types'; @@ -16,7 +20,9 @@ import debug from 'debug'; import pMap from 'p-map'; import { LOADING_FLAT } from '@/const/message'; +import { aiAgentService } from '@/services/aiAgent'; import type { ChatStore } from '@/store/chat/store'; +import { sleep } from '@/utils/sleep'; const log = debug('lobe-store:agent-executors'); @@ -558,21 +564,58 @@ export const createAgentExecutors = (context: { toolCost.toFixed(4), ); - // Check if tool wants to stop execution flow (e.g., group management tools) + // Check if tool wants to stop execution flow if (result?.stop) { - log( - '[%s][call_tool] Tool returned stop=true, terminating execution. state: %O', - sessionLogId, - result.state, - ); + log('[%s][call_tool] Tool returned stop=true, state: %O', sessionLogId, result.state); - // Mark state as done and return without nextContext to stop the runtime + const stateType = result.state?.type; + + // GTD async tasks need to be passed to Agent for exec_task/exec_tasks instruction + if (stateType === 'execTask' || stateType === 'execTasks') { + log( + '[%s][call_tool] Detected %s state, passing to Agent for decision', + sessionLogId, + stateType, + ); + + return { + events, + newState, + nextContext: { + payload: { + data: result, + executionTime, + isSuccess, + parentMessageId: toolMessageId, + stop: true, + toolCall: chatToolPayload, + toolCallId: chatToolPayload.id, + } as GeneralAgentCallToolResultPayload, + phase: 'tool_result', + session: { + eventCount: events.length, + messageCount: newState.messages.length, + sessionId: state.operationId, + status: 'running', + stepCount: state.stepCount + 1, + }, + stepUsage: { + cost: toolCost, + toolName, + unitPrice: toolCost, + usageCount: 1, + }, + } as AgentRuntimeContext, + }; + } + + // Other stop types (speak, delegate, broadcast, etc.) - stop execution immediately newState.status = 'done'; return { events, newState, - nextContext: undefined, // No next context means execution stops + nextContext: undefined, }; } @@ -817,6 +860,625 @@ export const createAgentExecutors = (context: { return { events, newState }; }, + + /** + * exec_task executor + * Executes a single async task + * + * Flow: + * 1. Create a task message (role: 'task') as placeholder + * 2. Call execSubAgentTask API (backend creates thread) + * 3. Poll for task completion + * 4. Update task message content with result on completion + * 5. Return task_result phase with result + */ + exec_task: async (instruction, state) => { + const { parentMessageId, task } = (instruction as AgentInstructionExecTask).payload; + + const events: AgentEvent[] = []; + const sessionLogId = `${state.operationId}:${state.stepCount}`; + + log('[%s][exec_task] Starting execution of task: %s', sessionLogId, task.description); + + // Get context from operation + const opContext = getOperationContext(); + const { agentId, topicId } = opContext; + + if (!agentId || !topicId) { + log('[%s][exec_task] No valid context, cannot execute task', sessionLogId); + return { + events, + newState: state, + nextContext: { + payload: { + parentMessageId, + result: { + error: 'No valid context available', + success: false, + taskMessageId: '', + threadId: '', + }, + } as TaskResultPayload, + phase: 'task_result', + session: { + messageCount: state.messages.length, + sessionId: state.operationId, + status: 'running', + stepCount: state.stepCount + 1, + }, + } as AgentRuntimeContext, + }; + } + + + + const taskLogId = `${sessionLogId}:task`; + + try { + // 1. Create task message as placeholder + const taskMessageResult = await context.get().optimisticCreateMessage( + { + agentId, + content: '', + metadata: { instruction: task.instruction }, + parentId: parentMessageId, + role: 'task', + topicId, + }, + { operationId: state.operationId }, + ); + + if (!taskMessageResult) { + log('[%s] Failed to create task message', taskLogId); + return { + events, + newState: state, + nextContext: { + payload: { + parentMessageId, + result: { + error: 'Failed to create task message', + success: false, + taskMessageId: '', + threadId: '', + }, + } as TaskResultPayload, + phase: 'task_result', + session: { + messageCount: state.messages.length, + sessionId: state.operationId, + status: 'running', + stepCount: state.stepCount + 1, + }, + } as AgentRuntimeContext, + }; + } + + const taskMessageId = taskMessageResult.id; + log('[%s] Created task message: %s', taskLogId, taskMessageId); + + // 2. Create task via backend API + const createResult = await aiAgentService.execSubAgentTask({ + agentId, + instruction: task.instruction, + parentMessageId: taskMessageId, + topicId, + }); + + if (!createResult.success) { + log('[%s] Failed to create task: %s', taskLogId, createResult.error); + await context + .get() + .optimisticUpdateMessageContent( + taskMessageId, + `Task creation failed: ${createResult.error}`, + undefined, + { operationId: state.operationId }, + ); + return { + events, + newState: state, + nextContext: { + payload: { + parentMessageId, + result: { + error: createResult.error, + success: false, + taskMessageId, + threadId: '', + }, + } as TaskResultPayload, + phase: 'task_result', + session: { + messageCount: state.messages.length, + sessionId: state.operationId, + status: 'running', + stepCount: state.stepCount + 1, + }, + } as AgentRuntimeContext, + }; + } + + log('[%s] Task created with threadId: %s', taskLogId, createResult.threadId); + + // 3. Poll for task completion + const pollInterval = 3000; // 3 seconds + const maxWait = task.timeout || 1_800_000; // Default 30 minutes + const startTime = Date.now(); + + while (Date.now() - startTime < maxWait) { + // Check if operation has been cancelled + const currentOperation = context.get().operations[state.operationId]; + if (currentOperation?.status === 'cancelled') { + log('[%s] Operation cancelled, stopping polling', taskLogId); + const updatedMessages = context.get().dbMessagesMap[context.messageKey] || []; + return { + events, + newState: { ...state, messages: updatedMessages }, + nextContext: { + payload: { + parentMessageId, + result: { + error: 'Operation cancelled', + success: false, + taskMessageId, + threadId: createResult.threadId, + }, + } as TaskResultPayload, + phase: 'task_result', + session: { + messageCount: updatedMessages.length, + sessionId: state.operationId, + status: 'running', + stepCount: state.stepCount + 1, + }, + } as AgentRuntimeContext, + }; + } + + const status = await aiAgentService.getSubAgentTaskStatus({ + threadId: createResult.threadId, + }); + + // Update taskDetail in message if available + if (status.taskDetail) { + context.get().internal_dispatchMessage( + { + id: taskMessageId, + type: 'updateMessage', + value: { taskDetail: status.taskDetail }, + }, + { operationId: state.operationId }, + ); + log('[%s] Updated task message with taskDetail', taskLogId); + } + + if (status.status === 'completed') { + log('[%s] Task completed successfully', taskLogId); + if (status.result) { + await context + .get() + .optimisticUpdateMessageContent(taskMessageId, status.result, undefined, { + operationId: state.operationId, + }); + } + const updatedMessages = context.get().dbMessagesMap[context.messageKey] || []; + return { + events, + newState: { ...state, messages: updatedMessages }, + nextContext: { + payload: { + parentMessageId, + result: { + result: status.result, + success: true, + taskMessageId, + threadId: createResult.threadId, + }, + } as TaskResultPayload, + phase: 'task_result', + session: { + messageCount: updatedMessages.length, + sessionId: state.operationId, + status: 'running', + stepCount: state.stepCount + 1, + }, + } as AgentRuntimeContext, + }; + } + + if (status.status === 'failed') { + log('[%s] Task failed: %s', taskLogId, status.error); + await context + .get() + .optimisticUpdateMessageContent( + taskMessageId, + `Task failed: ${status.error}`, + undefined, + { operationId: state.operationId }, + ); + const updatedMessages = context.get().dbMessagesMap[context.messageKey] || []; + return { + events, + newState: { ...state, messages: updatedMessages }, + nextContext: { + payload: { + parentMessageId, + result: { + error: status.error, + success: false, + taskMessageId, + threadId: createResult.threadId, + }, + } as TaskResultPayload, + phase: 'task_result', + session: { + messageCount: updatedMessages.length, + sessionId: state.operationId, + status: 'running', + stepCount: state.stepCount + 1, + }, + } as AgentRuntimeContext, + }; + } + + if (status.status === 'cancel') { + log('[%s] Task was cancelled', taskLogId); + await context + .get() + .optimisticUpdateMessageContent(taskMessageId, 'Task was cancelled', undefined, { + operationId: state.operationId, + }); + const updatedMessages = context.get().dbMessagesMap[context.messageKey] || []; + return { + events, + newState: { ...state, messages: updatedMessages }, + nextContext: { + payload: { + parentMessageId, + result: { + error: 'Task was cancelled', + success: false, + taskMessageId, + threadId: createResult.threadId, + }, + } as TaskResultPayload, + phase: 'task_result', + session: { + messageCount: updatedMessages.length, + sessionId: state.operationId, + status: 'running', + stepCount: state.stepCount + 1, + }, + } as AgentRuntimeContext, + }; + } + + // Still processing, wait and poll again + await sleep(pollInterval); + } + + // Timeout reached + log('[%s] Task timeout after %dms', taskLogId, maxWait); + await context + .get() + .optimisticUpdateMessageContent( + taskMessageId, + `Task timeout after ${maxWait}ms`, + undefined, + { operationId: state.operationId }, + ); + + const updatedMessages = context.get().dbMessagesMap[context.messageKey] || []; + return { + events, + newState: { ...state, messages: updatedMessages }, + nextContext: { + payload: { + parentMessageId, + result: { + error: `Task timeout after ${maxWait}ms`, + success: false, + taskMessageId, + threadId: createResult.threadId, + }, + } as TaskResultPayload, + phase: 'task_result', + session: { + messageCount: updatedMessages.length, + sessionId: state.operationId, + status: 'running', + stepCount: state.stepCount + 1, + }, + } as AgentRuntimeContext, + }; + } catch (error) { + log('[%s] Error executing task: %O', taskLogId, error); + return { + events, + newState: state, + nextContext: { + payload: { + parentMessageId, + result: { + error: error instanceof Error ? error.message : 'Unknown error', + success: false, + taskMessageId: '', + threadId: '', + }, + } as TaskResultPayload, + phase: 'task_result', + session: { + messageCount: state.messages.length, + sessionId: state.operationId, + status: 'running', + stepCount: state.stepCount + 1, + }, + } as AgentRuntimeContext, + }; + } + }, + + /** + * exec_tasks executor + * Executes one or more async tasks in parallel + * + * Flow: + * 1. For each task, create a task message (role: 'task') as placeholder + * 2. Call execSubAgentTask API (backend creates thread) + * 3. Poll for task completion + * 4. Update task message content with result on completion + * 5. Return tasks_batch_result phase with all results + */ + exec_tasks: async (instruction, state) => { + const { parentMessageId, tasks } = (instruction as AgentInstructionExecTasks).payload; + + const events: AgentEvent[] = []; + const sessionLogId = `${state.operationId}:${state.stepCount}`; + + log('[%s][exec_tasks] Starting execution of %d tasks', sessionLogId, tasks.length); + + // Get context from operation + const opContext = getOperationContext(); + const { agentId, topicId } = opContext; + + if (!agentId || !topicId) { + log('[%s][exec_tasks] No valid context, cannot execute tasks', sessionLogId); + return { + events, + newState: state, + nextContext: { + payload: { + parentMessageId, + results: tasks.map(() => ({ + error: 'No valid context available', + success: false, + taskMessageId: '', + threadId: '', + })), + } as TasksBatchResultPayload, + phase: 'tasks_batch_result', + session: { + messageCount: state.messages.length, + sessionId: state.operationId, + status: 'running', + stepCount: state.stepCount + 1, + }, + } as AgentRuntimeContext, + }; + } + + // Execute all tasks in parallel + const results = await pMap( + tasks, + async (task, taskIndex) => { + const taskLogId = `${sessionLogId}:task-${taskIndex}`; + log('[%s] Starting task: %s', taskLogId, task.description); + + try { + // 1. Create task message as placeholder + const taskMessageResult = await context.get().optimisticCreateMessage( + { + agentId, + content: '', + metadata: { instruction: task.instruction }, + parentId: parentMessageId, + role: 'task', + topicId, + }, + { operationId: state.operationId }, + ); + + if (!taskMessageResult) { + log('[%s] Failed to create task message', taskLogId); + return { + error: 'Failed to create task message', + success: false, + taskMessageId: '', + threadId: '', + }; + } + + const taskMessageId = taskMessageResult.id; + log('[%s] Created task message: %s', taskLogId, taskMessageId); + + // 2. Create task via backend API (no groupId for single agent mode) + const createResult = await aiAgentService.execSubAgentTask({ + agentId, + instruction: task.instruction, + parentMessageId: taskMessageId, + topicId, + }); + + if (!createResult.success) { + log('[%s] Failed to create task: %s', taskLogId, createResult.error); + // Update task message with error + await context + .get() + .optimisticUpdateMessageContent( + taskMessageId, + `Task creation failed: ${createResult.error}`, + undefined, + { operationId: state.operationId }, + ); + return { + error: createResult.error, + success: false, + taskMessageId, + threadId: '', + }; + } + + log('[%s] Task created with threadId: %s', taskLogId, createResult.threadId); + + // 3. Poll for task completion + const pollInterval = 3000; // 3 seconds + const maxWait = task.timeout || 1_800_000; // Default 30 minutes + const startTime = Date.now(); + + while (Date.now() - startTime < maxWait) { + // Check if operation has been cancelled + const currentOperation = context.get().operations[state.operationId]; + if (currentOperation?.status === 'cancelled') { + log('[%s] Operation cancelled, stopping polling', taskLogId); + return { + error: 'Operation cancelled', + success: false, + taskMessageId, + threadId: createResult.threadId, + }; + } + + const status = await aiAgentService.getSubAgentTaskStatus({ + threadId: createResult.threadId, + }); + + // Update taskDetail in message if available + if (status.taskDetail) { + context.get().internal_dispatchMessage( + { + id: taskMessageId, + type: 'updateMessage', + value: { taskDetail: status.taskDetail }, + }, + { operationId: state.operationId }, + ); + log('[%s] Updated task message with taskDetail', taskLogId); + } + + if (status.status === 'completed') { + log('[%s] Task completed successfully', taskLogId); + // 4. Update task message with result + if (status.result) { + await context + .get() + .optimisticUpdateMessageContent(taskMessageId, status.result, undefined, { + operationId: state.operationId, + }); + } + return { + result: status.result, + success: true, + taskMessageId, + threadId: createResult.threadId, + }; + } + + if (status.status === 'failed') { + log('[%s] Task failed: %s', taskLogId, status.error); + // Update task message with error + await context + .get() + .optimisticUpdateMessageContent( + taskMessageId, + `Task failed: ${status.error}`, + undefined, + { operationId: state.operationId }, + ); + return { + error: status.error, + success: false, + taskMessageId, + threadId: createResult.threadId, + }; + } + + if (status.status === 'cancel') { + log('[%s] Task was cancelled', taskLogId); + // Update task message with cancelled status + await context + .get() + .optimisticUpdateMessageContent(taskMessageId, 'Task was cancelled', undefined, { + operationId: state.operationId, + }); + return { + error: 'Task was cancelled', + success: false, + taskMessageId, + threadId: createResult.threadId, + }; + } + + // Still processing, wait and poll again + await sleep(pollInterval); + } + + // Timeout reached + log('[%s] Task timeout after %dms', taskLogId, maxWait); + // Update task message with timeout error + await context + .get() + .optimisticUpdateMessageContent( + taskMessageId, + `Task timeout after ${maxWait}ms`, + undefined, + { operationId: state.operationId }, + ); + + return { + error: `Task timeout after ${maxWait}ms`, + success: false, + taskMessageId, + threadId: createResult.threadId, + }; + } catch (error) { + log('[%s] Error executing task: %O', taskLogId, error); + return { + error: error instanceof Error ? error.message : 'Unknown error', + success: false, + taskMessageId: '', + threadId: '', + }; + } + }, + { concurrency: 5 }, // Limit concurrent tasks + ); + + log('[%s][exec_tasks] All tasks completed, results: %O', sessionLogId, results); + + // Get latest messages from store + const updatedMessages = context.get().dbMessagesMap[context.messageKey] || []; + const newState = { ...state, messages: updatedMessages }; + + // Return tasks_batch_result phase + return { + events, + newState, + nextContext: { + payload: { + parentMessageId, + results, + } as TasksBatchResultPayload, + phase: 'tasks_batch_result', + session: { + messageCount: newState.messages.length, + sessionId: state.operationId, + status: 'running', + stepCount: state.stepCount + 1, + }, + } as AgentRuntimeContext, + }; + }, }; return executors; diff --git a/src/store/chat/slices/plugin/actions/pluginTypes.ts b/src/store/chat/slices/plugin/actions/pluginTypes.ts index 3a6aa0f97d..b211883cd9 100644 --- a/src/store/chat/slices/plugin/actions/pluginTypes.ts +++ b/src/store/chat/slices/plugin/actions/pluginTypes.ts @@ -78,12 +78,13 @@ export interface PluginTypesAction { * @param id - Tool message ID * @param payload - Tool call payload * @param stepContext - Optional step context with dynamic state like GTD todos + * @returns The tool execution result (including stop flag for flow control) */ invokeBuiltinTool: ( id: string, payload: ChatToolPayload, stepContext?: RuntimeStepContext, - ) => Promise; + ) => Promise; /** * Invoke Cloud Code Interpreter tool diff --git a/src/tools/streamings.ts b/src/tools/streamings.ts index 35fbedefc6..2ad783eaf8 100644 --- a/src/tools/streamings.ts +++ b/src/tools/streamings.ts @@ -1,3 +1,4 @@ +import { GTDManifest, GTDStreamings } from '@lobechat/builtin-tool-gtd/client'; import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system'; import { type BuiltinStreaming } from '@lobechat/types'; @@ -15,6 +16,7 @@ import { LocalSystemStreamings } from './local-system/Streaming'; */ const BuiltinToolStreamings: Record> = { [CodeInterpreterIdentifier]: CodeInterpreterStreamings as Record, + [GTDManifest.identifier]: GTDStreamings as Record, [LocalSystemManifest.identifier]: LocalSystemStreamings as Record, }; diff --git a/vitest.config.mts b/vitest.config.mts index 81e6770674..3ca64e0692 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -48,6 +48,7 @@ export default defineConfig({ '@/utils/errorResponse': resolve(__dirname, './src/utils/errorResponse'), '@/utils/unzipFile': resolve(__dirname, './src/utils/unzipFile'), '@/utils/server': resolve(__dirname, './src/utils/server'), + '@/utils/identifier': resolve(__dirname, './src/utils/identifier'), '@/utils/electron': resolve(__dirname, './src/utils/electron'), '@/utils/identifier': resolve(__dirname, './src/utils/identifier'), '@/utils': resolve(__dirname, './packages/utils/src'),