Compare commits

...

18 Commits

Author SHA1 Message Date
ONLY-yours 72b87757c6 fix: delete error boudle 2025-11-19 17:54:58 +08:00
ONLY-yours b6dc3eac19 fix: open debuger 2025-11-19 16:23:41 +08:00
ONLY-yours 14d4786e2d fix: add hydrate debug 2025-11-19 15:44:17 +08:00
ONLY-yours a7b5bc428e fix: slove hydrations error 2025-11-19 15:29:22 +08:00
ONLY-yours 3079385980 fix: when not login should isAppHydrated as true 2025-11-19 13:30:30 +08:00
ONLY-yours 3f74db2399 test: add hydrationGateLoader back 2025-11-19 13:16:14 +08:00
Arvin Xu 0a056f3f0b ♻️ refactor: refactor chat selectors (#10274)
refactor chat selectors to displayMessageSelectors
2025-11-19 13:00:03 +08:00
lobehubbot c5d71fe165 📝 docs(bot): Auto sync agents & plugin to readme 2025-11-19 04:02:23 +00:00
semantic-release-bot 741f588cae 🔖 chore(release): v2.0.0-next.86 [skip ci]
## [Version 2.0.0-next.86](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.85...v2.0.0-next.86)
<sup>Released on **2025-11-19**</sup>

####  Features

- **misc**: Support user abort in the agent runtime.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### What's improved

* **misc**: Support user abort in the agent runtime, closes [#10289](https://github.com/lobehub/lobe-chat/issues/10289) ([0925069](https://github.com/lobehub/lobe-chat/commit/0925069))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-11-19 04:01:07 +00:00
Arvin Xu 092506906a feat: support user abort in the agent runtime (#10289)
* use operation

* add integration tests

* refactor context to operation id

* refactor to support cancel ai streaming

* refactor to support to cancel tools calling

* add finish type

* 初步实现 agent runtime 的中断逻辑

* refactor agent runtime config

* debug cancel

* 完成 tool operation 调用重构

* add tests

* fix tests

* fix tests

* refactor state to isAgentRuntimeRunning

* fix loading state

* add more tests

*  test: add test for human_abort extractAbortInfo path

- Add test for unified abort check with human_abort phase
- Covers extractAbortInfo lines 140-145
- Improves GeneralChatAgent coverage to 100% statements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix

* auto clean up

* 🐛 fix: prevent showing success status when tool execution is cancelled

- Add abort check after tool execution completes
- Skip completion and success logging if operation was cancelled during execution
- Prevents race condition where success message shows before abort status
- Add test for tool execution cancelled during execution scenario

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix thread send

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-19 11:48:51 +08:00
LobeHub Bot e8c7d1c568 🌐 chore: translate non-English comments to English in networkProxy (#10293)
🌐 chore: translate non-English comments to English in networkProxy module

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-19 11:42:31 +08:00
lobehubbot 61bb8aeaf2 📝 docs(bot): Auto sync agents & plugin to readme 2025-11-19 03:13:14 +00:00
semantic-release-bot caaa331002 🔖 chore(release): v2.0.0-next.85 [skip ci]
## [Version&nbsp;2.0.0-next.85](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.84...v2.0.0-next.85)
<sup>Released on **2025-11-19**</sup>

#### 🐛 Bug Fixes

- **misc**: Slove discover pagination router.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### What's fixed

* **misc**: Slove discover pagination router, closes [#10294](https://github.com/lobehub/lobe-chat/issues/10294) ([fcda0b5](https://github.com/lobehub/lobe-chat/commit/fcda0b5))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-11-19 03:12:04 +00:00
Shinji-Li fcda0b50f1 🐛 fix: slove discover pagination router (#10294)
fix: slove discover pagination router
2025-11-19 10:58:31 +08:00
lobehubbot 53a2c30a75 📝 docs(bot): Auto sync agents & plugin to readme 2025-11-19 02:15:09 +00:00
semantic-release-bot 203fdc4b22 🔖 chore(release): v2.0.0-next.84 [skip ci]
## [Version&nbsp;2.0.0-next.84](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.83...v2.0.0-next.84)
<sup>Released on **2025-11-19**</sup>

#### 💄 Styles

- **misc**: Add Gemini 3.0 Pro Preview to Google Provider.

<br/>

<details>
<summary><kbd>Improvements and Fixes</kbd></summary>

#### Styles

* **misc**: Add Gemini 3.0 Pro Preview to Google Provider, closes [#10290](https://github.com/lobehub/lobe-chat/issues/10290) ([25c4358](https://github.com/lobehub/lobe-chat/commit/25c4358))

</details>

<div align="right">

[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)

</div>
2025-11-19 02:14:01 +00:00
泠音 25c43587de 💄 style: add Gemini 3.0 Pro Preview to Google Provider (#10290)
* 💄 style: add Gemini 3.0 Pro Preview Thinking to Google Provider

* Update google.ts

* fix model id
2025-11-19 09:59:36 +08:00
lobehubbot 2cd2ca9a23 📝 docs(bot): Auto sync agents & plugin to readme 2025-11-19 01:36:26 +00:00
107 changed files with 11915 additions and 1804 deletions
+75
View File
@@ -2,6 +2,81 @@
# Changelog
## [Version 2.0.0-next.86](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.85...v2.0.0-next.86)
<sup>Released on **2025-11-19**</sup>
#### ✨ Features
- **misc**: Support user abort in the agent runtime.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **misc**: Support user abort in the agent runtime, closes [#10289](https://github.com/lobehub/lobe-chat/issues/10289) ([0925069](https://github.com/lobehub/lobe-chat/commit/0925069))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.0.0-next.85](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.84...v2.0.0-next.85)
<sup>Released on **2025-11-19**</sup>
#### 🐛 Bug Fixes
- **misc**: Slove discover pagination router.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's fixed
- **misc**: Slove discover pagination router, closes [#10294](https://github.com/lobehub/lobe-chat/issues/10294) ([fcda0b5](https://github.com/lobehub/lobe-chat/commit/fcda0b5))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.0.0-next.84](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.83...v2.0.0-next.84)
<sup>Released on **2025-11-19**</sup>
#### 💄 Styles
- **misc**: Add Gemini 3.0 Pro Preview to Google Provider.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **misc**: Add Gemini 3.0 Pro Preview to Google Provider, closes [#10290](https://github.com/lobehub/lobe-chat/issues/10290) ([25c4358](https://github.com/lobehub/lobe-chat/commit/25c4358))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.0.0-next.83](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.82...v2.0.0-next.83)
<sup>Released on **2025-11-19**</sup>
@@ -10,14 +10,14 @@ import { ProxyUrlBuilder } from './urlBuilder';
const logger = createLogger('modules:networkProxy:dispatcher');
/**
* 代理管理器
* Proxy dispatcher manager
*/
export class ProxyDispatcherManager {
private static isChanging = false;
private static changeQueue: Array<() => Promise<void>> = [];
/**
* 应用代理设置(带并发控制)
* Apply proxy settings (with concurrency control)
*/
static async applyProxySettings(config: NetworkProxySettings): Promise<void> {
return new Promise((resolve, reject) => {
@@ -31,17 +31,17 @@ export class ProxyDispatcherManager {
};
if (this.isChanging) {
// 如果正在切换,加入队列
// If currently switching, add to queue
this.changeQueue.push(operation);
} else {
// 立即执行
// Execute immediately
operation();
}
});
}
/**
* 执行代理设置应用
* Execute proxy settings application
*/
private static async doApplyProxySettings(config: NetworkProxySettings): Promise<void> {
this.isChanging = true;
@@ -49,22 +49,22 @@ export class ProxyDispatcherManager {
try {
const currentDispatcher = getGlobalDispatcher();
// 禁用代理,恢复默认连接
// Disable proxy, restore default connection
if (!config.enableProxy) {
await this.safeDestroyDispatcher(currentDispatcher);
// 创建一个新的默认 Agent 来替代代理
// Create a new default Agent to replace the proxy
setGlobalDispatcher(new Agent());
logger.debug('Proxy disabled, reset to direct connection mode');
return;
}
// 构建代理 URL
// Build proxy URL
const proxyUrl = ProxyUrlBuilder.build(config);
// 创建代理 agent
// Create proxy agent
const agent = this.createProxyAgent(config.proxyType, proxyUrl);
// 切换代理前销毁旧 dispatcher
// Destroy old dispatcher before switching proxy
await this.safeDestroyDispatcher(currentDispatcher);
setGlobalDispatcher(agent);
@@ -77,7 +77,7 @@ export class ProxyDispatcherManager {
} finally {
this.isChanging = false;
// 处理队列中的下一个操作
// Process next operation in queue
if (this.changeQueue.length > 0) {
const nextOperation = this.changeQueue.shift();
if (nextOperation) {
@@ -88,12 +88,12 @@ export class ProxyDispatcherManager {
}
/**
* 创建代理 agent
* Create proxy agent
*/
static createProxyAgent(proxyType: string, proxyUrl: string) {
try {
if (proxyType === 'socks5') {
// 解析 SOCKS5 代理 URL
// Parse SOCKS5 proxy URL
const url = new URL(proxyUrl);
const socksProxies: SocksProxies = [
{
@@ -109,10 +109,10 @@ export class ProxyDispatcherManager {
},
];
// 使用 fetch-socks 处理 SOCKS5 代理
// Use fetch-socks to handle SOCKS5 proxy
return socksDispatcher(socksProxies);
} else {
// undici ProxyAgent 支持 http, https
// undici's ProxyAgent supports http, https
return new ProxyAgent({ uri: proxyUrl });
}
} catch (error) {
@@ -124,7 +124,7 @@ export class ProxyDispatcherManager {
}
/**
* 安全销毁 dispatcher
* Safely destroy dispatcher
*/
private static async safeDestroyDispatcher(dispatcher: any): Promise<void> {
try {
@@ -11,7 +11,7 @@ import { ProxyConfigValidator } from './validator';
const logger = createLogger('modules:networkProxy:tester');
/**
* 代理连接测试结果
* Proxy connection test result
*/
export interface ProxyTestResult {
message?: string;
@@ -20,14 +20,14 @@ export interface ProxyTestResult {
}
/**
* 代理连接测试器
* Proxy connection tester
*/
export class ProxyConnectionTester {
private static readonly DEFAULT_TIMEOUT = 10_000; // 10秒超时
private static readonly DEFAULT_TIMEOUT = 10_000; // 10 seconds timeout
private static readonly DEFAULT_TEST_URL = 'https://www.google.com';
/**
* 测试代理连接
* Test proxy connection
*/
static async testConnection(
url: string = this.DEFAULT_TEST_URL,
@@ -77,13 +77,13 @@ export class ProxyConnectionTester {
}
/**
* 测试指定代理配置的连接
* Test connection with specified proxy configuration
*/
static async testProxyConfig(
config: NetworkProxySettings,
testUrl: string = this.DEFAULT_TEST_URL,
): Promise<ProxyTestResult> {
// 验证配置
// Validate configuration
const validation = ProxyConfigValidator.validate(config);
if (!validation.isValid) {
return {
@@ -92,12 +92,12 @@ export class ProxyConnectionTester {
};
}
// 如果未启用代理,直接测试
// If proxy is not enabled, test directly
if (!config.enableProxy) {
return this.testConnection(testUrl);
}
// 创建临时代理 agent 进行测试
// Create temporary proxy agent for testing
try {
const proxyUrl = ProxyUrlBuilder.build(config);
logger.debug(`Testing proxy with URL: ${proxyUrl}`);
@@ -108,7 +108,7 @@ export class ProxyConnectionTester {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.DEFAULT_TIMEOUT);
// 临时设置代理进行测试
// Temporarily set proxy for testing
const originalDispatcher = getGlobalDispatcher();
setGlobalDispatcher(agent);
@@ -138,9 +138,9 @@ export class ProxyConnectionTester {
clearTimeout(timeoutId);
throw fetchError;
} finally {
// 恢复原来的 dispatcher
// Restore original dispatcher
setGlobalDispatcher(originalDispatcher);
// 清理临时创建的代理 agent
// Clean up temporary proxy agent
if (agent && typeof agent.destroy === 'function') {
try {
await agent.destroy();
@@ -1,11 +1,11 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
/**
* 代理 URL 构建器
* Proxy URL builder
*/
export const ProxyUrlBuilder = {
/**
* 构建代理 URL
* Build proxy URL
*/
build(config: NetworkProxySettings): string {
const { proxyType, proxyServer, proxyPort, proxyRequireAuth, proxyUsername, proxyPassword } =
@@ -13,7 +13,7 @@ export const ProxyUrlBuilder = {
let proxyUrl = `${proxyType}://${proxyServer}:${proxyPort}`;
// 添加认证信息
// Add authentication information
if (proxyRequireAuth && proxyUsername && proxyPassword) {
const encodedUsername = encodeURIComponent(proxyUsername);
const encodedPassword = encodeURIComponent(proxyPassword);
@@ -1,7 +1,7 @@
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
/**
* 代理配置验证结果
* Proxy configuration validation result
*/
export interface ProxyValidationResult {
errors: string[];
@@ -9,38 +9,38 @@ export interface ProxyValidationResult {
}
/**
* 代理配置验证器
* Proxy configuration validator
*/
export class ProxyConfigValidator {
private static readonly SUPPORTED_TYPES = ['http', 'https', 'socks5'] as const;
private static readonly DEFAULT_BYPASS = 'localhost,127.0.0.1,::1';
/**
* 验证代理配置
* Validate proxy configuration
*/
static validate(config: NetworkProxySettings): ProxyValidationResult {
const errors: string[] = [];
// 如果未启用代理,跳过验证
// If proxy is not enabled, skip validation
if (!config.enableProxy) {
return { errors: [], isValid: true };
}
// 验证代理类型
// Validate proxy type
if (!this.SUPPORTED_TYPES.includes(config.proxyType as any)) {
errors.push(
`Unsupported proxy type: ${config.proxyType}. Supported types: ${this.SUPPORTED_TYPES.join(', ')}`,
);
}
// 验证代理服务器
// Validate proxy server
if (!config.proxyServer?.trim()) {
errors.push('Proxy server is required when proxy is enabled');
} else if (!this.isValidHost(config.proxyServer)) {
errors.push('Invalid proxy server format');
}
// 验证代理端口
// Validate proxy port
if (!config.proxyPort?.trim()) {
errors.push('Proxy port is required when proxy is enabled');
} else {
@@ -50,7 +50,7 @@ export class ProxyConfigValidator {
}
}
// 验证认证信息
// Validate authentication information
if (config.proxyRequireAuth) {
if (!config.proxyUsername?.trim()) {
errors.push('Proxy username is required when authentication is enabled');
@@ -67,10 +67,10 @@ export class ProxyConfigValidator {
}
/**
* 验证主机名格式
* Validate host format
*/
private static isValidHost(host: string): boolean {
// 简单的主机名验证(IP 地址或域名)
// Simple host validation (IP address or domain name)
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
const domainRegex =
/^[\dA-Za-z]([\dA-Za-z-]*[\dA-Za-z])?(\.[\dA-Za-z]([\dA-Za-z-]*[\dA-Za-z])?)*$/;
+29
View File
@@ -1,4 +1,33 @@
[
{
"children": {
"features": ["Support user abort in the agent runtime."]
},
"date": "2025-11-19",
"version": "2.0.0-next.86"
},
{
"children": {
"fixes": ["Slove discover pagination router."]
},
"date": "2025-11-19",
"version": "2.0.0-next.85"
},
{
"children": {
"improvements": ["Add Gemini 3.0 Pro Preview to Google Provider."]
},
"date": "2025-11-19",
"version": "2.0.0-next.84"
},
{
"children": {
"features": ["New API support switch Responses API mode."],
"improvements": ["Update i18n."]
},
"date": "2025-11-19",
"version": "2.0.0-next.83"
},
{
"children": {
"fixes": ["Fix noisy error notification."]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/lobehub",
"version": "2.0.0-next.83",
"version": "2.0.0-next.86",
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
"keywords": [
"framework",
+36 -1
View File
@@ -24,11 +24,16 @@ import {
*/
export class AgentRuntime {
private executors: Record<AgentInstruction['type'], InstructionExecutor>;
private operationId?: string;
private getOperation?: RuntimeConfig['getOperation'];
constructor(
private agent: Agent,
private config: RuntimeConfig = {},
) {
this.operationId = config.operationId;
this.getOperation = config.getOperation;
// Build executors with priority: agent.executors > config.executors > built-in
this.executors = {
call_llm: this.createCallLLMExecutor(),
@@ -44,6 +49,28 @@ export class AgentRuntime {
};
}
/**
* Get operation context (sessionId, topicId, etc.)
* Returns the business context captured by the operation
*/
getContext() {
if (!this.operationId || !this.getOperation) {
return undefined;
}
return this.getOperation(this.operationId).context;
}
/**
* Get operation abort controller
* Returns the AbortController for cancellation
*/
getAbortController(): AbortController | undefined {
if (!this.operationId || !this.getOperation) {
return undefined;
}
return this.getOperation(this.operationId).abortController;
}
/**
* Executes a single step of the Plan -> Execute loop.
* @param state - Current agent state
@@ -194,6 +221,7 @@ export class AgentRuntime {
approvedToolCall: ChatToolPayload,
): Promise<{ events: AgentEvent[]; newState: AgentState; nextContext?: AgentRuntimeContext }> {
const context: AgentRuntimeContext = {
operationId: this.operationId,
payload: { approvedToolCall },
phase: 'human_approved_tool',
session: this.createSessionContext(state),
@@ -289,10 +317,11 @@ export class AgentRuntime {
}
// Otherwise, just return the resumed state
const initialContext = this.createInitialContext(newState);
return {
events: [resumeEvent],
newState,
nextContext: this.createInitialContext(newState),
nextContext: initialContext,
};
}
@@ -439,6 +468,7 @@ export class AgentRuntime {
// Provide next context based on LLM result
const nextContext: AgentRuntimeContext = {
operationId: this.operationId,
payload: {
hasToolCalls: toolCalls.length > 0,
result: { content: assistantContent, tool_calls: toolCalls },
@@ -511,6 +541,7 @@ export class AgentRuntime {
// Provide next context for tool result
const nextContext: AgentRuntimeContext = {
operationId: this.operationId,
payload: {
result,
toolCall,
@@ -726,6 +757,7 @@ export class AgentRuntime {
events: allEvents,
newState,
nextContext: {
operationId: this.operationId,
payload: {
parentMessageId: lastParentMessageId,
toolCount: results.length,
@@ -792,6 +824,7 @@ export class AgentRuntime {
events: [warningEvent],
newState,
nextContext: {
operationId: this.operationId,
payload: { error: warningEvent.error, isCostWarning: true },
phase: 'error' as const,
session: this.createSessionContext(newState),
@@ -821,6 +854,7 @@ export class AgentRuntime {
if (lastMessage?.role === 'user') {
return {
operationId: this.operationId,
payload: {
isFirstMessage: state.messages.length === 1,
message: lastMessage,
@@ -831,6 +865,7 @@ export class AgentRuntime {
}
return {
operationId: this.operationId,
payload: undefined,
phase: 'init',
session: this.createSessionContext(state),
@@ -61,6 +61,7 @@ export interface AgentEventHumanSelectRequired {
export type FinishReason =
| 'completed' // Normal completion
| 'user_requested' // User requested to end
| 'user_aborted' // User abort
| 'max_steps_exceeded' // Reached maximum steps limit
| 'cost_limit_exceeded' // Reached cost limit
| 'timeout' // Execution timeout
@@ -42,6 +42,22 @@ export interface GeneralAgentCallToolsBatchResultPayload {
toolResults: GeneralAgentCallToolResultPayload[];
}
export interface GeneralAgentHumanAbortPayload {
/** Whether there are pending tool calls */
hasToolsCalling?: boolean;
/** Parent message ID (assistant message) */
parentMessageId: string;
/** Reason for the abort */
reason: string;
/** LLM result including content and tool_calls */
result?: {
content: string;
tool_calls?: any[];
};
/** Pending tool calls that need to be cancelled */
toolsCalling?: ChatToolPayload[];
}
export interface GeneralAgentConfig {
agentConfig?: {
[key: string]: any;
@@ -24,6 +24,7 @@ export interface AgentRuntimeContext {
| 'tools_batch_result'
| 'human_response'
| 'human_approved_tool'
| 'human_abort'
| 'error';
/** Session info (kept for backward compatibility, will be optional in the future) */
@@ -104,6 +105,22 @@ export interface CallingToolPayload {
type: 'mcp' | 'default' | 'markdown' | 'standalone';
}
export interface HumanAbortPayload {
/** Whether there are pending tool calls */
hasToolsCalling?: boolean;
/** Parent message ID (assistant message) */
parentMessageId: string;
/** Reason for the abort */
reason: string;
/** LLM result including content and tool_calls */
result?: {
content: string;
tool_calls?: any[];
};
/** Pending tool calls that need to be cancelled */
toolsCalling?: ChatToolPayload[];
}
export interface AgentInstructionCallLlm {
payload: any;
type: 'call_llm';
@@ -154,6 +171,18 @@ export interface AgentInstructionFinish {
type: 'finish';
}
export interface AgentInstructionResolveAbortedTools {
payload: {
/** Parent message ID (assistant message) */
parentMessageId: string;
/** Reason for the abort */
reason?: string;
/** Tool calls that need to be resolved/cancelled */
toolsCalling: ChatToolPayload[];
};
type: 'resolve_aborted_tools';
}
/**
* A serializable instruction object that the "Agent" (Brain) returns
* to the "AgentRuntime" (Engine) to execute.
@@ -165,4 +194,5 @@ export type AgentInstruction =
| AgentInstructionRequestHumanPrompt
| AgentInstructionRequestHumanSelect
| AgentInstructionRequestHumanApprove
| AgentInstructionResolveAbortedTools
| AgentInstructionFinish;
@@ -15,4 +15,11 @@ export type InstructionExecutor = (
export interface RuntimeConfig {
/** Custom executors for specific instruction types */
executors?: Partial<Record<AgentInstruction['type'], InstructionExecutor>>;
/** Function to get operation context and abort controller */
getOperation?: (operationId: string) => {
abortController: AbortController;
context: Record<string, any>;
};
/** Operation ID for tracking this runtime instance */
operationId?: string;
}
@@ -113,6 +113,60 @@ const googleChatModels: AIChatModelCard[] = [
},
type: 'chat',
},
{
abilities: {
functionCall: true,
reasoning: true,
search: true,
video: true,
vision: true,
},
contextWindowTokens: 1_048_576 + 65_536,
description:
'Gemini 3.0 Pro Preview 是 Google 最先进的思维模型,能够对代码、数学和STEM领域的复杂问题进行推理,以及使用长上下文分析大型数据集、代码库和文档。',
displayName: 'Gemini 3.0 Pro Preview',
enabled: true,
id: 'gemini-3-pro-preview',
maxOutput: 65_536,
pricing: {
units: [
{
name: 'textInput_cacheRead',
strategy: 'tiered',
tiers: [
{ rate: 0.20, upTo: 200_000 },
{ rate: 0.40, upTo: 'infinity' },
],
unit: 'millionTokens',
},
{
name: 'textInput',
strategy: 'tiered',
tiers: [
{ rate: 2.0, upTo: 200_000 },
{ rate: 4.0, upTo: 'infinity' },
],
unit: 'millionTokens',
},
{
name: 'textOutput',
strategy: 'tiered',
tiers: [
{ rate: 12.0, upTo: 200_000 },
{ rate: 18.0, upTo: 'infinity' },
],
unit: 'millionTokens',
},
],
},
releasedAt: '2025-11-18',
settings: {
extendParams: ['thinkingBudget', 'urlContext'],
searchImpl: 'params',
searchProvider: 'google',
},
type: 'chat',
},
{
abilities: {
functionCall: true,
@@ -108,10 +108,13 @@ export interface ModelPerformance {
export interface MessageMetadata extends ModelUsage, ModelPerformance {
activeBranchIndex?: number;
activeColumn?: boolean;
finishType?: string;
/**
* Message collapse state
* true: collapsed, false/undefined: expanded
*/
collapsed?: boolean;
compare?: boolean;
usage?: ModelUsage;
performance?: ModelPerformance;
}
+2 -2
View File
@@ -7,12 +7,12 @@ import { LobeToolRenderType } from '../../tool';
// ToolIntervention must be defined first to avoid circular dependency
export interface ToolIntervention {
rejectedReason?: string;
status?: 'pending' | 'approved' | 'rejected' | 'none';
status?: 'pending' | 'approved' | 'rejected' | 'aborted' | 'none';
}
export const ToolInterventionSchema = z.object({
rejectedReason: z.string().optional(),
status: z.enum(['pending', 'approved', 'rejected', 'none']).optional(),
status: z.enum(['pending', 'approved', 'rejected', 'aborted', 'none']).optional(),
});
export interface ChatPluginPayload {
+8 -2
View File
@@ -48,6 +48,12 @@ export interface UniformSearchResponse {
}
export interface SearchServiceImpl {
crawlPages(params: CrawlMultiPagesQuery): Promise<{ results: CrawlUniformResult[] }>;
webSearch(params: SearchQuery): Promise<UniformSearchResponse>;
crawlPages(
params: CrawlMultiPagesQuery,
options?: { signal?: AbortSignal },
): Promise<{ results: CrawlUniformResult[] }>;
webSearch(
params: SearchQuery,
options?: { signal?: AbortSignal },
): Promise<UniformSearchResponse>;
}
@@ -9,7 +9,7 @@ import { Flexbox } from 'react-layout-kit';
import { ActionKeys } from '@/features/ChatInput/ActionBar/config';
import { useInitAgentConfig } from '@/hooks/useInitAgentConfig';
import { useChatStore } from '@/store/chat';
import { messageStateSelectors } from '@/store/chat/selectors';
import { operationSelectors } from '@/store/chat/selectors';
import ActionBar from './ActionBar';
import Files from './Files';
@@ -37,7 +37,7 @@ const MobileChatInput = memo(() => {
const { isLoading } = useInitAgentConfig();
const [loading, value, onInput, onStop] = useChatStore((s) => [
messageStateSelectors.isAIGenerating(s),
operationSelectors.isAgentRuntimeRunning(s),
s.inputMessage,
s.updateMessageInput,
s.stopGenerateMessage,
@@ -6,7 +6,12 @@ import { useGeminiChineseWarning } from '@/hooks/useGeminiChineseWarning';
import { getAgentStoreState } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { chatSelectors, messageStateSelectors, topicSelectors } from '@/store/chat/selectors';
import {
chatSelectors,
messageStateSelectors,
operationSelectors,
topicSelectors,
} from '@/store/chat/selectors';
import { fileChatSelectors, useFileStore } from '@/store/file';
import { getUserStoreState } from '@/store/user';
@@ -34,7 +39,7 @@ export const useSendMessage = () => {
const send = useCallback(async (params: UseSendMessageParams = {}) => {
const store = useChatStore.getState();
if (messageStateSelectors.isAIGenerating(store)) return;
if (operationSelectors.isAgentRuntimeRunning(store)) return;
// if uploading file or send button is disabled by message, then we should not send the message
const isUploadingFiles = fileChatSelectors.isUploadingFiles(useFileStore.getState());
@@ -7,8 +7,9 @@ import { agentSelectors } from '@/store/agent/selectors';
import { getChatStoreState, useChatStore } from '@/store/chat';
import {
aiChatSelectors,
chatSelectors,
displayMessageSelectors,
messageStateSelectors,
operationSelectors,
topicSelectors,
} from '@/store/chat/selectors';
import { fileChatSelectors, useFileStore } from '@/store/file';
@@ -34,7 +35,7 @@ export const useSend = () => {
addAIMessage,
stopGenerateMessage,
cancelSendMessageInServer,
generating,
isAgentRuntimeRunning,
isSendButtonDisabledByMessage,
isSendingMessage,
] = useChatStore((s) => [
@@ -43,7 +44,7 @@ export const useSend = () => {
s.addAIMessage,
s.stopGenerateMessage,
s.cancelSendMessageInServer,
messageStateSelectors.isAIGenerating(s),
operationSelectors.isMainWindowAgentRuntimeRunning(s),
messageStateSelectors.isSendButtonDisabledByMessage(s),
aiChatSelectors.isCurrentSendMessageLoading(s),
]);
@@ -73,7 +74,7 @@ export const useSend = () => {
return;
}
if (messageStateSelectors.isAIGenerating(store)) return;
if (operationSelectors.isMainWindowAgentRuntimeRunning(store)) return;
const inputMessage = store.inputMessage;
// 发送时再取一次最新的文件列表,防止闭包拿到旧值
@@ -119,7 +120,7 @@ export const useSend = () => {
chat_id: store.activeId || 'unknown',
current_topic: topicSelectors.currentActiveTopic(store)?.title || null,
has_attachments: fileList.length > 0,
history_message_count: chatSelectors.activeBaseChats(store).length,
history_message_count: displayMessageSelectors.activeDisplayMessages(store).length,
message: inputMessage,
message_length: inputMessage.length,
message_type: messageType,
@@ -132,9 +133,9 @@ export const useSend = () => {
const stop = () => {
const store = getChatStoreState();
const generating = messageStateSelectors.isAIGenerating(store);
const isRunning = operationSelectors.isMainWindowAgentRuntimeRunning(store);
if (generating) {
if (isRunning) {
stopGenerateMessage();
return;
}
@@ -149,11 +150,11 @@ export const useSend = () => {
return useMemo(
() => ({
disabled: canNotSend,
generating: generating || isSendingMessage,
generating: isAgentRuntimeRunning || isSendingMessage,
send: handleSend,
stop,
}),
[canNotSend, generating, isSendingMessage, stop, handleSend],
[canNotSend, isAgentRuntimeRunning, isSendingMessage, stop, handleSend],
);
};
@@ -175,7 +176,7 @@ export const useSendGroupMessage = () => {
]);
const isSupervisorThinking = useChatStore((s) =>
chatSelectors.isSupervisorLoading(s.activeId)(s),
displayMessageSelectors.isSupervisorLoading(s.activeId)(s),
);
const { analytics } = useAnalytics();
const checkGeminiChineseWarning = useGeminiChineseWarning();
@@ -209,7 +210,7 @@ export const useSendGroupMessage = () => {
}
if (
chatSelectors.isSupervisorLoading(store.activeId)(store) ||
displayMessageSelectors.isSupervisorLoading(store.activeId)(store) ||
messageStateSelectors.isCreatingMessage(store)
)
return;
@@ -270,7 +271,7 @@ export const useSendGroupMessage = () => {
chat_id: store.activeId || 'unknown',
current_topic: topicSelectors.currentActiveTopic(store)?.title || null,
has_attachments: fileList.length > 0,
history_message_count: chatSelectors.activeBaseChats(store).length,
history_message_count: displayMessageSelectors.activeDisplayMessages(store).length,
message: inputMessage,
message_length: inputMessage.length,
message_type: messageType,
@@ -292,10 +293,10 @@ export const useSendGroupMessage = () => {
const stop = useCallback(() => {
const store = getChatStoreState();
const isAgentGenerating = messageStateSelectors.isAIGenerating(store);
const isAgentRunning = operationSelectors.isMainWindowAgentRuntimeRunning(store);
const isCreating = messageStateSelectors.isCreatingMessage(store);
if (isAgentGenerating) {
if (isAgentRunning) {
stopGenerateMessage();
return;
}
@@ -7,7 +7,7 @@ import { DEFAULT_AVATAR } from '@/const/meta';
import { INBOX_SESSION_ID } from '@/const/session';
import { isDesktop } from '@/const/version';
import { useChatStore } from '@/store/chat';
import { messageStateSelectors } from '@/store/chat/selectors';
import { operationSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
import { useSessionStore } from '@/store/session';
import { sessionHelpers } from '@/store/session/helpers';
@@ -32,7 +32,7 @@ const SessionItem = memo<SessionItemProps>(({ id }) => {
const [active] = useSessionStore((s) => [s.activeId === id]);
const [loading] = useChatStore((s) => [
messageStateSelectors.isAIGenerating(s) && id === s.activeId,
operationSelectors.isAgentRuntimeRunning(s) && id === s.activeId,
]);
const [pin, title, avatar, avatarBackground, updateAt, members, model, group, sessionType] =
@@ -41,7 +41,7 @@ const Pagination = memo<PaginationProps>(({ tab, currentPage, total, pageSize })
const handlePageChange = (newPage: number) => {
const searchParams = new URLSearchParams(location.search);
searchParams.set('page', String(newPage));
navigate(`/${tab}?${searchParams.toString()}`);
navigate(`/discover/${tab}?${searchParams.toString()}`);
const scrollableElement = document?.querySelector(`#${SCROLL_PARENT_ID}`);
if (!scrollableElement) return;
@@ -0,0 +1,48 @@
'use client';
import { memo, useEffect, useState } from 'react';
import { RouterProvider } from 'react-router-dom';
import Loading from '@/components/Loading/BrandTextLoading';
import type { Locales } from '@/types/locale';
import { createDesktopRouter } from './desktopRouter.config';
interface ClientRouterProps {
locale: Locales;
}
type RouterInstance = ReturnType<typeof createDesktopRouter>;
const DesktopClientRouter = memo<ClientRouterProps>(({ locale }) => {
// Use state to hold router instance, initially null
const [router, setRouter] = useState<RouterInstance | null>(null);
// useEffect ensures this only runs on the client
useEffect(() => {
// Create router instance only after component mounts in browser
const desktopRouter = createDesktopRouter(locale);
setRouter(desktopRouter);
// Cleanup is not necessary as router should persist until unmount
return () => {
// Cleanup if needed
};
}, [locale]); // Recreate router if locale changes
// If router hasn't been created yet (during SSR or first client render),
// show a loading placeholder. This ensures server output matches client output,
// avoiding hydration mismatch.
if (!router) {
return <Loading />;
}
// Once router is created, render RouterProvider
return (
<RouterProvider router={router} />
);
});
DesktopClientRouter.displayName = 'DesktopClientRouter';
export default DesktopClientRouter;
-40
View File
@@ -1,40 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
import { memo, useMemo } from 'react';
import { RouterProvider } from 'react-router-dom';
import BootErrorBoundary from '@/components/BootErrorBoundary';
import Loading from '@/components/Loading/BrandTextLoading';
import type { Locales } from '@/types/locale';
import { createDesktopRouter } from './desktopRouter.config';
interface ClientRouterProps {
locale: Locales;
}
const ClientRouter = memo<ClientRouterProps>(({ locale }) => {
const router = useMemo(() => createDesktopRouter(locale), [locale]);
return (
<BootErrorBoundary fallback={<Loading />}>
<RouterProvider router={router} />
</BootErrorBoundary>
);
});
ClientRouter.displayName = 'ClientRouter';
const DesktopRouterClient = dynamic(() => Promise.resolve(ClientRouter), {
loading: () => <Loading />,
ssr: false,
});
interface DesktopRouterProps {
locale: Locales;
}
const DesktopRouter = ({ locale }: DesktopRouterProps) => {
return <DesktopRouterClient locale={locale} />;
};
export default DesktopRouter;
+48
View File
@@ -0,0 +1,48 @@
'use client';
import { memo, useEffect, useState } from 'react';
import { RouterProvider } from 'react-router-dom';
import Loading from '@/components/Loading/BrandTextLoading';
import type { Locales } from '@/types/locale';
import { createMobileRouter } from './mobileRouter.config';
interface ClientRouterProps {
locale: Locales;
}
type RouterInstance = ReturnType<typeof createMobileRouter>;
const MobileClientRouter = memo<ClientRouterProps>(({ locale }) => {
// Use state to hold router instance, initially null
const [router, setRouter] = useState<RouterInstance | null>(null);
// useEffect ensures this only runs on the client
useEffect(() => {
// Create router instance only after component mounts in browser
const mobileRouter = createMobileRouter(locale);
setRouter(mobileRouter);
// Cleanup is not necessary as router should persist until unmount
return () => {
// Cleanup if needed
};
}, [locale]); // Recreate router if locale changes
// If router hasn't been created yet (during SSR or first client render),
// show a loading placeholder. This ensures server output matches client output,
// avoiding hydration mismatch.
if (!router) {
return <Loading />;
}
// Once router is created, render RouterProvider
return (
<RouterProvider router={router} />
);
});
MobileClientRouter.displayName = 'MobileClientRouter';
export default MobileClientRouter;
-41
View File
@@ -1,41 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
import { memo, useMemo } from 'react';
import { RouterProvider } from 'react-router-dom';
import BootErrorBoundary from '@/components/BootErrorBoundary';
import Loading from '@/components/Loading/BrandTextLoading';
import type { Locales } from '@/types/locale';
import { createMobileRouter } from './mobileRouter.config';
interface ClientRouterProps {
locale: Locales;
}
const ClientRouter = memo<ClientRouterProps>(({ locale }) => {
const router = useMemo(() => createMobileRouter(locale), [locale]);
return (
<BootErrorBoundary fallback={<Loading />}>
<RouterProvider router={router} />
</BootErrorBoundary>
);
});
ClientRouter.displayName = 'ClientRouter';
const MobileRouterClient = dynamic(() => Promise.resolve(ClientRouter), {
loading: () => <Loading />,
ssr: false,
});
interface MobileRouterProps {
locale: Locales;
}
const MobileRouter = ({ locale }: MobileRouterProps) => {
return <MobileRouterClient locale={locale} />;
};
export default MobileRouter;
+2 -2
View File
@@ -3,12 +3,12 @@
import { useEffect } from 'react';
import { type LoaderFunction, createBrowserRouter, redirect, useNavigate } from 'react-router-dom';
import Loading from '@/components/Loading/BrandTextLoading';
import { useGlobalStore } from '@/store/global';
import type { Locales } from '@/types/locale';
import DesktopMainLayout from './(main)/layouts/desktop';
import { idLoader, slugLoader } from './loaders/routeParams';
import Loading from '@/components/Loading/BrandTextLoading';
// Component to register navigate function in global store
const NavigatorRegistrar = () => {
@@ -38,7 +38,7 @@ const RootLayout = (props: { locale: Locales }) => {
// Hydration gate loader -always return true to bypass hydration gate
const hydrationGateLoader: LoaderFunction = () => {
return true
return null;
};
// Create desktop router configuration
+1 -1
View File
@@ -36,7 +36,7 @@ const RootLayout = (props: { locale: Locales }) => (
// Hydration gate loader -always return true to bypass hydration gate
const hydrationGateLoader: LoaderFunction = () => {
return true
return null;
};
// Create mobile router configuration
+8 -7
View File
@@ -1,20 +1,21 @@
import { DynamicLayoutProps } from '@/types/next';
import { RouteVariants } from '@/utils/server/routeVariants';
import DesktopRouter from './DesktopRouter';
import MobileRouter from './MobileRouter';
import DesktopClientRouter from './DesktopClientRouter';
import MobileClientRouter from './MobileClientRouter';
export default async (props: DynamicLayoutProps) => {
// Get isMobile from variants parameter on server side
const isMobile = await RouteVariants.getIsMobile(props);
const { locale } = await RouteVariants.getVariantsFromProps(props);
// Conditionally load and render based on device type
// Using native dynamic import ensures complete code splitting
// Mobile and Desktop bundles will be completely separate
// Pass device type and locale to client-side RouterSelector
// This ensures the server component only does data fetching,
// and the actual router rendering happens entirely on the client
if (isMobile) {
return <MobileRouter locale={locale} />;
return <MobileClientRouter locale={locale} />;
}
return <DesktopRouter locale={locale} />;
return <DesktopClientRouter locale={locale} />;
};
@@ -4,7 +4,7 @@ import { useAnalytics } from '@lobehub/analytics/react';
import { memo, useCallback, useEffect } from 'react';
import { getChatStoreState } from '@/store/chat';
import { chatSelectors } from '@/store/chat/slices/message/selectors';
import { displayMessageSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { getSessionStoreState } from '@/store/session';
@@ -18,7 +18,7 @@ const MainInterfaceTracker = memo(() => {
const activeSessionId = currentSession?.id;
const defaultSessions = sessionSelectors.defaultSessions(getSessionStoreState());
const showChatSideBar = systemStatusSelectors.showChatSideBar(useGlobalStore.getState());
const messages = chatSelectors.activeBaseChats(getChatStoreState());
const messages = displayMessageSelectors.activeDisplayMessages(getChatStoreState());
return {
active_assistant: activeSessionId === 'inbox' ? null : currentSession?.meta?.title || null,
has_chat_history: messages.length > 0,
@@ -9,7 +9,7 @@ import { SWRConfiguration } from 'swr';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/slices/chat';
import { useChatStore } from '@/store/chat';
import { messageStateSelectors } from '@/store/chat/selectors';
import { operationSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
import { globalGeneralSelectors } from '@/store/global/selectors';
import { useUserStore } from '@/store/user';
@@ -43,7 +43,7 @@ const BrowserSTT = memo<{ mobile?: boolean }>(({ mobile }) => {
const { t } = useTranslation('chat');
const [loading, updateMessageInput] = useChatStore((s) => [
messageStateSelectors.isAIGenerating(s),
operationSelectors.isAgentRuntimeRunning(s),
s.updateMessageInput,
]);
@@ -12,7 +12,7 @@ import { API_ENDPOINTS } from '@/services/_url';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { messageStateSelectors } from '@/store/chat/slices/message/selectors';
import { operationSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
import { globalGeneralSelectors } from '@/store/global/selectors';
import { useUserStore } from '@/store/user';
@@ -54,7 +54,7 @@ const OpenaiSTT = memo<{ mobile?: boolean }>(({ mobile }) => {
const { t } = useTranslation('chat');
const [loading, updateMessageInput] = useChatStore((s) => [
messageStateSelectors.isAIGenerating(s),
operationSelectors.isAgentRuntimeRunning(s),
s.updateMessageInput,
]);
@@ -1,9 +1,9 @@
import { ARTIFACT_THINKING_TAG } from '@lobechat/const';
import { memo } from 'react';
import Thinking from '@/components/Thinking';
import { ARTIFACT_THINKING_TAG } from '@/const/plugin';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { dbMessageSelectors } from '@/store/chat/selectors';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
@@ -12,7 +12,7 @@ import { isTagClosed } from '../utils';
const Render = memo<MarkdownElementProps>(({ children, id }) => {
const [isGenerating] = useChatStore((s) => {
const message = chatSelectors.getMessageById(id)(s);
const message = dbMessageSelectors.getDbMessageById(id)(s);
return [!isTagClosed(ARTIFACT_THINKING_TAG, message?.content)];
});
const transitionMode = useUserStore(userGeneralSettingsSelectors.transitionMode);
@@ -2,7 +2,7 @@ import { memo } from 'react';
import Thinking from '@/components/Thinking';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { dbMessageSelectors } from '@/store/chat/selectors';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
@@ -17,11 +17,11 @@ const isThinkingClosed = (input: string = '') => {
const Render = memo<MarkdownElementProps>(({ children, id }) => {
const [isGenerating] = useChatStore((s) => {
const message = chatSelectors.getMessageById(id)(s);
const message = dbMessageSelectors.getDbMessageById(id)(s);
return [!isThinkingClosed(message?.content)];
});
const citations = useChatStore((s) => {
const message = chatSelectors.getMessageById(id)(s);
const message = dbMessageSelectors.getDbMessageById(id)(s);
return message?.search?.citations;
});
@@ -119,7 +119,7 @@ const Inspectors = memo<InspectorProps>(
const hasResult = hasSuccessResult || hasError;
const isPending = intervention?.status === 'pending';
const isReject = intervention?.status === 'rejected';
const isReject = intervention?.status === 'rejected' || intervention?.status === 'aborted';
const isTitleLoading = !hasResult && !isPending;
// Compute actual render state based on pinned or hovered
@@ -60,13 +60,13 @@ const UserMessage = memo<UserMessageProps>(({ id, disableEditing, index }) => {
const displayMode = useAgentStore(agentChatConfigSelectors.displayMode);
const [editing, generating, isInRAGFlow] = useChatStore((s) => [
const [editing, creating, isInRAGFlow] = useChatStore((s) => [
messageStateSelectors.isMessageEditing(id)(s),
messageStateSelectors.isMessageGenerating(id)(s),
messageStateSelectors.isMessageCreating(id)(s), // User message only cares about creation (sendMessage)
messageStateSelectors.isMessageInRAGFlow(id)(s),
]);
const loading = isInRAGFlow || generating;
const loading = isInRAGFlow || creating;
// Get target name for DM indicator
const userName = useUserStore(userProfileSelectors.nickName) || 'User';
+3 -3
View File
@@ -59,9 +59,9 @@ const Item = memo<ChatListItemProps>(
const { styles, cx } = useStyles();
const containerRef = useRef<HTMLDivElement | null>(null);
const [isMessageLoading, role] = useChatStore((s) => [
messageStateSelectors.isMessageLoading(id)(s),
const [role, isMessageCreating] = useChatStore((s) => [
displayMessageSelectors.getDisplayMessageById(id)(s)?.role,
messageStateSelectors.isMessageCreating(id)(s),
]);
// ======================= Performance Optimization ======================= //
@@ -168,7 +168,7 @@ const Item = memo<ChatListItemProps>(
<InPortalThreadContext.Provider value={inPortalThread}>
{enableHistoryDivider && <History />}
<Flexbox
className={cx(styles.message, className, isMessageLoading && styles.loading)}
className={cx(styles.message, className, isMessageCreating && styles.loading)}
data-index={index}
onContextMenu={onContextMenu}
ref={containerRef}
@@ -1,7 +1,7 @@
import { memo, useEffect } from 'react';
import { useChatStore } from '@/store/chat';
import { displayMessageSelectors, messageStateSelectors } from '@/store/chat/selectors';
import { displayMessageSelectors, operationSelectors } from '@/store/chat/selectors';
import BackBottom from './BackBottom';
@@ -11,7 +11,7 @@ interface AutoScrollProps {
onScrollToBottom: (type: 'auto' | 'click') => void;
}
const AutoScroll = memo<AutoScrollProps>(({ atBottom, isScrolling, onScrollToBottom }) => {
const trackVisibility = useChatStore(messageStateSelectors.isAIGenerating);
const trackVisibility = useChatStore(operationSelectors.isAgentRuntimeRunning);
const str = useChatStore(displayMessageSelectors.mainAIChatsMessageString);
const reasoningStr = useChatStore(displayMessageSelectors.mainAILatestMessageReasoningContent);
@@ -3,7 +3,7 @@ import { Skeleton } from 'antd';
import { memo, useRef, useState } from 'react';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { dbMessageSelectors } from '@/store/chat/selectors';
import { useToolStore } from '@/store/tool';
import { pluginSelectors } from '@/store/tool/selectors';
@@ -28,7 +28,7 @@ import {
// just to simplify code a little, don't use this pattern everywhere
const getSettings = (identifier: string) =>
pluginSelectors.getPluginSettingsById(identifier)(useToolStore.getState());
const getMessage = (id: string) => chatSelectors.getMessageById(id)(useChatStore.getState());
const getMessage = (id: string) => dbMessageSelectors.getDbMessageById(id)(useChatStore.getState());
interface IFrameRenderProps {
height?: number;
@@ -61,7 +61,7 @@ const IFrameRender = memo<IFrameRenderProps>(({ url, id, payload, width = 600, h
const iframeWin = iframeRef.current?.contentWindow;
if (iframeWin) {
const message = chatSelectors.getMessageById(id)(useChatStore.getState());
const message = dbMessageSelectors.getDbMessageById(id)(useChatStore.getState());
if (!message) return;
const props = { content: '' };
@@ -8,14 +8,14 @@ import { Center, Flexbox } from 'react-layout-kit';
import Balancer from 'react-wrap-balancer';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { dbMessageSelectors, displayMessageSelectors } from '@/store/chat/selectors';
import ArtifactItem from './Item';
const ArtifactList = () => {
const { t } = useTranslation('portal');
const messages = useChatStore(chatSelectors.currentToolMessages, isEqual);
const isCurrentChatLoaded = useChatStore(chatSelectors.isCurrentChatLoaded);
const messages = useChatStore(dbMessageSelectors.dbToolMessages, isEqual);
const isCurrentChatLoaded = useChatStore(displayMessageSelectors.isCurrentDisplayChatLoaded);
const theme = useTheme();
return !isCurrentChatLoaded ? (
+3 -3
View File
@@ -1,3 +1,4 @@
import { FORM_STYLE } from '@lobechat/const';
import { exportFile } from '@lobechat/utils/client';
import { Button, Form, type FormItemProps, copyToClipboard } from '@lobehub/ui';
import { App, Switch } from 'antd';
@@ -7,12 +8,11 @@ import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { FORM_STYLE } from '@/const/layoutTokens';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { chatSelectors, topicSelectors } from '@/store/chat/selectors';
import { displayMessageSelectors, topicSelectors } from '@/store/chat/selectors';
import { useStyles } from '../style';
import Preview from './Preview';
@@ -67,7 +67,7 @@ const ShareText = memo(() => {
];
const [systemRole] = useAgentStore((s) => [agentSelectors.currentAgentSystemRole(s)]);
const messages = useChatStore(chatSelectors.activeBaseChats, isEqual);
const messages = useChatStore(displayMessageSelectors.activeDisplayMessages, isEqual);
const topic = useChatStore(topicSelectors.currentActiveTopic, isEqual);
const title = topic?.title || t('shareModal.exportTitle');
@@ -0,0 +1,146 @@
'use client';
import { useEffect } from 'react';
/**
* HydrationDebugger - 用于调试 React 水合问题的工具类
*/
class HydrationDebugger {
/**
* 比较服务端和客户端 HTML 的差异
* @param serverHtml - 从服务器获取的纯 HTML 字符串
*/
static debugHydration(serverHtml: string) {
// 确保这个方法只在浏览器环境中执行
if (typeof window === 'undefined') {
console.warn('[HydrationDebugger] debugHydration 只能在客户端调用。');
return;
}
// 格式化函数,使 HTML 更易于比较
const formatHtml = (html: string): string => {
const el = document.createElement('div');
el.innerHTML = html;
// 简单的格式化逻辑:通过缩进标准化结构
let formatted = '';
let indent = '';
const nodes = el.innerHTML.split(/>\s*</);
nodes.forEach((node, index, arr) => {
if (/^\/\w/.test(node)) {
indent = indent.slice(2);
}
let closing = '>';
// 如果不是自闭合标签或者最后一个节点
if (node.includes('</') === false && index !== arr.length - 1) {
closing = '>\n';
}
formatted += indent + '<' + node + closing;
if (/^<?\w[^>]*[^/]$/.test(node)) {
indent += ' ';
}
});
return formatted.trim();
};
console.log('--- 开始水合差异调试 ---');
// 1. 获取客户端渲染后的 HTML
const clientBodyHtml = document.body.innerHTML;
// 2. 为了简化对比,我们只关注 body 内部
const serverBodyMatch = serverHtml.match(/<body[^>]*>([\S\s]*)<\/body>/);
if (serverBodyMatch?.[1]) {
const serverBodyHtml = serverBodyMatch[1];
if (serverBodyHtml.trim() === clientBodyHtml.trim()) {
console.log(
'%c✅ 水合匹配成功!服务器和客户端主体内容一致。',
'color: green; font-weight: bold;',
);
} else {
console.error('%c❌ 水合不匹配!服务器和客户端主体内容存在差异。', 'color: red; font-weight: bold;');
// 使用 console.group 来组织输出,方便折叠
console.groupCollapsed('🔍 服务端 Body HTML (格式化后)');
console.log(formatHtml(serverBodyHtml));
console.groupEnd();
console.groupCollapsed('🔍 客户端 Body HTML (格式化后)');
console.log(formatHtml(clientBodyHtml));
console.groupEnd();
// 尝试找出具体差异点
this.findDifferences(serverBodyHtml, clientBodyHtml);
console.log('%c💡 提示: 请使用文本对比工具比较以上两份 HTML 以定位差异点。', 'color: blue;');
}
} else {
console.error('[HydrationDebugger] 无法从服务端 HTML 中提取 <body> 内容。');
}
console.log('--- 水合差异调试结束 ---');
}
/**
* 尝试找出具体的差异点
*/
private static findDifferences(serverHtml: string, clientHtml: string) {
// 简单的差异检测:比较长度和部分内容
console.groupCollapsed('📊 差异统计');
console.log(`服务端 HTML 长度: ${serverHtml.length} 字符`);
console.log(`客户端 HTML 长度: ${clientHtml.length} 字符`);
console.log(`差异: ${Math.abs(serverHtml.length - clientHtml.length)} 字符`);
// 检查常见的水合错误模式
const patterns = [
{ name: 'localStorage 相关', regex: /localStorage/g },
{ name: 'sessionStorage 相关', regex: /sessionStorage/g },
{ name: 'window 对象访问', regex: /window\./g },
{ name: 'document 对象访问', regex: /document\./g },
{ name: 'data-reactroot 属性', regex: /data-reactroot/g },
{ name: '空白字符差异', regex: /\s+/g },
];
patterns.forEach(({ name, regex }) => {
const serverMatches = serverHtml.match(regex)?.length || 0;
const clientMatches = clientHtml.match(regex)?.length || 0;
if (serverMatches !== clientMatches) {
console.warn(`⚠️ ${name} 出现次数不一致: 服务端 ${serverMatches}, 客户端 ${clientMatches}`);
}
});
console.groupEnd();
}
}
/**
* HydrationDebugHelper - 自动对比服务端和客户端 HTML 的调试组件
* 仅在开发环境使用
*/
const HydrationDebugHelper = () => {
useEffect(() => {
fetch(window.location.href)
.then((res) => res.text())
.then((serverHtml) => {
// 使用 setTimeout 确保在 React 完成水合后再执行比较
setTimeout(() => {
HydrationDebugger.debugHydration(serverHtml);
}, 1000); // 增加延迟以确保水合完成
})
.catch((error) => {
console.error('[HydrationDebugger] 获取服务端 HTML 失败:', error);
});
}, []);
return null; // 这个组件不渲染任何 UI
};
export default HydrationDebugHelper;
@@ -2,7 +2,7 @@
import { enableNextAuth } from '@lobechat/const';
import { useRouter } from 'next/navigation';
import { memo } from 'react';
import { memo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { createStoreUpdater } from 'zustand-utils';
@@ -55,6 +55,14 @@ const StoreInitialization = memo(() => {
*/
const isLoginOnInit = Boolean(enableNextAuth ? isSignedIn : isLogin);
// 如果用户未登录,直接设置水合完成,避免应用卡住
useEffect(() => {
if (!isLoginOnInit) {
useGlobalStore.setState({ isAppHydrated: true });
console.log('[Hydration] Client state hydration completed (not logged in).');
}
}, [isLoginOnInit]);
// init inbox agent and default agent config
useInitAgentStore(isLoginOnInit, serverConfig.defaultAgent?.config);
+2 -2
View File
@@ -3,13 +3,13 @@ import { ReactNode, Suspense } from 'react';
import { LobeAnalyticsProviderWrapper } from '@/components/Analytics/LobeAnalyticsProviderWrapper';
import { getServerFeatureFlagsValue } from '@/config/featureFlags';
import { appEnv } from '@/envs/app';
import DevPanel from '@/features/DevPanel';
import { getServerGlobalConfig } from '@/server/globalConfig';
import { ServerConfigStoreProvider } from '@/store/serverConfig/Provider';
import { getAntdLocale } from '@/utils/locale';
import AntdV5MonkeyPatch from './AntdV5MonkeyPatch';
import AppTheme from './AppTheme';
import HydrationDebugHelper from './HydrationDebugHelper';
import ImportSettings from './ImportSettings';
import Locale from './Locale';
import QueryProvider from './Query';
@@ -63,7 +63,7 @@ const GlobalLayout = async ({
<StoreInitialization />
<Suspense>
<ImportSettings />
{process.env.NODE_ENV === 'development' && <DevPanel />}
<HydrationDebugHelper />
</Suspense>
</ServerConfigStoreProvider>
</AppTheme>
+2 -2
View File
@@ -15,8 +15,8 @@ class SearchService {
return toolsClient.search.crawlPages.mutate(params);
}
async webSearch(params: SearchQuery) {
return toolsClient.search.webSearch.query(params);
async webSearch(params: SearchQuery, options?: { signal?: AbortSignal }) {
return toolsClient.search.webSearch.query(params, { signal: options?.signal });
}
}
+98
View File
@@ -9,6 +9,7 @@ import {
GeneralAgentCallToolsBatchInstructionPayload,
GeneralAgentCallingToolInstructionPayload,
GeneralAgentConfig,
HumanAbortPayload,
InterventionChecker,
} from '@lobechat/agent-runtime';
import type { ChatToolPayload, HumanInterventionConfig } from '@lobechat/types';
@@ -115,10 +116,90 @@ export class GeneralChatAgent implements Agent {
return [toolsNeedingIntervention, toolsToExecute];
}
/**
* Extract abort information from current context and state
* Returns the necessary data to handle abort scenario
*/
private extractAbortInfo(context: AgentRuntimeContext, state: AgentState) {
let hasToolsCalling = false;
let toolsCalling: ChatToolPayload[] = [];
let parentMessageId = '';
// Extract abort info based on current phase
switch (context.phase) {
case 'llm_result': {
const payload = context.payload as GeneralAgentCallLLMResultPayload;
hasToolsCalling = payload.hasToolsCalling || false;
toolsCalling = payload.toolsCalling || [];
parentMessageId = payload.parentMessageId;
break;
}
case 'human_abort': {
// When user cancels during LLM streaming, we enter human_abort phase
// The payload contains tool calls info if LLM had started returning them
const payload = context.payload as any;
hasToolsCalling = payload.hasToolsCalling || false;
toolsCalling = payload.toolsCalling || [];
parentMessageId = payload.parentMessageId;
break;
}
case 'tool_result':
case 'tools_batch_result': {
const payload = context.payload as GeneralAgentCallToolResultPayload;
parentMessageId = payload.parentMessageId;
// Check if there are pending tool messages
const pendingToolMessages = state.messages.filter(
(m: any) => m.role === 'tool' && m.pluginIntervention?.status === 'pending',
);
if (pendingToolMessages.length > 0) {
hasToolsCalling = true;
toolsCalling = pendingToolMessages.map((m: any) => m.plugin).filter(Boolean);
}
break;
}
}
return { hasToolsCalling, parentMessageId, toolsCalling };
}
/**
* Handle abort scenario - unified abort handling logic
*/
private handleAbort(
context: AgentRuntimeContext,
state: AgentState,
): AgentInstruction | AgentInstruction[] {
const { hasToolsCalling, parentMessageId, toolsCalling } = this.extractAbortInfo(
context,
state,
);
// If there are pending tool calls, resolve them
if (hasToolsCalling && toolsCalling.length > 0) {
return {
payload: { parentMessageId, toolsCalling },
type: 'resolve_aborted_tools',
};
}
// No tools to resolve, directly finish
return {
reason: 'user_requested',
reasonDetail: 'Operation cancelled by user',
type: 'finish',
};
}
async runner(
context: AgentRuntimeContext,
state: AgentState,
): Promise<AgentInstruction | AgentInstruction[]> {
// Unified abort check: if operation is interrupted, handle abort scenario
// This check is placed before phase handling to ensure consistent abort behavior
if (state.status === 'interrupted') {
return this.handleAbort(context, state);
}
switch (context.phase) {
case 'init':
case 'user_input': {
@@ -256,6 +337,23 @@ export class GeneralChatAgent implements Agent {
};
}
case 'human_abort': {
// User aborted the operation
const { hasToolsCalling, parentMessageId, toolsCalling, reason } =
context.payload as HumanAbortPayload;
// If there are pending tool calls, resolve them
if (hasToolsCalling && toolsCalling && toolsCalling.length > 0) {
return {
payload: { parentMessageId, toolsCalling },
type: 'resolve_aborted_tools',
};
}
// No tools to resolve, directly finish
return { reason: 'user_requested', reasonDetail: reason, type: 'finish' };
}
case 'error': {
// Error occurred, finish execution
const { error } = context.payload as { error: any };
@@ -220,6 +220,50 @@ describe('GeneralChatAgent', () => {
]);
});
it('should handle invalid JSON in tool arguments gracefully', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
sessionId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const toolCall: ChatToolPayload = {
id: 'call-1',
identifier: 'test-plugin',
apiName: 'test-api',
arguments: '{invalid json}', // Invalid JSON
type: 'default',
};
const state = createMockState({
toolManifestMap: {
'test-plugin': {
identifier: 'test-plugin',
// No humanIntervention config
},
},
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: [toolCall],
parentMessageId: 'msg-1',
});
// Should not throw, should proceed with call_tool (treats invalid JSON as empty args)
const result = await agent.runner(context, state);
expect(result).toEqual([
{
type: 'call_tool',
payload: {
parentMessageId: 'msg-1',
toolCalling: toolCall,
},
},
]);
});
it('should return request_human_approve for tools requiring intervention', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
@@ -526,6 +570,328 @@ describe('GeneralChatAgent', () => {
});
});
describe('unified abort check', () => {
it('should handle abort at llm_result phase when state is interrupted', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
sessionId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const toolCalls: ChatToolPayload[] = [
{
apiName: 'search',
arguments: '{"query":"test"}',
id: 'call-1',
identifier: 'lobe-web-browsing',
type: 'default',
},
];
const state = createMockState({
status: 'interrupted', // State is interrupted
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: toolCalls,
parentMessageId: 'msg-123',
});
const result = await agent.runner(context, state);
// Should handle abort and return resolve_aborted_tools
expect(result).toEqual({
type: 'resolve_aborted_tools',
payload: {
parentMessageId: 'msg-123',
toolsCalling: toolCalls,
},
});
});
it('should handle abort at tool_result phase when state is interrupted', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
sessionId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const state = createMockState({
status: 'interrupted',
messages: [
{
id: 'tool-msg-1',
role: 'tool',
content: '',
plugin: {
id: 'call-1',
identifier: 'bash',
apiName: 'bash',
arguments: '{"command":"ls"}',
type: 'builtin',
},
pluginIntervention: { status: 'pending' },
} as any,
],
});
const context = createMockContext('tool_result', {
parentMessageId: 'msg-456',
});
const result = await agent.runner(context, state);
// Should handle abort and resolve pending tools
expect(result).toEqual({
type: 'resolve_aborted_tools',
payload: {
parentMessageId: 'msg-456',
toolsCalling: [
{
id: 'call-1',
identifier: 'bash',
apiName: 'bash',
arguments: '{"command":"ls"}',
type: 'builtin',
},
],
},
});
});
it('should return finish when state is interrupted with no tools', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
sessionId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const state = createMockState({
status: 'interrupted',
});
const context = createMockContext('llm_result', {
hasToolsCalling: false,
toolsCalling: [],
parentMessageId: 'msg-789',
});
const result = await agent.runner(context, state);
// Should handle abort and return finish
expect(result).toEqual({
type: 'finish',
reason: 'user_requested',
reasonDetail: 'Operation cancelled by user',
});
});
it('should continue normal flow when state is not interrupted', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
sessionId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const toolCalls: ChatToolPayload[] = [
{
apiName: 'search',
arguments: '{"query":"test"}',
id: 'call-1',
identifier: 'lobe-web-browsing',
type: 'default',
},
];
const state = createMockState({
status: 'running', // Normal running state
});
const context = createMockContext('llm_result', {
hasToolsCalling: true,
toolsCalling: toolCalls,
parentMessageId: 'msg-999',
});
const result = await agent.runner(context, state);
// Should continue normal flow and execute tools
expect(result).toEqual([
{
type: 'call_tool',
payload: {
parentMessageId: 'msg-999',
toolCalling: toolCalls[0],
},
},
]);
});
});
describe('unified abort check', () => {
it('should handle abort at human_abort phase when state is interrupted', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
sessionId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const toolCalls: ChatToolPayload[] = [
{
apiName: 'search',
arguments: '{"query":"test"}',
id: 'call-1',
identifier: 'lobe-web-browsing',
type: 'default',
},
];
const state = createMockState({
status: 'interrupted', // Trigger unified abort check
});
const context = createMockContext('human_abort', {
reason: 'user_cancelled',
parentMessageId: 'msg-123',
hasToolsCalling: true,
toolsCalling: toolCalls,
result: { content: '', tool_calls: [] },
});
const result = await agent.runner(context, state);
// Should handle abort via extractAbortInfo and return resolve_aborted_tools
expect(result).toEqual({
type: 'resolve_aborted_tools',
payload: {
parentMessageId: 'msg-123',
toolsCalling: toolCalls,
},
});
});
});
describe('human_abort phase', () => {
it('should return resolve_aborted_tools when there are pending tool calls', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
sessionId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const toolCalls: ChatToolPayload[] = [
{
apiName: 'search',
arguments: '{"query":"test"}',
id: 'call-1',
identifier: 'lobe-web-browsing',
type: 'default',
},
{
apiName: 'getWeather',
arguments: '{"location":"NYC"}',
id: 'call-2',
identifier: 'weather-plugin',
type: 'default',
},
];
const state = createMockState();
const context = createMockContext('human_abort', {
reason: 'user_cancelled',
parentMessageId: 'msg-123',
hasToolsCalling: true,
toolsCalling: toolCalls,
result: { content: '', tool_calls: [] },
});
const result = await agent.runner(context, state);
expect(result).toEqual({
type: 'resolve_aborted_tools',
payload: {
parentMessageId: 'msg-123',
toolsCalling: toolCalls,
},
});
});
it('should return finish when there are no tool calls', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
sessionId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const state = createMockState();
const context = createMockContext('human_abort', {
reason: 'user_cancelled',
parentMessageId: 'msg-123',
hasToolsCalling: false,
toolsCalling: [],
result: { content: 'Hello', tool_calls: [] },
});
const result = await agent.runner(context, state);
expect(result).toEqual({
type: 'finish',
reason: 'user_requested',
reasonDetail: 'user_cancelled',
});
});
it('should return finish when toolsCalling is undefined', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
sessionId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const state = createMockState();
const context = createMockContext('human_abort', {
reason: 'operation_cancelled',
parentMessageId: 'msg-456',
hasToolsCalling: false,
result: { content: 'Partial response', tool_calls: [] },
});
const result = await agent.runner(context, state);
expect(result).toEqual({
type: 'finish',
reason: 'user_requested',
reasonDetail: 'operation_cancelled',
});
});
it('should return finish when toolsCalling is empty array', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
sessionId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const state = createMockState();
const context = createMockContext('human_abort', {
reason: 'user_cancelled',
parentMessageId: 'msg-789',
hasToolsCalling: true,
toolsCalling: [],
result: { content: '', tool_calls: [] },
});
const result = await agent.runner(context, state);
expect(result).toEqual({
type: 'finish',
reason: 'user_requested',
reasonDetail: 'user_cancelled',
});
});
});
describe('unknown phase', () => {
it('should return finish instruction for unknown phase', async () => {
const agent = new GeneralChatAgent({
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,453 @@
import type { AgentEventDone } from '@lobechat/agent-runtime';
import { describe, expect, it, vi } from 'vitest';
import { createFinishInstruction } from './fixtures';
import { createMockStore } from './fixtures/mockStore';
import { createInitialState, createTestContext, executeWithMockContext } from './helpers';
describe('finish executor', () => {
describe('Basic Behavior', () => {
it('should complete execution successfully with reason', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('completed', 'All tasks finished');
const state = createInitialState({ sessionId: 'test-session', stepCount: 5 });
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.status).toBe('done');
expect(result.newState.lastModified).toBeDefined();
expect(new Date(result.newState.lastModified).getTime()).toBeGreaterThanOrEqual(
new Date(state.lastModified).getTime(),
);
expect(result.events).toHaveLength(1);
expect(result.events[0]).toMatchObject({
type: 'done',
reason: 'completed',
reasonDetail: 'All tasks finished',
});
});
it('should preserve all state fields except status and lastModified', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('completed');
const state = createInitialState({
sessionId: 'test-session',
stepCount: 10,
messages: [{ role: 'user', content: 'test' } as any],
cost: {
total: 0.05,
calculatedAt: new Date().toISOString(),
currency: 'USD',
llm: { total: 0.04, currency: 'USD', byModel: [] },
tools: { total: 0.01, currency: 'USD', byTool: [] },
},
usage: {
humanInteraction: {
approvalRequests: 0,
promptRequests: 0,
selectRequests: 0,
totalWaitingTimeMs: 0,
},
llm: {
apiCalls: 2,
processingTimeMs: 100,
tokens: {
input: 100,
output: 200,
total: 300,
},
},
tools: {
totalCalls: 2,
totalTimeMs: 500,
byTool: [],
},
},
});
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.sessionId).toBe(state.sessionId);
expect(result.newState.stepCount).toBe(state.stepCount);
expect(result.newState.messages).toEqual(state.messages);
expect(result.newState.usage).toEqual(state.usage);
});
it('should work without reasonDetail', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('max_turns_reached');
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.status).toBe('done');
const doneEvent = result.events[0] as AgentEventDone;
expect(doneEvent).toMatchObject({
type: 'done',
reason: 'max_turns_reached',
});
expect(doneEvent.reasonDetail).toBeUndefined();
});
});
describe('Different Finish Reasons', () => {
it('should handle "completed" reason', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('completed', 'Task completed successfully');
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
const doneEvent = result.events[0] as AgentEventDone;
expect(doneEvent.reason).toBe('completed');
expect(doneEvent.reasonDetail).toBe('Task completed successfully');
});
it('should handle "max_turns_reached" reason', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction(
'max_turns_reached',
'Maximum conversation turns exceeded',
);
const state = createInitialState({ stepCount: 100 });
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
const doneEvent = result.events[0] as AgentEventDone;
expect(doneEvent.reason).toBe('max_turns_reached');
expect(result.newState.stepCount).toBe(100);
});
it('should handle "error" reason', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('error', 'Internal runtime error occurred');
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
const doneEvent = result.events[0] as AgentEventDone;
expect(doneEvent.reason).toBe('error');
expect(result.newState.status).toBe('done');
});
it('should handle "user_cancelled" reason', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('user_cancelled', 'User requested cancellation');
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
const doneEvent = result.events[0] as AgentEventDone;
expect(doneEvent.reason).toBe('user_cancelled');
});
});
describe('Event Structure', () => {
it('should emit done event with finalState', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('completed');
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.events).toHaveLength(1);
const doneEvent = result.events[0] as AgentEventDone;
expect(doneEvent).toHaveProperty('type', 'done');
expect(doneEvent).toHaveProperty('finalState');
expect(doneEvent).toHaveProperty('reason');
expect(doneEvent.finalState).toEqual(result.newState);
});
it('should include both reason and reasonDetail in event', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('completed', 'Detailed completion message');
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
const doneEvent = result.events[0] as AgentEventDone;
expect(doneEvent.reason).toBe('completed');
expect(doneEvent.reasonDetail).toBe('Detailed completion message');
});
});
describe('State Immutability', () => {
it('should not mutate original state', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('completed');
const state = createInitialState({ sessionId: 'test', stepCount: 5 });
const originalState = JSON.parse(JSON.stringify(state));
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
expect(state).toEqual(originalState);
expect(result.newState).not.toBe(state);
expect(result.newState.status).toBe('done');
expect(state.status).toBe('running');
});
it('should create deep clone of state', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('completed');
const state = createInitialState({
messages: [{ role: 'user', content: 'test' } as any],
});
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.messages).toEqual(state.messages);
expect(result.newState.messages).not.toBe(state.messages);
});
});
describe('Timestamp Handling', () => {
it('should update lastModified timestamp', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('completed');
const oldTimestamp = new Date('2024-01-01').toISOString();
const state = createInitialState({ lastModified: oldTimestamp });
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.lastModified).not.toBe(oldTimestamp);
expect(new Date(result.newState.lastModified).getTime()).toBeGreaterThan(
new Date(oldTimestamp).getTime(),
);
});
it('should use ISO 8601 format for lastModified', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('completed');
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
const isoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
expect(result.newState.lastModified).toMatch(isoPattern);
});
});
describe('Store Interaction', () => {
it('should not call any store methods', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('completed');
const state = createInitialState();
// When
await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then - finish executor is pure and doesn't interact with store
expect(mockStore.optimisticCreateMessage).not.toHaveBeenCalled();
expect(mockStore.startOperation).not.toHaveBeenCalled();
expect(mockStore.completeOperation).not.toHaveBeenCalled();
expect(mockStore.failOperation).not.toHaveBeenCalled();
expect(mockStore.onOperationCancel).not.toHaveBeenCalled();
});
});
describe('Edge Cases', () => {
it('should handle empty reason string', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('');
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.status).toBe('done');
const doneEvent = result.events[0] as AgentEventDone;
expect(doneEvent.reason).toBe('');
});
it('should handle very long reasonDetail', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const longDetail = 'A'.repeat(10000);
const instruction = createFinishInstruction('completed', longDetail);
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
const doneEvent = result.events[0] as AgentEventDone;
expect(doneEvent.reasonDetail).toBe(longDetail);
expect(doneEvent.reasonDetail?.length).toBe(10000);
});
it('should handle state with empty messages array', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createFinishInstruction('completed');
const state = createInitialState({ messages: [] });
// When
const result = await executeWithMockContext({
executor: 'finish',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.messages).toEqual([]);
expect(result.newState.status).toBe('done');
});
});
});
@@ -0,0 +1,4 @@
export * from './mockInstructions';
export * from './mockMessages';
export * from './mockOperations';
export * from './mockStore';
@@ -0,0 +1,126 @@
import type {
AgentInstruction,
AgentInstructionCallLlm,
AgentInstructionCallTool,
GeneralAgentCallLLMInstructionPayload,
GeneralAgentCallingToolInstructionPayload,
} from '@lobechat/agent-runtime';
import type { ChatToolPayload } from '@lobechat/types';
import { nanoid } from '@lobechat/utils';
/**
* Create a mock call_llm instruction
*/
export const createCallLLMInstruction = (
payload: Partial<GeneralAgentCallLLMInstructionPayload> = {},
): AgentInstructionCallLlm => {
return {
payload: {
messages: [],
model: 'gpt-4',
parentMessageId: `msg_${nanoid()}`,
provider: 'openai',
...payload,
} as GeneralAgentCallLLMInstructionPayload,
type: 'call_llm',
};
};
/**
* Create a mock call_tool instruction
*/
export const createCallToolInstruction = (
toolCall: Partial<ChatToolPayload> = {},
options: {
parentMessageId?: string;
skipCreateToolMessage?: boolean;
} = {},
): AgentInstructionCallTool => {
const toolPayload: ChatToolPayload = {
apiName: 'search',
arguments: JSON.stringify({ query: 'test' }),
id: `tool_call_${nanoid()}`,
identifier: 'lobe-web-browsing',
type: 'default',
...toolCall,
};
return {
payload: {
parentMessageId: options.parentMessageId || `msg_${nanoid()}`,
skipCreateToolMessage: options.skipCreateToolMessage || false,
toolCalling: toolPayload,
} as GeneralAgentCallingToolInstructionPayload,
type: 'call_tool',
};
};
/**
* Create a mock request_human_approve instruction
*/
export const createRequestHumanApproveInstruction = (
pendingTools: ChatToolPayload[] = [],
options: {
reason?: string;
skipCreateToolMessage?: boolean;
} = {},
): AgentInstruction => {
const pendingToolsCalling = pendingTools.length
? pendingTools
: [
{
apiName: 'search',
arguments: JSON.stringify({ query: 'test' }),
id: `tool_call_${nanoid()}`,
identifier: 'lobe-web-browsing',
type: 'default',
},
];
return {
pendingToolsCalling,
reason: options.reason,
skipCreateToolMessage: options.skipCreateToolMessage || false,
type: 'request_human_approve',
} as AgentInstruction;
};
/**
* Create a mock resolve_aborted_tools instruction
*/
export const createResolveAbortedToolsInstruction = (
toolsCalling: ChatToolPayload[] = [],
parentMessageId?: string,
): AgentInstruction => {
return {
payload: {
parentMessageId: parentMessageId || `msg_${nanoid()}`,
toolsCalling: toolsCalling.length
? toolsCalling
: [
{
apiName: 'search',
arguments: JSON.stringify({ query: 'test' }),
id: `tool_call_${nanoid()}`,
identifier: 'lobe-web-browsing',
type: 'default',
},
],
},
type: 'resolve_aborted_tools',
} as AgentInstruction;
};
/**
* Create a mock finish instruction
*/
export const createFinishInstruction = (
reason: string = 'completed',
reasonDetail?: string,
): AgentInstruction => {
return {
reason,
reasonDetail,
type: 'finish',
} as AgentInstruction;
};
@@ -0,0 +1,94 @@
import type { UIChatMessage } from '@lobechat/types';
import { nanoid } from '@lobechat/utils';
/**
* Create a mock assistant message
*/
export const createAssistantMessage = (overrides: Partial<UIChatMessage> = {}): UIChatMessage => {
return {
content: 'I am an AI assistant.',
createdAt: Date.now(),
id: `msg_${nanoid()}`,
meta: {},
model: 'gpt-4',
provider: 'openai',
role: 'assistant',
updatedAt: Date.now(),
...overrides,
} as UIChatMessage;
};
/**
* Create a mock user message
*/
export const createUserMessage = (overrides: Partial<UIChatMessage> = {}): UIChatMessage => {
return {
content: 'Hello, AI!',
createdAt: Date.now(),
id: `msg_${nanoid()}`,
meta: {},
role: 'user',
updatedAt: Date.now(),
...overrides,
} as UIChatMessage;
};
/**
* Create a mock tool message
*/
export const createToolMessage = (overrides: Partial<UIChatMessage> = {}): UIChatMessage => {
return {
content: '',
createdAt: Date.now(),
id: `msg_${nanoid()}`,
meta: {},
plugin: {
apiName: 'search',
arguments: JSON.stringify({ query: 'test' }),
identifier: 'lobe-web-browsing',
type: 'default',
},
role: 'tool',
tool_call_id: `tool_call_${nanoid()}`,
updatedAt: Date.now(),
...overrides,
} as UIChatMessage;
};
/**
* Create a mock tool message with pending intervention
*/
export const createPendingToolMessage = (overrides: Partial<UIChatMessage> = {}): UIChatMessage => {
return createToolMessage({
pluginIntervention: { status: 'pending' },
...overrides,
});
};
/**
* Create a mock tool message with aborted intervention
*/
export const createAbortedToolMessage = (overrides: Partial<UIChatMessage> = {}): UIChatMessage => {
return createToolMessage({
content: 'Tool execution was cancelled by user.',
pluginIntervention: { status: 'aborted' },
...overrides,
});
};
/**
* Create a conversation history
*/
export const createConversationHistory = (messageCount: number = 3): UIChatMessage[] => {
const messages: UIChatMessage[] = [];
for (let i = 0; i < messageCount; i++) {
if (i % 2 === 0) {
messages.push(createUserMessage({ content: `User message ${i + 1}` }));
} else {
messages.push(createAssistantMessage({ content: `Assistant response ${i + 1}` }));
}
}
return messages;
};
@@ -0,0 +1,96 @@
import { nanoid } from '@lobechat/utils';
import type { Operation, OperationType } from '@/store/chat/slices/operation/types';
/**
* Create a mock Operation object for testing
*/
export const createMockOperation = (
type: OperationType,
context: Record<string, any> = {},
overrides: Partial<Operation> = {},
): Operation => {
return {
abortController: new AbortController(),
childOperationIds: [],
context,
id: `op_${nanoid()}`,
metadata: {
startTime: Date.now(),
},
status: 'running',
type,
...overrides,
};
};
/**
* Create a cancelled operation
*/
export const createCancelledOperation = (
type: OperationType,
context: Record<string, any> = {},
): Operation => {
const operation = createMockOperation(type, context, { status: 'cancelled' });
operation.abortController.abort();
operation.metadata.cancelReason = 'Test cancellation';
return operation;
};
/**
* Create a completed operation
*/
export const createCompletedOperation = (
type: OperationType,
context: Record<string, any> = {},
): Operation => {
return createMockOperation(type, context, {
metadata: {
duration: 1000,
endTime: Date.now(),
startTime: Date.now() - 1000,
},
status: 'completed',
});
};
/**
* Create a failed operation
*/
export const createFailedOperation = (
type: OperationType,
context: Record<string, any> = {},
// eslint-disable-next-line unicorn/no-object-as-default-parameter
error: { message: string; type: string } = { message: 'Test error', type: 'TestError' },
): Operation => {
return createMockOperation(type, context, {
metadata: {
duration: 1000,
endTime: Date.now(),
error,
startTime: Date.now() - 1000,
},
status: 'failed',
});
};
/**
* Create an operation tree (parent with children)
*/
export const createOperationTree = (
parentType: OperationType,
childTypes: OperationType[],
context: Record<string, any> = {},
) => {
const parent = createMockOperation(parentType, context);
const children = childTypes.map((childType) =>
createMockOperation(childType, context, {
parentOperationId: parent.id,
}),
);
parent.childOperationIds = children.map((c) => c.id);
return { children, parent };
};
@@ -0,0 +1,138 @@
import { nanoid } from '@lobechat/utils';
import { vi } from 'vitest';
import type { ChatStore } from '@/store/chat/store';
/**
* Create a mock ChatStore for testing executors
* All methods are mocked with vi.fn() and can be customized
*/
export const createMockStore = (overrides: Partial<ChatStore> = {}): ChatStore => {
const operations: Record<string, any> = {};
const messageOperationMap: Record<string, string> = {};
const operationsByMessage: Record<string, string[]> = {};
const dbMessagesMap: Record<string, any[]> = {};
const store = {
// Other store properties (add as needed)
activeId: 'test-session',
activeTopicId: 'test-topic',
associateMessageWithOperation: vi.fn().mockImplementation((messageId, operationId) => {
messageOperationMap[messageId] = operationId;
if (!operationsByMessage[messageId]) {
operationsByMessage[messageId] = [];
}
if (!operationsByMessage[messageId].includes(operationId)) {
operationsByMessage[messageId].push(operationId);
}
}),
cancelOperation: vi.fn().mockImplementation((operationId) => {
if (operations[operationId]) {
operations[operationId].abortController.abort();
operations[operationId].status = 'cancelled';
}
}),
completeOperation: vi.fn().mockImplementation((operationId) => {
if (operations[operationId]) {
operations[operationId].status = 'completed';
operations[operationId].metadata.endTime = Date.now();
}
}),
// Message state
dbMessagesMap,
failOperation: vi.fn().mockImplementation((operationId, error) => {
if (operations[operationId]) {
operations[operationId].status = 'failed';
operations[operationId].metadata.error = error;
operations[operationId].metadata.endTime = Date.now();
}
}),
// AI chat methods
internal_fetchAIChatMessage: vi.fn().mockResolvedValue(undefined),
internal_invokeDifferentTypePlugin: vi.fn().mockResolvedValue({ error: null }),
messageOperationMap,
onOperationCancel: vi.fn(),
// Operation state
operations,
operationsByContext: {},
operationsByMessage,
operationsByType: {} as any,
optimisticAddToolToAssistantMessage: vi.fn().mockResolvedValue(undefined),
// Message management methods
optimisticCreateMessage: vi.fn().mockImplementation(async (params) => {
const id = nanoid();
const message = { id, ...params, createdAt: Date.now(), updatedAt: Date.now() };
return message;
}),
optimisticUpdateMessageContent: vi.fn().mockResolvedValue(undefined),
optimisticUpdateMessagePlugin: vi.fn().mockResolvedValue(undefined),
optimisticUpdateMessagePluginError: vi.fn().mockResolvedValue(undefined),
optimisticUpdatePluginArguments: vi.fn().mockResolvedValue(undefined),
optimisticUpdatePluginState: vi.fn().mockResolvedValue(undefined),
// Operation management methods
startOperation: vi.fn().mockImplementation((config) => {
const operationId = `op_${nanoid()}`;
const abortController = new AbortController();
const operation = {
abortController,
childOperationIds: [],
context: config.context || {},
id: operationId,
metadata: config.metadata || { startTime: Date.now() },
parentOperationId: config.parentOperationId,
status: 'running',
type: config.type,
};
operations[operationId] = operation;
// Auto-associate message with operation if messageId exists
if (config.context?.messageId) {
messageOperationMap[config.context.messageId] = operationId;
if (!operationsByMessage[config.context.messageId]) {
operationsByMessage[config.context.messageId] = [];
}
operationsByMessage[config.context.messageId].push(operationId);
}
return { abortController, operationId };
}),
updateOperationMetadata: vi.fn().mockImplementation((operationId, metadata) => {
if (operations[operationId]) {
operations[operationId].metadata = {
...operations[operationId].metadata,
...metadata,
};
}
}),
...overrides,
} as unknown as ChatStore;
return store;
};
@@ -0,0 +1,185 @@
import { expect } from 'vitest';
import type { OperationType } from '@/store/chat/slices/operation/types';
import type { ChatStore } from '@/store/chat/store';
/**
* Assert that an operation was created with specific type
*/
export const expectOperationCreated = (mockStore: ChatStore, type: OperationType) => {
expect(mockStore.startOperation).toHaveBeenCalledWith(
expect.objectContaining({
type,
}),
);
};
/**
* Assert that a message was created with specific role
*/
export const expectMessageCreated = (mockStore: ChatStore, role: 'assistant' | 'tool' | 'user') => {
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
role,
}),
);
};
/**
* Assert that a cancel handler was registered
*/
export const expectCancelHandlerRegistered = (mockStore: ChatStore, operationId?: string) => {
if (operationId) {
expect(mockStore.onOperationCancel).toHaveBeenCalledWith(operationId, expect.any(Function));
} else {
expect(mockStore.onOperationCancel).toHaveBeenCalled();
}
};
/**
* Assert that an operation was completed
*/
export const expectOperationCompleted = (mockStore: ChatStore, operationId: string) => {
expect(mockStore.completeOperation).toHaveBeenCalledWith(operationId);
};
/**
* Assert that an operation was failed
*/
export const expectOperationFailed = (
mockStore: ChatStore,
operationId: string,
errorType?: string,
) => {
if (errorType) {
expect(mockStore.failOperation).toHaveBeenCalledWith(
operationId,
expect.objectContaining({
type: errorType,
}),
);
} else {
expect(mockStore.failOperation).toHaveBeenCalledWith(operationId, expect.any(Object));
}
};
/**
* Assert that message content was updated
*/
export const expectMessageContentUpdated = (
mockStore: ChatStore,
messageId: string,
content?: string,
) => {
if (content) {
expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith(
messageId,
content,
expect.anything(),
expect.anything(),
);
} else {
expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith(
messageId,
expect.any(String),
expect.anything(),
expect.anything(),
);
}
};
/**
* Assert that message plugin was updated
*/
export const expectMessagePluginUpdated = (
mockStore: ChatStore,
messageId: string,
interventionStatus?: string,
) => {
if (interventionStatus) {
expect(mockStore.optimisticUpdateMessagePlugin).toHaveBeenCalledWith(
messageId,
expect.objectContaining({
intervention: expect.objectContaining({
status: interventionStatus,
}),
}),
expect.anything(),
);
} else {
expect(mockStore.optimisticUpdateMessagePlugin).toHaveBeenCalled();
}
};
/**
* Assert that internal_fetchAIChatMessage was called with correct params
*/
export const expectFetchAIChatMessageCalled = (mockStore: ChatStore, messageId?: string) => {
if (messageId) {
expect(mockStore.internal_fetchAIChatMessage).toHaveBeenCalledWith(
messageId,
expect.anything(),
);
} else {
expect(mockStore.internal_fetchAIChatMessage).toHaveBeenCalled();
}
};
/**
* Assert that internal_invokeDifferentTypePlugin was called
*/
export const expectInvokePluginCalled = (mockStore: ChatStore, messageId?: string) => {
if (messageId) {
expect(mockStore.internal_invokeDifferentTypePlugin).toHaveBeenCalledWith(
messageId,
expect.anything(),
);
} else {
expect(mockStore.internal_invokeDifferentTypePlugin).toHaveBeenCalled();
}
};
/**
* Assert that operation metadata was updated
*/
export const expectOperationMetadataUpdated = (
mockStore: ChatStore,
operationId: string,
metadata?: Record<string, any>,
) => {
if (metadata) {
expect(mockStore.updateOperationMetadata).toHaveBeenCalledWith(
operationId,
expect.objectContaining(metadata),
);
} else {
expect(mockStore.updateOperationMetadata).toHaveBeenCalledWith(operationId, expect.any(Object));
}
};
/**
* Assert executor result structure
*/
export const expectValidExecutorResult = (result: any) => {
expect(result).toHaveProperty('events');
expect(result).toHaveProperty('newState');
expect(Array.isArray(result.events)).toBe(true);
expect(result.newState).toBeDefined();
};
/**
* Assert executor returned specific event type
*/
export const expectEventType = (result: any, eventType: string) => {
expect(result.events.some((e: any) => e.type === eventType)).toBe(true);
};
/**
* Assert executor returned next context
*/
export const expectNextContext = (result: any, phase?: string) => {
expect(result.nextContext).toBeDefined();
if (phase) {
expect(result.nextContext.phase).toBe(phase);
}
};
@@ -0,0 +1,3 @@
export * from './assertions';
export * from './operationTestUtils';
export * from './testExecutor';
@@ -0,0 +1,94 @@
import type { Operation } from '@/store/chat/slices/operation/types';
/**
* Simulate operation cancellation
*/
export const simulateOperationCancellation = (
operation: Operation,
reason: string = 'Test cancellation',
) => {
operation.abortController.abort();
operation.status = 'cancelled';
if (operation.metadata) {
operation.metadata.cancelReason = reason;
}
};
/**
* Simulate cascading cancellation through operation tree
*/
export const simulateCascadingCancellation = (
parentOperation: Operation,
childOperations: Operation[],
reason: string = 'Parent operation cancelled',
) => {
simulateOperationCancellation(parentOperation, reason);
childOperations.forEach((child) => simulateOperationCancellation(child, reason));
};
/**
* Wait for operation status change
*/
export const waitForOperationStatus = async (
getOperation: () => Operation | undefined,
targetStatus: Operation['status'],
timeout: number = 1000,
): Promise<boolean> => {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const operation = getOperation();
if (operation?.status === targetStatus) {
return true;
}
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
return false;
};
/**
* Verify operation tree structure
*/
export const verifyOperationTree = (parent: Operation, expectedChildIds: string[]): boolean => {
if (!parent.childOperationIds) return false;
if (parent.childOperationIds.length !== expectedChildIds.length) return false;
return expectedChildIds.every((id) => parent.childOperationIds!.includes(id));
};
/**
* Get all operations in a tree (parent + all descendants)
*/
export const getAllOperationsInTree = (
operations: Record<string, Operation>,
rootOperationId: string,
): Operation[] => {
const result: Operation[] = [];
const visited = new Set<string>();
const traverse = (operationId: string) => {
if (visited.has(operationId)) return;
visited.add(operationId);
const operation = operations[operationId];
if (!operation) return;
result.push(operation);
if (operation.childOperationIds) {
operation.childOperationIds.forEach(traverse);
}
};
traverse(rootOperationId);
return result;
};
/**
* Create AbortSignal that aborts after delay
*/
export const createDelayedAbortSignal = (delayMs: number): AbortSignal => {
const controller = new AbortController();
setTimeout(() => controller.abort(), delayMs);
return controller.signal;
};
@@ -0,0 +1,139 @@
import type {
AgentInstruction,
AgentState,
} from '@lobechat/agent-runtime';
import { createAgentExecutors } from '@/store/chat/agents/createAgentExecutors';
import type { OperationType } from '@/store/chat/slices/operation/types';
import type { ChatStore } from '@/store/chat/store';
/**
* Execute an executor with mock context
*
* @example
* const result = await executeWithMockContext({
* executor: 'call_llm',
* instruction: createCallLLMInstruction(),
* state: createInitialState(),
* mockStore,
* context: { operationId: 'op_123', messageKey: 'session_topic', parentId: 'msg_456' }
* });
*/
export const executeWithMockContext = async ({
executor,
instruction,
state,
mockStore,
context,
skipCreateFirstMessage = false,
}: {
context: {
messageKey: string;
operationId: string;
parentId: string;
sessionId?: string;
topicId?: string | null;
};
executor: AgentInstruction['type'];
instruction: AgentInstruction;
mockStore: ChatStore;
skipCreateFirstMessage?: boolean;
state: AgentState;
}) => {
// Ensure operation exists in store
if (!mockStore.operations[context.operationId]) {
mockStore.operations[context.operationId] = {
abortController: new AbortController(),
childOperationIds: [],
context: {
messageId: context.parentId,
sessionId: context.sessionId || 'test-session',
topicId: context.topicId !== undefined ? context.topicId : 'test-topic',
},
id: context.operationId,
metadata: { startTime: Date.now() },
status: 'running',
type: 'execAgentRuntime' as OperationType,
};
}
// Create executors with mock context
const executors = createAgentExecutors({
get: () => mockStore,
messageKey: context.messageKey,
operationId: context.operationId,
parentId: context.parentId,
skipCreateFirstMessage,
});
const executorFn = executors[executor];
if (!executorFn) {
throw new Error(`Executor ${executor} not found`);
}
// Execute
const result = await executorFn(instruction, state);
return result;
};
/**
* Create initial agent runtime state for testing
*/
export const createInitialState = (overrides: Partial<AgentState> = {}): AgentState => {
const defaultState: any = {
lastModified: new Date().toISOString(),
messages: [],
sessionId: 'test-session',
status: 'running',
stepCount: 1,
usage: {
humanInteraction: {
approvalRequests: 0,
promptRequests: 0,
selectRequests: 0,
totalWaitingTimeMs: 0,
},
llm: {
apiCalls: 0,
processingTimeMs: 0,
tokens: {
input: 0,
output: 0,
total: 0,
},
},
tools: {
byTool: [],
totalCalls: 0,
totalTimeMs: 0,
},
},
};
return {
...defaultState,
...overrides,
} as AgentState;
};
/**
* Create a test context object for executor
*/
export const createTestContext = (
overrides: {
messageKey?: string;
operationId?: string;
parentId?: string;
sessionId?: string;
topicId?: string | null;
} = {},
) => {
return {
messageKey:
overrides.messageKey ||
`${overrides.sessionId || 'test-session'}_${overrides.topicId !== undefined ? overrides.topicId : 'test-topic'}`,
operationId: overrides.operationId || 'op_test',
parentId: overrides.parentId || 'msg_parent',
};
};
@@ -0,0 +1,545 @@
import type { ChatToolPayload } from '@lobechat/types';
import { describe, expect, it, vi } from 'vitest';
import {
createAssistantMessage,
createMockStore,
createRequestHumanApproveInstruction,
} from './fixtures';
import { createInitialState, createTestContext, executeWithMockContext } from './helpers';
describe('request_human_approve executor', () => {
describe('Basic Behavior', () => {
it('should create tool messages with pending intervention status', async () => {
// Given
const mockStore = createMockStore();
const assistantMessage = createAssistantMessage({ id: 'msg_assistant' });
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const toolCalls: ChatToolPayload[] = [
{
id: 'tool_1',
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: 'test' }),
type: 'default',
},
];
const instruction = createRequestHumanApproveInstruction(toolCalls);
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledTimes(1);
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: 'tool',
content: '',
plugin: toolCalls[0],
pluginIntervention: { status: 'pending' },
tool_call_id: 'tool_1',
parentId: 'msg_assistant',
groupId: assistantMessage.groupId,
}),
);
});
it('should update state to waiting_for_human', async () => {
// Given
const mockStore = createMockStore();
const assistantMessage = createAssistantMessage();
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const instruction = createRequestHumanApproveInstruction();
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.status).toBe('waiting_for_human');
});
it('should store pendingToolsCalling in state', async () => {
// Given
const mockStore = createMockStore();
const assistantMessage = createAssistantMessage();
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const toolCalls: ChatToolPayload[] = [
{
id: 'tool_1',
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: 'test' }),
type: 'default',
},
{
id: 'tool_2',
identifier: 'lobe-web-browsing',
apiName: 'craw',
arguments: JSON.stringify({ url: 'https://example.com' }),
type: 'default',
},
];
const instruction = createRequestHumanApproveInstruction(toolCalls);
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.pendingToolsCalling).toEqual(toolCalls);
});
it('should emit human_approve_required event', async () => {
// Given
const mockStore = createMockStore();
const assistantMessage = createAssistantMessage();
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const toolCalls: ChatToolPayload[] = [
{
id: 'tool_1',
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: 'test' }),
type: 'default',
},
];
const instruction = createRequestHumanApproveInstruction(toolCalls);
const state = createInitialState({ sessionId: 'test-session' });
// When
const result = await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.events).toHaveLength(1);
expect(result.events[0]).toMatchObject({
type: 'human_approve_required',
pendingToolsCalling: toolCalls,
sessionId: 'test-session',
});
});
});
describe('Assistant Message Handling', () => {
it('should throw error if no assistant message found', async () => {
// Given
const mockStore = createMockStore();
mockStore.dbMessagesMap['test-session_test-topic'] = []; // No messages
const context = createTestContext();
const instruction = createRequestHumanApproveInstruction();
const state = createInitialState();
// When/Then
await expect(
executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
}),
).rejects.toThrow('No assistant message found for intervention');
});
it('should use groupId from assistant message', async () => {
// Given
const mockStore = createMockStore();
const assistantMessage = createAssistantMessage({
id: 'msg_assistant',
groupId: 'group_123',
});
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const instruction = createRequestHumanApproveInstruction();
const state = createInitialState();
// When
await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
groupId: 'group_123',
}),
);
});
it('should use assistant message id as parentId', async () => {
// Given
const mockStore = createMockStore();
const assistantMessage = createAssistantMessage({ id: 'msg_assistant_456' });
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const instruction = createRequestHumanApproveInstruction();
const state = createInitialState();
// When
await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
parentId: 'msg_assistant_456',
}),
);
});
});
describe('Skip Create Tool Message Mode', () => {
it('should skip message creation when skipCreateToolMessage is true', async () => {
// Given
const mockStore = createMockStore();
const assistantMessage = createAssistantMessage();
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const toolCalls: ChatToolPayload[] = [
{
id: 'tool_1',
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: 'test' }),
type: 'default',
},
];
const instruction = createRequestHumanApproveInstruction(toolCalls, {
skipCreateToolMessage: true,
});
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).not.toHaveBeenCalled();
expect(result.newState.status).toBe('waiting_for_human');
expect(result.events).toHaveLength(1);
});
});
describe('Multiple Tool Messages', () => {
it('should create multiple tool messages for multiple pending tools', async () => {
// Given
const mockStore = createMockStore();
const assistantMessage = createAssistantMessage();
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const toolCalls: ChatToolPayload[] = [
{
id: 'tool_1',
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: 'test1' }),
type: 'default',
},
{
id: 'tool_2',
identifier: 'lobe-web-browsing',
apiName: 'craw',
arguments: JSON.stringify({ url: 'https://example.com' }),
type: 'default',
},
{
id: 'tool_3',
identifier: 'lobe-image-generator',
apiName: 'generate',
arguments: JSON.stringify({ prompt: 'test' }),
type: 'default',
},
];
const instruction = createRequestHumanApproveInstruction(toolCalls);
const state = createInitialState();
// When
await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledTimes(3);
toolCalls.forEach((toolCall) => {
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
plugin: expect.objectContaining({
id: toolCall.id,
}),
tool_call_id: toolCall.id,
pluginIntervention: { status: 'pending' },
}),
);
});
});
});
describe('State Management', () => {
it('should update lastModified timestamp', async () => {
// Given
const mockStore = createMockStore();
const assistantMessage = createAssistantMessage();
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const instruction = createRequestHumanApproveInstruction();
const oldTimestamp = new Date('2024-01-01').toISOString();
const state = createInitialState({ lastModified: oldTimestamp });
// When
const result = await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.lastModified).not.toBe(oldTimestamp);
expect(new Date(result.newState.lastModified).getTime()).toBeGreaterThan(
new Date(oldTimestamp).getTime(),
);
});
it('should preserve other state fields', async () => {
// Given
const mockStore = createMockStore();
const assistantMessage = createAssistantMessage();
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const instruction = createRequestHumanApproveInstruction();
const state = createInitialState({
sessionId: 'test-session',
stepCount: 10,
messages: [{ role: 'user', content: 'test' } as any],
cost: {
total: 0.05,
calculatedAt: new Date().toISOString(),
currency: 'USD',
llm: { total: 0.04, currency: 'USD', byModel: [] },
tools: { total: 0.01, currency: 'USD', byTool: [] },
},
usage: {
humanInteraction: {
approvalRequests: 0,
promptRequests: 0,
selectRequests: 0,
totalWaitingTimeMs: 0,
},
llm: {
apiCalls: 2,
processingTimeMs: 100,
tokens: {
input: 100,
output: 200,
total: 300,
},
},
tools: {
totalCalls: 2,
totalTimeMs: 500,
byTool: [],
},
},
});
// When
const result = await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.sessionId).toBe(state.sessionId);
expect(result.newState.stepCount).toBe(state.stepCount);
expect(result.newState.messages).toEqual(state.messages);
expect(result.newState.usage).toEqual(state.usage);
});
it('should not mutate original state', async () => {
// Given
const mockStore = createMockStore();
const assistantMessage = createAssistantMessage();
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const instruction = createRequestHumanApproveInstruction();
const state = createInitialState({ status: 'running' });
const originalState = JSON.parse(JSON.stringify(state));
// When
const result = await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(state).toEqual(originalState);
expect(result.newState).not.toBe(state);
expect(result.newState.status).toBe('waiting_for_human');
expect(state.status).toBe('running');
});
});
describe('Error Handling', () => {
it('should throw error if message creation fails', async () => {
// Given
const mockStore = createMockStore({
optimisticCreateMessage: vi.fn().mockResolvedValue(null),
});
const assistantMessage = createAssistantMessage();
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const instruction = createRequestHumanApproveInstruction();
const state = createInitialState();
// When/Then
await expect(
executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
}),
).rejects.toThrow('Failed to create tool message');
});
});
describe('Edge Cases', () => {
it('should handle very large number of pending tools', async () => {
// Given
const mockStore = createMockStore();
const assistantMessage = createAssistantMessage();
mockStore.dbMessagesMap['test-session_test-topic'] = [assistantMessage];
const context = createTestContext();
const toolCalls: ChatToolPayload[] = Array.from({ length: 50 }, (_, i) => ({
id: `tool_${i}`,
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: `query_${i}` }),
type: 'default' as const,
}));
const instruction = createRequestHumanApproveInstruction(toolCalls);
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledTimes(50);
expect(result.newState.pendingToolsCalling).toHaveLength(50);
});
it('should find last assistant message in conversation with multiple messages', async () => {
// Given
const mockStore = createMockStore();
const messages = [
createAssistantMessage({ id: 'msg_1' }),
{ id: 'msg_user_1', role: 'user', content: 'Hello' } as any,
createAssistantMessage({ id: 'msg_2' }),
{ id: 'msg_user_2', role: 'user', content: 'Follow up' } as any,
createAssistantMessage({ id: 'msg_3_last' }),
];
mockStore.dbMessagesMap['test-session_test-topic'] = messages;
const context = createTestContext();
const instruction = createRequestHumanApproveInstruction();
const state = createInitialState();
// When
await executeWithMockContext({
executor: 'request_human_approve',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
parentId: 'msg_3_last',
}),
);
});
});
});
@@ -0,0 +1,686 @@
import type { AgentEventDone } from '@lobechat/agent-runtime';
import type { ChatToolPayload } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import {
createAssistantMessage,
createMockStore,
createResolveAbortedToolsInstruction,
} from './fixtures';
import {
createInitialState,
createTestContext,
executeWithMockContext,
expectMessageCreated,
} from './helpers';
describe('resolve_aborted_tools executor', () => {
describe('Basic Behavior', () => {
it('should create tool messages with aborted status', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext({ sessionId: 'test-session', topicId: 'test-topic' });
const toolCalls: ChatToolPayload[] = [
{
id: 'tool_1',
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: 'test' }),
type: 'default',
},
];
const parentMessage = createAssistantMessage();
const instruction = createResolveAbortedToolsInstruction(toolCalls, parentMessage.id);
const state = createInitialState({ sessionId: 'test-session' });
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledTimes(1);
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: 'tool',
content: 'Tool execution was aborted by user.',
plugin: toolCalls[0],
pluginIntervention: { status: 'aborted' },
tool_call_id: 'tool_1',
sessionId: 'test-session',
topicId: 'test-topic',
parentId: parentMessage.id,
}),
);
});
it('should handle multiple aborted tools', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext({ sessionId: 'test-session' });
const toolCalls: ChatToolPayload[] = [
{
id: 'tool_1',
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: 'test1' }),
type: 'default',
},
{
id: 'tool_2',
identifier: 'lobe-web-browsing',
apiName: 'craw',
arguments: JSON.stringify({ url: 'https://example.com' }),
type: 'default',
},
{
id: 'tool_3',
identifier: 'lobe-image-generator',
apiName: 'generate',
arguments: JSON.stringify({ prompt: 'test prompt' }),
type: 'default',
},
];
const instruction = createResolveAbortedToolsInstruction(toolCalls);
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledTimes(3);
// Verify each tool message
toolCalls.forEach((toolCall) => {
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: 'tool',
content: 'Tool execution was aborted by user.',
plugin: toolCall,
pluginIntervention: { status: 'aborted' },
tool_call_id: toolCall.id,
}),
);
});
});
it('should mark state as done', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createResolveAbortedToolsInstruction();
const state = createInitialState({ status: 'waiting_for_human' });
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.status).toBe('done');
});
it('should emit done event with user_aborted reason', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createResolveAbortedToolsInstruction();
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.events).toHaveLength(1);
const doneEvent = result.events[0] as AgentEventDone;
expect(doneEvent).toMatchObject({
type: 'done',
reason: 'user_aborted',
reasonDetail: 'User aborted operation with pending tool calls',
finalState: result.newState,
});
});
});
describe('Tool Message Creation', () => {
it('should create tool messages with correct structure', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext({ sessionId: 'sess_123', topicId: 'topic_456' });
const toolCall: ChatToolPayload = {
id: 'tool_abc',
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: 'AI news' }),
type: 'default',
};
const instruction = createResolveAbortedToolsInstruction([toolCall], 'msg_parent');
const state = createInitialState();
// When
await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context: { ...context, sessionId: 'sess_123', topicId: 'topic_456' },
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith({
role: 'tool',
content: 'Tool execution was aborted by user.',
plugin: toolCall,
pluginIntervention: { status: 'aborted' },
tool_call_id: 'tool_abc',
parentId: 'msg_parent',
sessionId: 'sess_123',
topicId: 'topic_456',
threadId: undefined,
});
});
it('should preserve tool payload details', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const toolCall: ChatToolPayload = {
id: 'tool_complex',
identifier: 'custom-plugin',
apiName: 'complexApi',
arguments: JSON.stringify({
param1: 'value1',
param2: { nested: 'value2' },
param3: [1, 2, 3],
}),
type: 'builtin',
};
const instruction = createResolveAbortedToolsInstruction([toolCall]);
const state = createInitialState();
// When
await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
plugin: toolCall,
}),
);
});
it('should handle tool without topicId', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext({ sessionId: 'test-session', topicId: null });
const instruction = createResolveAbortedToolsInstruction();
const state = createInitialState();
// When
await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context: { ...context, sessionId: 'test-session', topicId: null },
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
topicId: undefined,
}),
);
});
});
describe('State Management', () => {
it('should update lastModified timestamp', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createResolveAbortedToolsInstruction();
const oldTimestamp = new Date('2024-01-01').toISOString();
const state = createInitialState({ lastModified: oldTimestamp });
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.lastModified).not.toBe(oldTimestamp);
expect(new Date(result.newState.lastModified).getTime()).toBeGreaterThan(
new Date(oldTimestamp).getTime(),
);
});
it('should preserve other state fields', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createResolveAbortedToolsInstruction();
const state = createInitialState({
sessionId: 'test-session',
stepCount: 10,
messages: [{ role: 'user', content: 'test' } as any],
cost: {
total: 0.05,
calculatedAt: new Date().toISOString(),
currency: 'USD',
llm: { total: 0.04, currency: 'USD', byModel: [] },
tools: { total: 0.01, currency: 'USD', byTool: [] },
},
usage: {
humanInteraction: {
approvalRequests: 0,
promptRequests: 0,
selectRequests: 0,
totalWaitingTimeMs: 0,
},
llm: {
apiCalls: 2,
processingTimeMs: 100,
tokens: {
input: 100,
output: 200,
total: 300,
},
},
tools: {
totalCalls: 2,
totalTimeMs: 500,
byTool: [],
},
},
});
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.newState.sessionId).toBe(state.sessionId);
expect(result.newState.stepCount).toBe(state.stepCount);
expect(result.newState.messages).toEqual(state.messages);
expect(result.newState.usage).toEqual(state.usage);
});
it('should not mutate original state', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createResolveAbortedToolsInstruction();
const state = createInitialState({ status: 'waiting_for_human' });
const originalState = JSON.parse(JSON.stringify(state));
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(state).toEqual(originalState);
expect(result.newState).not.toBe(state);
expect(result.newState.status).toBe('done');
expect(state.status).toBe('waiting_for_human');
});
});
describe('Event Handling', () => {
it('should emit single done event', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createResolveAbortedToolsInstruction();
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(result.events).toHaveLength(1);
expect(result.events[0].type).toBe('done');
});
it('should include finalState in event', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createResolveAbortedToolsInstruction();
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
const doneEvent = result.events[0] as AgentEventDone;
expect(doneEvent.finalState).toEqual(result.newState);
expect(doneEvent.finalState.status).toBe('done');
});
});
describe('Edge Cases', () => {
it('should handle empty toolsCalling array', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
// Manually construct instruction with truly empty array
const instruction: any = {
type: 'resolve_aborted_tools',
payload: {
toolsCalling: [],
parentMessageId: 'msg_parent',
},
};
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).not.toHaveBeenCalled();
expect(result.newState.status).toBe('done');
expect(result.events).toHaveLength(1);
});
it('should handle tools with special characters in arguments', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const toolCall: ChatToolPayload = {
id: 'tool_special',
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({
query: 'Test with "quotes" and \'apostrophes\' and <tags>',
}),
type: 'default',
};
const instruction = createResolveAbortedToolsInstruction([toolCall]);
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
plugin: toolCall,
}),
);
});
it('should handle very large toolsCalling array', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const toolCalls: ChatToolPayload[] = Array.from({ length: 50 }, (_, i) => ({
id: `tool_${i}`,
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: `query_${i}` }),
type: 'default' as const,
}));
const instruction = createResolveAbortedToolsInstruction(toolCalls);
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledTimes(50);
expect(result.newState.status).toBe('done');
});
it('should handle failed message creation gracefully', async () => {
// Given
const mockStore = createMockStore({
optimisticCreateMessage: vi.fn().mockResolvedValue(null),
});
const context = createTestContext();
const instruction = createResolveAbortedToolsInstruction();
const state = createInitialState();
// When
const result = await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then - should complete despite message creation failure
expect(result.newState.status).toBe('done');
expect(result.events).toHaveLength(1);
});
});
describe('Different Tool Types', () => {
it('should handle builtin tools', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const toolCall: ChatToolPayload = {
id: 'tool_builtin',
identifier: 'builtin-search',
apiName: 'vectorSearch',
arguments: JSON.stringify({ query: 'test' }),
type: 'builtin',
};
const instruction = createResolveAbortedToolsInstruction([toolCall]);
const state = createInitialState();
// When
await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
plugin: expect.objectContaining({
type: 'builtin',
}),
}),
);
});
it('should handle default/plugin tools', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const toolCall: ChatToolPayload = {
id: 'tool_plugin',
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: 'test' }),
type: 'default',
};
const instruction = createResolveAbortedToolsInstruction([toolCall]);
const state = createInitialState();
// When
await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
plugin: expect.objectContaining({
type: 'default',
}),
}),
);
});
it('should handle mixed tool types', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const toolCalls: ChatToolPayload[] = [
{
id: 'tool_1',
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: 'test' }),
type: 'default',
},
{
id: 'tool_2',
identifier: 'builtin-search',
apiName: 'vectorSearch',
arguments: JSON.stringify({ query: 'test' }),
type: 'builtin',
},
];
const instruction = createResolveAbortedToolsInstruction(toolCalls);
const state = createInitialState();
// When
await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledTimes(2);
});
});
describe('Concurrent Tool Message Creation', () => {
it('should create all tool messages concurrently', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const toolCalls: ChatToolPayload[] = Array.from({ length: 5 }, (_, i) => ({
id: `tool_${i}`,
identifier: 'lobe-web-browsing',
apiName: 'search',
arguments: JSON.stringify({ query: `query_${i}` }),
type: 'default' as const,
}));
const instruction = createResolveAbortedToolsInstruction(toolCalls);
const state = createInitialState();
const startTime = Date.now();
// When
await executeWithMockContext({
executor: 'resolve_aborted_tools',
instruction,
state,
mockStore,
context,
});
const duration = Date.now() - startTime;
// Then - should complete quickly (concurrent execution)
expect(duration).toBeLessThan(100); // Should be fast since mocked
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledTimes(5);
});
});
});
+313 -80
View File
@@ -29,24 +29,34 @@ const TOOL_PRICING: Record<string, number> = {
/**
* Creates custom executors for the Chat Agent Runtime
* These executors wrap existing chat store methods to integrate with agent-runtime
*
* @param context.operationId - Operation ID to get business context (sessionId, topicId, etc.)
* @param context.get - Store getter function
* @param context.messageKey - Message map key
* @param context.parentId - Parent message ID
* @param context.skipCreateFirstMessage - Skip first message creation
*/
export const createAgentExecutors = (context: {
get: () => ChatStore;
messageKey: string;
params: {
inPortalThread?: boolean;
inSearchWorkflow?: boolean;
ragQuery?: string;
sessionId?: string;
threadId?: string;
topicId?: string | null;
traceId?: string;
};
operationId: string;
parentId: string;
skipCreateFirstMessage?: boolean;
}) => {
let shouldSkipCreateMessage = context.skipCreateFirstMessage;
/**
* Get operation context via closure
* Returns the business context (sessionId, topicId, etc.) captured by the operation
*/
const getOperationContext = () => {
const operation = context.get().operations[context.operationId];
if (!operation) {
throw new Error(`Operation not found: ${context.operationId}`);
}
return operation.context;
};
/* eslint-disable sort-keys-fix/sort-keys-fix */
const executors: Partial<Record<AgentInstruction['type'], InstructionExecutor>> = {
/**
@@ -60,8 +70,6 @@ export const createAgentExecutors = (context: {
const llmPayload = (instruction as AgentInstructionCallLlm)
.payload as GeneralAgentCallLLMInstructionPayload;
const events: AgentEvent[] = [];
log(`${stagePrefix} Starting session`);
let assistantMessageId: string;
@@ -71,6 +79,9 @@ export const createAgentExecutors = (context: {
assistantMessageId = context.parentId;
shouldSkipCreateMessage = false;
} else {
// Get context from operation
const opContext = getOperationContext();
// 如果是 userMessage 的第一次 regenerated 创建, llmPayload 不存在 parentMessageId
// 因此用这种方式做个赋值
// TODO: 也许未来这个应该用 init 方法实现
@@ -78,27 +89,24 @@ export const createAgentExecutors = (context: {
llmPayload.parentMessageId = context.parentId;
}
// Create assistant message (following server-side pattern)
const assistantMessageItem = await context.get().optimisticCreateMessage(
{
content: LOADING_FLAT,
model: llmPayload.model,
parentId: llmPayload.parentMessageId,
provider: llmPayload.provider,
role: 'assistant',
sessionId: state.metadata!.sessionId!,
threadId: state.metadata?.threadId,
topicId: state.metadata?.topicId,
},
{
sessionId: state.metadata!.sessionId!,
topicId: state.metadata?.topicId,
},
);
const assistantMessageItem = await context.get().optimisticCreateMessage({
content: LOADING_FLAT,
model: llmPayload.model,
parentId: llmPayload.parentMessageId,
provider: llmPayload.provider,
role: 'assistant',
sessionId: opContext.sessionId!,
threadId: opContext.threadId,
topicId: opContext.topicId ?? undefined,
});
if (!assistantMessageItem) {
throw new Error('Failed to create assistant message');
}
assistantMessageId = assistantMessageItem.id;
// Associate the assistant message with the operation for UI loading states
context.get().associateMessageWithOperation(assistantMessageId, context.operationId);
}
log(`${stagePrefix} Created assistant message, id: %s`, assistantMessageId);
@@ -124,12 +132,13 @@ export const createAgentExecutors = (context: {
tools,
usage: currentStepUsage,
tool_calls,
finishType,
} = await context.get().internal_fetchAIChatMessage({
messageId: assistantMessageId,
messages: messages,
messages,
model: llmPayload.model,
params: context.params,
provider: llmPayload.provider,
operationId: context.operationId,
});
log(`[${sessionLogId}] finish model-runtime calling`);
@@ -142,6 +151,7 @@ export const createAgentExecutors = (context: {
const toolCalls = tools || [];
// Log llm result
if (content) {
log(`[${sessionLogId}][content]`, content);
}
@@ -157,32 +167,12 @@ export const createAgentExecutors = (context: {
log(`[${sessionLogId}][usage] %O`, currentStepUsage);
}
// Add llm_stream events (similar to backend)
if (content) {
events.push({
chunk: { text: content, type: 'text' },
type: 'llm_stream',
});
}
if (assistantMessage?.reasoning?.content) {
events.push({
chunk: { text: assistantMessage.reasoning.content, type: 'reasoning' },
type: 'llm_stream',
});
}
events.push({
result: {
content,
reasoning: assistantMessage?.reasoning?.content,
tool_calls: toolCalls,
usage: currentStepUsage,
},
type: 'llm_result',
});
log('[%s:%d] call_llm completed', state.sessionId, state.stepCount);
log(
'[%s:%d] call_llm completed, finishType: %s',
state.sessionId,
state.stepCount,
finishType,
);
// Accumulate usage and cost to state
const newState = { ...state, messages: latestMessages };
@@ -201,8 +191,38 @@ export const createAgentExecutors = (context: {
if (cost) newState.cost = cost;
}
// If operation was aborted, enter human_abort phase to let agent decide how to handle
if (finishType === 'abort') {
log(
'[%s:%d] call_llm aborted by user, entering human_abort phase',
state.sessionId,
state.stepCount,
);
return {
events: [],
newState,
nextContext: {
payload: {
reason: 'user_cancelled',
parentMessageId: assistantMessageId,
hasToolsCalling: isFunctionCall,
toolsCalling: toolCalls,
result: { content, tool_calls },
},
phase: 'human_abort',
session: {
messageCount: newState.messages.length,
sessionId: state.sessionId,
status: 'running',
stepCount: state.stepCount + 1,
},
} as AgentRuntimeContext,
};
}
return {
events,
events: [],
newState,
nextContext: {
payload: {
@@ -213,7 +233,6 @@ export const createAgentExecutors = (context: {
} as GeneralAgentCallLLMResultPayload,
phase: 'llm_result',
session: {
eventCount: events.length,
messageCount: newState.messages.length,
sessionId: state.sessionId,
status: 'running',
@@ -244,6 +263,27 @@ export const createAgentExecutors = (context: {
const toolName = `${chatToolPayload.identifier}/${chatToolPayload.apiName}`;
const startTime = performance.now();
// Get context from operation
const opContext = getOperationContext();
let toolOperationId: string | undefined;
// ============ Create toolCalling operation (top-level) ============
const { operationId } = context.get().startOperation({
type: 'toolCalling',
context: {
sessionId: opContext.sessionId!,
topicId: opContext.topicId,
},
parentOperationId: context.operationId,
metadata: {
startTime: Date.now(),
identifier: chatToolPayload.identifier,
apiName: chatToolPayload.apiName,
tool_call_id: chatToolPayload.id,
},
});
toolOperationId = operationId;
try {
// Get assistant message to extract groupId
const latestMessages = context.get().dbMessagesMap[context.messageKey] || [];
@@ -272,29 +312,76 @@ export const createAgentExecutors = (context: {
chatToolPayload.id,
);
// ============ Sub-operation 1: Create tool message ============
const createToolMsgOpId = context.get().startOperation({
type: 'createToolMessage',
context: {
sessionId: opContext.sessionId!,
topicId: opContext.topicId,
},
parentOperationId: toolOperationId,
metadata: {
startTime: Date.now(),
tool_call_id: chatToolPayload.id,
},
}).operationId;
// Register cancel handler: Ensure message creation completes, then mark as aborted
context.get().onOperationCancel(createToolMsgOpId, async ({ metadata }) => {
log(
'[%s][call_tool] createToolMessage cancelled, ensuring creation completes',
sessionLogId,
);
// Wait for message creation to complete (ensure-complete strategy)
const createResult = await metadata?.createMessagePromise;
if (createResult) {
const msgId = createResult.id;
// Update message to aborted state
await Promise.all([
context
.get()
.optimisticUpdateMessageContent(
msgId,
'Tool execution was cancelled by user.',
undefined,
{ operationId: createToolMsgOpId },
),
context
.get()
.optimisticUpdateMessagePlugin(
msgId,
{ intervention: { status: 'aborted' } },
{ operationId: createToolMsgOpId },
),
]);
}
});
// Execute creation and save Promise to metadata
const toolMessageParams: CreateMessageParams = {
content: '',
groupId: assistantMessage?.groupId,
parentId: payload.parentMessageId,
plugin: chatToolPayload,
role: 'tool',
sessionId: state.metadata!.sessionId!,
threadId: context.params.threadId,
sessionId: opContext.sessionId!,
threadId: opContext.threadId,
tool_call_id: chatToolPayload.id,
topicId: state.metadata?.topicId,
topicId: opContext.topicId ?? undefined,
};
const createResult = await context.get().optimisticCreateMessage(toolMessageParams, {
sessionId: state.metadata!.sessionId!,
topicId: state.metadata?.topicId,
const createPromise = context.get().optimisticCreateMessage(toolMessageParams);
context.get().updateOperationMetadata(createToolMsgOpId, {
createMessagePromise: createPromise,
});
const createResult = await createPromise;
if (!createResult) {
log(
'[%s][call_tool] ERROR: Failed to create tool message for tool_call_id: %s',
sessionLogId,
chatToolPayload.id,
);
context.get().failOperation(createToolMsgOpId, {
type: 'CreateMessageError',
message: `Failed to create tool message for tool_call_id: ${chatToolPayload.id}`,
});
throw new Error(
`Failed to create tool message for tool_call_id: ${chatToolPayload.id}`,
);
@@ -302,18 +389,80 @@ export const createAgentExecutors = (context: {
toolMessageId = createResult.id;
log('[%s][call_tool] Created tool message, id: %s', sessionLogId, toolMessageId);
context.get().completeOperation(createToolMsgOpId);
}
// Execute tool
// Check if parent operation was cancelled while creating message
const toolOperation = toolOperationId
? context.get().operations[toolOperationId]
: undefined;
if (toolOperation?.abortController.signal.aborted) {
log('[%s][call_tool] Parent operation cancelled, skipping tool execution', sessionLogId);
// Message already created with aborted status by cancel handler
return { events, newState: state };
}
// ============ Sub-operation 2: Execute tool call ============
// Auto-associates message with this operation via messageId in context
const { operationId: executeToolOpId } = context.get().startOperation({
type: 'executeToolCall',
context: {
messageId: toolMessageId,
},
parentOperationId: toolOperationId,
metadata: {
startTime: Date.now(),
tool_call_id: chatToolPayload.id,
},
});
log(
'[%s][call_tool] Created executeToolCall operation %s for message %s',
sessionLogId,
executeToolOpId,
toolMessageId,
);
// Register cancel handler: Just update message (message already exists)
context.get().onOperationCancel(executeToolOpId, async () => {
log('[%s][call_tool] executeToolCall cancelled, updating message', sessionLogId);
// Update message to aborted state (cleanup strategy)
await Promise.all([
context
.get()
.optimisticUpdateMessageContent(
toolMessageId,
'Tool execution was cancelled by user.',
undefined,
{ operationId: executeToolOpId },
),
context
.get()
.optimisticUpdateMessagePlugin(
toolMessageId,
{ intervention: { status: 'aborted' } },
{ operationId: executeToolOpId },
),
]);
});
// Execute tool - abort handling is done by cancel handler
log('[%s][call_tool] Executing tool %s ...', sessionLogId, toolName);
// This method handles:
// - Tool execution (builtin, plugin, MCP)
// - Content updates via optimisticUpdateMessageContent
// - Error handling via internal_updateMessageError
const result = await context
.get()
.internal_invokeDifferentTypePlugin(toolMessageId, chatToolPayload);
// Check if operation was cancelled during tool execution
const executeToolOperation = context.get().operations[executeToolOpId];
if (executeToolOperation?.abortController.signal.aborted) {
log('[%s][call_tool] Tool execution completed but operation was cancelled', sessionLogId);
// Don't complete - cancel handler already updated message to aborted
return { events, newState: state };
}
context.get().completeOperation(executeToolOpId);
const executionTime = Math.round(performance.now() - startTime);
const isSuccess = !result.error;
@@ -325,6 +474,18 @@ export const createAgentExecutors = (context: {
result,
);
// Complete or fail the toolCalling operation
if (toolOperationId) {
if (isSuccess) {
context.get().completeOperation(toolOperationId);
} else {
context.get().failOperation(toolOperationId, {
type: 'ToolExecutionError',
message: result.error || 'Tool execution failed',
});
}
}
events.push({ id: chatToolPayload.id, result, type: 'tool_result' });
// Get latest messages from store (already updated by internal_invokeDifferentTypePlugin)
@@ -443,6 +604,9 @@ export const createAgentExecutors = (context: {
// Resumption mode: Tool messages already exist, just verify them
log('[%s][request_human_approve] Resuming with existing tool messages', sessionLogId);
} else {
// Get context from operation
const opContext = getOperationContext();
// Create tool messages for each pending tool call with intervention status
await pMap(pendingToolsCalling, async (toolPayload) => {
const toolName = `${toolPayload.identifier}/${toolPayload.apiName}`;
@@ -462,16 +626,13 @@ export const createAgentExecutors = (context: {
},
pluginIntervention: { status: 'pending' },
role: 'tool',
sessionId: state.metadata!.sessionId!,
threadId: context.params.threadId,
sessionId: opContext.sessionId!,
threadId: opContext.threadId,
tool_call_id: toolPayload.id,
topicId: state.metadata?.topicId,
topicId: opContext.topicId ?? undefined,
};
const createResult = await context.get().optimisticCreateMessage(toolMessageParams, {
sessionId: state.metadata!.sessionId!,
topicId: state.metadata?.topicId,
});
const createResult = await context.get().optimisticCreateMessage(toolMessageParams);
if (!createResult) {
log(
@@ -504,6 +665,78 @@ export const createAgentExecutors = (context: {
return { events, newState };
},
/**
* Resolve aborted tools executor
* Creates tool messages with 'aborted' intervention status for cancelled tools
*/
resolve_aborted_tools: async (instruction, state) => {
const { parentMessageId, toolsCalling } = (
instruction as Extract<AgentInstruction, { type: 'resolve_aborted_tools' }>
).payload;
const events: AgentEvent[] = [];
const sessionLogId = `${state.sessionId}:${state.stepCount}`;
const newState = structuredClone(state);
log(
'[%s][resolve_aborted_tools] Resolving %d aborted tools',
sessionLogId,
toolsCalling.length,
);
// Get context from operation
const opContext = getOperationContext();
// Create tool messages for each aborted tool
await pMap(toolsCalling, async (toolPayload) => {
const toolName = `${toolPayload.identifier}/${toolPayload.apiName}`;
log(
'[%s][resolve_aborted_tools] Creating aborted tool message for %s',
sessionLogId,
toolName,
);
const toolMessageParams: CreateMessageParams = {
content: 'Tool execution was aborted by user.',
parentId: parentMessageId,
plugin: toolPayload,
pluginIntervention: { status: 'aborted' },
role: 'tool',
sessionId: opContext.sessionId!,
threadId: opContext.threadId,
tool_call_id: toolPayload.id,
topicId: opContext.topicId ?? undefined,
};
const createResult = await context.get().optimisticCreateMessage(toolMessageParams);
if (createResult) {
log(
'[%s][resolve_aborted_tools] Created aborted tool message: %s for %s',
sessionLogId,
createResult.id,
toolName,
);
}
});
log('[%s][resolve_aborted_tools] All aborted tool messages created', sessionLogId);
// Mark state as done since we're finishing after abort
newState.lastModified = new Date().toISOString();
newState.status = 'done';
events.push({
finalState: newState,
reason: 'user_aborted',
reasonDetail: 'User aborted operation with pending tool calls',
type: 'done',
});
return { events, newState };
},
/**
* Finish executor
* Completes the runtime execution
+1
View File
@@ -1,6 +1,7 @@
export { aiChatSelectors } from './slices/aiChat/selectors';
export { chatToolSelectors } from './slices/builtinTool/selectors';
export * from './slices/message/selectors';
export * from './slices/operation/selectors';
export * from './slices/portal/selectors';
export { threadSelectors } from './slices/thread/selectors';
export { topicSelectors } from './slices/topic/selectors';
@@ -0,0 +1,667 @@
/**
* Integration test for AI Chat with Operation Management System
* Tests the integration between AI chat actions and the unified operation system
*/
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { operationSelectors } from '@/store/chat/selectors';
import { useChatStore } from '@/store/chat/store';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
vi.mock('zustand/traditional');
describe('AI Chat Operation Integration Tests', () => {
beforeEach(() => {
act(() => {
useChatStore.setState({
activeId: 'test-session',
activeTopicId: 'test-topic',
operations: {},
operationsByType: {} as any,
operationsByMessage: {},
operationsByContext: {},
messageOperationMap: {},
mainInputEditor: undefined,
});
});
});
describe('SendMessage Operation Lifecycle', () => {
it('should create sendMessage operation with editor state', () => {
const { result } = renderHook(() => useChatStore());
const sessionId = 'test-session';
const topicId = 'test-topic';
const mockEditorState = { type: 'doc', content: [{ type: 'text', text: 'Hello' }] };
let operationId: string;
act(() => {
const { operationId: id } = result.current.startOperation({
type: 'sendMessage',
context: { sessionId, topicId },
metadata: {
inputEditorTempState: mockEditorState,
},
});
operationId = id;
});
const operation = result.current.operations[operationId!];
expect(operation).toBeDefined();
expect(operation.type).toBe('sendMessage');
expect(operation.status).toBe('running');
expect(operation.metadata.inputEditorTempState).toEqual(mockEditorState);
});
it('should restore editor state when cancelling sendMessage', () => {
const { result } = renderHook(() => useChatStore());
const sessionId = 'test-session';
const topicId = 'test-topic';
const mockEditorState = { type: 'doc', content: [{ type: 'text', text: 'Hello World' }] };
const mockEditor = {
setJSONState: vi.fn(),
};
// Set mock editor
act(() => {
useChatStore.setState({ mainInputEditor: mockEditor as any });
});
let operationId: string;
act(() => {
const { operationId: id } = result.current.startOperation({
type: 'sendMessage',
context: { sessionId, topicId },
metadata: {
inputEditorTempState: mockEditorState,
},
});
operationId = id;
});
// Cancel operation
act(() => {
result.current.cancelOperation(operationId!, 'User cancelled');
});
// Verify operation cancelled
expect(result.current.operations[operationId!].status).toBe('cancelled');
expect(result.current.operations[operationId!].metadata.cancelReason).toBe('User cancelled');
});
it('should handle error message in sendMessage operation', () => {
const { result } = renderHook(() => useChatStore());
const sessionId = 'test-session';
const topicId = 'test-topic';
let operationId: string;
act(() => {
const { operationId: id } = result.current.startOperation({
type: 'sendMessage',
context: { sessionId, topicId },
});
operationId = id;
});
// Set error message
const errorMsg = 'Failed to send message: Network error';
act(() => {
result.current.updateOperationMetadata(operationId!, {
inputSendErrorMsg: errorMsg,
});
});
// Verify error message stored
expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBe(errorMsg);
// Clear error message
act(() => {
result.current.updateOperationMetadata(operationId!, {
inputSendErrorMsg: undefined,
});
});
// Verify error message cleared
expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBeUndefined();
});
it('should handle abort controller for sendMessage operation', () => {
const { result } = renderHook(() => useChatStore());
let operationId = '';
let abortController: AbortController | undefined;
act(() => {
const res = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'test-session', topicId: 'test-topic' },
});
operationId = res.operationId;
abortController = res.abortController;
});
expect(abortController!.signal.aborted).toBe(false);
// Cancel operation
act(() => {
result.current.cancelOperation(operationId, 'User stopped');
});
expect(abortController!.signal.aborted).toBe(true);
expect(result.current.operations[operationId].status).toBe('cancelled');
});
});
describe('AI Generation Operation Integration', () => {
it('should create generateAI operation and associate with message', () => {
const { result } = renderHook(() => useChatStore());
const sessionId = 'session-1';
const topicId = 'topic-1';
const messageId = 'msg-1';
let operationId = '';
act(() => {
const { operationId: id } = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId, topicId, messageId },
label: 'AI Generation',
});
operationId = id;
});
// Associate message
act(() => {
result.current.associateMessageWithOperation(messageId, operationId);
});
// Verify operation and association
expect(result.current.operations[operationId]).toBeDefined();
expect(result.current.messageOperationMap[messageId]).toBe(operationId);
expect(operationSelectors.isAIGenerating(result.current)).toBe(true);
});
it('should handle AI generation with child operations (reasoning, toolCalling, rag)', () => {
const { result } = renderHook(() => useChatStore());
// Create parent generateAI operation
let parentOpId = '';
act(() => {
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-1', topicId: 'topic-1', messageId: 'msg-1' },
});
parentOpId = operationId;
});
// Create child operations without explicit context (should inherit)
let reasoningOpId = '';
let ragOpId = '';
let toolCallingOpId = '';
act(() => {
reasoningOpId = result.current.startOperation({
type: 'reasoning',
parentOperationId: parentOpId,
}).operationId;
ragOpId = result.current.startOperation({
type: 'rag',
parentOperationId: parentOpId,
}).operationId;
toolCallingOpId = result.current.startOperation({
type: 'toolCalling',
parentOperationId: parentOpId,
}).operationId;
});
// Verify all child operations inherited parent context
const parentContext = result.current.operations[parentOpId].context;
expect(result.current.operations[reasoningOpId].context).toEqual(parentContext);
expect(result.current.operations[ragOpId].context).toEqual(parentContext);
expect(result.current.operations[toolCallingOpId].context).toEqual(parentContext);
// Verify parent-child relationships
const parent = result.current.operations[parentOpId];
expect(parent.childOperationIds).toContain(reasoningOpId);
expect(parent.childOperationIds).toContain(ragOpId);
expect(parent.childOperationIds).toContain(toolCallingOpId);
// Verify all operations are running
expect(operationSelectors.getRunningOperations(result.current)).toHaveLength(4);
});
it('should cancel all child operations when parent AI generation is cancelled', () => {
const { result } = renderHook(() => useChatStore());
// Create complex operation hierarchy (AI generation -> tool calling -> plugin API)
let parentOpId = '';
let reasoningOpId = '';
let toolCallingOpId = '';
let pluginApiOpId = '';
act(() => {
parentOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-1', messageId: 'msg-1' },
}).operationId;
reasoningOpId = result.current.startOperation({
type: 'reasoning',
parentOperationId: parentOpId,
}).operationId;
toolCallingOpId = result.current.startOperation({
type: 'toolCalling',
parentOperationId: parentOpId,
}).operationId;
// Create grandchild operation
pluginApiOpId = result.current.startOperation({
type: 'pluginApi',
parentOperationId: toolCallingOpId,
}).operationId;
});
// Cancel parent AI generation
act(() => {
result.current.cancelOperation(parentOpId, 'User stopped generation');
});
// Verify entire hierarchy is cancelled
expect(result.current.operations[parentOpId].status).toBe('cancelled');
expect(result.current.operations[reasoningOpId].status).toBe('cancelled');
expect(result.current.operations[toolCallingOpId].status).toBe('cancelled');
expect(result.current.operations[pluginApiOpId].status).toBe('cancelled');
// Verify no running operations
expect(operationSelectors.hasAnyRunningOperation(result.current)).toBe(false);
expect(operationSelectors.canSendMessage(result.current)).toBe(true);
});
it('should complete AI generation and all child operations', () => {
const { result } = renderHook(() => useChatStore());
let parentOpId = '';
let childOpId = '';
act(() => {
parentOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-1' },
}).operationId;
childOpId = result.current.startOperation({
type: 'toolCalling',
parentOperationId: parentOpId,
}).operationId;
});
// Complete child first
act(() => {
result.current.completeOperation(childOpId);
});
expect(result.current.operations[childOpId].status).toBe('completed');
expect(result.current.operations[parentOpId].status).toBe('running');
// Complete parent
act(() => {
result.current.completeOperation(parentOpId);
});
expect(result.current.operations[parentOpId].status).toBe('completed');
expect(result.current.operations[parentOpId].metadata.duration).toBeGreaterThanOrEqual(0);
expect(operationSelectors.canSendMessage(result.current)).toBe(true);
});
});
describe('Multi-Context Operation Isolation', () => {
it('should handle multiple sendMessage operations in different contexts', () => {
const { result } = renderHook(() => useChatStore());
const op1Context = { sessionId: 'session-1', topicId: 'topic-a' };
const op2Context = { sessionId: 'session-1', topicId: 'topic-b' };
const op3Context = { sessionId: 'session-2', topicId: 'topic-a' };
let op1Id = '';
let op2Id = '';
let op3Id = '';
act(() => {
op1Id = result.current.startOperation({
type: 'sendMessage',
context: op1Context,
}).operationId;
op2Id = result.current.startOperation({
type: 'sendMessage',
context: op2Context,
}).operationId;
op3Id = result.current.startOperation({
type: 'sendMessage',
context: op3Context,
}).operationId;
});
// Verify all operations created with correct contexts
expect(result.current.operations[op1Id].context).toMatchObject(op1Context);
expect(result.current.operations[op2Id].context).toMatchObject(op2Context);
expect(result.current.operations[op3Id].context).toMatchObject(op3Context);
// Verify context index
const contextKey1 = messageMapKey('session-1', 'topic-a');
const contextKey2 = messageMapKey('session-1', 'topic-b');
const contextKey3 = messageMapKey('session-2', 'topic-a');
expect(result.current.operationsByContext[contextKey1]).toContain(op1Id);
expect(result.current.operationsByContext[contextKey2]).toContain(op2Id);
expect(result.current.operationsByContext[contextKey3]).toContain(op3Id);
});
it('should cancel operations only in specific topic', () => {
const { result } = renderHook(() => useChatStore());
let topicAOpId = '';
let topicBOpId = '';
act(() => {
topicAOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-1', topicId: 'topic-a' },
}).operationId;
topicBOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-1', topicId: 'topic-b' },
}).operationId;
});
// Cancel operations in topic-a only
let cancelledIds: string[] = [];
act(() => {
cancelledIds = result.current.cancelOperations({
sessionId: 'session-1',
topicId: 'topic-a',
});
});
// Verify only topic-a operations cancelled
expect(cancelledIds).toHaveLength(1);
expect(cancelledIds).toContain(topicAOpId);
expect(result.current.operations[topicAOpId].status).toBe('cancelled');
expect(result.current.operations[topicBOpId].status).toBe('running');
});
it('should isolate operations between different sessions', () => {
const { result } = renderHook(() => useChatStore());
let session1OpId = '';
let session2OpId = '';
act(() => {
session1OpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-1', topicId: 'topic-1' },
}).operationId;
session2OpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-2', topicId: 'topic-1' },
}).operationId;
});
// Cancel operations in session-1 only
let cancelledIds: string[] = [];
act(() => {
cancelledIds = result.current.cancelOperations({
sessionId: 'session-1',
});
});
// Verify only session-1 operations cancelled
expect(cancelledIds).toHaveLength(1);
expect(cancelledIds).toContain(session1OpId);
expect(result.current.operations[session1OpId].status).toBe('cancelled');
expect(result.current.operations[session2OpId].status).toBe('running');
});
});
describe('Operation Error Handling', () => {
it('should handle operation failure with error details', () => {
const { result } = renderHook(() => useChatStore());
let operationId = '';
act(() => {
const { operationId: id } = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-1' },
});
operationId = id;
});
// Fail operation with error
const error = {
type: 'NetworkError',
message: 'Failed to connect to AI service',
code: 'ERR_NETWORK',
details: { statusCode: 503 },
};
act(() => {
result.current.failOperation(operationId, error);
});
const operation = result.current.operations[operationId];
expect(operation.status).toBe('failed');
expect(operation.metadata.error).toEqual(error);
expect(operation.metadata.endTime).toBeDefined();
expect(operation.metadata.duration).toBeGreaterThanOrEqual(0);
});
it('should handle sendMessage error display', () => {
const { result } = renderHook(() => useChatStore());
const sessionId = 'session-1';
const topicId = 'topic-1';
let operationId = '';
act(() => {
const { operationId: id } = result.current.startOperation({
type: 'sendMessage',
context: { sessionId, topicId },
});
operationId = id;
});
// Set error message for UI display
const errorMsg = 'Message too long';
act(() => {
result.current.updateOperationMetadata(operationId, {
inputSendErrorMsg: errorMsg,
});
});
// Verify error message can be retrieved
expect(result.current.operations[operationId].metadata.inputSendErrorMsg).toBe(errorMsg);
// User fixes the error and clears it
act(() => {
result.current.updateOperationMetadata(operationId, {
inputSendErrorMsg: undefined,
});
});
expect(result.current.operations[operationId].metadata.inputSendErrorMsg).toBeUndefined();
});
});
describe('Tool Execution Cancellation', () => {
it('should abort tool execution when executeToolCall operation is cancelled', () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'tool-msg-1';
// Create toolCalling parent operation
let toolCallingOpId = '';
act(() => {
toolCallingOpId = result.current.startOperation({
type: 'toolCalling',
context: { sessionId: 'session-1', messageId },
}).operationId;
});
// Create executeToolCall child operation
let executeToolOpId = '';
let executeToolAbortController: AbortController | undefined;
act(() => {
const res = result.current.startOperation({
type: 'executeToolCall',
context: { sessionId: 'session-1', messageId },
parentOperationId: toolCallingOpId,
});
executeToolOpId = res.operationId;
executeToolAbortController = res.abortController;
});
// Associate message with executeToolCall operation (not parent)
act(() => {
result.current.associateMessageWithOperation(messageId, executeToolOpId);
});
// Verify message is associated with executeToolCall operation
expect(result.current.messageOperationMap[messageId]).toBe(executeToolOpId);
// Verify abort signal is not aborted yet
expect(executeToolAbortController!.signal.aborted).toBe(false);
// Cancel parent toolCalling operation (should cascade to child)
act(() => {
result.current.cancelOperation(toolCallingOpId, 'User stopped');
});
// Verify both operations are cancelled
expect(result.current.operations[toolCallingOpId].status).toBe('cancelled');
expect(result.current.operations[executeToolOpId].status).toBe('cancelled');
// Verify abort signal is triggered
expect(executeToolAbortController!.signal.aborted).toBe(true);
// Verify tool can check abort status via messageOperationMap
const toolOperation =
result.current.operations[result.current.messageOperationMap[messageId]];
expect(toolOperation.status).toBe('cancelled');
expect(toolOperation.abortController.signal.aborted).toBe(true);
});
it('should allow tool execution to check abort signal before starting', () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'tool-msg-2';
// Create and immediately cancel executeToolCall operation
let executeToolOpId = '';
let abortController: AbortController | undefined;
act(() => {
const res = result.current.startOperation({
type: 'executeToolCall',
context: { sessionId: 'session-1', messageId },
});
executeToolOpId = res.operationId;
abortController = res.abortController;
});
// Associate message
act(() => {
result.current.associateMessageWithOperation(messageId, executeToolOpId);
});
// Cancel immediately
act(() => {
result.current.cancelOperation(executeToolOpId, 'Cancelled before execution');
});
// Simulate tool checking abort signal before execution
const operationId = result.current.messageOperationMap[messageId];
const operation = operationId ? result.current.operations[operationId] : undefined;
const toolAbortController = operation?.abortController;
// Tool should detect cancellation
expect(toolAbortController?.signal.aborted).toBe(true);
expect(operation?.status).toBe('cancelled');
});
});
describe('Operation State Queries', () => {
it('should correctly report AI generation state', () => {
const { result } = renderHook(() => useChatStore());
// Initially no AI generation
expect(operationSelectors.isAIGenerating(result.current)).toBe(false);
expect(operationSelectors.canSendMessage(result.current)).toBe(true);
// Start AI generation in current context
let operationId = '';
act(() => {
const { operationId: id } = result.current.startOperation({
type: 'execAgentRuntime',
context: {
sessionId: result.current.activeId,
topicId: result.current.activeTopicId,
},
});
operationId = id;
});
expect(operationSelectors.isAIGenerating(result.current)).toBe(true);
expect(operationSelectors.canSendMessage(result.current)).toBe(false);
// Complete generation
act(() => {
result.current.completeOperation(operationId);
});
expect(operationSelectors.isAIGenerating(result.current)).toBe(false);
expect(operationSelectors.canSendMessage(result.current)).toBe(true);
});
it('should report running operations by type', () => {
const { result } = renderHook(() => useChatStore());
let sendOpId = '';
let genOpId1 = '';
let genOpId2 = '';
act(() => {
sendOpId = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session-1' },
}).operationId;
genOpId1 = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-1' },
}).operationId;
genOpId2 = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-2' },
}).operationId;
});
// Verify type index
expect(result.current.operationsByType.sendMessage).toContain(sendOpId);
expect(result.current.operationsByType.execAgentRuntime).toContain(genOpId1);
expect(result.current.operationsByType.execAgentRuntime).toContain(genOpId2);
// Complete one generateAI
act(() => {
result.current.completeOperation(genOpId1);
});
// Verify AI still generating (genOpId2 is still running)
expect(operationSelectors.isAIGenerating(result.current)).toBe(true);
});
});
});
@@ -1,7 +1,8 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { useChatStore } from '../../../../store';
import { messageMapKey } from '../../../../utils/messageMapKey';
describe('Cancel send message functionality tests', () => {
describe('cancelSendMessageInServer', () => {
@@ -13,7 +14,8 @@ describe('Cancel send message functionality tests', () => {
useChatStore.setState({
activeId: 'session-1',
activeTopicId: 'topic-1',
mainSendMessageOperations: {},
operations: {},
operationsByContext: {},
});
});
@@ -28,13 +30,91 @@ describe('Cancel send message functionality tests', () => {
}).not.toThrow();
});
it('should cancel running sendMessage operations', () => {
const { result } = renderHook(() => useChatStore());
const sessionId = 'session-1';
const topicId = 'topic-1';
act(() => {
useChatStore.setState({
activeId: sessionId,
activeTopicId: topicId,
});
});
// Start a sendMessage operation
let operationId: string;
act(() => {
const res = result.current.startOperation({
type: 'sendMessage',
context: { sessionId, topicId },
});
operationId = res.operationId;
});
expect(result.current.operations[operationId!].status).toBe('running');
// Cancel the operation
act(() => {
result.current.cancelSendMessageInServer();
});
expect(result.current.operations[operationId!].status).toBe('cancelled');
});
it('should restore editor state when cancelling', () => {
const { result } = renderHook(() => useChatStore());
const sessionId = 'session-1';
const topicId = 'topic-1';
const mockEditorState = { content: 'test message' };
// Mock editor
const mockEditor = {
setJSONState: vi.fn(),
getJSONState: vi.fn().mockReturnValue(mockEditorState),
};
act(() => {
useChatStore.setState({
activeId: sessionId,
activeTopicId: topicId,
mainInputEditor: mockEditor as any,
});
});
// Create operation with editor state
let operationId: string;
act(() => {
const res = result.current.startOperation({
type: 'sendMessage',
context: { sessionId, topicId },
});
operationId = res.operationId;
result.current.updateOperationMetadata(res.operationId, {
inputEditorTempState: mockEditorState,
});
});
// Cancel
act(() => {
result.current.cancelSendMessageInServer();
});
// Verify editor state was restored
expect(mockEditor.setJSONState).toHaveBeenCalledWith(mockEditorState);
});
it('should be able to call with specified topic ID', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({
activeId: 'session-1',
mainSendMessageOperations: {},
operations: {},
operationsByContext: {},
});
});
@@ -54,7 +134,8 @@ describe('Cancel send message functionality tests', () => {
useChatStore.setState({
activeId: 'session-1',
activeTopicId: 'topic-1',
mainSendMessageOperations: {},
operations: {},
operationsByContext: {},
});
});
@@ -66,42 +147,71 @@ describe('Cancel send message functionality tests', () => {
});
}).not.toThrow();
});
});
describe('Internal methods', () => {
it('should have internal state management methods', () => {
it('should clear error messages from sendMessage operations', () => {
const { result } = renderHook(() => useChatStore());
expect(typeof result.current.internal_toggleSendMessageOperation).toBe('function');
expect(typeof result.current.internal_updateSendMessageOperation).toBe('function');
});
it('internal_toggleSendMessageOperation should work normally', () => {
const { result } = renderHook(() => useChatStore());
const sessionId = 'session-1';
const topicId = 'topic-1';
act(() => {
useChatStore.setState({ mainSendMessageOperations: {} });
useChatStore.setState({
activeId: sessionId,
activeTopicId: topicId,
});
});
expect(() => {
act(() => {
const abortController = result.current.internal_toggleSendMessageOperation(
'test-key',
true,
);
expect(abortController).toBeInstanceOf(AbortController);
// Create operation with error
let operationId: string;
act(() => {
const res = result.current.startOperation({
type: 'sendMessage',
context: { sessionId, topicId },
});
}).not.toThrow();
operationId = res.operationId;
result.current.updateOperationMetadata(res.operationId, {
inputSendErrorMsg: 'Test error',
});
});
expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBe('Test error');
// Clear error
act(() => {
result.current.clearSendMessageError();
});
expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBeUndefined();
});
});
describe('State structure', () => {
it('should have mainSendMessageOperations state', () => {
describe('Operation system', () => {
it('should have operation management methods', () => {
const { result } = renderHook(() => useChatStore());
// Ensure state exists
expect(result.current.mainSendMessageOperations).toBeDefined();
expect(typeof result.current.mainSendMessageOperations).toBe('object');
expect(typeof result.current.startOperation).toBe('function');
expect(typeof result.current.cancelOperation).toBe('function');
expect(typeof result.current.updateOperationMetadata).toBe('function');
});
it('should track operations by context', () => {
const { result } = renderHook(() => useChatStore());
const sessionId = 'session-1';
const topicId = 'topic-1';
let operationId: string;
act(() => {
const res = result.current.startOperation({
type: 'sendMessage',
context: { sessionId, topicId },
});
operationId = res.operationId;
});
const contextKey = messageMapKey(sessionId, topicId);
expect(result.current.operationsByContext[contextKey]).toContain(operationId!);
});
});
});
@@ -19,103 +19,156 @@ afterEach(() => {
describe('ConversationControl actions', () => {
describe('stopGenerateMessage', () => {
it('should abort generation and clear loading state when controller exists', () => {
const abortController = new AbortController();
act(() => {
useChatStore.setState({ chatLoadingIdsAbortController: abortController });
});
it('should cancel running generateAI operations in current context', () => {
const { result } = renderHook(() => useChatStore());
const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading');
act(() => {
result.current.stopGenerateMessage();
});
expect(abortController.signal.aborted).toBe(true);
expect(toggleLoadingSpy).toHaveBeenCalledWith(false, undefined, expect.any(String));
});
it('should do nothing when abort controller is not set', () => {
act(() => {
useChatStore.setState({ chatLoadingIdsAbortController: undefined });
});
const { result } = renderHook(() => useChatStore());
const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading');
act(() => {
result.current.stopGenerateMessage();
});
expect(toggleLoadingSpy).not.toHaveBeenCalled();
});
});
describe('cancelSendMessageInServer', () => {
it('should abort operation and restore editor state when cancelling', () => {
const { result } = renderHook(() => useChatStore());
const mockAbort = vi.fn();
const mockSetJSONState = vi.fn();
act(() => {
useChatStore.setState({
activeId: TEST_IDS.SESSION_ID,
activeTopicId: TEST_IDS.TOPIC_ID,
mainSendMessageOperations: {
[messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)]: {
isLoading: true,
abortController: { abort: mockAbort, signal: {} as any },
inputEditorTempState: { content: 'saved content' },
},
});
});
// Create a generateAI operation
let operationId: string;
act(() => {
const res = result.current.startOperation({
type: 'execAgentRuntime',
context: {
sessionId: TEST_IDS.SESSION_ID,
topicId: TEST_IDS.TOPIC_ID,
},
});
operationId = res.operationId;
});
expect(result.current.operations[operationId!].status).toBe('running');
// Stop generation
act(() => {
result.current.stopGenerateMessage();
});
expect(result.current.operations[operationId!].status).toBe('cancelled');
});
it('should not cancel operations from different context', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({
activeId: TEST_IDS.SESSION_ID,
activeTopicId: TEST_IDS.TOPIC_ID,
});
});
// Create a generateAI operation in a different context
let operationId: string;
act(() => {
const res = result.current.startOperation({
type: 'execAgentRuntime',
context: {
sessionId: 'different-session',
topicId: 'different-topic',
},
});
operationId = res.operationId;
});
expect(result.current.operations[operationId!].status).toBe('running');
// Stop generation - should not affect different context
act(() => {
result.current.stopGenerateMessage();
});
expect(result.current.operations[operationId!].status).toBe('running');
});
});
describe('cancelSendMessageInServer', () => {
it('should cancel operation and restore editor state', () => {
const { result } = renderHook(() => useChatStore());
const mockSetJSONState = vi.fn();
const editorState = { content: 'saved content' };
act(() => {
useChatStore.setState({
activeId: TEST_IDS.SESSION_ID,
activeTopicId: TEST_IDS.TOPIC_ID,
mainInputEditor: { setJSONState: mockSetJSONState } as any,
});
});
// Create operation
let operationId: string;
act(() => {
const res = result.current.startOperation({
type: 'sendMessage',
context: {
sessionId: TEST_IDS.SESSION_ID,
topicId: TEST_IDS.TOPIC_ID,
},
});
operationId = res.operationId;
result.current.updateOperationMetadata(res.operationId, {
inputEditorTempState: editorState,
});
});
expect(result.current.operations[operationId!].status).toBe('running');
// Cancel
act(() => {
result.current.cancelSendMessageInServer();
});
expect(mockAbort).toHaveBeenCalledWith('User cancelled sendMessage operation');
expect(
result.current.mainSendMessageOperations[
messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)
]?.isLoading,
).toBe(false);
expect(mockSetJSONState).toHaveBeenCalledWith({ content: 'saved content' });
expect(result.current.operations[operationId!].status).toBe('cancelled');
expect(mockSetJSONState).toHaveBeenCalledWith(editorState);
});
it('should cancel operation for specified topic ID', () => {
const { result } = renderHook(() => useChatStore());
const mockAbort = vi.fn();
const customTopicId = 'custom-topic-id';
act(() => {
useChatStore.setState({
activeId: TEST_IDS.SESSION_ID,
mainSendMessageOperations: {
[messageMapKey(TEST_IDS.SESSION_ID, customTopicId)]: {
isLoading: true,
abortController: { abort: mockAbort, signal: {} as any },
},
},
});
});
// Create operation
let operationId: string;
act(() => {
const res = result.current.startOperation({
type: 'sendMessage',
context: {
sessionId: TEST_IDS.SESSION_ID,
topicId: customTopicId,
},
});
operationId = res.operationId;
});
expect(result.current.operations[operationId!].status).toBe('running');
// Cancel
act(() => {
result.current.cancelSendMessageInServer(customTopicId);
});
expect(mockAbort).toHaveBeenCalledWith('User cancelled sendMessage operation');
expect(result.current.operations[operationId!].status).toBe('cancelled');
});
it('should handle gracefully when operation does not exist', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({ mainSendMessageOperations: {} });
useChatStore.setState({
operations: {},
operationsByContext: {},
});
});
expect(() => {
@@ -134,31 +187,44 @@ describe('ConversationControl actions', () => {
useChatStore.setState({
activeId: TEST_IDS.SESSION_ID,
activeTopicId: TEST_IDS.TOPIC_ID,
mainSendMessageOperations: {
[messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)]: {
isLoading: false,
inputSendErrorMsg: 'Some error',
},
},
});
});
// Create operation with error
let operationId: string;
act(() => {
const res = result.current.startOperation({
type: 'sendMessage',
context: {
sessionId: TEST_IDS.SESSION_ID,
topicId: TEST_IDS.TOPIC_ID,
},
});
operationId = res.operationId;
result.current.updateOperationMetadata(res.operationId, {
inputSendErrorMsg: 'Some error',
});
});
expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBe('Some error');
// Clear error
act(() => {
result.current.clearSendMessageError();
});
expect(
result.current.mainSendMessageOperations[
messageMapKey(TEST_IDS.SESSION_ID, TEST_IDS.TOPIC_ID)
],
).toBeUndefined();
expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBeUndefined();
});
it('should handle gracefully when no error operation exists', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({ mainSendMessageOperations: {} });
useChatStore.setState({
operations: {},
operationsByContext: {},
});
});
expect(() => {
@@ -169,112 +235,80 @@ describe('ConversationControl actions', () => {
});
});
describe('internal_toggleSendMessageOperation', () => {
it('should create new send operation with abort controller', () => {
describe('Operation system integration', () => {
it('should create operation with abort controller', () => {
const { result } = renderHook(() => useChatStore());
let operationId: string = '';
let abortController: AbortController | undefined;
act(() => {
abortController = result.current.internal_toggleSendMessageOperation('test-key', true);
const res = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'test-session' },
});
operationId = res.operationId;
abortController = res.abortController;
});
expect(abortController!).toBeInstanceOf(AbortController);
expect(result.current.mainSendMessageOperations['test-key']?.isLoading).toBe(true);
expect(result.current.mainSendMessageOperations['test-key']?.abortController).toBe(
abortController,
);
expect(result.current.operations[operationId!].abortController).toBe(abortController);
expect(result.current.operations[operationId!].status).toBe('running');
});
it('should stop send operation and clear abort controller', () => {
it('should update operation metadata', () => {
const { result } = renderHook(() => useChatStore());
const mockAbortController = { abort: vi.fn() } as any;
let abortController: AbortController | undefined;
act(() => {
result.current.internal_updateSendMessageOperation('test-key', {
isLoading: true,
abortController: mockAbortController,
});
abortController = result.current.internal_toggleSendMessageOperation('test-key', false);
});
expect(abortController).toBeUndefined();
expect(result.current.mainSendMessageOperations['test-key']?.isLoading).toBe(false);
expect(result.current.mainSendMessageOperations['test-key']?.abortController).toBeNull();
});
it('should call abort with cancel reason when stopping', () => {
const { result } = renderHook(() => useChatStore());
const mockAbortController = { abort: vi.fn() } as any;
let operationId: string;
act(() => {
result.current.internal_updateSendMessageOperation('test-key', {
isLoading: true,
abortController: mockAbortController,
const res = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'test-session' },
});
operationId = res.operationId;
result.current.internal_toggleSendMessageOperation('test-key', false, 'Test cancel reason');
result.current.updateOperationMetadata(res.operationId, {
inputSendErrorMsg: 'test error',
inputEditorTempState: { content: 'test' },
});
});
expect(mockAbortController.abort).toHaveBeenCalledWith('Test cancel reason');
expect(result.current.operations[operationId!].metadata.inputSendErrorMsg).toBe('test error');
expect(result.current.operations[operationId!].metadata.inputEditorTempState).toEqual({
content: 'test',
});
});
it('should support multiple parallel operations', () => {
const { result } = renderHook(() => useChatStore());
let abortController1, abortController2;
act(() => {
abortController1 = result.current.internal_toggleSendMessageOperation('key1', true);
abortController2 = result.current.internal_toggleSendMessageOperation('key2', true);
});
expect(result.current.mainSendMessageOperations['key1']?.isLoading).toBe(true);
expect(result.current.mainSendMessageOperations['key2']?.isLoading).toBe(true);
expect(abortController1).not.toBe(abortController2);
});
});
describe('internal_updateSendMessageOperation', () => {
it('should update operation state', () => {
const { result } = renderHook(() => useChatStore());
const mockAbortController = new AbortController();
let opId1: string = '';
let opId2: string = '';
act(() => {
result.current.internal_updateSendMessageOperation('test-key', {
isLoading: true,
abortController: mockAbortController,
inputSendErrorMsg: 'test error',
const res1 = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session-1', topicId: 'topic-1' },
});
});
expect(result.current.mainSendMessageOperations['test-key']).toEqual({
isLoading: true,
abortController: mockAbortController,
inputSendErrorMsg: 'test error',
});
});
it('should support partial update of operation state', () => {
const { result } = renderHook(() => useChatStore());
const initialController = new AbortController();
act(() => {
result.current.internal_updateSendMessageOperation('test-key', {
isLoading: true,
abortController: initialController,
const res2 = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session-1', topicId: 'topic-2' },
});
result.current.internal_updateSendMessageOperation('test-key', {
inputSendErrorMsg: 'new error',
});
opId1 = res1.operationId;
opId2 = res2.operationId;
});
expect(result.current.mainSendMessageOperations['test-key']).toEqual({
isLoading: true,
abortController: initialController,
inputSendErrorMsg: 'new error',
});
expect(result.current.operations[opId1!].status).toBe('running');
expect(result.current.operations[opId2!].status).toBe('running');
expect(opId1).not.toBe(opId2);
const contextKey1 = messageMapKey('session-1', 'topic-1');
const contextKey2 = messageMapKey('session-1', 'topic-2');
expect(result.current.operationsByContext[contextKey1]).toContain(opId1!);
expect(result.current.operationsByContext[contextKey2]).toContain(opId2!);
});
});
@@ -292,7 +326,9 @@ describe('ConversationControl actions', () => {
await result.current.switchMessageBranch(messageId, branchIndex);
});
expect(optimisticUpdateSpy).toHaveBeenCalledWith(messageId, { activeBranchIndex: branchIndex });
expect(optimisticUpdateSpy).toHaveBeenCalledWith(messageId, {
activeBranchIndex: branchIndex,
});
});
it('should handle switching to branch 0', async () => {
@@ -326,7 +362,9 @@ describe('ConversationControl actions', () => {
}),
).rejects.toThrow('Update failed');
expect(optimisticUpdateSpy).toHaveBeenCalledWith(messageId, { activeBranchIndex: branchIndex });
expect(optimisticUpdateSpy).toHaveBeenCalledWith(messageId, {
activeBranchIndex: branchIndex,
});
});
});
});
@@ -151,9 +151,14 @@ describe('ConversationLifecycle actions', () => {
it('should not regenerate when already regenerating', async () => {
const { result } = renderHook(() => useChatStore());
// Create a regenerate operation to simulate already regenerating
act(() => {
const { operationId } = result.current.startOperation({
type: 'regenerate',
context: { sessionId: TEST_IDS.SESSION_ID, messageId: TEST_IDS.USER_MESSAGE_ID },
});
useChatStore.setState({
regeneratingIds: [TEST_IDS.USER_MESSAGE_ID],
internal_execAgentRuntime: vi.fn(),
});
});
@@ -204,9 +209,14 @@ describe('ConversationLifecycle actions', () => {
it('should not regenerate when already regenerating', async () => {
const { result } = renderHook(() => useChatStore());
// Create a regenerate operation to simulate already regenerating
act(() => {
result.current.startOperation({
type: 'regenerate',
context: { sessionId: TEST_IDS.SESSION_ID, messageId: TEST_IDS.MESSAGE_ID },
});
useChatStore.setState({
regeneratingIds: [TEST_IDS.MESSAGE_ID],
internal_execAgentRuntime: vi.fn(),
});
});
@@ -59,8 +59,6 @@ export const createMockChatConfig = (overrides = {}) => ({
export const createMockStoreState = (overrides = {}) => ({
activeId: TEST_IDS.SESSION_ID,
activeTopicId: TEST_IDS.TOPIC_ID,
chatLoadingIds: [],
chatLoadingIdsAbortController: undefined,
messagesMap: {},
toolCallingStreamIds: {},
...overrides,
@@ -109,8 +109,6 @@ export const resetTestEnvironment = () => {
{
activeId: TEST_IDS.SESSION_ID,
activeTopicId: TEST_IDS.TOPIC_ID,
chatLoadingIds: [],
chatLoadingIdsAbortController: undefined,
messagesMap: {},
toolCallingStreamIds: {},
},
@@ -68,7 +68,7 @@ describe('StreamingExecutor actions', () => {
await onErrorHandle?.({ type: 'InvalidProviderAPIKey', message: 'Network error' } as any);
});
const updateMessageErrorSpy = vi.spyOn(messageService, 'updateMessageError');
const updateMessageSpy = vi.spyOn(messageService, 'updateMessage');
await act(async () => {
await result.current.internal_fetchAIChatMessage({
@@ -79,12 +79,14 @@ describe('StreamingExecutor actions', () => {
});
});
expect(updateMessageErrorSpy).toHaveBeenCalledWith(
expect(updateMessageSpy).toHaveBeenCalledWith(
TEST_IDS.ASSISTANT_MESSAGE_ID,
expect.objectContaining({ type: 'InvalidProviderAPIKey' }),
expect.objectContaining({
error: expect.objectContaining({ type: 'InvalidProviderAPIKey' }),
}),
expect.objectContaining({
sessionId: TEST_IDS.SESSION_ID,
topicId: undefined,
topicId: TEST_IDS.TOPIC_ID,
}),
);
@@ -130,6 +132,17 @@ describe('StreamingExecutor actions', () => {
const messages = [createMockMessage({ role: 'user' })];
const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
// Create operation for this test
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: {
sessionId: TEST_IDS.SESSION_ID,
topicId: null,
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
},
label: 'Test AI Generation',
});
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onMessageHandle, onFinish }) => {
@@ -144,6 +157,7 @@ describe('StreamingExecutor actions', () => {
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
});
});
@@ -154,7 +168,7 @@ describe('StreamingExecutor actions', () => {
value: expect.objectContaining({ content: 'Hello' }),
}),
expect.objectContaining({
sessionId: expect.any(String),
operationId: expect.any(String),
}),
);
@@ -166,6 +180,17 @@ describe('StreamingExecutor actions', () => {
const messages = [createMockMessage({ role: 'user' })];
const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
// Create operation for this test
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: {
sessionId: TEST_IDS.SESSION_ID,
topicId: null,
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
},
label: 'Test AI Generation',
});
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onMessageHandle, onFinish }) => {
@@ -180,6 +205,7 @@ describe('StreamingExecutor actions', () => {
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
});
});
@@ -190,7 +216,7 @@ describe('StreamingExecutor actions', () => {
value: expect.objectContaining({ reasoning: { content: 'Thinking...' } }),
}),
expect.objectContaining({
sessionId: expect.any(String),
operationId: expect.any(String),
}),
);
@@ -236,6 +262,17 @@ describe('StreamingExecutor actions', () => {
const messages = [createMockMessage({ role: 'user' })];
const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
// Create operation for this test
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: {
sessionId: TEST_IDS.SESSION_ID,
topicId: null,
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
},
label: 'Test AI Generation',
});
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onMessageHandle, onFinish }) => {
@@ -255,6 +292,7 @@ describe('StreamingExecutor actions', () => {
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
});
});
@@ -269,7 +307,7 @@ describe('StreamingExecutor actions', () => {
}),
}),
expect.objectContaining({
sessionId: expect.any(String),
operationId: expect.any(String),
}),
);
@@ -281,6 +319,17 @@ describe('StreamingExecutor actions', () => {
const messages = [createMockMessage({ role: 'user' })];
const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
// Create operation for this test
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: {
sessionId: TEST_IDS.SESSION_ID,
topicId: null,
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
},
label: 'Test AI Generation',
});
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onMessageHandle, onFinish }) => {
@@ -298,6 +347,7 @@ describe('StreamingExecutor actions', () => {
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
model: 'gpt-4o-mini',
provider: 'openai',
operationId,
});
});
@@ -310,7 +360,7 @@ describe('StreamingExecutor actions', () => {
}),
}),
expect.objectContaining({
sessionId: expect.any(String),
operationId: expect.any(String),
}),
);
@@ -370,7 +420,7 @@ describe('StreamingExecutor actions', () => {
expect.objectContaining({ traceId }),
expect.objectContaining({
sessionId: expect.any(String),
topicId: undefined,
topicId: expect.any(String),
}),
);
@@ -394,7 +444,11 @@ describe('StreamingExecutor actions', () => {
} as UIChatMessage;
const messages = [userMessage];
const streamSpy = vi.spyOn(chatService, 'createAssistantMessageStream');
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onFinish }) => {
await onFinish?.(TEST_CONTENT.AI_RESPONSE, {} as any);
});
await act(async () => {
await result.current.internal_execAgentRuntime({
@@ -404,8 +458,215 @@ describe('StreamingExecutor actions', () => {
});
});
// Verify agent runtime executed successfully
expect(streamSpy).toHaveBeenCalled();
expect(result.current.refreshMessages).toHaveBeenCalled();
// Verify operation was completed
const operations = Object.values(result.current.operations);
const execOperation = operations.find((op) => op.type === 'execAgentRuntime');
expect(execOperation?.status).toBe('completed');
streamSpy.mockRestore();
});
it('should stop agent runtime loop when operation is cancelled before step execution', async () => {
act(() => {
useChatStore.setState({ internal_execAgentRuntime: realExecAgentRuntime });
});
const { result } = renderHook(() => useChatStore());
const userMessage = {
id: TEST_IDS.USER_MESSAGE_ID,
role: 'user',
content: TEST_CONTENT.USER_MESSAGE,
sessionId: TEST_IDS.SESSION_ID,
topicId: TEST_IDS.TOPIC_ID,
} as UIChatMessage;
let streamCallCount = 0;
let cancelDuringFirstCall = false;
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onFinish }) => {
streamCallCount++;
// Cancel during the first LLM call to simulate mid-execution cancellation
if (streamCallCount === 1) {
const operations = Object.values(result.current.operations);
const execOperation = operations.find((op) => op.type === 'execAgentRuntime');
if (execOperation) {
act(() => {
result.current.cancelOperation(execOperation.id, 'user_cancelled');
});
cancelDuringFirstCall = true;
}
}
await onFinish?.(TEST_CONTENT.AI_RESPONSE, {
toolCalls: [
{ id: 'tool-1', type: 'function', function: { name: 'test', arguments: '{}' } },
],
} as any);
});
await act(async () => {
await result.current.internal_execAgentRuntime({
messages: [userMessage],
parentMessageId: userMessage.id,
parentMessageType: 'user',
});
});
// Verify cancellation happened during execution
expect(cancelDuringFirstCall).toBe(true);
// The loop should stop after first call, not continue to second LLM call after tool execution
expect(streamCallCount).toBe(1);
streamSpy.mockRestore();
});
it('should stop agent runtime loop when operation is cancelled after step completion', async () => {
act(() => {
useChatStore.setState({ internal_execAgentRuntime: realExecAgentRuntime });
});
const { result } = renderHook(() => useChatStore());
const userMessage = {
id: TEST_IDS.USER_MESSAGE_ID,
role: 'user',
content: TEST_CONTENT.USER_MESSAGE,
sessionId: TEST_IDS.SESSION_ID,
topicId: TEST_IDS.TOPIC_ID,
} as UIChatMessage;
let streamCallCount = 0;
let cancelledAfterStep = false;
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onFinish }) => {
streamCallCount++;
// First call - LLM returns tool calls
if (streamCallCount === 1) {
await onFinish?.(TEST_CONTENT.AI_RESPONSE, {
toolCalls: [
{ id: 'tool-1', type: 'function', function: { name: 'test', arguments: '{}' } },
],
} as any);
// Cancel immediately after LLM step completes
// This triggers the after-step cancellation check
await new Promise((resolve) => setTimeout(resolve, 20));
const operations = Object.values(result.current.operations);
const execOperation = operations.find((op) => op.type === 'execAgentRuntime');
if (execOperation && execOperation.status === 'running') {
act(() => {
result.current.cancelOperation(execOperation.id, 'user_cancelled');
});
cancelledAfterStep = true;
}
}
});
await act(async () => {
await result.current.internal_execAgentRuntime({
messages: [userMessage],
parentMessageId: userMessage.id,
parentMessageType: 'user',
});
});
// Verify cancellation happened after step completion
expect(cancelledAfterStep).toBe(true);
// Verify that only one LLM call was made (no tool execution happened)
expect(streamCallCount).toBe(1);
// Verify the execution stopped and didn't proceed to tool calling
const operations = Object.values(result.current.operations);
const toolOperations = operations.filter((op) => op.type === 'toolCalling');
// If any tool operations were started, they should have been cancelled
if (toolOperations.length > 0) {
expect(toolOperations.every((op) => op.status === 'cancelled')).toBe(true);
}
streamSpy.mockRestore();
});
it('should resolve aborted tools when cancelled after LLM returns tool calls', async () => {
act(() => {
useChatStore.setState({ internal_execAgentRuntime: realExecAgentRuntime });
});
const { result } = renderHook(() => useChatStore());
const userMessage = {
id: TEST_IDS.USER_MESSAGE_ID,
role: 'user',
content: TEST_CONTENT.USER_MESSAGE,
sessionId: TEST_IDS.SESSION_ID,
topicId: TEST_IDS.TOPIC_ID,
} as UIChatMessage;
let cancelledAfterLLM = false;
let streamCallCount = 0;
const streamSpy = vi
.spyOn(chatService, 'createAssistantMessageStream')
.mockImplementation(async ({ onFinish }) => {
streamCallCount++;
// First call - LLM returns with tool calls
if (streamCallCount === 1) {
await onFinish?.(TEST_CONTENT.AI_RESPONSE, {
toolCalls: [
{
id: 'tool-1',
type: 'function',
function: { name: 'weatherQuery', arguments: '{"city":"Beijing"}' },
},
{
id: 'tool-2',
type: 'function',
function: { name: 'calculator', arguments: '{"expression":"1+1"}' },
},
],
} as any);
// User cancels after LLM completes but before tool execution
await new Promise((resolve) => setTimeout(resolve, 20));
const operations = Object.values(result.current.operations);
const execOperation = operations.find((op) => op.type === 'execAgentRuntime');
if (execOperation && execOperation.status === 'running') {
act(() => {
result.current.cancelOperation(execOperation.id, 'user_cancelled');
});
cancelledAfterLLM = true;
}
}
});
await act(async () => {
await result.current.internal_execAgentRuntime({
messages: [userMessage],
parentMessageId: userMessage.id,
parentMessageType: 'user',
});
});
// Verify cancellation happened after LLM call
expect(cancelledAfterLLM).toBe(true);
// Verify only one LLM call was made (no tool execution happened)
expect(streamCallCount).toBe(1);
// Verify the agent runtime completed (not just cancelled mid-flight)
const operations = Object.values(result.current.operations);
const execOperation = operations.find((op) => op.type === 'execAgentRuntime');
expect(execOperation?.status).toBe('completed');
streamSpy.mockRestore();
});
it('should use provided sessionId/topicId for trace parameters', async () => {
@@ -538,16 +799,24 @@ describe('StreamingExecutor actions', () => {
await onFinish?.(TEST_CONTENT.AI_RESPONSE, {});
});
// Create operation with specific context
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: {
sessionId: contextSessionId,
topicId: contextTopicId,
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
},
label: 'Test AI Generation',
});
await act(async () => {
await result.current.internal_fetchAIChatMessage({
messages,
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
model: 'gpt-4o-mini',
provider: 'openai',
params: {
sessionId: contextSessionId,
topicId: contextTopicId,
},
operationId,
});
});
@@ -556,8 +825,7 @@ describe('StreamingExecutor actions', () => {
TEST_CONTENT.AI_RESPONSE,
expect.any(Object),
{
sessionId: contextSessionId,
topicId: contextTopicId,
operationId: expect.any(String),
},
);
@@ -598,8 +866,7 @@ describe('StreamingExecutor actions', () => {
TEST_CONTENT.AI_RESPONSE,
expect.any(Object),
{
sessionId: 'active-session',
topicId: undefined,
operationId: undefined,
},
);
@@ -17,68 +17,6 @@ afterEach(() => {
});
describe('StreamingStates actions', () => {
describe('internal_toggleChatLoading', () => {
it('should enable loading state with new abort controller', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.internal_toggleChatLoading(true, TEST_IDS.MESSAGE_ID, 'test-action');
});
const state = useChatStore.getState();
expect(state.chatLoadingIdsAbortController).toBeInstanceOf(AbortController);
expect(state.chatLoadingIds).toEqual([TEST_IDS.MESSAGE_ID]);
});
it('should disable loading state and clear abort controller', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.internal_toggleChatLoading(true, TEST_IDS.MESSAGE_ID, 'start');
result.current.internal_toggleChatLoading(false, undefined, 'stop');
});
const state = useChatStore.getState();
expect(state.chatLoadingIdsAbortController).toBeUndefined();
expect(state.chatLoadingIds).toEqual([]);
});
it('should manage beforeunload event listener', () => {
const { result } = renderHook(() => useChatStore());
const addListenerSpy = vi.spyOn(window, 'addEventListener');
const removeListenerSpy = vi.spyOn(window, 'removeEventListener');
act(() => {
result.current.internal_toggleChatLoading(true, TEST_IDS.MESSAGE_ID, 'start');
});
expect(addListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
act(() => {
result.current.internal_toggleChatLoading(false, undefined, 'stop');
});
expect(removeListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
});
it('should reuse existing abort controller', () => {
const existingController = new AbortController();
act(() => {
useChatStore.setState({ chatLoadingIdsAbortController: existingController });
});
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.internal_toggleChatLoading(true, TEST_IDS.MESSAGE_ID, 'test');
});
const state = useChatStore.getState();
expect(state.chatLoadingIdsAbortController).toStrictEqual(existingController);
});
});
describe('internal_toggleToolCallingStreaming', () => {
it('should track tool calling stream status', () => {
const { result } = renderHook(() => useChatStore());
@@ -126,54 +64,4 @@ describe('StreamingStates actions', () => {
expect(state.searchWorkflowLoadingIds).toEqual([]);
});
});
describe('internal_toggleChatReasoning', () => {
it('should enable reasoning loading state', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.internal_toggleChatReasoning(true, TEST_IDS.MESSAGE_ID, 'test-action');
});
const state = useChatStore.getState();
expect(state.reasoningLoadingIds).toEqual([TEST_IDS.MESSAGE_ID]);
});
it('should disable reasoning loading state', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.internal_toggleChatReasoning(true, TEST_IDS.MESSAGE_ID, 'start');
result.current.internal_toggleChatReasoning(false, TEST_IDS.MESSAGE_ID, 'stop');
});
const state = useChatStore.getState();
expect(state.reasoningLoadingIds).toEqual([]);
});
});
describe('internal_toggleMessageInToolsCalling', () => {
it('should enable tools calling state', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.internal_toggleMessageInToolsCalling(true, TEST_IDS.MESSAGE_ID);
});
const state = useChatStore.getState();
expect(state.messageInToolsCallingIds).toEqual([TEST_IDS.MESSAGE_ID]);
});
it('should disable tools calling state', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.internal_toggleMessageInToolsCalling(true, TEST_IDS.MESSAGE_ID);
result.current.internal_toggleMessageInToolsCalling(false, TEST_IDS.MESSAGE_ID);
});
const state = useChatStore.getState();
expect(state.messageInToolsCallingIds).toEqual([]);
});
});
});
@@ -2,18 +2,13 @@
// Disable the auto sort key eslint rule to make the code more logic and readable
import { type AgentRuntimeContext } from '@lobechat/agent-runtime';
import { MESSAGE_CANCEL_FLAT } from '@lobechat/const';
import { produce } from 'immer';
import { StateCreator } from 'zustand/vanilla';
import { ChatStore } from '@/store/chat/store';
import { setNamespace } from '@/utils/storeDebug';
import { displayMessageSelectors } from '../../../selectors';
import { messageMapKey } from '../../../utils/messageMapKey';
import { dbMessageSelectors } from '../../message/selectors';
import { MainSendMessageOperation } from '../initialState';
const n = setNamespace('ai');
/**
* Actions for controlling conversation operations like cancellation and error handling
@@ -47,22 +42,6 @@ export interface ConversationControlAction {
* Reject tool intervention and continue
*/
rejectAndContinueToolCalling: (messageId: string, reason?: string) => Promise<void>;
/**
* Toggle sendMessage operation state
*/
internal_toggleSendMessageOperation: (
key: string | { sessionId: string; topicId?: string | null },
loading: boolean,
cancelReason?: string,
) => AbortController | undefined;
/**
* Update sendMessage operation metadata
*/
internal_updateSendMessageOperation: (
key: string | { sessionId: string; topicId?: string | null },
value: Partial<MainSendMessageOperation> | null,
actionName?: any,
) => void;
}
export const conversationControl: StateCreator<
@@ -72,13 +51,18 @@ export const conversationControl: StateCreator<
ConversationControlAction
> = (set, get) => ({
stopGenerateMessage: () => {
const { chatLoadingIdsAbortController, internal_toggleChatLoading } = get();
const { activeId, activeTopicId, cancelOperations } = get();
if (!chatLoadingIdsAbortController) return;
chatLoadingIdsAbortController.abort(MESSAGE_CANCEL_FLAT);
internal_toggleChatLoading(false, undefined, n('stopGenerateMessage') as string);
// Cancel all running execAgentRuntime operations in the current context
cancelOperations(
{
type: 'execAgentRuntime',
status: 'running',
sessionId: activeId,
topicId: activeTopicId,
},
MESSAGE_CANCEL_FLAT,
);
},
cancelSendMessageInServer: (topicId?: string) => {
@@ -86,66 +70,48 @@ export const conversationControl: StateCreator<
// Determine which operation to cancel
const targetTopicId = topicId ?? activeTopicId;
const operationKey = messageMapKey(activeId, targetTopicId);
const contextKey = messageMapKey(activeId, targetTopicId);
// Cancel the specific operation
get().internal_toggleSendMessageOperation(
operationKey,
false,
'User cancelled sendMessage operation',
);
// Cancel operations in the operation system
const operationIds = get().operationsByContext[contextKey] || [];
// Only clear creating message state if it's the active session
if (operationKey === messageMapKey(activeId, activeTopicId)) {
const editorTempState = get().mainSendMessageOperations[operationKey]?.inputEditorTempState;
operationIds.forEach((opId) => {
const operation = get().operations[opId];
if (operation && operation.type === 'sendMessage' && operation.status === 'running') {
get().cancelOperation(opId, 'User cancelled');
}
});
if (editorTempState) get().mainInputEditor?.setJSONState(editorTempState);
// Restore editor state if it's the active session
if (contextKey === messageMapKey(activeId, activeTopicId)) {
// Find the latest sendMessage operation with editor state
for (const opId of [...operationIds].reverse()) {
const op = get().operations[opId];
if (op && op.type === 'sendMessage' && op.metadata.inputEditorTempState) {
get().mainInputEditor?.setJSONState(op.metadata.inputEditorTempState);
break;
}
}
}
},
clearSendMessageError: () => {
get().internal_updateSendMessageOperation(
{ sessionId: get().activeId, topicId: get().activeTopicId },
null,
'clearSendMessageError',
);
const { activeId, activeTopicId } = get();
const contextKey = messageMapKey(activeId, activeTopicId);
const operationIds = get().operationsByContext[contextKey] || [];
// Clear error message from all sendMessage operations in current context
operationIds.forEach((opId) => {
const op = get().operations[opId];
if (op && op.type === 'sendMessage' && op.metadata.inputSendErrorMsg) {
get().updateOperationMetadata(opId, { inputSendErrorMsg: undefined });
}
});
},
switchMessageBranch: async (messageId, branchIndex) => {
await get().optimisticUpdateMessageMetadata(messageId, { activeBranchIndex: branchIndex });
},
internal_toggleSendMessageOperation: (key, loading: boolean, cancelReason?: string) => {
if (loading) {
const abortController = new AbortController();
get().internal_updateSendMessageOperation(
key,
{ isLoading: true, abortController },
n('toggleSendMessageOperation(start)', { key }),
);
return abortController;
} else {
const operationKey =
typeof key === 'string' ? key : messageMapKey(key.sessionId, key.topicId);
const operation = get().mainSendMessageOperations[operationKey];
// If cancelReason is provided, abort the operation first
if (cancelReason && operation?.isLoading) {
operation.abortController?.abort(cancelReason);
}
get().internal_updateSendMessageOperation(
key,
{ isLoading: false, abortController: null },
n('toggleSendMessageOperation(stop)', { key, cancelReason }),
);
return undefined;
}
},
approveToolCalling: async (toolMessageId) => {
const { activeThreadId, internal_execAgentRuntime } = get();
@@ -251,27 +217,4 @@ export const conversationControl: StateCreator<
console.error('[rejectAndContinueToolCalling] Error executing agent runtime:', error);
}
},
internal_updateSendMessageOperation: (key, value, actionName) => {
const operationKey = typeof key === 'string' ? key : messageMapKey(key.sessionId, key.topicId);
set(
produce((draft) => {
if (!draft.mainSendMessageOperations[operationKey])
draft.mainSendMessageOperations[operationKey] = value;
else {
if (value === null) {
delete draft.mainSendMessageOperations[operationKey];
} else {
draft.mainSendMessageOperations[operationKey] = {
...draft.mainSendMessageOperations[operationKey],
...value,
};
}
}
}),
false,
actionName ?? n('updateSendMessageOperation', { operationKey, value }),
);
},
});
@@ -134,17 +134,31 @@ export const conversationLifecycle: StateCreator<
});
get().internal_toggleMessageLoading(true, tempId);
const operationKey = messageMapKey(activeId, activeTopicId);
// Create operation for send message
const { operationId, abortController } = get().startOperation({
type: 'sendMessage',
context: {
sessionId: activeId,
topicId: activeTopicId,
threadId: activeThreadId,
messageId: tempId,
},
label: 'Send Message',
metadata: {
// Mark this as main window operation (not thread)
inThread: false,
},
});
// Start tracking sendMessage operation with AbortController
const abortController = get().internal_toggleSendMessageOperation(operationKey, true)!;
// Associate temp message with operation
get().associateMessageWithOperation(tempId, operationId);
// Store editor state in operation metadata for cancel restoration
const jsonState = mainInputEditor?.getJSONState();
get().internal_updateSendMessageOperation(
operationKey,
{ inputSendErrorMsg: undefined, inputEditorTempState: jsonState },
'creatingMessage/start',
);
get().updateOperationMetadata(operationId, {
inputEditorTempState: jsonState,
inputSendErrorMsg: undefined,
});
let data: SendMessageServerResponse | undefined;
try {
@@ -172,6 +186,9 @@ export const conversationLifecycle: StateCreator<
if (data?.topics) {
get().internal_dispatchTopic({ type: 'updateTopics', value: data.topics });
topicId = data.topicId;
// Record the created topicId in metadata (not context)
get().updateOperationMetadata(operationId, { createdTopicId: data.topicId });
}
get().replaceMessages(data.messages, {
@@ -184,33 +201,36 @@ export const conversationLifecycle: StateCreator<
await get().switchTopic(data.topicId, true);
}
} catch (e) {
// Fail operation on error
get().failOperation(operationId, {
type: e instanceof Error ? e.name : 'unknown_error',
message: e instanceof Error ? e.message : 'Unknown error',
});
if (e instanceof TRPCClientError) {
const isAbort = e.message.includes('aborted') || e.name === 'AbortError';
// Check if error is due to cancellation
if (!isAbort) {
get().internal_updateSendMessageOperation(operationKey, { inputSendErrorMsg: e.message });
get().updateOperationMetadata(operationId, { inputSendErrorMsg: e.message });
get().mainInputEditor?.setJSONState(jsonState);
}
}
} finally {
// Stop tracking sendMessage operation
get().internal_toggleSendMessageOperation(operationKey, false);
}
// remove temporally message
if (data?.isCreateNewTopic) {
get().internal_dispatchMessage(
{ type: 'deleteMessages', ids: [tempId, tempAssistantId] },
{ topicId: activeTopicId, sessionId: activeId },
);
// 创建了新topic 或者 用户 cancel 了消息(或者失败了),此时无 data
if (data?.isCreateNewTopic || !data) {
get().internal_dispatchMessage(
{ type: 'deleteMessages', ids: [tempId, tempAssistantId] },
{ operationId },
);
}
}
get().internal_toggleMessageLoading(false, tempId);
get().internal_updateSendMessageOperation(
operationKey,
{ inputEditorTempState: null },
'creatingMessage/finished',
);
// Clear editor temp state after message created
if (data) {
get().updateOperationMetadata(operationId, { inputEditorTempState: null });
}
if (!data) return;
@@ -251,6 +271,7 @@ export const conversationLifecycle: StateCreator<
parentMessageType: 'assistant',
sessionId: activeId,
topicId: data.topicId ?? activeTopicId,
parentOperationId: operationId, // Pass as parent operation
ragQuery: get().internal_shouldUseRAG() ? message : undefined,
threadId: activeThreadId,
skipCreateFirstMessage: true,
@@ -267,8 +288,16 @@ export const conversationLifecycle: StateCreator<
if (userFiles.length > 0) {
await getAgentStoreState().addFilesToAgent(userFiles, false);
}
// Complete operation on success
get().completeOperation(operationId);
} catch (e) {
console.error(e);
// Fail operation on error
get().failOperation(operationId, {
type: e instanceof Error ? e.name : 'unknown_error',
message: e instanceof Error ? e.message : 'AI generation failed',
});
} finally {
if (data.topicId) get().internal_updateTopicLoading(data.topicId, false);
}
@@ -288,16 +317,15 @@ export const conversationLifecycle: StateCreator<
if (contextMessages.length <= 0) return;
const { internal_execAgentRuntime, activeThreadId, activeId, activeTopicId } = get();
// Create regenerate operation
const { operationId } = get().startOperation({
type: 'regenerate',
context: { sessionId: activeId, topicId: activeTopicId, messageId: id },
});
try {
const { internal_execAgentRuntime, activeThreadId } = get();
// Mark message as regenerating
set(
{ regeneratingIds: [...get().regeneratingIds, id] },
false,
'regenerateUserMessage/start',
);
const traceId = params?.traceId ?? dbMessageSelectors.getTraceIdByDbMessageId(id)(get());
// 切一个新的激活分支
@@ -307,23 +335,25 @@ export const conversationLifecycle: StateCreator<
messages: contextMessages,
parentMessageId: id,
parentMessageType: 'user',
sessionId: get().activeId,
topicId: get().activeTopicId,
sessionId: activeId,
topicId: activeTopicId,
traceId,
ragQuery: get().internal_shouldUseRAG() ? item.content : undefined,
threadId: activeThreadId,
parentOperationId: operationId,
});
// trace the regenerate message
if (!params?.skipTrace)
get().internal_traceMessage(id, { eventType: TraceEventType.RegenerateMessage });
} finally {
// Remove message from regenerating state
set(
{ regeneratingIds: get().regeneratingIds.filter((msgId) => msgId !== id) },
false,
'regenerateUserMessage/end',
);
get().completeOperation(operationId);
} catch (error) {
get().failOperation(operationId, {
type: 'RegenerateError',
message: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
@@ -350,30 +380,33 @@ export const conversationLifecycle: StateCreator<
const message = dbMessageSelectors.getDbMessageById(id)(get());
if (!message) return;
try {
// Mark message as continuing
set(
{ continuingIds: [...get().continuingIds, messageId] },
false,
'continueGenerationMessage/start',
);
const { activeId, activeTopicId } = get();
// Create continue operation
const { operationId } = get().startOperation({
type: 'continue',
context: { sessionId: activeId, topicId: activeTopicId, messageId },
});
try {
const chats = displayMessageSelectors.mainAIChatsWithHistoryConfig(get());
await get().internal_execAgentRuntime({
messages: chats,
parentMessageId: id,
parentMessageType: message.role as 'assistant' | 'tool' | 'user',
sessionId: get().activeId,
topicId: get().activeTopicId,
sessionId: activeId,
topicId: activeTopicId,
parentOperationId: operationId,
});
} finally {
// Remove message from continuing state
set(
{ continuingIds: get().continuingIds.filter((msgId) => msgId !== messageId) },
false,
'continueGenerationMessage/end',
);
get().completeOperation(operationId);
} catch (error) {
get().failOperation(operationId, {
type: 'ContinueError',
message: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
@@ -1,5 +1,6 @@
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
// Disable the auto sort key eslint rule to make the code more logic and readable
import { LOADING_FLAT } from '@lobechat/const';
import {
GroupMemberInfo,
buildGroupChatSystemPrompt,
@@ -14,7 +15,6 @@ import {
import { produce } from 'immer';
import { StateCreator } from 'zustand/vanilla';
import { LOADING_FLAT } from '@/const/message';
import { DEFAULT_CHAT_GROUP_CHAT_CONFIG } from '@/const/settings';
import { ChatStore } from '@/store/chat/store';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
@@ -602,8 +602,6 @@ export const chatAiGroupChat: StateCreator<
refreshMessages,
activeTopicId,
internal_dispatchMessage,
internal_toggleChatLoading,
triggerToolCalls,
} = get();
try {
@@ -710,30 +708,14 @@ export const chatAiGroupChat: StateCreator<
const messagesForAPI = [systemMessage, ...messagesWithAuthors];
if (assistantId) {
const { isFunctionCall } = await internal_fetchAIChatMessage({
messages: messagesForAPI,
await internal_fetchAIChatMessage({
messageId: assistantId,
messages: messagesForAPI,
model: agentModel,
provider: agentProvider,
params: {
traceId: `group-${groupId}-agent-${agentId}`,
agentConfig: agentData,
},
agentConfig: agentData,
traceId: `group-${groupId}-agent-${agentId}`,
});
// Handle tool calling in group chat like single chat
if (isFunctionCall) {
get().internal_toggleMessageInToolsCalling(true, assistantId);
await refreshMessages();
await triggerToolCalls(assistantId, {
threadId: undefined,
inPortalThread: false,
});
// Change: if an agent message is a tool call, make the same agent speak again
// instead of asking supervisor for a decision.
await get().internal_processAgentMessage(groupId, agentId, targetId, instruction);
return;
}
}
await refreshMessages();
@@ -770,8 +752,6 @@ export const chatAiGroupChat: StateCreator<
},
});
}
} finally {
internal_toggleChatLoading(false, undefined, n('processAgentMessage(end)'));
}
},
@@ -28,39 +28,12 @@ import { ChatStore } from '@/store/chat/store';
import { getFileStoreState } from '@/store/file/store';
import { toolInterventionSelectors } from '@/store/user/selectors';
import { getUserStoreState } from '@/store/user/store';
import { setNamespace } from '@/utils/storeDebug';
import { topicSelectors } from '../../../selectors';
import { messageMapKey } from '../../../utils/messageMapKey';
const n = setNamespace('ai');
const log = debug('lobe-store:streaming-executor');
interface ProcessMessageParams {
traceId?: string;
isWelcomeQuestion?: boolean;
inSearchWorkflow?: boolean;
/**
* the RAG query content, should be embedding and used in the semantic search
*/
ragQuery?: string;
threadId?: string;
inPortalThread?: boolean;
groupId?: string;
agentId?: string;
agentConfig?: any; // Agent configuration for group chat agents
/**
* Explicit sessionId for this execution (avoids using global activeId)
*/
sessionId?: string;
/**
* Explicit topicId for this execution (avoids using global activeTopicId)
*/
topicId?: string | null;
}
/**
* Core streaming execution actions for AI chat
*/
@@ -89,18 +62,21 @@ export interface StreamingExecutorAction {
/**
* Retrieves an AI-generated chat message from the backend service with streaming
*/
internal_fetchAIChatMessage: (input: {
messages: UIChatMessage[];
internal_fetchAIChatMessage: (params: {
messageId: string;
params?: ProcessMessageParams;
messages: UIChatMessage[];
model: string;
provider: string;
operationId?: string;
agentConfig?: any;
traceId?: string;
}) => Promise<{
isFunctionCall: boolean;
tools?: ChatToolPayload[];
tool_calls?: MessageToolCall[];
content: string;
traceId?: string;
finishType?: 'done' | 'error' | 'abort';
usage?: ModelUsage;
}>;
/**
@@ -119,6 +95,14 @@ export interface StreamingExecutorAction {
* Explicit topicId for this execution (avoids using global activeTopicId)
*/
topicId?: string | null;
/**
* Operation ID for this execution (automatically created if not provided)
*/
operationId?: string;
/**
* Parent operation ID (creates a child operation if provided)
*/
parentOperationId?: string;
inSearchWorkflow?: boolean;
/**
* the RAG query content, should be embedding and used in the semantic search
@@ -219,37 +203,74 @@ export const streamingExecutor: StateCreator<
return { state, context };
},
internal_fetchAIChatMessage: async ({ messages, messageId, params, provider, model }) => {
internal_fetchAIChatMessage: async ({
messageId,
messages,
model,
provider,
operationId,
agentConfig,
traceId: traceIdParam,
}) => {
const {
internal_toggleChatLoading,
refreshMessages,
optimisticUpdateMessageContent,
internal_dispatchMessage,
internal_toggleToolCallingStreaming,
internal_toggleChatReasoning,
} = get();
const abortController = internal_toggleChatLoading(
true,
messageId,
n('generateMessage(start)', { messageId, messages }),
);
// Get sessionId, topicId, and abortController from operation
let sessionId: string;
let topicId: string | null | undefined;
let traceId: string | undefined = traceIdParam;
let abortController: AbortController;
const agentConfig =
params?.agentConfig || agentSelectors.currentAgentConfig(getAgentStoreState());
if (operationId) {
const operation = get().operations[operationId];
if (!operation) {
log('[internal_fetchAIChatMessage] ERROR: Operation not found: %s', operationId);
throw new Error(`Operation not found: ${operationId}`);
}
sessionId = operation.context.sessionId!;
topicId = operation.context.topicId;
abortController = operation.abortController; // 👈 Use operation's abortController
log(
'[internal_fetchAIChatMessage] get context from operation %s: sessionId=%s, topicId=%s, aborted=%s',
operationId,
sessionId,
topicId,
abortController.signal.aborted,
);
// Get traceId from operation metadata if not explicitly provided
if (!traceId) {
traceId = operation.metadata?.traceId;
}
} else {
// Fallback to global state (for legacy code paths without operation)
sessionId = get().activeId;
topicId = get().activeTopicId;
abortController = new AbortController();
log(
'[internal_fetchAIChatMessage] use global context: sessionId=%s, topicId=%s',
sessionId,
topicId,
);
}
// Get agent config from params or use current
const finalAgentConfig = agentConfig || agentSelectors.currentAgentConfig(getAgentStoreState());
const chatConfig = agentChatConfigSelectors.currentChatConfig(getAgentStoreState());
// ================================== //
// messages uniformly preprocess //
// ================================== //
// 4. handle max_tokens
agentConfig.params.max_tokens = chatConfig.enableMaxTokens
? agentConfig.params.max_tokens
finalAgentConfig.params.max_tokens = chatConfig.enableMaxTokens
? finalAgentConfig.params.max_tokens
: undefined;
// 5. handle reasoning_effort
agentConfig.params.reasoning_effort = chatConfig.enableReasoningEffort
? agentConfig.params.reasoning_effort
finalAgentConfig.params.reasoning_effort = chatConfig.enableReasoningEffort
? finalAgentConfig.params.reasoning_effort
: undefined;
let isFunctionCall = false;
@@ -261,13 +282,11 @@ export const streamingExecutor: StateCreator<
let thinking = '';
let thinkingStartAt: number;
let duration: number | undefined;
let reasoningOperationId: string | undefined;
let finishType: 'done' | 'error' | 'abort' | undefined;
// to upload image
const uploadTasks: Map<string, Promise<{ id?: string; url?: string }>> = new Map();
const context: { sessionId: string; topicId?: string | null } = {
sessionId: params?.sessionId || get().activeId,
topicId: params?.topicId,
};
// Throttle tool_calls updates to prevent excessive re-renders (max once per 300ms)
const throttledUpdateToolCalls = throttle(
(toolCalls: any[]) => {
@@ -277,7 +296,7 @@ export const streamingExecutor: StateCreator<
type: 'updateMessage',
value: { tools: get().internal_transformToolCalls(toolCalls) },
},
context,
{ operationId },
);
},
300,
@@ -293,24 +312,28 @@ export const streamingExecutor: StateCreator<
messages,
model,
provider,
...agentConfig.params,
plugins: agentConfig.plugins,
...finalAgentConfig.params,
plugins: finalAgentConfig.plugins,
},
historySummary: historySummary?.content,
trace: {
traceId: params?.traceId,
sessionId: params?.sessionId ?? get().activeId,
topicId:
(params?.topicId !== undefined ? params.topicId : get().activeTopicId) ?? undefined,
traceId,
sessionId,
topicId: topicId ?? undefined,
traceName: TraceNameMap.Conversation,
},
onErrorHandle: async (error) => {
await messageService.updateMessageError(messageId, error, context);
await refreshMessages(params?.sessionId, params?.topicId);
log(
'[internal_fetchAIChatMessage] onError: messageId=%s, error=%s, operationId=%s',
messageId,
error.message,
operationId,
);
await get().optimisticUpdateMessageError(messageId, error, { operationId });
},
onFinish: async (
content,
{ traceId, observationId, toolCalls, reasoning, grounding, usage, speed },
{ traceId, observationId, toolCalls, reasoning, grounding, usage, speed, type },
) => {
// if there is traceId, update it
if (traceId) {
@@ -318,7 +341,7 @@ export const streamingExecutor: StateCreator<
messageService.updateMessage(
messageId,
{ traceId, observationId: observationId ?? undefined },
context,
{ sessionId, topicId },
);
}
@@ -358,7 +381,14 @@ export const streamingExecutor: StateCreator<
}
finalUsage = usage;
internal_toggleChatReasoning(false, messageId, n('toggleChatReasoning/false') as string);
finishType = type;
log(
'[internal_fetchAIChatMessage] onFinish: messageId=%s, finishType=%s, operationId=%s',
messageId,
type,
operationId,
);
// update the content after fetch result
await optimisticUpdateMessageContent(
@@ -371,9 +401,9 @@ export const streamingExecutor: StateCreator<
: undefined,
search: !!grounding?.citations ? grounding : undefined,
imageList: finalImages.length > 0 ? finalImages : undefined,
metadata: speed ? { ...usage, ...speed } : usage,
metadata: { ...usage, ...speed, performance: speed, usage, finishType: type },
},
context,
{ operationId },
);
},
onMessageHandle: async (chunk) => {
@@ -398,7 +428,7 @@ export const streamingExecutor: StateCreator<
},
},
},
context,
{ operationId },
);
break;
}
@@ -412,7 +442,7 @@ export const streamingExecutor: StateCreator<
imageList: chunk.images.map((i) => ({ id: i.id, url: i.data, alt: i.id })),
},
},
context,
{ operationId },
);
const image = chunk.image;
@@ -436,16 +466,20 @@ export const streamingExecutor: StateCreator<
if (!duration) {
duration = Date.now() - thinkingStartAt;
const isInChatReasoning = get().reasoningLoadingIds.includes(messageId);
if (isInChatReasoning) {
internal_toggleChatReasoning(
false,
messageId,
n('toggleChatReasoning/false') as string,
);
// Complete reasoning operation if it exists
if (reasoningOperationId) {
get().completeOperation(reasoningOperationId);
reasoningOperationId = undefined;
}
}
log(
'[text stream] messageId=%s, output length=%d, operationId=%s',
messageId,
output.length,
operationId,
);
internal_dispatchMessage(
{
id: messageId,
@@ -455,7 +489,7 @@ export const streamingExecutor: StateCreator<
reasoning: !!thinking ? { content: thinking, duration } : undefined,
},
},
context,
{ operationId },
);
break;
}
@@ -464,11 +498,17 @@ export const streamingExecutor: StateCreator<
// if there is no thinkingStartAt, it means the start of reasoning
if (!thinkingStartAt) {
thinkingStartAt = Date.now();
internal_toggleChatReasoning(
true,
messageId,
n('toggleChatReasoning/true') as string,
);
// Create reasoning operation
const { operationId: reasoningOpId } = get().startOperation({
type: 'reasoning',
context: { sessionId, topicId, messageId },
parentOperationId: operationId,
});
reasoningOperationId = reasoningOpId;
// Associate message with reasoning operation
get().associateMessageWithOperation(messageId, reasoningOperationId);
}
thinking += chunk.text;
@@ -479,7 +519,7 @@ export const streamingExecutor: StateCreator<
type: 'updateMessage',
value: { reasoning: { content: thinking } },
},
context,
{ operationId },
);
break;
}
@@ -489,24 +529,25 @@ export const streamingExecutor: StateCreator<
internal_toggleToolCallingStreaming(messageId, chunk.isAnimationActives);
throttledUpdateToolCalls(chunk.tool_calls);
isFunctionCall = true;
const isInChatReasoning = get().reasoningLoadingIds.includes(messageId);
if (isInChatReasoning) {
if (!duration) {
duration = Date.now() - thinkingStartAt;
}
internal_toggleChatReasoning(
false,
messageId,
n('toggleChatReasoning/false') as string,
);
// Complete reasoning operation if it exists
if (!duration && reasoningOperationId) {
duration = Date.now() - thinkingStartAt;
get().completeOperation(reasoningOperationId);
reasoningOperationId = undefined;
}
}
}
},
});
internal_toggleChatLoading(false, messageId, n('generateMessage(end)') as string);
log(
'[internal_fetchAIChatMessage] completed: messageId=%s, finishType=%s, isFunctionCall=%s, operationId=%s',
messageId,
finishType,
isFunctionCall,
operationId,
);
return {
isFunctionCall,
@@ -515,6 +556,7 @@ export const streamingExecutor: StateCreator<
tools,
usage: finalUsage,
tool_calls,
finishType,
};
},
@@ -533,8 +575,34 @@ export const streamingExecutor: StateCreator<
const topicId = paramTopicId !== undefined ? paramTopicId : activeTopicId;
const messageKey = messageMapKey(sessionId, topicId);
// Create or use provided operation
let operationId = params.operationId;
if (!operationId) {
const { operationId: newOperationId } = get().startOperation({
type: 'execAgentRuntime',
context: {
sessionId,
topicId,
messageId: parentMessageId,
threadId: params.threadId,
},
parentOperationId: params.parentOperationId, // Pass parent operation ID
label: 'AI Generation',
metadata: {
// Mark if this operation is in thread context
// Thread operations should not affect main window UI state
inThread: params.inPortalThread || false,
},
});
operationId = newOperationId;
// Associate message with operation
get().associateMessageWithOperation(parentMessageId, operationId);
}
log(
'[internal_execAgentRuntime] start, sessionId: %s, topicId: %s, messageKey: %s, parentMessageId: %s, parentMessageType: %s, messages count: %d',
'[internal_execAgentRuntime] start, operationId: %s, sessionId: %s, topicId: %s, messageKey: %s, parentMessageId: %s, parentMessageType: %s, messages count: %d',
operationId,
sessionId,
topicId,
messageKey,
@@ -624,14 +692,19 @@ export const streamingExecutor: StateCreator<
executors: createAgentExecutors({
get,
messageKey,
operationId,
parentId: params.parentMessageId,
params: {
...params,
sessionId,
topicId,
},
skipCreateFirstMessage: params.skipCreateFirstMessage,
}),
getOperation: (opId: string) => {
const op = get().operations[opId];
if (!op) throw new Error(`Operation not found: ${opId}`);
return {
abortController: op.abortController,
context: op.context,
};
},
operationId,
});
// Create agent state and context with user intervention config
@@ -657,6 +730,22 @@ export const streamingExecutor: StateCreator<
// Execute the agent runtime loop
let stepCount = 0;
while (state.status !== 'done' && state.status !== 'error') {
// Check if operation has been cancelled
const currentOperation = get().operations[operationId];
if (currentOperation?.status === 'cancelled') {
log('[internal_execAgentRuntime] Operation cancelled, marking state as interrupted');
// Update state status to 'interrupted' so agent can handle abort
state = { ...state, status: 'interrupted' };
// Let agent handle the abort (will clean up pending tools if needed)
const result = await runtime.step(state, nextContext);
state = result.newState;
log('[internal_execAgentRuntime] Operation cancelled, stopping loop');
break;
}
stepCount++;
log(
'[internal_execAgentRuntime][step-%d]: phase=%s, status=%s',
@@ -702,6 +791,28 @@ export const streamingExecutor: StateCreator<
state = result.newState;
// Check if operation was cancelled after step completion
const operationAfterStep = get().operations[operationId];
if (operationAfterStep?.status === 'cancelled') {
log(
'[internal_execAgentRuntime] Operation cancelled after step %d, marking state as interrupted',
stepCount,
);
// Set state.status to 'interrupted' to trigger agent abort handling
state = { ...state, status: 'interrupted' };
// Let agent handle the abort (will clean up pending tools if needed)
// Use result.nextContext if available (e.g., llm_result with tool calls)
// otherwise fallback to current nextContext
const contextForAbort = result.nextContext || nextContext;
const abortResult = await runtime.step(state, contextForAbort);
state = abortResult.newState;
log('[internal_execAgentRuntime] Operation cancelled, stopping loop');
break;
}
// If no nextContext, stop execution
if (!result.nextContext) {
log('[internal_execAgentRuntime] No next context, stopping loop');
@@ -723,13 +834,24 @@ export const streamingExecutor: StateCreator<
const assistantMessage = finalMessages.findLast((m) => m.role === 'assistant');
if (assistantMessage) {
await get().optimisticUpdateMessageRAG(assistantMessage.id, params.ragMetadata, {
sessionId,
topicId,
operationId,
});
log('[internal_execAgentRuntime] RAG metadata updated for assistant message');
}
}
// Complete operation
if (state.status === 'done') {
get().completeOperation(operationId);
log('[internal_execAgentRuntime] Operation completed successfully');
} else if (state.status === 'error') {
get().failOperation(operationId, {
type: 'runtime_error',
message: 'Agent runtime execution failed',
});
log('[internal_execAgentRuntime] Operation failed');
}
log('[internal_execAgentRuntime] completed');
// Desktop notification (if not in tools calling mode)
@@ -3,36 +3,11 @@ import { produce } from 'immer';
import { StateCreator } from 'zustand/vanilla';
import { ChatStore } from '@/store/chat/store';
import { Action } from '@/utils/storeDebug';
/**
* Manages loading states during streaming operations
*/
export interface StreamingStatesAction {
/**
* Toggles the loading state for AI message generation, managing the UI feedback
*/
internal_toggleChatLoading: (
loading: boolean,
id?: string,
action?: Action,
) => AbortController | undefined;
/**
* Toggles the loading state for AI message reasoning, managing the UI feedback
*/
internal_toggleChatReasoning: (
loading: boolean,
id?: string,
action?: string,
) => AbortController | undefined;
/**
* Toggles the loading state for messages in tools calling
*/
internal_toggleMessageInToolsCalling: (
loading: boolean,
id?: string,
action?: Action,
) => AbortController | undefined;
/**
* Toggles the loading state for search workflow
*/
@@ -49,15 +24,6 @@ export const streamingStates: StateCreator<
[],
StreamingStatesAction
> = (set, get) => ({
internal_toggleChatLoading: (loading, id, action) => {
return get().internal_toggleLoadingArrays('chatLoadingIds', loading, id, action);
},
internal_toggleChatReasoning: (loading, id, action) => {
return get().internal_toggleLoadingArrays('reasoningLoadingIds', loading, id, action);
},
internal_toggleMessageInToolsCalling: (loading, id) => {
return get().internal_toggleLoadingArrays('messageInToolsCallingIds', loading, id);
},
internal_toggleSearchWorkflow: (loading, id) => {
return get().internal_toggleLoadingArrays('searchWorkflowLoadingIds', loading, id);
},
@@ -1,36 +1,13 @@
import type { ChatInputEditor } from '@/features/ChatInput';
export interface MainSendMessageOperation {
abortController?: AbortController | null;
inputEditorTempState?: any | null;
inputSendErrorMsg?: string;
isLoading: boolean;
}
export interface ChatAIChatState {
/**
* is the AI message is generating
*/
chatLoadingIds: string[];
chatLoadingIdsAbortController?: AbortController;
inputFiles: File[];
inputMessage: string;
mainInputEditor: ChatInputEditor | null;
/**
* sendMessageInServer operations map, keyed by sessionId|topicId
* Contains both loading state and AbortController
*/
mainSendMessageOperations: Record<string, MainSendMessageOperation>;
messageInToolsCallingIds: string[];
/**
* is the message is in RAG flow
*/
messageRAGLoadingIds: string[];
pluginApiLoadingIds: string[];
/**
* is the AI message is reasoning
*/
reasoningLoadingIds: string[];
searchWorkflowLoadingIds: string[];
threadInputEditor: ChatInputEditor | null;
/**
@@ -40,15 +17,10 @@ export interface ChatAIChatState {
}
export const initialAiChatState: ChatAIChatState = {
chatLoadingIds: [],
inputFiles: [],
inputMessage: '',
mainInputEditor: null,
mainSendMessageOperations: {},
messageInToolsCallingIds: [],
messageRAGLoadingIds: [],
pluginApiLoadingIds: [],
reasoningLoadingIds: [],
searchWorkflowLoadingIds: [],
threadInputEditor: null,
toolCallingStreamIds: {},
@@ -0,0 +1,280 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useChatStore } from '@/store/chat/store';
import { aiChatSelectors } from './selectors';
describe('aiChatSelectors', () => {
beforeEach(() => {
useChatStore.setState(useChatStore.getInitialState());
});
describe('isMessageInReasoning', () => {
it('should return true when message has reasoning operation', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
result.current.startOperation({
type: 'reasoning',
context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
});
});
expect(aiChatSelectors.isMessageInReasoning('msg1')(result.current)).toBe(true);
});
it('should return false when message has no reasoning operation', () => {
const { result } = renderHook(() => useChatStore());
expect(aiChatSelectors.isMessageInReasoning('msg1')(result.current)).toBe(false);
});
});
describe('isMessageInSearchWorkflow', () => {
it('should return true when message is in search workflow', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({ searchWorkflowLoadingIds: ['msg1', 'msg2'] });
});
expect(aiChatSelectors.isMessageInSearchWorkflow('msg1')(result.current)).toBe(true);
expect(aiChatSelectors.isMessageInSearchWorkflow('msg2')(result.current)).toBe(true);
});
it('should return false when message is not in search workflow', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({ searchWorkflowLoadingIds: ['msg1'] });
});
expect(aiChatSelectors.isMessageInSearchWorkflow('msg2')(result.current)).toBe(false);
});
});
describe('isIntentUnderstanding', () => {
it('should return true when message is in search workflow', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({ searchWorkflowLoadingIds: ['msg1'] });
});
expect(aiChatSelectors.isIntentUnderstanding('msg1')(result.current)).toBe(true);
});
it('should return false when message is not in search workflow', () => {
const { result } = renderHook(() => useChatStore());
expect(aiChatSelectors.isIntentUnderstanding('msg1')(result.current)).toBe(false);
});
});
describe('isCurrentSendMessageLoading', () => {
it('should return true when there is a running sendMessage operation in current context', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session1', topicId: 'topic1' },
});
});
expect(aiChatSelectors.isCurrentSendMessageLoading(result.current)).toBe(true);
});
it('should return false when there is no sendMessage operation', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
});
expect(aiChatSelectors.isCurrentSendMessageLoading(result.current)).toBe(false);
});
it('should return false when sendMessage operation is completed', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
opId = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session1', topicId: 'topic1' },
}).operationId;
});
act(() => {
result.current.completeOperation(opId);
});
expect(aiChatSelectors.isCurrentSendMessageLoading(result.current)).toBe(false);
});
it('should return false for different context', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session2', topicId: 'topic2' },
});
});
expect(aiChatSelectors.isCurrentSendMessageLoading(result.current)).toBe(false);
});
});
describe('isCurrentSendMessageError', () => {
it('should return error message when latest sendMessage operation has error', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
opId = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session1', topicId: 'topic1' },
}).operationId;
});
act(() => {
result.current.updateOperationMetadata(opId, {
inputSendErrorMsg: 'Network error',
});
});
expect(aiChatSelectors.isCurrentSendMessageError(result.current)).toBe('Network error');
});
it('should return undefined when there is no error', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session1', topicId: 'topic1' },
});
});
expect(aiChatSelectors.isCurrentSendMessageError(result.current)).toBeUndefined();
});
it('should return undefined when there are no operations', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
});
expect(aiChatSelectors.isCurrentSendMessageError(result.current)).toBeUndefined();
});
it('should return the latest error when multiple operations exist', () => {
const { result } = renderHook(() => useChatStore());
let op1Id: string;
let op2Id: string;
act(() => {
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
op1Id = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session1', topicId: 'topic1' },
}).operationId;
op2Id = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session1', topicId: 'topic1' },
}).operationId;
});
act(() => {
result.current.updateOperationMetadata(op1Id, {
inputSendErrorMsg: 'First error',
});
result.current.updateOperationMetadata(op2Id, {
inputSendErrorMsg: 'Second error',
});
});
// Should return the latest (second) error
expect(aiChatSelectors.isCurrentSendMessageError(result.current)).toBe('Second error');
});
});
describe('isSendMessageLoadingForTopic', () => {
it('should return true when sendMessage operation is running for the topic', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session1', topicId: 'topic1' },
});
});
expect(aiChatSelectors.isSendMessageLoadingForTopic('session1_topic1')(result.current)).toBe(
true,
);
});
it('should return false when no sendMessage operation exists', () => {
const { result } = renderHook(() => useChatStore());
expect(aiChatSelectors.isSendMessageLoadingForTopic('session1_topic1')(result.current)).toBe(
false,
);
});
it('should return false when sendMessage operation is completed', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session1', topicId: 'topic1' },
}).operationId;
});
act(() => {
result.current.completeOperation(opId);
});
expect(aiChatSelectors.isSendMessageLoadingForTopic('session1_topic1')(result.current)).toBe(
false,
);
});
it('should distinguish between different topics', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session1', topicId: 'topic1' },
});
});
expect(aiChatSelectors.isSendMessageLoadingForTopic('session1_topic1')(result.current)).toBe(
true,
);
expect(aiChatSelectors.isSendMessageLoadingForTopic('session1_topic2')(result.current)).toBe(
false,
);
});
});
});
+31 -7
View File
@@ -1,9 +1,10 @@
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import type { ChatStoreState } from '../../initialState';
import { operationSelectors } from '../operation/selectors';
const isMessageInReasoning = (id: string) => (s: ChatStoreState) =>
s.reasoningLoadingIds.includes(id);
operationSelectors.isMessageInReasoning(id)(s);
const isMessageInSearchWorkflow = (id: string) => (s: ChatStoreState) =>
s.searchWorkflowLoadingIds.includes(id);
@@ -12,17 +13,40 @@ const isIntentUnderstanding = (id: string) => (s: ChatStoreState) =>
isMessageInSearchWorkflow(id)(s);
const isCurrentSendMessageLoading = (s: ChatStoreState) => {
const operationKey = messageMapKey(s.activeId, s.activeTopicId);
return s.mainSendMessageOperations[operationKey]?.isLoading || false;
const contextKey = messageMapKey(s.activeId, s.activeTopicId);
const operationIds = s.operationsByContext[contextKey] || [];
// Check if there's any running sendMessage operation
return operationIds.some((opId) => {
const op = s.operations[opId];
return op && op.type === 'sendMessage' && op.status === 'running';
});
};
const isCurrentSendMessageError = (s: ChatStoreState) => {
const operationKey = messageMapKey(s.activeId, s.activeTopicId);
return s.mainSendMessageOperations[operationKey]?.inputSendErrorMsg;
const contextKey = messageMapKey(s.activeId, s.activeTopicId);
const operationIds = s.operationsByContext[contextKey] || [];
// Find the latest sendMessage operation with error
for (const opId of [...operationIds].reverse()) {
const op = s.operations[opId];
if (op && op.type === 'sendMessage' && op.metadata.inputSendErrorMsg) {
return op.metadata.inputSendErrorMsg;
}
}
return undefined;
};
const isSendMessageLoadingForTopic = (topicKey: string) => (s: ChatStoreState) =>
s.mainSendMessageOperations[topicKey]?.isLoading ?? false;
const isSendMessageLoadingForTopic = (topicKey: string) => (s: ChatStoreState) => {
const operationIds = s.operationsByContext[topicKey] || [];
// Check if there's any running sendMessage operation for this topic
return operationIds.some((opId) => {
const op = s.operations[opId];
return op && op.type === 'sendMessage' && op.status === 'running';
});
};
export const aiChatSelectors = {
isCurrentSendMessageError,
@@ -21,13 +21,19 @@ vi.mock('@/services/electron/localFileService', () => ({
const mockSet = vi.fn();
const mockStore = {
completeOperation: vi.fn(),
failOperation: vi.fn(),
internal_triggerLocalFileToolCalling: vi.fn(),
messageOperationMap: {},
optimisticUpdateMessageContent: vi.fn(),
optimisticUpdateMessagePluginError: vi.fn(),
optimisticUpdatePluginArguments: vi.fn(),
optimisticUpdatePluginState: vi.fn(),
set: mockSet,
toggleLocalFileLoading: vi.fn(),
startOperation: vi.fn().mockReturnValue({
abortController: new AbortController(),
operationId: 'test-op-id',
}),
} as unknown as ChatStore;
const createStore = () => {
@@ -58,10 +64,10 @@ describe('localFileSlice', () => {
await store.internal_triggerLocalFileToolCalling('test-id', mockService);
expect(mockStore.toggleLocalFileLoading).toBeCalledWith('test-id', true);
expect(mockStore.optimisticUpdatePluginState).toBeCalledWith('test-id', mockState);
expect(mockStore.optimisticUpdateMessageContent).toBeCalledWith('test-id', mockContent);
expect(mockStore.toggleLocalFileLoading).toBeCalledWith('test-id', false);
expect(mockStore.startOperation).toBeCalled();
expect(mockStore.completeOperation).toBeCalled();
expect(mockStore.optimisticUpdatePluginState).toBeCalled();
expect(mockStore.optimisticUpdateMessageContent).toBeCalled();
});
it('should handle error', async () => {
@@ -70,11 +76,15 @@ describe('localFileSlice', () => {
await store.internal_triggerLocalFileToolCalling('test-id', mockService);
expect(mockStore.optimisticUpdateMessagePluginError).toBeCalledWith('test-id', {
body: mockError,
message: 'test error',
type: 'PluginServerError',
});
expect(mockStore.optimisticUpdateMessagePluginError).toBeCalledWith(
'test-id',
{
body: mockError,
message: 'test error',
type: 'PluginServerError',
},
{ operationId: 'test-op-id' },
);
});
});
@@ -187,24 +197,5 @@ describe('localFileSlice', () => {
});
});
describe('toggleLocalFileLoading', () => {
it('should toggle loading state', () => {
const mockSetFn = vi.fn();
const testStore = localSystemSlice(mockSetFn, () => mockStore, {} as any);
testStore.toggleLocalFileLoading('test-id', true);
expect(mockSetFn).toHaveBeenCalledWith(
expect.any(Function),
false,
'toggleLocalFileLoading/start',
);
testStore.toggleLocalFileLoading('test-id', false);
expect(mockSetFn).toHaveBeenCalledWith(
expect.any(Function),
false,
'toggleLocalFileLoading/end',
);
});
});
// toggleLocalFileLoading is no longer needed as we use operation-based state management
});
@@ -85,16 +85,18 @@ describe('search actions', () => {
},
];
expect(searchService.webSearch).toHaveBeenCalledWith({
searchEngines: ['google'],
query: 'test query',
});
expect(result.current.searchLoading[messageId]).toBe(false);
expect(searchService.webSearch).toHaveBeenCalledWith(
{
searchEngines: ['google'],
query: 'test query',
},
{ signal: expect.any(AbortSignal) },
);
expect(result.current.optimisticUpdateMessageContent).toHaveBeenCalledWith(
messageId,
searchResultsPrompt(expectedContent),
undefined,
{ sessionId: undefined, topicId: undefined },
{ operationId: expect.any(String) },
);
});
@@ -122,17 +124,19 @@ describe('search actions', () => {
await search(messageId, query);
});
expect(searchService.webSearch).toHaveBeenCalledWith({
searchEngines: ['custom-engine'],
searchTimeRange: 'year',
query: 'test query',
});
expect(result.current.searchLoading[messageId]).toBe(false);
expect(searchService.webSearch).toHaveBeenCalledWith(
{
searchEngines: ['custom-engine'],
searchTimeRange: 'year',
query: 'test query',
},
{ signal: expect.any(AbortSignal) },
);
expect(result.current.optimisticUpdateMessageContent).toHaveBeenCalledWith(
messageId,
searchResultsPrompt([]),
undefined,
{ sessionId: undefined, topicId: undefined },
{ operationId: expect.any(String) },
);
});
@@ -159,14 +163,7 @@ describe('search actions', () => {
message: 'Search failed',
type: 'PluginServerError',
},
{ sessionId: undefined, topicId: undefined },
);
expect(result.current.searchLoading[messageId]).toBe(false);
expect(result.current.optimisticUpdateMessageContent).toHaveBeenCalledWith(
messageId,
'Search failed',
undefined,
{ sessionId: undefined, topicId: undefined },
{ operationId: expect.any(String) },
);
});
});
@@ -207,7 +204,7 @@ describe('search actions', () => {
messageId,
crawlResultsPrompt(expectedContent as any),
undefined,
{ sessionId: undefined, topicId: undefined },
{ operationId: expect.any(String) },
);
});
@@ -235,7 +232,7 @@ describe('search actions', () => {
messageId,
crawlResultsPrompt(mockResponse.results),
undefined,
{ sessionId: undefined, topicId: undefined },
{ operationId: expect.any(String) },
);
});
});
@@ -304,7 +301,7 @@ describe('search actions', () => {
sessionId: 'session-id',
topicId: 'topic-id',
}),
{ sessionId: 'session-id', topicId: 'topic-id' },
{ operationId: expect.any(String) },
);
expect(result.current.optimisticAddToolToAssistantMessage).toHaveBeenCalledWith(
@@ -313,7 +310,7 @@ describe('search actions', () => {
identifier: 'search',
type: 'default',
}),
{ sessionId: undefined, topicId: undefined },
{ operationId: expect.any(String) },
);
});
@@ -332,24 +329,7 @@ describe('search actions', () => {
});
});
describe('toggleSearchLoading', () => {
it('should toggle search loading state', () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'test-message-id';
act(() => {
result.current.toggleSearchLoading(messageId, true);
});
expect(result.current.searchLoading[messageId]).toBe(true);
act(() => {
result.current.toggleSearchLoading(messageId, false);
});
expect(result.current.searchLoading[messageId]).toBe(false);
});
});
// toggleSearchLoading is no longer needed as we use operation-based state management
describe('OptimisticUpdateContext isolation', () => {
it('search should pass context to optimistic methods', async () => {
@@ -401,13 +381,13 @@ describe('search actions', () => {
expect(result.current.optimisticUpdatePluginState).toHaveBeenCalledWith(
messageId,
expect.any(Object),
{ sessionId: contextSessionId, topicId: contextTopicId },
{ operationId: expect.any(String) },
);
expect(result.current.optimisticUpdateMessageContent).toHaveBeenCalledWith(
messageId,
expect.any(String),
undefined,
{ sessionId: contextSessionId, topicId: contextTopicId },
{ operationId: expect.any(String) },
);
});
@@ -456,12 +436,12 @@ describe('search actions', () => {
messageId,
expect.any(String),
undefined,
{ sessionId: contextSessionId, topicId: contextTopicId },
{ operationId: expect.any(String) },
);
expect(result.current.optimisticUpdatePluginState).toHaveBeenCalledWith(
messageId,
expect.any(Object),
{ sessionId: contextSessionId, topicId: contextTopicId },
{ operationId: expect.any(String) },
);
});
@@ -510,14 +490,14 @@ describe('search actions', () => {
identifier: 'search',
type: 'default',
}),
{ sessionId: contextSessionId, topicId: contextTopicId },
{ operationId: expect.any(String) },
);
expect(result.current.optimisticCreateMessage).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: contextSessionId,
topicId: contextTopicId,
}),
{ sessionId: contextSessionId, topicId: contextTopicId },
{ operationId: expect.any(String) },
);
});
});
@@ -3,6 +3,7 @@ import {
CodeInterpreterParams,
CodeInterpreterResponse,
} from '@lobechat/types';
import debug from 'debug';
import { produce } from 'immer';
import pMap from 'p-map';
import { SWRResponse } from 'swr';
@@ -18,12 +19,12 @@ import { CodeInterpreterIdentifier } from '@/tools/code-interpreter';
import { setNamespace } from '@/utils/storeDebug';
const n = setNamespace('codeInterpreter');
const log = debug('lobe-store:builtin-tool');
const SWR_FETCH_INTERPRETER_FILE_KEY = 'FetchCodeInterpreterFileItem';
export interface ChatCodeInterpreterAction {
python: (id: string, params: CodeInterpreterParams) => Promise<boolean | undefined>;
toggleInterpreterExecuting: (id: string, loading: boolean) => void;
updateInterpreterFileItem: (
id: string,
updater: (data: CodeInterpreterResponse) => void,
@@ -39,66 +40,100 @@ export const codeInterpreterSlice: StateCreator<
ChatCodeInterpreterAction
> = (set, get) => ({
python: async (id: string, params: CodeInterpreterParams) => {
const {
toggleInterpreterExecuting,
optimisticUpdatePluginState,
optimisticUpdateMessageContent,
uploadInterpreterFiles,
} = get();
// Get parent operationId from messageOperationMap (should be executeToolCall)
const parentOperationId = get().messageOperationMap[id];
toggleInterpreterExecuting(id, true);
// Create child operation for interpreter execution
// Auto-associates message with this operation via messageId in context
const { operationId: interpreterOpId, abortController } = get().startOperation({
context: {
messageId: id,
},
metadata: {
startTime: Date.now(),
},
parentOperationId,
type: 'builtinToolInterpreter',
});
// TODO: 应该只下载 AI 用到的文件
const files: File[] = [];
for (const message of dbMessageSelectors.dbUserMessages(get())) {
for (const file of message.fileList ?? []) {
const blob = await fetch(file.url).then((res) => res.blob());
files.push(new File([blob], file.name));
}
for (const image of message.imageList ?? []) {
const blob = await fetch(image.url).then((res) => res.blob());
files.push(new File([blob], image.alt));
}
for (const tool of message.tools ?? []) {
if (tool.identifier === CodeInterpreterIdentifier) {
const message = dbMessageSelectors.getDbMessageByToolCallId(tool.id)(get());
if (message?.content) {
const content = JSON.parse(message.content) as CodeInterpreterResponse;
for (const file of content.files ?? []) {
const item = await fileService.getFile(file.fileId!);
const blob = await fetch(item.url).then((res) => res.blob());
files.push(new File([blob], file.filename));
log(
'[python] messageId=%s, parentOpId=%s, interpreterOpId=%s, aborted=%s',
id,
parentOperationId,
interpreterOpId,
abortController.signal.aborted,
);
const context = { operationId: interpreterOpId };
try {
// TODO: 应该只下载 AI 用到的文件
const files: File[] = [];
for (const message of dbMessageSelectors.dbUserMessages(get())) {
for (const file of message.fileList ?? []) {
const blob = await fetch(file.url).then((res) => res.blob());
files.push(new File([blob], file.name));
}
for (const image of message.imageList ?? []) {
const blob = await fetch(image.url).then((res) => res.blob());
files.push(new File([blob], image.alt));
}
for (const tool of message.tools ?? []) {
if (tool.identifier === CodeInterpreterIdentifier) {
const message = dbMessageSelectors.getDbMessageByToolCallId(tool.id)(get());
if (message?.content) {
const content = JSON.parse(message.content) as CodeInterpreterResponse;
for (const file of content.files ?? []) {
const item = await fileService.getFile(file.fileId!);
const blob = await fetch(item.url).then((res) => res.blob());
files.push(new File([blob], file.filename));
}
}
}
}
}
}
try {
const result = await pythonService.runPython(params.code, params.packages, files);
// Complete interpreter operation
get().completeOperation(interpreterOpId);
if (result?.files) {
await optimisticUpdateMessageContent(id, JSON.stringify(result));
await uploadInterpreterFiles(id, result.files);
await get().optimisticUpdateMessageContent(id, JSON.stringify(result), undefined, context);
await get().uploadInterpreterFiles(id, result.files);
} else {
await optimisticUpdateMessageContent(id, JSON.stringify(result));
await get().optimisticUpdateMessageContent(id, JSON.stringify(result), undefined, context);
}
return true;
} catch (error) {
optimisticUpdatePluginState(id, { error });
const err = error as Error;
log('[python] Error: messageId=%s, error=%s', id, err.message);
// Check if it's an abort error
if (err.message.includes('The user aborted a request.') || err.name === 'AbortError') {
log('[python] Request aborted: messageId=%s', id);
// Fail interpreter operation for abort
get().failOperation(interpreterOpId, {
message: 'User cancelled the request',
type: 'UserAborted',
});
// Don't update error message for user aborts
return;
}
// Fail interpreter operation for other errors
get().failOperation(interpreterOpId, {
message: err.message,
type: 'PluginServerError',
});
// For other errors, update message
await get().optimisticUpdatePluginState(id, { error }, context);
// 如果调用过程中出现了错误,不要触发 AI 消息
return;
} finally {
toggleInterpreterExecuting(id, false);
}
return true;
},
toggleInterpreterExecuting: (id: string, executing: boolean) => {
set(
{ codeInterpreterExecuting: { ...get().codeInterpreterExecuting, [id]: executing } },
false,
n('toggleInterpreterExecuting'),
);
},
updateInterpreterFileItem: async (
@@ -13,11 +13,14 @@ import {
RunCommandParams,
WriteLocalFileParams,
} from '@lobechat/electron-client-ipc';
import debug from 'debug';
import { StateCreator } from 'zustand/vanilla';
import { ChatStore } from '@/store/chat/store';
import { LocalSystemExecutionRuntime } from '@/tools/local-system/ExecutionRuntime';
const log = debug('lobe-store:builtin-tool');
/* eslint-disable typescript-sort-keys/interface */
export interface LocalFileAction {
internal_triggerLocalFileToolCalling: (
@@ -33,7 +36,6 @@ export interface LocalFileAction {
renameLocalFile: (id: string, params: RenameLocalFileParams) => Promise<boolean>;
reSearchLocalFiles: (id: string, params: LocalSearchFilesParams) => Promise<boolean>;
searchLocalFiles: (id: string, params: LocalSearchFilesParams) => Promise<boolean>;
toggleLocalFileLoading: (id: string, loading: boolean) => void;
writeLocalFile: (id: string, params: WriteLocalFileParams) => Promise<boolean>;
// Shell Commands
@@ -106,8 +108,6 @@ export const localSystemSlice: StateCreator<
},
reSearchLocalFiles: async (id, params) => {
get().toggleLocalFileLoading(id, true);
await get().optimisticUpdatePluginArguments(id, params);
return get().searchLocalFiles(id, params);
@@ -144,44 +144,94 @@ export const localSystemSlice: StateCreator<
// ==================== utils ====================
toggleLocalFileLoading: (id, loading) => {
// Assuming a loading state structure similar to searchLoading
set(
(state) => ({
localFileLoading: { ...state.localFileLoading, [id]: loading },
}),
false,
`toggleLocalFileLoading/${loading ? 'start' : 'end'}`,
);
},
internal_triggerLocalFileToolCalling: async (id, callingService) => {
get().toggleLocalFileLoading(id, true);
// Get parent operationId from messageOperationMap (should be executeToolCall)
const parentOperationId = get().messageOperationMap[id];
// Create child operation for local system execution
// Auto-associates message with this operation via messageId in context
const { operationId: localSystemOpId, abortController } = get().startOperation({
type: 'builtinToolLocalSystem',
context: {
messageId: id,
},
parentOperationId,
metadata: {
startTime: Date.now(),
},
});
log(
'[localSystem] messageId=%s, parentOpId=%s, localSystemOpId=%s, aborted=%s',
id,
parentOperationId,
localSystemOpId,
abortController.signal.aborted,
);
const context = { operationId: localSystemOpId };
try {
const { state, content, success, error } = await callingService();
// Complete local system operation
get().completeOperation(localSystemOpId);
if (success) {
if (state) {
await get().optimisticUpdatePluginState(id, state);
await get().optimisticUpdatePluginState(id, state, context);
}
await get().optimisticUpdateMessageContent(id, content);
await get().optimisticUpdateMessageContent(id, content, undefined, context);
} else {
await get().optimisticUpdateMessagePluginError(id, {
body: error,
message: error?.message || 'Operation failed',
type: 'PluginServerError',
});
await get().optimisticUpdateMessagePluginError(
id,
{
body: error,
message: error?.message || 'Operation failed',
type: 'PluginServerError',
},
context,
);
// Still update content even if failed, to show error message
await get().optimisticUpdateMessageContent(id, content);
await get().optimisticUpdateMessageContent(id, content, undefined, context);
}
return true;
} catch (error) {
await get().optimisticUpdateMessagePluginError(id, {
body: error,
message: (error as Error).message,
const err = error as Error;
log('[localSystem] Error: messageId=%s, error=%s', id, err.message);
// Check if it's an abort error
if (err.message.includes('The user aborted a request.') || err.name === 'AbortError') {
log('[localSystem] Request aborted: messageId=%s', id);
// Fail local system operation for abort
get().failOperation(localSystemOpId, {
message: 'User cancelled the request',
type: 'UserAborted',
});
// Don't update error message for user aborts
return false;
}
// Fail local system operation for other errors
get().failOperation(localSystemOpId, {
message: err.message,
type: 'PluginServerError',
});
}
get().toggleLocalFileLoading(id, false);
return true;
// For other errors, update message
await get().optimisticUpdateMessagePluginError(
id,
{
body: error,
message: err.message,
type: 'PluginServerError',
},
context,
);
return false;
}
},
});
@@ -1,6 +1,7 @@
import { crawlResultsPrompt } from '@lobechat/prompts';
import { CreateMessageParams, SEARCH_SEARXNG_NOT_CONFIG, SearchQuery } from '@lobechat/types';
import { nanoid } from '@lobechat/utils';
import debug from 'debug';
import { StateCreator } from 'zustand/vanilla';
import { searchService } from '@/services/search';
@@ -8,6 +9,8 @@ import { dbMessageSelectors } from '@/store/chat/selectors';
import { ChatStore } from '@/store/chat/store';
import { WebBrowsingExecutionRuntime } from '@/tools/web-browsing/ExecutionRuntime';
const log = debug('lobe-store:builtin-tool');
export interface SearchAction {
crawlMultiPages: (
id: string,
@@ -22,7 +25,6 @@ export interface SearchAction {
saveSearchResult: (id: string) => Promise<void>;
search: (id: string, data: SearchQuery, aiSummary?: boolean) => Promise<void | boolean>;
togglePageContent: (url: string) => void;
toggleSearchLoading: (id: string, loading: boolean) => void;
/**
*
* @description arguments
@@ -43,36 +45,79 @@ export const searchSlice: StateCreator<
SearchAction
> = (set, get) => ({
crawlMultiPages: async (id, params, aiSummary = true) => {
const { optimisticUpdateMessageContent } = get();
get().toggleSearchLoading(id, true);
// Get parent operationId from messageOperationMap (should be executeToolCall)
const parentOperationId = get().messageOperationMap[id];
// Get message to extract sessionId/topicId
const message = dbMessageSelectors.getDbMessageById(id)(get());
const context = { sessionId: message?.sessionId, topicId: message?.topicId };
// Create child operation for crawl execution
// Auto-associates message with this operation via messageId in context
const { operationId: crawlOpId, abortController } = get().startOperation({
context: {
messageId: id,
},
metadata: {
startTime: Date.now(),
urls: params.urls,
},
parentOperationId,
type: 'builtinToolSearch',
});
log(
'[crawlMultiPages] messageId=%s, parentOpId=%s, crawlOpId=%s, urls=%o, aborted=%s',
id,
parentOperationId,
crawlOpId,
params.urls,
abortController.signal.aborted,
);
const context = { operationId: crawlOpId };
try {
const { content, success, error, state } = await runtime.crawlMultiPages(params);
await optimisticUpdateMessageContent(id, content, undefined, context);
// Complete crawl operation
get().completeOperation(crawlOpId);
await get().optimisticUpdateMessageContent(id, content, undefined, context);
if (success) {
await get().optimisticUpdatePluginState(id, state, context);
} else {
await get().optimisticUpdatePluginError(id, error, context);
}
get().toggleSearchLoading(id, false);
// Convert to XML format to save tokens
// if aiSummary is true, then trigger ai message
return aiSummary;
} catch (e) {
const err = e as Error;
log('[crawlMultiPages] Error: messageId=%s, error=%s', id, err.message);
// Check if it's an abort error
if (err.message.includes('The user aborted a request.') || err.name === 'AbortError') {
log('[crawlMultiPages] Request aborted: messageId=%s', id);
// Fail crawl operation for abort
get().failOperation(crawlOpId, {
message: 'User cancelled the request',
type: 'UserAborted',
});
// Don't update error message for user aborts
return;
}
// Fail crawl operation for other errors
get().failOperation(crawlOpId, {
message: err.message,
type: 'PluginServerError',
});
// For other errors, update message
console.error(e);
const content = [{ errorMessage: err.message, errorType: err.name }];
const xmlContent = crawlResultsPrompt(content);
await optimisticUpdateMessageContent(id, xmlContent, undefined, context);
await get().optimisticUpdateMessageContent(id, xmlContent, undefined, context);
}
},
@@ -88,7 +133,9 @@ export const searchSlice: StateCreator<
const { optimisticAddToolToAssistantMessage, optimisticCreateMessage, openToolUI } = get();
const context = { sessionId: message.sessionId, topicId: message.topicId };
// Get operationId from messageOperationMap
const operationId = get().messageOperationMap[id];
const context = operationId ? { operationId } : undefined;
// 1. 创建一个新的 tool call message
const newToolCallId = `tool_call_${nanoid()}`;
@@ -120,10 +167,7 @@ export const searchSlice: StateCreator<
const [result] = await Promise.all([
// 1. 添加 tool message
optimisticCreateMessage(toolMessage, {
sessionId: toolMessage.sessionId,
topicId: toolMessage.topicId,
}),
optimisticCreateMessage(toolMessage, context),
// 2. 将这条 tool call message 插入到 ai 消息的 tools 中
addToolItem(),
]);
@@ -134,61 +178,104 @@ export const searchSlice: StateCreator<
},
search: async (id, params, aiSummary = true) => {
get().toggleSearchLoading(id, true);
// Get parent operationId from messageOperationMap (should be executeToolCall)
const parentOperationId = get().messageOperationMap[id];
// Get message to extract sessionId/topicId
const message = dbMessageSelectors.getDbMessageById(id)(get());
const context = { sessionId: message?.sessionId, topicId: message?.topicId };
// Create child operation for search execution
// Auto-associates message with this operation via messageId in context
const { operationId: searchOpId, abortController } = get().startOperation({
context: {
messageId: id,
},
metadata: {
query: params.query,
startTime: Date.now(),
},
parentOperationId,
type: 'builtinToolSearch',
});
const { content, success, error, state } = await runtime.search(params);
log(
'[search] messageId=%s, parentOpId=%s, searchOpId=%s, aborted=%s',
id,
parentOperationId,
searchOpId,
abortController.signal.aborted,
);
if (success) {
await get().optimisticUpdatePluginState(id, state, context);
} else {
if ((error as Error).message === SEARCH_SEARXNG_NOT_CONFIG) {
await get().optimisticUpdateMessagePluginError(
id,
{
body: { provider: 'searxng' },
message: 'SearXNG is not configured',
type: 'PluginSettingsInvalid',
},
context,
);
const context = { operationId: searchOpId };
try {
const { content, success, error, state } = await runtime.search(params, {
signal: abortController.signal,
});
// Complete search operation
get().completeOperation(searchOpId);
if (success) {
await get().optimisticUpdatePluginState(id, state, context);
} else {
await get().optimisticUpdateMessagePluginError(
id,
{
body: error,
message: (error as Error).message,
type: 'PluginServerError',
},
context,
);
if ((error as Error).message === SEARCH_SEARXNG_NOT_CONFIG) {
await get().optimisticUpdateMessagePluginError(
id,
{
body: { provider: 'searxng' },
message: 'SearXNG is not configured',
type: 'PluginSettingsInvalid',
},
context,
);
} else {
await get().optimisticUpdateMessagePluginError(
id,
{
body: error,
message: (error as Error).message,
type: 'PluginServerError',
},
context,
);
}
}
await get().optimisticUpdateMessageContent(id, content, undefined, context);
// 如果 aiSummary 为 true,则会自动触发总结
return aiSummary;
} catch (error) {
const err = error as Error;
log('[search] Error: messageId=%s, error=%s', id, err.message);
// Check if it's an abort error
if (err.message.includes('The user aborted a request.') || err.name === 'AbortError') {
log('[search] Request aborted: messageId=%s', id);
// Fail search operation for abort
get().failOperation(searchOpId, {
message: 'User cancelled the request',
type: 'UserAborted',
});
// Don't update error message for user aborts
return;
}
// Fail search operation for other errors
get().failOperation(searchOpId, { message: err.message, type: 'PluginServerError' });
// For other errors, update message
await get().optimisticUpdateMessagePluginError(
id,
{ body: error, message: err.message, type: 'PluginServerError' },
context,
);
}
get().toggleSearchLoading(id, false);
await get().optimisticUpdateMessageContent(id, content, undefined, context);
// 如果 aiSummary 为 true,则会自动触发总结
return aiSummary;
},
togglePageContent: (url) => {
set({ activePageContentUrl: url });
},
toggleSearchLoading: (id, loading) => {
set(
{ searchLoading: { ...get().searchLoading, [id]: loading } },
false,
`toggleSearchLoading/${loading ? 'start' : 'end'}`,
);
},
triggerSearchAgain: async (id, data, options) => {
get().toggleSearchLoading(id, true);
await get().optimisticUpdatePluginArguments(id, data);
await get().search(id, data, options?.aiSummary);
@@ -0,0 +1,258 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useChatStore } from '@/store/chat/store';
import { chatToolSelectors } from './selectors';
describe('chatToolSelectors', () => {
beforeEach(() => {
useChatStore.setState(useChatStore.getInitialState());
});
describe('isDallEImageGenerating', () => {
it('should return true when DALL-E image is generating for message', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({
dalleImageLoading: {
msg1: true,
msg2: false,
},
});
});
expect(chatToolSelectors.isDallEImageGenerating('msg1')(result.current)).toBe(true);
expect(chatToolSelectors.isDallEImageGenerating('msg2')(result.current)).toBe(false);
});
it('should return undefined when message not in loading state', () => {
const { result } = renderHook(() => useChatStore());
expect(chatToolSelectors.isDallEImageGenerating('msg1')(result.current)).toBeUndefined();
});
});
describe('isGeneratingDallEImage', () => {
it('should return true when any DALL-E image is generating', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({
dalleImageLoading: {
msg1: false,
msg2: true,
msg3: false,
},
});
});
expect(chatToolSelectors.isGeneratingDallEImage(result.current)).toBe(true);
});
it('should return false when no DALL-E images are generating', () => {
const { result } = renderHook(() => useChatStore());
act(() => {
useChatStore.setState({
dalleImageLoading: {
msg1: false,
msg2: false,
},
});
});
expect(chatToolSelectors.isGeneratingDallEImage(result.current)).toBe(false);
});
it('should return false when dalleImageLoading is empty', () => {
const { result } = renderHook(() => useChatStore());
expect(chatToolSelectors.isGeneratingDallEImage(result.current)).toBe(false);
});
});
describe('isInterpreterExecuting', () => {
it('should return true when interpreter is executing for message', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'builtinToolInterpreter',
context: { sessionId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId);
});
expect(chatToolSelectors.isInterpreterExecuting('msg1')(result.current)).toBe(true);
});
it('should return false when no operation exists for message', () => {
const { result } = renderHook(() => useChatStore());
expect(chatToolSelectors.isInterpreterExecuting('msg1')(result.current)).toBe(false);
});
it('should return false when operation is not builtinToolInterpreter', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId);
});
expect(chatToolSelectors.isInterpreterExecuting('msg1')(result.current)).toBe(false);
});
it('should return false when operation is completed', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'builtinToolInterpreter',
context: { sessionId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId);
});
act(() => {
result.current.completeOperation(opId);
});
expect(chatToolSelectors.isInterpreterExecuting('msg1')(result.current)).toBe(false);
});
});
describe('isSearXNGSearching', () => {
it('should return true when SearXNG search is running for message', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'builtinToolSearch',
context: { sessionId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId);
});
expect(chatToolSelectors.isSearXNGSearching('msg1')(result.current)).toBe(true);
});
it('should return false when no operation exists', () => {
const { result } = renderHook(() => useChatStore());
expect(chatToolSelectors.isSearXNGSearching('msg1')(result.current)).toBe(false);
});
it('should return false when operation type is different', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'builtinToolInterpreter',
context: { sessionId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId);
});
expect(chatToolSelectors.isSearXNGSearching('msg1')(result.current)).toBe(false);
});
it('should return false when operation is not running', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'builtinToolSearch',
context: { sessionId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId);
result.current.completeOperation(opId);
});
expect(chatToolSelectors.isSearXNGSearching('msg1')(result.current)).toBe(false);
});
});
describe('isSearchingLocalFiles', () => {
it('should return true when local system search is running', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'builtinToolLocalSystem',
context: { sessionId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId);
});
expect(chatToolSelectors.isSearchingLocalFiles('msg1')(result.current)).toBe(true);
});
it('should return false when no operation exists', () => {
const { result } = renderHook(() => useChatStore());
expect(chatToolSelectors.isSearchingLocalFiles('msg1')(result.current)).toBe(false);
});
it('should return false when operation type is different', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'builtinToolSearch',
context: { sessionId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId);
});
expect(chatToolSelectors.isSearchingLocalFiles('msg1')(result.current)).toBe(false);
});
it('should return false when operation is completed', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'builtinToolLocalSystem',
context: { sessionId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId);
result.current.completeOperation(opId);
});
expect(chatToolSelectors.isSearchingLocalFiles('msg1')(result.current)).toBe(false);
});
});
});
+25 -4
View File
@@ -5,11 +5,32 @@ const isDallEImageGenerating = (id: string) => (s: ChatStoreState) => s.dalleIma
const isGeneratingDallEImage = (s: ChatStoreState) =>
Object.values(s.dalleImageLoading).some(Boolean);
const isInterpreterExecuting = (id: string) => (s: ChatStoreState) =>
s.codeInterpreterExecuting[id];
const isInterpreterExecuting = (id: string) => (s: ChatStoreState) => {
// Check if there's a running builtinToolInterpreter operation for this message
const operationId = s.messageOperationMap[id];
if (!operationId) return false;
const isSearXNGSearching = (id: string) => (s: ChatStoreState) => s.searchLoading[id];
const isSearchingLocalFiles = (id: string) => (s: ChatStoreState) => s.localFileLoading[id];
const operation = s.operations[operationId];
return operation?.type === 'builtinToolInterpreter' && operation?.status === 'running';
};
const isSearXNGSearching = (id: string) => (s: ChatStoreState) => {
// Check if there's a running builtinToolSearch operation for this message
const operationId = s.messageOperationMap[id];
if (!operationId) return false;
const operation = s.operations[operationId];
return operation?.type === 'builtinToolSearch' && operation?.status === 'running';
};
const isSearchingLocalFiles = (id: string) => (s: ChatStoreState) => {
// Check if there's a running builtinToolLocalSystem operation for this message
const operationId = s.messageOperationMap[id];
if (!operationId) return false;
const operation = s.operations[operationId];
return operation?.type === 'builtinToolLocalSystem' && operation?.status === 'running';
};
export const chatToolSelectors = {
isDallEImageGenerating,
+134 -16
View File
@@ -28,6 +28,7 @@ vi.mock('@/services/message', () => ({
createMessage: vi.fn(() => Promise.resolve({ id: 'new-message-id', messages: [] })),
updateMessage: vi.fn(() => Promise.resolve({ success: true, messages: [] })),
updateMessageMetadata: vi.fn(() => Promise.resolve({ success: true, messages: [] })),
updateMessagePlugin: vi.fn(() => Promise.resolve({ success: true, messages: [] })),
updateMessagePluginError: vi.fn(() => Promise.resolve({ success: true, messages: [] })),
updateMessageRAG: vi.fn(() => Promise.resolve({ success: true, messages: [] })),
removeAllMessages: vi.fn(() => Promise.resolve()),
@@ -688,11 +689,14 @@ describe('chatMessage actions', () => {
await result.current.optimisticUpdateMessageContent(messageId, newContent);
});
expect(internal_dispatchMessageSpy).toHaveBeenCalledWith({
id: messageId,
type: 'updateMessage',
value: { content: newContent },
});
expect(internal_dispatchMessageSpy).toHaveBeenCalledWith(
{
id: messageId,
type: 'updateMessage',
value: { content: newContent },
},
undefined,
);
});
it('should replace messages after updating content', async () => {
@@ -872,10 +876,17 @@ describe('chatMessage actions', () => {
const updateMessageSpy = vi.spyOn(messageService, 'updateMessage');
let operationId: string;
await act(async () => {
// Create operation with desired context
const op = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: contextSessionId, topicId: contextTopicId },
});
operationId = op.operationId;
await result.current.optimisticUpdateMessageContent(messageId, content, undefined, {
sessionId: contextSessionId,
topicId: contextTopicId,
operationId,
});
});
@@ -886,7 +897,7 @@ describe('chatMessage actions', () => {
);
});
it('optimisticUpdateMessageError should use context sessionId/topicId', async () => {
it('optimisticUpdateMessageError should use context operationId', async () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const error = { message: 'Error occurred', type: 'error' as any };
@@ -895,10 +906,17 @@ describe('chatMessage actions', () => {
const updateMessageSpy = vi.spyOn(messageService, 'updateMessage');
let operationId: string;
await act(async () => {
// Create operation with desired context
const op = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: contextSessionId, topicId: contextTopicId },
});
operationId = op.operationId;
await result.current.optimisticUpdateMessageError(messageId, error, {
sessionId: contextSessionId,
topicId: contextTopicId,
operationId,
});
});
@@ -909,7 +927,7 @@ describe('chatMessage actions', () => {
);
});
it('optimisticDeleteMessage should use context sessionId/topicId', async () => {
it('optimisticDeleteMessage should use context operationId', async () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const contextSessionId = 'context-session';
@@ -917,10 +935,17 @@ describe('chatMessage actions', () => {
const removeMessageSpy = vi.spyOn(messageService, 'removeMessage');
let operationId: string;
await act(async () => {
// Create operation with desired context
const op = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: contextSessionId, topicId: contextTopicId },
});
operationId = op.operationId;
await result.current.optimisticDeleteMessage(messageId, {
sessionId: contextSessionId,
topicId: contextTopicId,
operationId,
});
});
@@ -930,7 +955,7 @@ describe('chatMessage actions', () => {
});
});
it('optimisticDeleteMessages should use context sessionId/topicId', async () => {
it('optimisticDeleteMessages should use context operationId', async () => {
const { result } = renderHook(() => useChatStore());
const ids = ['id-1', 'id-2'];
const contextSessionId = 'context-session';
@@ -938,10 +963,17 @@ describe('chatMessage actions', () => {
const removeMessagesSpy = vi.spyOn(messageService, 'removeMessages');
let operationId: string;
await act(async () => {
// Create operation with desired context
const op = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: contextSessionId, topicId: contextTopicId },
});
operationId = op.operationId;
await result.current.optimisticDeleteMessages(ids, {
sessionId: contextSessionId,
topicId: contextTopicId,
operationId,
});
});
@@ -951,4 +983,90 @@ describe('chatMessage actions', () => {
});
});
});
describe('optimisticUpdateMessagePlugin', () => {
it('should dispatch message update action with plugin value', async () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const pluginValue = { arguments: '{"test":"value"}' };
const internal_dispatchMessageSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
await act(async () => {
await result.current.optimisticUpdateMessagePlugin(messageId, pluginValue);
});
expect(internal_dispatchMessageSpy).toHaveBeenCalledWith(
{
id: messageId,
type: 'updateMessagePlugin',
value: pluginValue,
},
undefined,
);
});
it('should call messageService.updateMessagePlugin with correct parameters', async () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const pluginValue = { state: 'success' };
const updateMessagePluginSpy = vi.spyOn(messageService, 'updateMessagePlugin');
await act(async () => {
await result.current.optimisticUpdateMessagePlugin(messageId, pluginValue);
});
expect(updateMessagePluginSpy).toHaveBeenCalledWith(messageId, pluginValue, {
sessionId: 'session-id',
topicId: 'topic-id',
});
});
it('should replace messages after updating plugin', async () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const pluginValue = { apiName: 'test-api' };
const replaceMessagesSpy = vi.spyOn(result.current, 'replaceMessages');
await act(async () => {
await result.current.optimisticUpdateMessagePlugin(messageId, pluginValue);
});
expect(replaceMessagesSpy).toHaveBeenCalledWith(
[],
expect.objectContaining({
sessionId: 'session-id',
topicId: 'topic-id',
}),
);
});
it('should use context operationId when provided', async () => {
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const pluginValue = { identifier: 'test-plugin' };
const contextSessionId = 'context-session';
const contextTopicId = 'context-topic';
const updateMessagePluginSpy = vi.spyOn(messageService, 'updateMessagePlugin');
let operationId: string;
await act(async () => {
// Create operation with desired context
const op = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: contextSessionId, topicId: contextTopicId },
});
operationId = op.operationId;
await result.current.optimisticUpdateMessagePlugin(messageId, pluginValue, {
operationId,
});
});
expect(updateMessagePluginSpy).toHaveBeenCalledWith(messageId, pluginValue, {
sessionId: contextSessionId,
topicId: contextTopicId,
});
});
});
});
@@ -1,5 +1,6 @@
import { parse } from '@lobechat/conversation-flow';
import { TraceEventPayloads } from '@lobechat/types';
import debug from 'debug';
import isEqual from 'fast-deep-equal';
import { StateCreator } from 'zustand/vanilla';
@@ -10,6 +11,8 @@ import { displayMessageSelectors } from '../../../selectors';
import { messageMapKey } from '../../../utils/messageMapKey';
import { MessageDispatch, messagesReducer } from '../reducer';
const log = debug('lobe-store:message-internals');
/**
* Internal core methods that serve as building blocks for other actions
*/
@@ -18,10 +21,7 @@ export interface MessageInternalsAction {
* update message at the frontend
* this method will not update messages to database
*/
internal_dispatchMessage: (
payload: MessageDispatch,
context?: { sessionId: string; topicId?: string | null },
) => void;
internal_dispatchMessage: (payload: MessageDispatch, context?: { operationId?: string }) => void;
/**
* trace message events for analytics
@@ -37,10 +37,36 @@ export const messageInternals: StateCreator<
> = (set, get) => ({
// the internal process method of the AI message
internal_dispatchMessage: (payload, context) => {
const activeId = typeof context !== 'undefined' ? context.sessionId : get().activeId;
const topicId = typeof context !== 'undefined' ? context.topicId : get().activeTopicId;
let sessionId: string;
let topicId: string | null | undefined;
const messagesKey = messageMapKey(activeId, topicId);
// Get context from operation if operationId is provided
if (context?.operationId) {
const operation = get().operations[context.operationId];
if (!operation) {
log('[internal_dispatchMessage] ERROR: Operation not found: %s', context.operationId);
throw new Error(`Operation not found: ${context.operationId}`);
}
sessionId = operation.context.sessionId!;
topicId = operation.context.topicId;
log(
'[internal_dispatchMessage] get context from operation %s: sessionId=%s, topicId=%s',
context.operationId,
sessionId,
topicId,
);
} else {
// Fallback to global state
sessionId = get().activeId;
topicId = get().activeTopicId;
log(
'[internal_dispatchMessage] use global context: sessionId=%s, topicId=%s',
sessionId,
topicId,
);
}
const messagesKey = messageMapKey(sessionId, topicId);
// Get raw messages from dbMessagesMap and apply reducer
const rawMessages = get().dbMessagesMap[messagesKey] || [];
@@ -6,6 +6,7 @@ import {
CreateMessageParams,
GroundingSearch,
MessageMetadata,
MessagePluginItem,
MessageToolCall,
ModelReasoning,
UIChatMessage,
@@ -21,8 +22,7 @@ import { ChatStore } from '@/store/chat/store';
* Context for optimistic updates to specify session/topic isolation
*/
export interface OptimisticUpdateContext {
sessionId?: string;
topicId?: string | null;
operationId?: string;
}
/**
@@ -38,10 +38,8 @@ export interface MessageOptimisticUpdateAction {
params: CreateMessageParams,
context?: {
groupMessageId?: string;
sessionId?: string;
skipRefresh?: boolean;
operationId?: string;
tempMessageId?: string;
topicId?: string | null;
},
) => Promise<{ id: string; messages: UIChatMessage[] } | undefined>;
@@ -94,6 +92,16 @@ export interface MessageOptimisticUpdateAction {
context?: OptimisticUpdateContext,
) => Promise<void>;
/**
* Optimistic update for message pluginIntervention field (frontend only, no database persistence)
* Use this when you need to update plugin intervention status
*/
optimisticUpdateMessagePlugin: (
id: string,
value: Partial<MessagePluginItem>,
context?: OptimisticUpdateContext,
) => Promise<void>;
/**
* update the message plugin error with optimistic update
*/
@@ -136,28 +144,28 @@ export const messageOptimisticUpdate: StateCreator<
try {
const result = await messageService.createMessage(message);
if (!context?.skipRefresh) {
// Use the messages returned from createMessage (already grouped)
const sessionId = context?.sessionId ?? get().activeId;
const topicId = context?.topicId !== undefined ? context.topicId : get().activeTopicId;
replaceMessages(result.messages, { sessionId, topicId });
}
// Use the messages returned from createMessage (already grouped)
const { sessionId, topicId } = get().internal_getSessionContext(context);
replaceMessages(result.messages, { sessionId, topicId });
internal_toggleMessageLoading(false, tempId);
return result;
} catch (e) {
internal_toggleMessageLoading(false, tempId);
internal_dispatchMessage({
id: tempId,
type: 'updateMessage',
value: {
error: {
body: e,
message: (e as Error).message,
type: ChatErrorType.CreateMessageError,
internal_dispatchMessage(
{
id: tempId,
type: 'updateMessage',
value: {
error: {
body: e,
message: (e as Error).message,
type: ChatErrorType.CreateMessageError,
},
},
},
});
context,
);
}
},
@@ -172,9 +180,8 @@ export const messageOptimisticUpdate: StateCreator<
},
optimisticDeleteMessage: async (id: string, context) => {
get().internal_dispatchMessage({ id, type: 'deleteMessage' });
const sessionId = context?.sessionId ?? get().activeId;
const topicId = context?.topicId !== undefined ? context.topicId : get().activeTopicId;
get().internal_dispatchMessage({ id, type: 'deleteMessage' }, context);
const { sessionId, topicId } = get().internal_getSessionContext(context);
const result = await messageService.removeMessage(id, {
sessionId,
topicId,
@@ -185,9 +192,8 @@ export const messageOptimisticUpdate: StateCreator<
},
optimisticDeleteMessages: async (ids, context) => {
get().internal_dispatchMessage({ ids, type: 'deleteMessages' });
const sessionId = context?.sessionId ?? get().activeId;
const topicId = context?.topicId !== undefined ? context.topicId : get().activeTopicId;
get().internal_dispatchMessage({ ids, type: 'deleteMessages' }, context);
const { sessionId, topicId } = get().internal_getSessionContext(context);
const result = await messageService.removeMessages(ids, {
sessionId,
topicId,
@@ -209,21 +215,26 @@ export const messageOptimisticUpdate: StateCreator<
// we need to update the message content at the frontend to avoid the update flick
// refs: https://medium.com/@kyledeguzmanx/what-are-optimistic-updates-483662c3e171
if (extra?.toolCalls) {
internal_dispatchMessage({
id,
type: 'updateMessage',
value: { tools: internal_transformToolCalls(extra?.toolCalls) },
});
internal_dispatchMessage(
{
id,
type: 'updateMessage',
value: { tools: internal_transformToolCalls(extra?.toolCalls) },
},
context,
);
} else {
internal_dispatchMessage({
id,
type: 'updateMessage',
value: { content },
});
internal_dispatchMessage(
{
id,
type: 'updateMessage',
value: { content },
},
context,
);
}
const sessionId = context?.sessionId ?? get().activeId;
const topicId = context?.topicId !== undefined ? context.topicId : get().activeTopicId;
const { sessionId, topicId } = get().internal_getSessionContext(context);
const result = await messageService.updateMessage(
id,
@@ -252,9 +263,8 @@ export const messageOptimisticUpdate: StateCreator<
},
optimisticUpdateMessageError: async (id, error, context) => {
get().internal_dispatchMessage({ id, type: 'updateMessage', value: { error } });
const sessionId = context?.sessionId ?? get().activeId;
const topicId = context?.topicId !== undefined ? context.topicId : get().activeTopicId;
get().internal_dispatchMessage({ id, type: 'updateMessage', value: { error } }, context);
const { sessionId, topicId } = get().internal_getSessionContext(context);
const result = await messageService.updateMessage(id, { error }, { sessionId, topicId });
if (result?.success && result.messages) {
get().replaceMessages(result.messages, { sessionId, topicId });
@@ -267,14 +277,16 @@ export const messageOptimisticUpdate: StateCreator<
const { internal_dispatchMessage, refreshMessages, replaceMessages } = get();
// Optimistic update: update the frontend immediately
internal_dispatchMessage({
id,
type: 'updateMessageMetadata',
value: metadata,
});
internal_dispatchMessage(
{
id,
type: 'updateMessageMetadata',
value: metadata,
},
context,
);
const sessionId = context?.sessionId ?? get().activeId;
const topicId = context?.topicId !== undefined ? context.topicId : get().activeTopicId;
const { sessionId, topicId } = get().internal_getSessionContext(context);
// Persist to database
const result = await messageService.updateMessageMetadata(id, metadata, {
@@ -289,9 +301,31 @@ export const messageOptimisticUpdate: StateCreator<
}
},
optimisticUpdateMessagePlugin: async (id, value, context) => {
const { internal_dispatchMessage, replaceMessages } = get();
// Optimistic update: update the frontend immediately
internal_dispatchMessage(
{
id,
type: 'updateMessagePlugin',
value,
},
context,
);
const { sessionId, topicId } = get().internal_getSessionContext(context);
// Persist to database
const result = await messageService.updateMessagePlugin(id, value, { sessionId, topicId });
if (result?.success && result.messages) {
replaceMessages(result.messages, { sessionId, topicId });
}
},
optimisticUpdateMessagePluginError: async (id, error, context) => {
const sessionId = context?.sessionId ?? get().activeId;
const topicId = context?.topicId !== undefined ? context.topicId : get().activeTopicId;
const { sessionId, topicId } = get().internal_getSessionContext(context);
const result = await messageService.updateMessagePluginError(id, error, {
sessionId,
topicId,
@@ -302,8 +336,7 @@ export const messageOptimisticUpdate: StateCreator<
},
optimisticUpdateMessageRAG: async (id, data, context) => {
const sessionId = context?.sessionId ?? get().activeId;
const topicId = context?.topicId !== undefined ? context.topicId : get().activeTopicId;
const { sessionId, topicId } = get().internal_getSessionContext(context);
const result = await messageService.updateMessageRAG(id, data, {
sessionId,
topicId,
@@ -15,10 +15,6 @@ export interface ChatMessageState {
* Derived from session.type, used for caching to avoid repeated lookups
*/
activeSessionType?: 'agent' | 'group';
/**
* is the message is continuing generation (used for disable continue button)
*/
continuingIds: string[];
/**
* Raw messages from database (flat structure)
*/
@@ -52,10 +48,6 @@ export interface ChatMessageState {
* Parsed messages for display (includes assistantGroup from conversation-flow)
*/
messagesMap: Record<string, UIChatMessage[]>;
/**
* is the message is regenerating (used for disable regenerate button)
*/
regeneratingIds: string[];
/**
* Supervisor decision debounce timers by group ID
*/
@@ -77,7 +69,6 @@ export interface ChatMessageState {
export const initialMessageState: ChatMessageState = {
activeId: 'inbox',
activeSessionType: undefined,
continuingIds: [],
dbMessagesMap: {},
groupAgentMaps: {},
groupMaps: {},
@@ -87,7 +78,6 @@ export const initialMessageState: ChatMessageState = {
messageLoadingIds: [],
messagesInit: false,
messagesMap: {},
regeneratingIds: [],
supervisorDebounceTimers: {},
supervisorDecisionAbortControllers: {},
supervisorDecisionLoading: [],
@@ -1,26 +1,50 @@
import type { ChatStoreState } from '../../../initialState';
import { operationSelectors } from '../../operation/selectors';
import { mainDisplayChatIDs } from './chat';
import { getDbMessageByToolCallId } from './dbMessage';
import { getDisplayMessageById } from './displayMessage';
const isMessageEditing = (id: string) => (s: ChatStoreState) => s.messageEditingIds.includes(id);
const isMessageLoading = (id: string) => (s: ChatStoreState) => s.messageLoadingIds.includes(id);
const isMessageRegenerating = (id: string) => (s: ChatStoreState) => s.regeneratingIds.includes(id);
const isMessageContinuing = (id: string) => (s: ChatStoreState) => s.continuingIds.includes(id);
const isMessageGenerating = (id: string) => (s: ChatStoreState) => s.chatLoadingIds.includes(id);
/**
* Check if a message is in loading state
* Priority: operation system (for AI flows) > legacy messageLoadingIds (for CRUD operations)
*/
const isMessageLoading = (id: string) => (s: ChatStoreState) => {
// First check operation system (sendMessage, etc.)
const hasOperation = operationSelectors.isMessageProcessing(id)(s);
if (hasOperation) return true;
// Fallback to legacy loading state (for non-operation CRUD)
return s.messageLoadingIds.includes(id);
};
// Use operation system for AI-related loading states
const isMessageRegenerating = (id: string) => (s: ChatStoreState) =>
operationSelectors.isMessageRegenerating(id)(s);
const isMessageContinuing = (id: string) => (s: ChatStoreState) =>
operationSelectors.isMessageContinuing(id)(s);
const isMessageGenerating = (id: string) => (s: ChatStoreState) =>
operationSelectors.isMessageGenerating(id)(s); // Only check generateAI operations
const isMessageInChatReasoning = (id: string) => (s: ChatStoreState) =>
operationSelectors.isMessageInReasoning(id)(s);
// Use operation system for message CRUD operations
const isMessageCreating = (id: string) => (s: ChatStoreState) =>
operationSelectors.isMessageCreating(id)(s); // Only check sendMessage operations
// RAG flow still uses dedicated state
const isMessageInRAGFlow = (id: string) => (s: ChatStoreState) =>
s.messageRAGLoadingIds.includes(id);
const isMessageInChatReasoning = (id: string) => (s: ChatStoreState) =>
s.reasoningLoadingIds.includes(id);
const isMessageCollapsed = (id: string) => (s: ChatStoreState) => {
const message = getDisplayMessageById(id)(s);
return message?.metadata?.collapsed ?? false;
};
// Use operation system for plugin API invocation
const isPluginApiInvoking = (id: string) => (s: ChatStoreState) =>
s.pluginApiLoadingIds.includes(id);
operationSelectors.isMessageInToolCalling(id)(s);
const isToolCallStreaming = (id: string, index: number) => (s: ChatStoreState) => {
const isLoading = s.toolCallingStreamIds[id];
@@ -33,7 +57,8 @@ const isToolCallStreaming = (id: string, index: number) => (s: ChatStoreState) =
const isInToolsCalling = (id: string, index: number) => (s: ChatStoreState) => {
const isStreamingToolsCalling = isToolCallStreaming(id, index)(s);
const isInvokingPluginApi = s.messageInToolsCallingIds.includes(id);
// Check if assistant message has any tool calling operations
const isInvokingPluginApi = operationSelectors.isMessageInToolCalling(id)(s);
return isStreamingToolsCalling || isInvokingPluginApi;
};
@@ -47,9 +72,6 @@ const isToolApiNameShining =
return isStreaming || isPluginInvoking;
};
const isAIGenerating = (s: ChatStoreState) =>
s.chatLoadingIds.some((id) => mainDisplayChatIDs(s).includes(id));
const isInRAGFlow = (s: ChatStoreState) =>
s.messageRAGLoadingIds.some((id) => mainDisplayChatIDs(s).includes(id));
@@ -72,13 +94,13 @@ const isSendButtonDisabledByMessage = (s: ChatStoreState) =>
isInRAGFlow(s);
export const messageStateSelectors = {
isAIGenerating,
isCreatingMessage,
isHasMessageLoading,
isInRAGFlow,
isInToolsCalling,
isMessageCollapsed,
isMessageContinuing,
isMessageCreating,
isMessageEditing,
isMessageGenerating,
isMessageInChatReasoning,
@@ -1,4 +1,5 @@
import { act, renderHook } from '@testing-library/react';
import { produce } from 'immer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useChatStore } from '@/store/chat/store';
@@ -21,7 +22,7 @@ describe('Operation Actions', () => {
act(() => {
const res = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
label: 'Generating...',
});
@@ -32,7 +33,7 @@ describe('Operation Actions', () => {
const operation = result.current.operations[operationId!];
expect(operation).toBeDefined();
expect(operation.type).toBe('generateAI');
expect(operation.type).toBe('execAgentRuntime');
expect(operation.status).toBe('running');
expect(operation.context.sessionId).toBe('session1');
expect(operation.context.topicId).toBe('topic1');
@@ -57,7 +58,7 @@ describe('Operation Actions', () => {
// Create child operation (inherits context)
const child = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { messageId: 'msg1' }, // Only override messageId
parentOperationId: parentOpId,
});
@@ -88,7 +89,7 @@ describe('Operation Actions', () => {
// Create child operation without context (undefined)
const child = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
parentOperationId: parentOpId,
});
childOpId = child.operationId;
@@ -110,13 +111,13 @@ describe('Operation Actions', () => {
act(() => {
operationId = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
}).operationId;
});
// Check type index
expect(result.current.operationsByType.generateAI).toContain(operationId!);
expect(result.current.operationsByType.execAgentRuntime).toContain(operationId!);
// Check message index
expect(result.current.operationsByMessage.msg1).toContain(operationId!);
@@ -135,7 +136,7 @@ describe('Operation Actions', () => {
act(() => {
operationId = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
});
@@ -164,7 +165,7 @@ describe('Operation Actions', () => {
act(() => {
const res = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
});
operationId = res.operationId;
@@ -191,7 +192,7 @@ describe('Operation Actions', () => {
act(() => {
parentOpId = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
@@ -214,6 +215,119 @@ describe('Operation Actions', () => {
expect(result.current.operations[child1OpId!].status).toBe('cancelled');
expect(result.current.operations[child2OpId!].status).toBe('cancelled');
});
it('should not cancel already completed child operations', () => {
const { result } = renderHook(() => useChatStore());
let parentOpId: string;
let completedChildOpId: string;
let runningChildOpId: string;
act(() => {
parentOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
completedChildOpId = result.current.startOperation({
type: 'toolCalling',
parentOperationId: parentOpId,
}).operationId;
runningChildOpId = result.current.startOperation({
type: 'reasoning',
parentOperationId: parentOpId,
}).operationId;
// Complete the first child
result.current.completeOperation(completedChildOpId!);
});
// Verify initial states
expect(result.current.operations[completedChildOpId!].status).toBe('completed');
expect(result.current.operations[runningChildOpId!].status).toBe('running');
act(() => {
result.current.cancelOperation(parentOpId!);
});
// Parent and running child should be cancelled
expect(result.current.operations[parentOpId!].status).toBe('cancelled');
expect(result.current.operations[runningChildOpId!].status).toBe('cancelled');
// Completed child should remain completed (not cancelled)
expect(result.current.operations[completedChildOpId!].status).toBe('completed');
});
it('should not invoke cancel handler for already completed operations', async () => {
const { result } = renderHook(() => useChatStore());
let parentOpId: string;
let completedChildOpId: string;
const completedChildHandler = vi.fn();
act(() => {
parentOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
completedChildOpId = result.current.startOperation({
type: 'toolCalling',
parentOperationId: parentOpId,
}).operationId;
// Register cancel handler
result.current.onOperationCancel(completedChildOpId!, completedChildHandler);
// Complete the child operation
result.current.completeOperation(completedChildOpId!);
});
act(() => {
result.current.cancelOperation(parentOpId!);
});
// Wait a bit to ensure no async handler calls
await new Promise((resolve) => setTimeout(resolve, 50));
// Handler should NOT be called for completed operation
expect(completedChildHandler).not.toHaveBeenCalled();
});
it('should skip cancellation of already cancelled operations', () => {
const { result } = renderHook(() => useChatStore());
let operationId: string;
act(() => {
operationId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
});
// Cancel the operation
act(() => {
result.current.cancelOperation(operationId!, 'First cancellation');
});
expect(result.current.operations[operationId!].status).toBe('cancelled');
expect(result.current.operations[operationId!].metadata.cancelReason).toBe(
'First cancellation',
);
// Try to cancel again
act(() => {
result.current.cancelOperation(operationId!, 'Second cancellation');
});
// Should still have the first cancellation reason (not updated)
expect(result.current.operations[operationId!].status).toBe('cancelled');
expect(result.current.operations[operationId!].metadata.cancelReason).toBe(
'First cancellation',
);
});
});
describe('failOperation', () => {
@@ -224,7 +338,7 @@ describe('Operation Actions', () => {
act(() => {
operationId = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
});
@@ -258,12 +372,12 @@ describe('Operation Actions', () => {
act(() => {
op1 = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
op2 = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
@@ -274,7 +388,7 @@ describe('Operation Actions', () => {
});
act(() => {
const cancelled = result.current.cancelOperations({ type: 'generateAI' });
const cancelled = result.current.cancelOperations({ type: 'execAgentRuntime' });
expect(cancelled).toHaveLength(2);
});
@@ -284,6 +398,192 @@ describe('Operation Actions', () => {
});
});
describe('cleanupCompletedOperations', () => {
it('should remove operations completed longer than specified time', () => {
const { result } = renderHook(() => useChatStore());
let op1: string;
let op2: string;
let op3: string;
act(() => {
// Create and complete operations at different times
op1 = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
op2 = result.current.startOperation({
type: 'reasoning',
context: { sessionId: 'session1' },
}).operationId;
op3 = result.current.startOperation({
type: 'toolCalling',
context: { sessionId: 'session1' },
}).operationId;
// Complete op1 and op2, leave op3 running
result.current.completeOperation(op1!);
result.current.completeOperation(op2!);
});
// Manually set endTime to simulate operations completed long ago
act(() => {
useChatStore.setState(
produce((state) => {
const now = Date.now();
if (state.operations[op1!]) {
state.operations[op1!].metadata.endTime = now - 70_000; // 70 seconds ago
}
if (state.operations[op2!]) {
state.operations[op2!].metadata.endTime = now - 20_000; // 20 seconds ago
}
}),
);
});
// Cleanup operations older than 60 seconds
let cleanedCount = 0;
act(() => {
cleanedCount = result.current.cleanupCompletedOperations(60_000);
});
expect(cleanedCount).toBe(1);
expect(result.current.operations[op1!]).toBeUndefined(); // Removed (70s old)
expect(result.current.operations[op2!]).toBeDefined(); // Kept (20s old)
expect(result.current.operations[op3!]).toBeDefined(); // Kept (running)
});
it('should clean up operations on startOperation for top-level operations', () => {
const { result } = renderHook(() => useChatStore());
let completedOp: string;
act(() => {
// Create and complete an operation
completedOp = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
result.current.completeOperation(completedOp!);
// Set endTime to 40 seconds ago
useChatStore.setState(
produce((state) => {
if (state.operations[completedOp!]) {
state.operations[completedOp!].metadata.endTime = Date.now() - 40_000;
}
}),
);
});
expect(result.current.operations[completedOp!]).toBeDefined();
// Start a new top-level operation (should trigger cleanup)
act(() => {
result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
});
});
// Old operation should be cleaned up (older than 30s)
expect(result.current.operations[completedOp!]).toBeUndefined();
});
it('should not clean up operations when starting child operations', () => {
const { result } = renderHook(() => useChatStore());
let parentOp: string;
let oldCompletedOp: string;
act(() => {
// Create parent operation
parentOp = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
// Create and complete an old operation
oldCompletedOp = result.current.startOperation({
type: 'reasoning',
context: { sessionId: 'session1' },
}).operationId;
result.current.completeOperation(oldCompletedOp!);
// Set endTime to 40 seconds ago
useChatStore.setState(
produce((state) => {
if (state.operations[oldCompletedOp!]) {
state.operations[oldCompletedOp!].metadata.endTime = Date.now() - 40_000;
}
}),
);
});
// Start a child operation (should NOT trigger cleanup)
act(() => {
result.current.startOperation({
type: 'callLLM',
parentOperationId: parentOp!,
});
});
// Old operation should still exist (cleanup not triggered for child operations)
expect(result.current.operations[oldCompletedOp!]).toBeDefined();
});
it('should clean up cancelled and failed operations', () => {
const { result } = renderHook(() => useChatStore());
let cancelledOp: string;
let failedOp: string;
act(() => {
cancelledOp = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
failedOp = result.current.startOperation({
type: 'reasoning',
context: { sessionId: 'session1' },
}).operationId;
result.current.cancelOperation(cancelledOp!, 'User cancelled');
result.current.failOperation(failedOp!, {
type: 'Error',
message: 'Failed',
});
// Set endTime to 70 seconds ago
useChatStore.setState(
produce((state) => {
const now = Date.now();
if (state.operations[cancelledOp!]) {
state.operations[cancelledOp!].metadata.endTime = now - 70_000;
}
if (state.operations[failedOp!]) {
state.operations[failedOp!].metadata.endTime = now - 70_000;
}
}),
);
});
let cleanedCount = 0;
act(() => {
cleanedCount = result.current.cleanupCompletedOperations(60_000);
});
expect(cleanedCount).toBe(2);
expect(result.current.operations[cancelledOp!]).toBeUndefined();
expect(result.current.operations[failedOp!]).toBeUndefined();
});
});
describe('associateMessageWithOperation', () => {
it('should create message-operation mapping', () => {
const { result } = renderHook(() => useChatStore());
@@ -292,7 +592,7 @@ describe('Operation Actions', () => {
act(() => {
operationId = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
@@ -300,6 +600,56 @@ describe('Operation Actions', () => {
});
expect(result.current.messageOperationMap.msg1).toBe(operationId!);
expect(result.current.operationsByMessage.msg1).toContain(operationId!);
});
it('should update operationsByMessage index', () => {
const { result } = renderHook(() => useChatStore());
let op1: string;
let op2: string;
act(() => {
op1 = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
op2 = result.current.startOperation({
type: 'regenerate',
context: { sessionId: 'session1' },
}).operationId;
// Associate same message with multiple operations
result.current.associateMessageWithOperation('msg1', op1!);
result.current.associateMessageWithOperation('msg1', op2!);
});
// Both operations should be in the index
expect(result.current.operationsByMessage.msg1).toContain(op1!);
expect(result.current.operationsByMessage.msg1).toContain(op2!);
expect(result.current.operationsByMessage.msg1).toHaveLength(2);
});
it('should not duplicate operation IDs in operationsByMessage', () => {
const { result } = renderHook(() => useChatStore());
let operationId: string;
act(() => {
operationId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
// Associate same operation twice
result.current.associateMessageWithOperation('msg1', operationId!);
result.current.associateMessageWithOperation('msg1', operationId!);
});
// Should only appear once
expect(result.current.operationsByMessage.msg1).toHaveLength(1);
expect(result.current.operationsByMessage.msg1[0]).toBe(operationId!);
});
});
@@ -312,12 +662,12 @@ describe('Operation Actions', () => {
act(() => {
op1 = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
op2 = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
});
@@ -350,4 +700,350 @@ describe('Operation Actions', () => {
expect(result.current.operations[op2!]).toBeDefined(); // Still running
});
});
describe('getOperationAbortSignal', () => {
it('should return the AbortSignal for a given operation', () => {
const { result } = renderHook(() => useChatStore());
let operationId: string;
let abortController: AbortController;
act(() => {
const res = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
});
operationId = res.operationId;
abortController = res.abortController;
});
const signal = result.current.getOperationAbortSignal(operationId!);
expect(signal).toBe(abortController!.signal);
expect(signal.aborted).toBe(false);
});
it('should throw error when operation not found', () => {
const { result } = renderHook(() => useChatStore());
expect(() => {
result.current.getOperationAbortSignal('non-existent-id');
}).toThrow('Operation not found');
});
it('should return aborted signal after operation is cancelled', () => {
const { result } = renderHook(() => useChatStore());
let operationId: string;
act(() => {
operationId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
});
const signal = result.current.getOperationAbortSignal(operationId!);
expect(signal.aborted).toBe(false);
act(() => {
result.current.cancelOperation(operationId!);
});
expect(signal.aborted).toBe(true);
});
});
describe('onOperationCancel', () => {
it('should register cancel handler for an operation', () => {
const { result } = renderHook(() => useChatStore());
let operationId: string;
const handler = vi.fn();
act(() => {
operationId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
result.current.onOperationCancel(operationId!, handler);
});
const operation = result.current.operations[operationId!];
expect(operation.onCancelHandler).toBe(handler);
});
it('should handle registering handler for non-existent operation gracefully', () => {
const { result } = renderHook(() => useChatStore());
const handler = vi.fn();
act(() => {
result.current.onOperationCancel('non-existent-id', handler);
});
// Should not throw, just log warning
expect(handler).not.toHaveBeenCalled();
});
});
describe('cancelOperation with cancel handler', () => {
it('should call cancel handler when operation is cancelled', async () => {
const { result } = renderHook(() => useChatStore());
let operationId: string;
const handler = vi.fn();
act(() => {
operationId = result.current.startOperation({
type: 'createAssistantMessage',
context: { sessionId: 'session1', messageId: 'msg1' },
metadata: { tempMessageId: 'temp-123' },
}).operationId;
result.current.onOperationCancel(operationId!, handler);
});
act(() => {
result.current.cancelOperation(operationId!, 'User clicked stop');
});
// Wait for async handler
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledTimes(1);
});
expect(handler).toHaveBeenCalledWith({
operationId: operationId!,
type: 'createAssistantMessage',
reason: 'User clicked stop',
metadata: expect.objectContaining({
tempMessageId: 'temp-123',
}),
});
});
it('should call handler with correct type for different operation types', async () => {
const { result } = renderHook(() => useChatStore());
const testCases = [
{ type: 'createAssistantMessage' as const, reason: 'Rollback creation' },
{ type: 'callLLM' as const, reason: 'LLM streaming cancelled' },
{ type: 'createToolMessage' as const, reason: 'Tool message creation cancelled' },
{ type: 'executeToolCall' as const, reason: 'Tool execution cancelled' },
];
for (const { type, reason } of testCases) {
const handler = vi.fn();
let opId: string;
act(() => {
opId = result.current.startOperation({
type,
context: { sessionId: 'session1' },
}).operationId;
result.current.onOperationCancel(opId!, handler);
});
act(() => {
result.current.cancelOperation(opId!, reason);
});
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
type,
reason,
}),
);
});
}
});
it('should handle async cancel handler correctly', async () => {
const { result } = renderHook(() => useChatStore());
let operationId: string;
const asyncHandler = vi.fn(async ({ type }) => {
// Simulate async cleanup
await new Promise((resolve) => setTimeout(resolve, 10));
// Don't return anything (void)
});
act(() => {
operationId = result.current.startOperation({
type: 'createToolMessage',
context: { sessionId: 'session1' },
}).operationId;
result.current.onOperationCancel(operationId!, asyncHandler);
});
act(() => {
result.current.cancelOperation(operationId!);
});
// Handler should be called
await vi.waitFor(() => {
expect(asyncHandler).toHaveBeenCalledTimes(1);
});
// Operation should be marked as cancelled even if handler is still running
expect(result.current.operations[operationId!].status).toBe('cancelled');
});
it('should not block cancellation flow if handler throws error', async () => {
const { result } = renderHook(() => useChatStore());
let operationId: string;
const errorHandler = vi.fn(() => {
throw new Error('Handler error');
});
act(() => {
operationId = result.current.startOperation({
type: 'executeToolCall',
context: { sessionId: 'session1' },
}).operationId;
result.current.onOperationCancel(operationId!, errorHandler);
});
act(() => {
result.current.cancelOperation(operationId!);
});
// Operation should still be cancelled despite handler error
expect(result.current.operations[operationId!].status).toBe('cancelled');
expect(errorHandler).toHaveBeenCalledTimes(1);
});
it('should not call handler if operation is already cancelled', async () => {
const { result } = renderHook(() => useChatStore());
let operationId: string;
const handler = vi.fn();
act(() => {
operationId = result.current.startOperation({
type: 'callLLM',
context: { sessionId: 'session1' },
}).operationId;
result.current.onOperationCancel(operationId!, handler);
});
// Cancel first time
act(() => {
result.current.cancelOperation(operationId!);
});
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledTimes(1);
});
// Try to cancel again
act(() => {
result.current.cancelOperation(operationId!);
});
// Handler should not be called again
expect(handler).toHaveBeenCalledTimes(1);
});
});
describe('cancelOperation with child operations and handlers', () => {
it('should call handlers for both parent and child operations', async () => {
const { result } = renderHook(() => useChatStore());
let parentOpId: string;
let childOpId: string;
const parentHandler = vi.fn();
const childHandler = vi.fn();
act(() => {
parentOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
childOpId = result.current.startOperation({
type: 'callLLM',
context: { messageId: 'msg1' },
parentOperationId: parentOpId!,
}).operationId;
result.current.onOperationCancel(parentOpId!, parentHandler);
result.current.onOperationCancel(childOpId!, childHandler);
});
act(() => {
result.current.cancelOperation(parentOpId!);
});
// Both handlers should be called
await vi.waitFor(() => {
expect(parentHandler).toHaveBeenCalledTimes(1);
expect(childHandler).toHaveBeenCalledTimes(1);
});
// Child should be cancelled due to parent cancellation
expect(childHandler).toHaveBeenCalledWith(
expect.objectContaining({
operationId: childOpId!,
reason: 'Parent operation cancelled',
}),
);
});
it('should handle nested operations with multiple levels', async () => {
const { result } = renderHook(() => useChatStore());
let level1: string;
let level2: string;
let level3: string;
const handler1 = vi.fn();
const handler2 = vi.fn();
const handler3 = vi.fn();
act(() => {
level1 = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
level2 = result.current.startOperation({
type: 'createAssistantMessage',
parentOperationId: level1!,
}).operationId;
level3 = result.current.startOperation({
type: 'callLLM',
parentOperationId: level2!,
}).operationId;
result.current.onOperationCancel(level1!, handler1);
result.current.onOperationCancel(level2!, handler2);
result.current.onOperationCancel(level3!, handler3);
});
act(() => {
result.current.cancelOperation(level1!);
});
await vi.waitFor(() => {
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);
expect(handler3).toHaveBeenCalledTimes(1);
});
// All operations should be cancelled
expect(result.current.operations[level1!].status).toBe('cancelled');
expect(result.current.operations[level2!].status).toBe('cancelled');
expect(result.current.operations[level3!].status).toBe('cancelled');
});
});
});
@@ -0,0 +1,342 @@
/**
* Integration test for Operation Management System
* Tests the full lifecycle of operations in realistic scenarios
*/
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { operationSelectors } from '@/store/chat/slices/operation/selectors';
import { useChatStore } from '@/store/chat/store';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
vi.mock('zustand/traditional');
describe('Operation Management Integration Tests', () => {
beforeEach(() => {
act(() => {
useChatStore.setState({
activeId: 'test-session',
activeTopicId: 'test-topic',
operations: {},
operationsByType: {} as any,
operationsByMessage: {},
operationsByContext: {},
messageOperationMap: {},
});
});
});
describe('Complete Operation Lifecycle', () => {
it('should handle full AI generation lifecycle', async () => {
const { result } = renderHook(() => useChatStore());
const sessionId = 'test-session';
const topicId = 'test-topic';
const messageId = 'user-msg-1';
// 1. Start operation
let operationId: string = '';
act(() => {
const { operationId: id } = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId, topicId, messageId },
label: 'AI Generation',
});
operationId = id;
});
// Verify operation created
expect(result.current.operations[operationId!]).toBeDefined();
expect(result.current.operations[operationId!].status).toBe('running');
expect(result.current.operations[operationId!].context).toEqual({
sessionId,
topicId,
messageId,
});
// 2. Associate message
act(() => {
result.current.associateMessageWithOperation(messageId, operationId!);
});
expect(result.current.messageOperationMap[messageId]).toBe(operationId);
// 3. Check status during execution
expect(operationSelectors.hasAnyRunningOperation(result.current)).toBe(true);
expect(operationSelectors.canSendMessage(result.current)).toBe(false);
expect(operationSelectors.isAgentRuntimeRunning(result.current)).toBe(true);
// 4. Complete operation
act(() => {
result.current.completeOperation(operationId!);
});
expect(result.current.operations[operationId!].status).toBe('completed');
expect(operationSelectors.hasAnyRunningOperation(result.current)).toBe(false);
expect(operationSelectors.canSendMessage(result.current)).toBe(true);
});
it('should handle operation cancellation', async () => {
const { result } = renderHook(() => useChatStore());
let operationId: string = '';
let abortController: AbortController | undefined;
act(() => {
const res = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'test-session' },
});
operationId = res.operationId;
abortController = res.abortController;
});
const abortSpy = vi.spyOn(abortController!, 'abort');
// Cancel operation
act(() => {
result.current.cancelOperation(operationId!, 'User cancelled');
});
expect(result.current.operations[operationId!].status).toBe('cancelled');
expect(abortSpy).toHaveBeenCalled();
});
});
describe('Parent-Child Operation Relationship', () => {
it('should handle nested operations with context inheritance', async () => {
const { result } = renderHook(() => useChatStore());
// Create parent operation
let parentOpId: string = '';
act(() => {
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'test-session', topicId: 'test-topic' },
});
parentOpId = operationId;
});
// Create child operation (should inherit context)
let childOpId: string = '';
act(() => {
const { operationId } = result.current.startOperation({
type: 'toolCalling',
parentOperationId: parentOpId!,
});
childOpId = operationId;
});
// Verify context inheritance
const parentOp = result.current.operations[parentOpId!];
const childOp = result.current.operations[childOpId!];
expect(childOp.context.sessionId).toBe(parentOp.context.sessionId);
expect(childOp.context.topicId).toBe(parentOp.context.topicId);
expect(childOp.parentOperationId).toBe(parentOpId);
expect(parentOp.childOperationIds).toContain(childOpId);
});
it('should cascade cancel child operations', async () => {
const { result } = renderHook(() => useChatStore());
// Create parent and child operations
let parentOpId: string = '';
let childOpId1: string = '';
let childOpId2: string = '';
act(() => {
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'test-session' },
});
parentOpId = operationId;
});
act(() => {
const { operationId } = result.current.startOperation({
type: 'toolCalling',
parentOperationId: parentOpId!,
});
childOpId1 = operationId;
});
act(() => {
const { operationId } = result.current.startOperation({
type: 'toolCalling',
parentOperationId: parentOpId!,
});
childOpId2 = operationId;
});
// Cancel parent
act(() => {
result.current.cancelOperation(parentOpId!, 'Parent cancelled');
});
// Verify all cancelled
expect(result.current.operations[parentOpId!].status).toBe('cancelled');
expect(result.current.operations[childOpId1!].status).toBe('cancelled');
expect(result.current.operations[childOpId2!].status).toBe('cancelled');
});
});
describe('Context Isolation', () => {
it('should maintain correct context when switching topics', async () => {
const { result } = renderHook(() => useChatStore());
// Create operation in topic A
let opIdTopicA: string = '';
act(() => {
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-1', topicId: 'topic-a' },
});
opIdTopicA = operationId;
});
// Switch to topic B
act(() => {
useChatStore.setState({ activeTopicId: 'topic-b' });
});
// Create operation in topic B
let opIdTopicB: string = '';
act(() => {
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-1', topicId: 'topic-b' },
});
opIdTopicB = operationId;
});
// Verify context isolation
const opA = result.current.operations[opIdTopicA!];
const opB = result.current.operations[opIdTopicB!];
expect(opA.context.topicId).toBe('topic-a');
expect(opB.context.topicId).toBe('topic-b');
// Get operations by context
const contextA = messageMapKey('session-1', 'topic-a');
const contextB = messageMapKey('session-1', 'topic-b');
const opsInA = result.current.operationsByContext[contextA] || [];
const opsInB = result.current.operationsByContext[contextB] || [];
expect(opsInA).toContain(opIdTopicA);
expect(opsInB).toContain(opIdTopicB);
expect(opsInA).not.toContain(opIdTopicB);
expect(opsInB).not.toContain(opIdTopicA);
});
});
describe('Batch Operations', () => {
it('should handle multiple concurrent operations', async () => {
const { result } = renderHook(() => useChatStore());
const opIds: string[] = [];
// Start 5 operations concurrently
act(() => {
for (let i = 0; i < 5; i++) {
const { operationId } = result.current.startOperation({
type: i % 2 === 0 ? 'execAgentRuntime' : 'toolCalling',
context: { sessionId: 'session-1', messageId: `msg-${i}` },
});
opIds.push(operationId);
}
});
// Verify all running
expect(operationSelectors.getRunningOperations(result.current)).toHaveLength(5);
// Cancel all generateAI operations
let cancelledIds: string[];
act(() => {
cancelledIds = result.current.cancelOperations({
type: 'execAgentRuntime',
status: 'running',
});
});
// Verify correct operations cancelled
expect(cancelledIds!).toHaveLength(3); // 0, 2, 4
expect(operationSelectors.getRunningOperations(result.current)).toHaveLength(2);
});
});
describe('Memory Management', () => {
it('should cleanup old completed operations', async () => {
const { result } = renderHook(() => useChatStore());
const oldTimestamp = Date.now() - 2 * 60 * 1000; // 2 minutes ago
const recentTimestamp = Date.now() - 30 * 1000; // 30 seconds ago
// Create old completed operation
let oldOpId: string;
act(() => {
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-1' },
});
oldOpId = operationId;
});
// Complete and manually set old timestamp
act(() => {
result.current.completeOperation(oldOpId!);
useChatStore.setState((state) => ({
operations: {
...state.operations,
[oldOpId!]: {
...state.operations[oldOpId!],
status: 'completed' as const,
metadata: {
...state.operations[oldOpId!].metadata,
startTime: oldTimestamp,
endTime: oldTimestamp + 1000,
},
},
},
}));
});
// Create recent completed operation
let recentOpId: string;
act(() => {
const { operationId } = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session-1' },
});
recentOpId = operationId;
});
act(() => {
result.current.completeOperation(recentOpId!);
useChatStore.setState((state) => ({
operations: {
...state.operations,
[recentOpId!]: {
...state.operations[recentOpId!],
status: 'completed' as const,
metadata: {
...state.operations[recentOpId!].metadata,
startTime: recentTimestamp,
endTime: recentTimestamp + 1000,
},
},
},
}));
});
// Cleanup operations older than 1 minute
act(() => {
result.current.cleanupCompletedOperations(60 * 1000);
});
// Verify old operation cleaned up, recent kept
expect(result.current.operations[oldOpId!]).toBeUndefined();
expect(result.current.operations[recentOpId!]).toBeDefined();
});
});
});
@@ -16,12 +16,12 @@ describe('Operation Selectors', () => {
act(() => {
result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
});
result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
});
@@ -31,7 +31,9 @@ describe('Operation Selectors', () => {
});
});
const generateOps = operationSelectors.getOperationsByType('generateAI')(result.current);
const generateOps = operationSelectors.getOperationsByType('execAgentRuntime')(
result.current,
);
const reasoningOps = operationSelectors.getOperationsByType('reasoning')(result.current);
expect(generateOps).toHaveLength(2);
@@ -48,7 +50,7 @@ describe('Operation Selectors', () => {
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1', topicId: 'topic1' },
});
@@ -59,7 +61,7 @@ describe('Operation Selectors', () => {
// Operation in different context
result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session2', topicId: 'topic2' },
});
});
@@ -82,7 +84,7 @@ describe('Operation Selectors', () => {
act(() => {
opId = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
}).operationId;
});
@@ -103,12 +105,14 @@ describe('Operation Selectors', () => {
act(() => {
result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
});
});
expect(operationSelectors.hasRunningOperationType('generateAI')(result.current)).toBe(true);
expect(operationSelectors.hasRunningOperationType('execAgentRuntime')(result.current)).toBe(
true,
);
expect(operationSelectors.hasRunningOperationType('reasoning')(result.current)).toBe(false);
});
});
@@ -125,7 +129,7 @@ describe('Operation Selectors', () => {
act(() => {
result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1', topicId: 'topic1' },
});
});
@@ -146,7 +150,7 @@ describe('Operation Selectors', () => {
act(() => {
result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1', topicId: 'topic1' },
});
});
@@ -163,7 +167,7 @@ describe('Operation Selectors', () => {
useChatStore.setState({ activeId: 'session1', activeTopicId: 'topic1' });
result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1', topicId: 'topic1' },
label: 'Generating response...',
});
@@ -190,7 +194,7 @@ describe('Operation Selectors', () => {
act(() => {
result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1', messageId: 'msg1' },
});
});
@@ -200,6 +204,122 @@ describe('Operation Selectors', () => {
});
});
describe('isMessageCreating', () => {
it('should return true for user message during sendMessage operation', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session1', messageId: 'user_msg_1' },
}).operationId;
// Associate message with operation
result.current.associateMessageWithOperation('user_msg_1', opId!);
});
expect(operationSelectors.isMessageCreating('user_msg_1')(result.current)).toBe(true);
expect(operationSelectors.isMessageCreating('other_msg')(result.current)).toBe(false);
});
it('should return true for assistant message during createAssistantMessage operation', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'createAssistantMessage',
context: { sessionId: 'session1', messageId: 'assistant_msg_1' },
}).operationId;
// Associate message with operation
result.current.associateMessageWithOperation('assistant_msg_1', opId!);
});
expect(operationSelectors.isMessageCreating('assistant_msg_1')(result.current)).toBe(true);
expect(operationSelectors.isMessageCreating('other_msg')(result.current)).toBe(false);
});
it('should return false when operation completes', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId!);
});
expect(operationSelectors.isMessageCreating('msg1')(result.current)).toBe(true);
act(() => {
result.current.completeOperation(opId!);
});
expect(operationSelectors.isMessageCreating('msg1')(result.current)).toBe(false);
});
it('should return false for other operation types', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
// execAgentRuntime should not be considered as "creating"
opId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1', messageId: 'msg1' },
}).operationId;
result.current.associateMessageWithOperation('msg1', opId!);
});
expect(operationSelectors.isMessageCreating('msg1')(result.current)).toBe(false);
});
it('should only check sendMessage and createAssistantMessage operations', () => {
const { result } = renderHook(() => useChatStore());
let sendMsgOpId: string;
let createAssistantOpId: string;
let toolCallOpId: string;
act(() => {
// sendMessage - should be creating
sendMsgOpId = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: 'session1', messageId: 'user_msg' },
}).operationId;
result.current.associateMessageWithOperation('user_msg', sendMsgOpId!);
// createAssistantMessage - should be creating
createAssistantOpId = result.current.startOperation({
type: 'createAssistantMessage',
context: { sessionId: 'session1', messageId: 'assistant_msg' },
}).operationId;
result.current.associateMessageWithOperation('assistant_msg', createAssistantOpId!);
// toolCalling - should NOT be creating
toolCallOpId = result.current.startOperation({
type: 'toolCalling',
context: { sessionId: 'session1', messageId: 'tool_msg' },
}).operationId;
result.current.associateMessageWithOperation('tool_msg', toolCallOpId!);
});
expect(operationSelectors.isMessageCreating('user_msg')(result.current)).toBe(true);
expect(operationSelectors.isMessageCreating('assistant_msg')(result.current)).toBe(true);
expect(operationSelectors.isMessageCreating('tool_msg')(result.current)).toBe(false);
});
});
describe('getOperationContextFromMessage', () => {
it('should return operation context from message ID', () => {
const { result } = renderHook(() => useChatStore());
@@ -208,7 +328,7 @@ describe('Operation Selectors', () => {
act(() => {
opId = result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1', topicId: 'topic1', messageId: 'msg1' },
}).operationId;
@@ -225,19 +345,19 @@ describe('Operation Selectors', () => {
});
describe('backward compatibility selectors', () => {
it('isAIGenerating should work', () => {
it('isAgentRuntimeRunning should work', () => {
const { result } = renderHook(() => useChatStore());
expect(operationSelectors.isAIGenerating(result.current)).toBe(false);
expect(operationSelectors.isAgentRuntimeRunning(result.current)).toBe(false);
act(() => {
result.current.startOperation({
type: 'generateAI',
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
});
});
expect(operationSelectors.isAIGenerating(result.current)).toBe(true);
expect(operationSelectors.isAgentRuntimeRunning(result.current)).toBe(true);
});
it('isSendingMessage should work', () => {
@@ -269,5 +389,125 @@ describe('Operation Selectors', () => {
expect(operationSelectors.isInRAGFlow(result.current)).toBe(true);
});
it('isMainWindowAgentRuntimeRunning should only detect main window operations', () => {
const { result } = renderHook(() => useChatStore());
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(false);
// Start a main window operation (inThread: false)
let mainOpId: string;
act(() => {
mainOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
metadata: { inThread: false },
}).operationId;
});
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(true);
expect(operationSelectors.isAgentRuntimeRunning(result.current)).toBe(true);
// Complete main window operation
act(() => {
result.current.completeOperation(mainOpId!);
});
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(false);
expect(operationSelectors.isAgentRuntimeRunning(result.current)).toBe(false);
});
it('isMainWindowAgentRuntimeRunning should exclude thread operations', () => {
const { result } = renderHook(() => useChatStore());
// Start a thread operation (inThread: true)
let threadOpId: string;
act(() => {
threadOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1', threadId: 'thread1' },
metadata: { inThread: true },
}).operationId;
});
// Thread operation should not affect main window state
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(false);
// But should be detected by general isAgentRuntimeRunning
expect(operationSelectors.isAgentRuntimeRunning(result.current)).toBe(true);
// Complete thread operation
act(() => {
result.current.completeOperation(threadOpId!);
});
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(false);
expect(operationSelectors.isAgentRuntimeRunning(result.current)).toBe(false);
});
it('isMainWindowAgentRuntimeRunning should distinguish between main and thread operations', () => {
const { result } = renderHook(() => useChatStore());
let mainOpId: string;
let threadOpId: string;
// Start both main window and thread operations
act(() => {
mainOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
metadata: { inThread: false },
}).operationId;
threadOpId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1', threadId: 'thread1' },
metadata: { inThread: true },
}).operationId;
});
// Both selectors should detect their respective operations
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(true);
expect(operationSelectors.isAgentRuntimeRunning(result.current)).toBe(true);
// Complete main window operation, thread operation still running
act(() => {
result.current.completeOperation(mainOpId!);
});
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(false);
expect(operationSelectors.isAgentRuntimeRunning(result.current)).toBe(true);
// Complete thread operation
act(() => {
result.current.completeOperation(threadOpId!);
});
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(false);
expect(operationSelectors.isAgentRuntimeRunning(result.current)).toBe(false);
});
it('isMainWindowAgentRuntimeRunning should exclude aborting operations', () => {
const { result } = renderHook(() => useChatStore());
let opId: string;
act(() => {
opId = result.current.startOperation({
type: 'execAgentRuntime',
context: { sessionId: 'session1' },
metadata: { inThread: false },
}).operationId;
});
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(true);
// Mark as aborting
act(() => {
result.current.updateOperationMetadata(opId!, { isAborting: true });
});
// Should exclude aborting operations
expect(operationSelectors.isMainWindowAgentRuntimeRunning(result.current)).toBe(false);
expect(operationSelectors.isAgentRuntimeRunning(result.current)).toBe(false);
});
});
});
+219 -12
View File
@@ -1,5 +1,6 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
import { nanoid } from '@lobechat/utils';
import debug from 'debug';
import { produce } from 'immer';
import { StateCreator } from 'zustand/vanilla';
@@ -9,6 +10,7 @@ import { setNamespace } from '@/utils/storeDebug';
import type {
Operation,
OperationCancelContext,
OperationContext,
OperationFilter,
OperationMetadata,
@@ -17,6 +19,7 @@ import type {
} from './types';
const n = setNamespace('operation');
const log = debug('lobe-store:operation');
/**
* Operation Actions
@@ -43,9 +46,10 @@ export interface OperationActions {
cancelOperations: (filter: OperationFilter, reason?: string) => string[];
/**
* Cleanup completed operations (prevent memory leak)
* Clean up completed or cancelled operations
* Removes operations that are older than the specified age (default: 30 seconds)
*/
cleanupCompletedOperations: (olderThan?: number) => void;
cleanupCompletedOperations: (maxAgeMs?: number) => number;
/**
* Complete operation
@@ -60,6 +64,29 @@ export interface OperationActions {
error: { code?: string; details?: any; message: string; type: string },
) => void;
/**
* Get operation's AbortSignal (for passing to async operations like fetch)
*/
getOperationAbortSignal: (operationId: string) => AbortSignal;
/**
* Get sessionId and topicId from operation or fallback to global state
* This is a helper method that can be used by other slices
*/
internal_getSessionContext: (context?: { operationId?: string }) => {
sessionId: string;
topicId: string | null | undefined;
};
/**
* Register cancel handler for an operation
* The handler will be called when the operation is cancelled
*/
onOperationCancel: (
operationId: string,
handler: (context: OperationCancelContext) => void | Promise<void>,
) => void;
/**
* Start an operation (supports auto-inheriting context from parent operation)
*/
@@ -72,6 +99,11 @@ export interface OperationActions {
type: OperationType;
}) => { abortController: AbortController; operationId: string };
/**
* Update operation metadata
*/
updateOperationMetadata: (operationId: string, metadata: Partial<OperationMetadata>) => void;
/**
* Update operation progress
*/
@@ -93,6 +125,35 @@ export const operationActions: StateCreator<
[],
OperationActions
> = (set, get) => ({
internal_getSessionContext: (context) => {
if (context?.operationId) {
const operation = get().operations[context.operationId];
if (!operation) {
log('[internal_getSessionContext] ERROR: Operation not found: %s', context.operationId);
throw new Error(`Operation not found: ${context.operationId}`);
}
const sessionId = operation.context.sessionId!;
const topicId = operation.context.topicId;
log(
'[internal_getSessionContext] get from operation %s: sessionId=%s, topicId=%s',
context.operationId,
sessionId,
topicId,
);
return { sessionId, topicId };
}
// Fallback to global state
const sessionId = get().activeId;
const topicId = get().activeTopicId;
log(
'[internal_getSessionContext] use global state: sessionId=%s, topicId=%s',
sessionId,
topicId,
);
return { sessionId, topicId };
},
startOperation: (params) => {
const {
type,
@@ -113,9 +174,12 @@ export const operationActions: StateCreator<
if (parentOp) {
// Inherit parent's context, allow partial override
context = { ...parentOp.context, ...partialContext };
log('[startOperation] inherit context from parent %s: %o', parentOperationId, context);
}
}
log('[startOperation] create operation %s (type=%s, context=%o)', operationId, type, context);
const abortController = new AbortController();
const now = Date.now();
@@ -152,6 +216,10 @@ export const operationActions: StateCreator<
state.operationsByMessage[context.messageId] = [];
}
state.operationsByMessage[context.messageId].push(operationId);
// Auto-associate message with this operation (most granular)
// This allows tools to access the correct AbortController via messageOperationMap
state.messageOperationMap[context.messageId] = operationId;
}
// Update context index (if sessionId exists)
@@ -178,9 +246,41 @@ export const operationActions: StateCreator<
n(`startOperation/${type}/${operationId}`),
);
// Periodically cleanup old completed operations
// Only cleanup for top-level operations (no parent) to avoid excessive cleanup calls
if (!parentOperationId) {
// Clean up operations completed more than 30 seconds ago
get().cleanupCompletedOperations(30_000);
}
return { operationId, abortController };
},
updateOperationMetadata: (operationId, metadata) => {
const operation = get().operations[operationId];
if (metadata.isAborting) {
log(
'[updateOperationMetadata] Setting isAborting=true for operation %s (type=%s)',
operationId,
operation?.type,
);
}
set(
produce((state: ChatStore) => {
const operation = state.operations[operationId];
if (!operation) return;
operation.metadata = {
...operation.metadata,
...metadata,
};
}),
false,
n(`updateOperationMetadata/${operationId}`),
);
},
updateOperationStatus: (operationId, status, metadata) => {
set(
produce((state: ChatStore) => {
@@ -219,6 +319,16 @@ export const operationActions: StateCreator<
},
completeOperation: (operationId, metadata) => {
const operation = get().operations[operationId];
if (operation) {
log(
'[completeOperation] operation %s (type=%s) completed, duration=%dms',
operationId,
operation.type,
Date.now() - operation.metadata.startTime,
);
}
set(
produce((state: ChatStore) => {
const operation = state.operations[operationId];
@@ -241,25 +351,92 @@ export const operationActions: StateCreator<
);
},
getOperationAbortSignal: (operationId) => {
const operation = get().operations[operationId];
if (!operation) {
throw new Error(`[getOperationAbortSignal] Operation not found: ${operationId}`);
}
return operation.abortController.signal;
},
onOperationCancel: (operationId, handler) => {
set(
produce((state: ChatStore) => {
const operation = state.operations[operationId];
if (!operation) {
log('[onOperationCancel] WARNING: Operation not found: %s', operationId);
return;
}
operation.onCancelHandler = handler;
log(
'[onOperationCancel] registered cancel handler for %s (type=%s)',
operationId,
operation.type,
);
}),
false,
n(`onOperationCancel/${operationId}`),
);
},
cancelOperation: (operationId, reason = 'User cancelled') => {
const operation = get().operations[operationId];
if (!operation) return;
// Cancel all child operations recursively
if (operation.childOperationIds && operation.childOperationIds.length > 0) {
operation.childOperationIds.forEach((childId) => {
get().cancelOperation(childId, 'Parent operation cancelled');
});
if (!operation) {
log('[cancelOperation] operation not found: %s', operationId);
return;
}
// Abort the operation
// Skip if already cancelled or completed
if (operation.status === 'cancelled' || operation.status === 'completed') {
log('[cancelOperation] operation %s already %s, skipping', operationId, operation.status);
return;
}
log(
'[cancelOperation] cancelling operation %s (type=%s), reason: %s',
operationId,
operation.type,
reason,
);
// 1. Abort the operation (triggers AbortSignal for all async operations)
try {
operation.abortController.abort(reason);
} catch {
// Ignore abort errors
}
// Update status
// 2. Set isAborting flag immediately for execAgentRuntime operations
// This ensures UI (loading button) responds instantly to user cancellation
if (operation.type === 'execAgentRuntime') {
get().updateOperationMetadata(operationId, { isAborting: true });
}
// 3. Call cancel handler if registered
if (operation.onCancelHandler) {
log('[cancelOperation] calling cancel handler for %s (type=%s)', operationId, operation.type);
const cancelContext: OperationCancelContext = {
operationId,
type: operation.type,
reason,
metadata: operation.metadata,
};
// Execute handler asynchronously (don't block cancellation flow)
// Use try-catch to handle synchronous errors, then wrap in Promise for async errors
try {
Promise.resolve(operation.onCancelHandler(cancelContext)).catch((err) => {
log('[cancelOperation] cancel handler error for %s: %O', operationId, err);
});
} catch (err) {
// Handle synchronous errors from handler
log('[cancelOperation] cancel handler synchronous error for %s: %O', operationId, err);
}
}
// 4. Update status
set(
produce((state: ChatStore) => {
const op = state.operations[operationId];
@@ -274,9 +451,27 @@ export const operationActions: StateCreator<
false,
n(`cancelOperation/${operationId}`),
);
// 4. Cancel all child operations recursively
if (operation.childOperationIds && operation.childOperationIds.length > 0) {
log('[cancelOperation] cancelling %d child operations', operation.childOperationIds.length);
operation.childOperationIds.forEach((childId) => {
get().cancelOperation(childId, 'Parent operation cancelled');
});
}
},
failOperation: (operationId, error) => {
const operation = get().operations[operationId];
if (operation) {
log(
'[failOperation] operation %s (type=%s) failed: %s',
operationId,
operation.type,
error.message,
);
}
set(
produce((state: ChatStore) => {
const operation = state.operations[operationId];
@@ -373,7 +568,7 @@ export const operationActions: StateCreator<
}
});
if (operationsToDelete.length === 0) return;
if (operationsToDelete.length === 0) return 0;
set(
produce((state: ChatStore) => {
@@ -437,12 +632,24 @@ export const operationActions: StateCreator<
false,
n(`cleanupCompletedOperations/count=${operationsToDelete.length}`),
);
log('[cleanupCompletedOperations] cleaned up %d operations', operationsToDelete.length);
return operationsToDelete.length;
},
associateMessageWithOperation: (messageId, operationId) => {
set(
produce((state: ChatStore) => {
// Update messageOperationMap (for single operation lookup)
state.messageOperationMap[messageId] = operationId;
// Update operationsByMessage index (for multiple operations lookup)
if (!state.operationsByMessage[messageId]) {
state.operationsByMessage[messageId] = [];
}
if (!state.operationsByMessage[messageId].includes(operationId)) {
state.operationsByMessage[messageId].push(operationId);
}
}),
false,
n(`associateMessageWithOperation/${messageId}/${operationId}`),
+135 -6
View File
@@ -165,11 +165,30 @@ const getCurrentOperationProgress = (s: ChatStoreState): number | undefined => {
// === Backward Compatibility ===
/**
* Check if AI is generating (for backward compatibility)
* Equivalent to: hasRunningOperationType('generateAI')
* Check if agent runtime is running (including both main window and thread)
* Excludes operations that are aborting (cleaning up after cancellation)
*/
const isAIGenerating = (s: ChatStoreState): boolean => {
return hasRunningOperationType('generateAI')(s);
const isAgentRuntimeRunning = (s: ChatStoreState): boolean => {
const operationIds = s.operationsByType['execAgentRuntime'] || [];
return operationIds.some((id) => {
const op = s.operations[id];
// Exclude operations that are aborting (user already cancelled, just cleaning up)
return op && op.status === 'running' && !op.metadata.isAborting;
});
};
/**
* Check if agent runtime is running in main window only
* Used for main window UI state (e.g., send button loading)
* Excludes thread operations to prevent cross-contamination
*/
const isMainWindowAgentRuntimeRunning = (s: ChatStoreState): boolean => {
const operationIds = s.operationsByType['execAgentRuntime'] || [];
return operationIds.some((id) => {
const op = s.operations[id];
// Only include main window operations (not thread)
return op && op.status === 'running' && !op.metadata.isAborting && !op.metadata.inThread;
});
};
/**
@@ -194,7 +213,7 @@ const isInSearchWorkflow = (s: ChatStoreState): boolean => {
};
/**
* Check if a specific message is being processed
* Check if a specific message is being processed (any operation type)
*/
const isMessageProcessing =
(messageId: string) =>
@@ -203,6 +222,102 @@ const isMessageProcessing =
return operations.some((op) => op.status === 'running');
};
/**
* Check if a specific message is being generated (AI generation only)
* This is more specific than isMessageProcessing - only checks execAgentRuntime operations
*/
const isMessageGenerating =
(messageId: string) =>
(s: ChatStoreState): boolean => {
const operations = getOperationsByMessage(messageId)(s);
return operations.some((op) => op.type === 'execAgentRuntime' && op.status === 'running');
};
/**
* Check if a specific message is being created (CRUD operation only)
* Checks message creation operations:
* - User messages: sendMessage
* - Assistant messages: createAssistantMessage
*/
const isMessageCreating =
(messageId: string) =>
(s: ChatStoreState): boolean => {
const operations = getOperationsByMessage(messageId)(s);
return operations.some(
(op) =>
(op.type === 'sendMessage' || op.type === 'createAssistantMessage') &&
op.status === 'running',
);
};
/**
* Check if any message in a list is being processed
*/
const isAnyMessageLoading =
(messageIds: string[]) =>
(s: ChatStoreState): boolean => {
return messageIds.some((id) => isMessageProcessing(id)(s));
};
/**
* Check if a specific message is being regenerated
*/
const isMessageRegenerating =
(messageId: string) =>
(s: ChatStoreState): boolean => {
const operations = getOperationsByMessage(messageId)(s);
return operations.some((op) => op.type === 'regenerate' && op.status === 'running');
};
/**
* Check if a specific message is continuing generation
*/
const isMessageContinuing =
(messageId: string) =>
(s: ChatStoreState): boolean => {
const operations = getOperationsByMessage(messageId)(s);
return operations.some((op) => op.type === 'continue' && op.status === 'running');
};
/**
* Check if a specific message is in reasoning state
*/
const isMessageInReasoning =
(messageId: string) =>
(s: ChatStoreState): boolean => {
const operations = getOperationsByMessage(messageId)(s);
return operations.some((op) => op.type === 'reasoning' && op.status === 'running');
};
/**
* Check if a specific message is in tool calling (plugin API invocation)
*/
const isMessageInToolCalling =
(messageId: string) =>
(s: ChatStoreState): boolean => {
const operations = getOperationsByMessage(messageId)(s);
return operations.some((op) => op.type === 'toolCalling' && op.status === 'running');
};
/**
* Check if currently aborting (cleaning up after user cancellation)
* Used to show "Cleaning up tool calls..." message
*/
const isAborting = (s: ChatStoreState): boolean => {
const currentOps = getCurrentContextOperations(s);
return currentOps.some((op) => op.status === 'running' && op.metadata.isAborting);
};
/**
* Check if a specific message is aborting
*/
const isMessageAborting =
(messageId: string) =>
(s: ChatStoreState): boolean => {
const operations = getOperationsByMessage(messageId)(s);
return operations.some((op) => op.status === 'running' && op.metadata.isAborting);
};
/**
* Check if regenerating (for backward compatibility)
*/
@@ -236,11 +351,25 @@ export const operationSelectors = {
getRunningOperations,
hasAnyRunningOperation,
hasRunningOperationType,
isAIGenerating,
/** @deprecated Use isAgentRuntimeRunning instead */
isAIGenerating: isAgentRuntimeRunning,
isAborting,
isAgentRuntimeRunning,
isAnyMessageLoading,
isContinuing,
isInRAGFlow,
isInSearchWorkflow,
isMainWindowAgentRuntimeRunning,
isMessageAborting,
isMessageContinuing,
isMessageCreating,
isMessageGenerating,
isMessageInReasoning,
isMessageInToolCalling,
isMessageProcessing,
isMessageRegenerating,
isRegenerating,
isSendingMessage,
};
+30 -4
View File
@@ -11,19 +11,27 @@ export type OperationType =
// === Message sending ===
| 'sendMessage' // Send message to server
| 'createTopic' // Auto create topic
// === AI generation ===
| 'generateAI' // AI generate response (entire agent runtime execution)
| 'reasoning' // AI reasoning process (child operation)
| 'regenerate' // Regenerate message
| 'continue' // Continue generation
// === AI generation ===
| 'execAgentRuntime' // Execute agent runtime (entire agent runtime execution)
| 'createAssistantMessage' // Create assistant message (sub-operation of execAgentRuntime)
// === LLM execution (sub-operations) ===
| 'callLLM' // Call LLM streaming response (sub-operation of execAgentRuntime)
// === (sub-operations) ===
| 'reasoning' // AI reasoning process (child operation)
// === RAG and retrieval ===
| 'rag' // RAG retrieval flow (child operation)
| 'searchWorkflow' // Search workflow
// === Tool calling ===
| 'toolCalling' // Tool calling (streaming, child operation)
// === (sub-operations) ===
| 'createToolMessage' // Create tool message (sub-operation of executeToolCall)
| 'executeToolCall' // Execute tool call (sub-operation of toolCalling)
// === (sub-operations of executeToolCall) ===
| 'pluginApi' // Plugin API call
| 'builtinToolSearch' // Builtin tool: search
| 'builtinToolInterpreter' // Builtin tool: code interpreter
@@ -34,6 +42,7 @@ export type OperationType =
| 'groupAgentGenerate' // Group agent generate
// === Others ===
| 'translate' // Translate message
| 'topicSummary' // Topic summary
| 'historySummary'; // History summary
@@ -61,6 +70,16 @@ export interface OperationContext {
agentId?: string; // Associated agent ID (specific agent in Group Chat)
}
/**
* Operation cancel context - passed to cancel handler
*/
export interface OperationCancelContext {
operationId: string;
type: OperationType;
reason: string;
metadata?: OperationMetadata;
}
/**
* Operation metadata
*/
@@ -88,6 +107,10 @@ export interface OperationMetadata {
// Cancel information
cancelReason?: string;
// UI state (for sendMessage operation)
inputEditorTempState?: any | null; // Editor state snapshot for cancel restoration
inputSendErrorMsg?: string; // Error message to display in UI
// Other metadata (extensible)
[key: string]: any;
}
@@ -110,6 +133,9 @@ export interface Operation {
// === Metadata ===
metadata: OperationMetadata;
// === Cancel handler ===
onCancelHandler?: (context: OperationCancelContext) => void | Promise<void>; // Cancel callback
// === Dependencies ===
parentOperationId?: string; // Parent operation ID (for operation nesting)
childOperationIds?: string[]; // Child operation IDs
+30 -322
View File
@@ -115,41 +115,6 @@ describe('ChatPluginAction', () => {
});
});
describe('internal_togglePluginApiCalling', () => {
it('should toggle plugin API calling state', () => {
const internal_toggleLoadingArraysMock = vi.fn();
act(() => {
useChatStore.setState({
internal_toggleLoadingArrays: internal_toggleLoadingArraysMock,
});
});
const { result } = renderHook(() => useChatStore());
const messageId = 'message-id';
const action = 'test-action';
result.current.internal_togglePluginApiCalling(true, messageId, action);
expect(internal_toggleLoadingArraysMock).toHaveBeenCalledWith(
'pluginApiLoadingIds',
true,
messageId,
action,
);
result.current.internal_togglePluginApiCalling(false, messageId, action);
expect(internal_toggleLoadingArraysMock).toHaveBeenCalledWith(
'pluginApiLoadingIds',
false,
messageId,
action,
);
});
});
describe('fillPluginMessageContent', () => {
it('should update message content and trigger the ai message', async () => {
// 设置模拟函数的返回值
@@ -234,7 +199,6 @@ describe('ChatPluginAction', () => {
vi.spyOn(storeState, 'refreshMessages');
vi.spyOn(storeState, 'triggerAIMessage').mockResolvedValue(undefined);
vi.spyOn(storeState, 'internal_togglePluginApiCalling').mockReturnValue(undefined);
vi.spyOn(storeState, 'optimisticUpdateMessageContent').mockResolvedValue();
const runSpy = vi.spyOn(chatService, 'runPluginApi').mockResolvedValue({
@@ -248,25 +212,12 @@ describe('ChatPluginAction', () => {
await result.current.invokeDefaultTypePlugin(messageId, pluginPayload);
});
expect(storeState.internal_togglePluginApiCalling).toHaveBeenCalledWith(
true,
messageId,
expect.any(String),
);
expect(runSpy).toHaveBeenCalledWith(pluginPayload, { signal: undefined, trace: {} });
expect(storeState.optimisticUpdateMessageContent).toHaveBeenCalledWith(
messageId,
pluginApiResponse,
undefined,
{
sessionId: undefined,
topicId: undefined,
},
);
expect(storeState.internal_togglePluginApiCalling).toHaveBeenCalledWith(
false,
'message-id',
'plugin/fetchPlugin/end',
undefined,
);
});
@@ -285,7 +236,6 @@ describe('ChatPluginAction', () => {
const storeState = useChatStore.getState();
const replaceMessagesSpy = vi.spyOn(storeState, 'replaceMessages');
vi.spyOn(storeState, 'triggerAIMessage').mockResolvedValue(undefined);
vi.spyOn(storeState, 'internal_togglePluginApiCalling').mockReturnValue(undefined);
vi.spyOn(chatService, 'runPluginApi').mockRejectedValue(error);
@@ -294,11 +244,6 @@ describe('ChatPluginAction', () => {
await result.current.invokeDefaultTypePlugin(messageId, pluginPayload);
});
expect(storeState.internal_togglePluginApiCalling).toHaveBeenCalledWith(
true,
messageId,
expect.any(String),
);
expect(chatService.runPluginApi).toHaveBeenCalledWith(pluginPayload, { trace: {} });
expect(messageService.updateMessageError).toHaveBeenCalledWith(messageId, error, {
sessionId: undefined,
@@ -308,260 +253,10 @@ describe('ChatPluginAction', () => {
sessionId: undefined,
topicId: undefined,
});
expect(storeState.internal_togglePluginApiCalling).toHaveBeenCalledWith(
false,
'message-id',
'plugin/fetchPlugin/end',
);
expect(storeState.triggerAIMessage).not.toHaveBeenCalled(); // 确保在错误情况下不调用此方法
});
});
describe('triggerToolCalls', () => {
it('should trigger tool calls for the assistant message', async () => {
const assistantId = 'assistant-id';
const message = {
id: assistantId,
role: 'assistant',
content: 'Assistant message',
tools: [
{
id: 'tool1',
type: 'standalone',
identifier: 'plugin1',
apiName: 'api1',
arguments: '{}',
},
{
id: 'tool2',
type: 'markdown',
identifier: 'plugin2',
apiName: 'api2',
arguments: '{}',
},
{
id: 'tool3',
type: 'builtin',
identifier: 'builtin1',
apiName: 'api3',
arguments: '{}',
},
{
id: 'tool4',
type: 'default',
identifier: 'plugin3',
apiName: 'api4',
arguments: '{}',
},
],
} as UIChatMessage;
const invokeStandaloneTypePluginMock = vi.fn();
const invokeMarkdownTypePluginMock = vi.fn();
const invokeBuiltinToolMock = vi.fn();
const invokeDefaultTypePluginMock = vi.fn().mockResolvedValue('Default tool response');
const triggerAIMessageMock = vi.fn();
const optimisticCreateMessageMock = vi
.fn()
.mockResolvedValue({ id: 'tool-message-id', messages: [] });
const getTraceIdByMessageIdMock = vi.fn().mockReturnValue('trace-id');
act(() => {
useChatStore.setState({
messagesMap: {
[messageMapKey('session-id', 'topic-id')]: [message],
},
invokeStandaloneTypePlugin: invokeStandaloneTypePluginMock,
invokeMarkdownTypePlugin: invokeMarkdownTypePluginMock,
invokeBuiltinTool: invokeBuiltinToolMock,
invokeDefaultTypePlugin: invokeDefaultTypePluginMock,
triggerAIMessage: triggerAIMessageMock,
optimisticCreateMessage: optimisticCreateMessageMock,
activeId: 'session-id',
activeTopicId: 'topic-id',
});
});
const { result } = renderHook(() => useChatStore());
await act(async () => {
await result.current.triggerToolCalls(assistantId);
});
// Verify that tool messages were created for each tool call
expect(optimisticCreateMessageMock).toHaveBeenCalledTimes(4);
expect(optimisticCreateMessageMock).toHaveBeenNthCalledWith(
1,
{
content: '',
parentId: assistantId,
plugin: message.tools![0],
role: 'tool',
sessionId: 'session-id',
tool_call_id: 'tool1',
topicId: 'topic-id',
threadId: undefined,
groupId: undefined,
},
{
sessionId: 'session-id',
topicId: 'topic-id',
},
);
expect(optimisticCreateMessageMock).toHaveBeenNthCalledWith(
2,
{
content: '',
parentId: assistantId,
plugin: message.tools![1],
role: 'tool',
sessionId: 'session-id',
tool_call_id: 'tool2',
topicId: 'topic-id',
threadId: undefined,
groupId: undefined,
},
{
sessionId: 'session-id',
topicId: 'topic-id',
},
);
expect(optimisticCreateMessageMock).toHaveBeenNthCalledWith(
3,
{
content: '',
parentId: assistantId,
plugin: message.tools![2],
role: 'tool',
sessionId: 'session-id',
tool_call_id: 'tool3',
topicId: 'topic-id',
threadId: undefined,
groupId: undefined,
},
{
sessionId: 'session-id',
topicId: 'topic-id',
},
);
expect(optimisticCreateMessageMock).toHaveBeenNthCalledWith(
4,
{
content: '',
parentId: assistantId,
plugin: message.tools![3],
role: 'tool',
sessionId: 'session-id',
tool_call_id: 'tool4',
topicId: 'topic-id',
threadId: undefined,
groupId: undefined,
},
{
sessionId: 'session-id',
topicId: 'topic-id',
},
);
// Verify that the appropriate plugin types were invoked
expect(invokeStandaloneTypePluginMock).toHaveBeenCalledWith(
'tool-message-id',
message.tools![0],
);
expect(invokeMarkdownTypePluginMock).toHaveBeenCalledWith(
'tool-message-id',
message.tools![1],
);
expect(invokeBuiltinToolMock).toHaveBeenCalledWith('tool-message-id', message.tools![2]);
expect(invokeDefaultTypePluginMock).toHaveBeenCalledWith(
'tool-message-id',
message.tools![3],
);
// Verify that AI message was triggered for default type tool call
// expect(getTraceIdByMessageIdMock).toHaveBeenCalledWith('tool-message-id');
// expect(triggerAIMessageMock).toHaveBeenCalledWith({ traceId: 'trace-id' });
});
it('should not trigger AI message if no default type tool calls', async () => {
const assistantId = 'assistant-id';
const message = {
id: assistantId,
role: 'assistant',
content: 'Assistant message',
tools: [
{
id: 'tool1',
type: 'standalone',
identifier: 'plugin1',
apiName: 'api1',
arguments: '{}',
},
{
id: 'tool2',
type: 'markdown',
identifier: 'plugin2',
apiName: 'api2',
arguments: '{}',
},
{
id: 'tool3',
type: 'builtin',
identifier: 'builtin1',
apiName: 'api3',
arguments: '{}',
},
],
} as UIChatMessage;
const invokeStandaloneTypePluginMock = vi.fn();
const invokeMarkdownTypePluginMock = vi.fn();
const invokeBuiltinToolMock = vi.fn();
const triggerAIMessageMock = vi.fn();
const optimisticCreateMessageMock = vi
.fn()
.mockResolvedValue({ id: 'tool-message-id', messages: [] });
act(() => {
useChatStore.setState({
invokeStandaloneTypePlugin: invokeStandaloneTypePluginMock,
invokeMarkdownTypePlugin: invokeMarkdownTypePluginMock,
invokeBuiltinTool: invokeBuiltinToolMock,
triggerAIMessage: triggerAIMessageMock,
optimisticCreateMessage: optimisticCreateMessageMock,
activeId: 'session-id',
messagesMap: {
[messageMapKey('session-id', 'topic-id')]: [message],
},
activeTopicId: 'topic-id',
});
});
const { result } = renderHook(() => useChatStore());
await act(async () => {
await result.current.triggerToolCalls(assistantId);
});
// Verify that tool messages were created for each tool call
expect(optimisticCreateMessageMock).toHaveBeenCalledTimes(3);
// Verify that the appropriate plugin types were invoked
expect(invokeStandaloneTypePluginMock).toHaveBeenCalledWith(
'tool-message-id',
message.tools![0],
);
expect(invokeMarkdownTypePluginMock).toHaveBeenCalledWith(
'tool-message-id',
message.tools![1],
);
expect(invokeBuiltinToolMock).toHaveBeenCalledWith('tool-message-id', message.tools![2]);
// Verify that AI message was not triggered
expect(triggerAIMessageMock).not.toHaveBeenCalled();
});
});
describe('updatePluginState', () => {
it('should update the plugin state for a message', async () => {
const messageId = 'message-id';
@@ -896,10 +591,7 @@ describe('ChatPluginAction', () => {
await result.current.reInvokeToolMessage(messageId);
});
expect(internal_updateMessageErrorMock).toHaveBeenCalledWith(messageId, null, {
sessionId: undefined,
topicId: undefined,
});
expect(internal_updateMessageErrorMock).toHaveBeenCalledWith(messageId, null, undefined);
});
});
@@ -973,7 +665,6 @@ describe('ChatPluginAction', () => {
act(() => {
useChatStore.setState({
internal_togglePluginApiCalling: vi.fn(),
optimisticUpdateMessageContent: vi.fn(),
refreshMessages: vi.fn(),
});
@@ -990,10 +681,7 @@ describe('ChatPluginAction', () => {
messageId,
apiResponse,
undefined,
{
sessionId: undefined,
topicId: undefined,
},
undefined,
);
expect(messageService.updateMessage).toHaveBeenCalledWith(messageId, { traceId: 'trace-id' });
});
@@ -1022,7 +710,6 @@ describe('ChatPluginAction', () => {
act(() => {
useChatStore.setState({
internal_togglePluginApiCalling: vi.fn(),
replaceMessages: replaceMessagesSpy,
});
});
@@ -1238,10 +925,17 @@ describe('ChatPluginAction', () => {
const replaceMessagesSpy = vi.spyOn(result.current, 'replaceMessages');
let operationId: string;
await act(async () => {
// Create operation with desired context
const op = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: contextSessionId, topicId: contextTopicId },
});
operationId = op.operationId;
await result.current.optimisticUpdatePluginState(messageId, pluginState, {
sessionId: contextSessionId,
topicId: contextTopicId,
operationId,
});
});
@@ -1298,10 +992,17 @@ describe('ChatPluginAction', () => {
messages: [],
});
let operationId: string;
await act(async () => {
// Create operation with desired context
const op = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: contextSessionId, topicId: contextTopicId },
});
operationId = op.operationId;
await result.current.optimisticUpdatePluginError(messageId, error, {
sessionId: contextSessionId,
topicId: contextTopicId,
operationId,
});
});
@@ -1331,7 +1032,15 @@ describe('ChatPluginAction', () => {
// Set up both dbMessagesMap and messagesMap
const key = messageMapKey(contextSessionId, contextTopicId);
let operationId: string;
act(() => {
// Create operation with desired context
const op = result.current.startOperation({
type: 'sendMessage',
context: { sessionId: contextSessionId, topicId: contextTopicId },
});
operationId = op.operationId;
useChatStore.setState({
dbMessagesMap: {
[key]: [message],
@@ -1346,8 +1055,7 @@ describe('ChatPluginAction', () => {
await act(async () => {
await result.current.internal_refreshToUpdateMessageTools(messageId, {
sessionId: contextSessionId,
topicId: contextTopicId,
operationId,
});
});
@@ -8,7 +8,6 @@ import { ChatStore } from '@/store/chat/store';
import { useToolStore } from '@/store/tool';
import { pluginSelectors } from '@/store/tool/selectors';
import { builtinTools } from '@/tools';
import { Action } from '@/utils/storeDebug';
import { displayMessageSelectors } from '../../message/selectors';
@@ -17,15 +16,6 @@ import { displayMessageSelectors } from '../../message/selectors';
* These are building blocks used by other actions
*/
export interface PluginInternalsAction {
/**
* Toggle plugin API calling state
*/
internal_togglePluginApiCalling: (
loading: boolean,
id?: string,
action?: Action,
) => AbortController | undefined;
/**
* Transform tool calls from runtime format to storage format
*/
@@ -43,10 +33,6 @@ export const pluginInternals: StateCreator<
[],
PluginInternalsAction
> = (set, get) => ({
internal_togglePluginApiCalling: (loading, id, action) => {
return get().internal_toggleLoadingArrays('pluginApiLoadingIds', loading, id, action);
},
internal_transformToolCalls: (toolCalls) => {
const toolNameResolver = new ToolNameResolver();
@@ -86,13 +86,15 @@ export const pluginOptimisticUpdate: StateCreator<
PluginOptimisticUpdateAction
> = (set, get) => ({
optimisticUpdatePluginState: async (id, value, context) => {
const { replaceMessages } = get();
const { replaceMessages, internal_getSessionContext } = get();
// optimistic update
get().internal_dispatchMessage({ id, type: 'updateMessage', value: { pluginState: value } });
get().internal_dispatchMessage(
{ id, type: 'updateMessage', value: { pluginState: value } },
context,
);
const sessionId = context?.sessionId ?? get().activeId;
const topicId = context?.topicId !== undefined ? context.topicId : get().activeTopicId;
const { sessionId, topicId } = internal_getSessionContext(context);
const result = await messageService.updateMessagePluginState(id, value, {
sessionId,
@@ -152,17 +154,19 @@ export const pluginOptimisticUpdate: StateCreator<
},
optimisticUpdatePlugin: async (id, value, context) => {
const { replaceMessages } = get();
const { replaceMessages, internal_getSessionContext } = get();
// optimistic update
get().internal_dispatchMessage({
id,
type: 'updateMessagePlugin',
value,
});
get().internal_dispatchMessage(
{
id,
type: 'updateMessagePlugin',
value,
},
context,
);
const sessionId = context?.sessionId ?? get().activeId;
const topicId = context?.topicId !== undefined ? context.topicId : get().activeTopicId;
const { sessionId, topicId } = internal_getSessionContext(context);
const result = await messageService.updateMessagePlugin(id, value, {
sessionId,
@@ -202,12 +206,11 @@ export const pluginOptimisticUpdate: StateCreator<
},
optimisticUpdatePluginError: async (id, error, context) => {
const { replaceMessages } = get();
const { replaceMessages, internal_getSessionContext } = get();
get().internal_dispatchMessage({ id, type: 'updateMessage', value: { error } });
get().internal_dispatchMessage({ id, type: 'updateMessage', value: { error } }, context);
const sessionId = context?.sessionId ?? get().activeId;
const topicId = context?.topicId !== undefined ? context.topicId : get().activeTopicId;
const { sessionId, topicId } = internal_getSessionContext(context);
const result = await messageService.updateMessage(
id,
@@ -227,10 +230,9 @@ export const pluginOptimisticUpdate: StateCreator<
const message = dbMessageSelectors.getDbMessageById(id)(get());
if (!message || !message.tools) return;
const { internal_toggleMessageLoading, replaceMessages } = get();
const { internal_toggleMessageLoading, replaceMessages, internal_getSessionContext } = get();
const sessionId = context?.sessionId ?? get().activeId;
const topicId = context?.topicId !== undefined ? context.topicId : get().activeTopicId;
const { sessionId, topicId } = internal_getSessionContext(context);
internal_toggleMessageLoading(true, id);
const result = await messageService.updateMessage(
@@ -1,6 +1,7 @@
/* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
import { ChatToolPayload } from '@lobechat/types';
import { PluginErrorType } from '@lobehub/chat-plugin-sdk';
import debug from 'debug';
import { t } from 'i18next';
import { StateCreator } from 'zustand/vanilla';
@@ -11,11 +12,10 @@ import { messageService } from '@/services/message';
import { ChatStore } from '@/store/chat/store';
import { useToolStore } from '@/store/tool';
import { safeParseJSON } from '@/utils/safeParseJSON';
import { setNamespace } from '@/utils/storeDebug';
import { dbMessageSelectors } from '../../message/selectors';
const n = setNamespace('plugin');
const log = debug('lobe-store:plugin-types');
/**
* Plugin type-specific implementations
@@ -125,7 +125,6 @@ export const pluginTypes: StateCreator<
invokeMCPTypePlugin: async (id, payload) => {
const {
optimisticUpdateMessageContent,
internal_togglePluginApiCalling,
internal_constructToolsCallingContext,
optimisticUpdatePluginState,
optimisticUpdateMessagePluginError,
@@ -135,13 +134,20 @@ export const pluginTypes: StateCreator<
// Get message to extract sessionId/topicId
const message = dbMessageSelectors.getDbMessageById(id)(get());
try {
const abortController = internal_togglePluginApiCalling(
true,
id,
n('fetchPlugin/start') as string,
);
// Get abort controller from operation
const operationId = get().messageOperationMap[id];
const operation = operationId ? get().operations[operationId] : undefined;
const abortController = operation?.abortController;
log(
'[invokeMCPTypePlugin] messageId=%s, tool=%s, operationId=%s, aborted=%s',
id,
payload.apiName,
operationId,
abortController?.signal.aborted,
);
try {
const context = internal_constructToolsCallingContext(id);
const result = await mcpService.invokeMcpToolCall(payload, {
signal: abortController?.signal,
@@ -154,7 +160,9 @@ export const pluginTypes: StateCreator<
const err = error as Error;
// ignore the aborted request error
if (!err.message.includes('The user aborted a request.')) {
if (err.message.includes('The user aborted a request.')) {
log('[invokeMCPTypePlugin] Request aborted: messageId=%s, tool=%s', id, payload.apiName);
} else {
const result = await messageService.updateMessageError(id, error as any, {
sessionId: message?.sessionId,
topicId: message?.topicId,
@@ -168,13 +176,12 @@ export const pluginTypes: StateCreator<
}
}
internal_togglePluginApiCalling(false, id, n('fetchPlugin/end') as string);
// 如果报错则结束了
if (!data) return;
const context = { sessionId: message?.sessionId, topicId: message?.topicId };
// operationId already declared above, reuse it
const context = operationId ? { operationId } : undefined;
await Promise.all([
optimisticUpdateMessageContent(id, data.content, undefined, context),
@@ -188,19 +195,26 @@ export const pluginTypes: StateCreator<
},
internal_callPluginApi: async (id, payload) => {
const { optimisticUpdateMessageContent, internal_togglePluginApiCalling } = get();
const { optimisticUpdateMessageContent } = get();
let data: string;
// Get message to extract sessionId/topicId
const message = dbMessageSelectors.getDbMessageById(id)(get());
try {
const abortController = internal_togglePluginApiCalling(
true,
id,
n('fetchPlugin/start') as string,
);
// Get abort controller from operation
const operationId = get().messageOperationMap[id];
const operation = operationId ? get().operations[operationId] : undefined;
const abortController = operation?.abortController;
log(
'[internal_callPluginApi] messageId=%s, plugin=%s, operationId=%s, aborted=%s',
id,
payload.identifier,
operationId,
abortController?.signal.aborted,
);
try {
const res = await chatService.runPluginApi(payload, {
signal: abortController?.signal,
trace: { observationId: message?.observationId, traceId: message?.traceId },
@@ -216,7 +230,13 @@ export const pluginTypes: StateCreator<
const err = error as Error;
// ignore the aborted request error
if (!err.message.includes('The user aborted a request.')) {
if (err.message.includes('The user aborted a request.')) {
log(
'[internal_callPluginApi] Request aborted: messageId=%s, plugin=%s',
id,
payload.identifier,
);
} else {
const result = await messageService.updateMessageError(id, error as any, {
sessionId: message?.sessionId,
topicId: message?.topicId,
@@ -231,15 +251,13 @@ export const pluginTypes: StateCreator<
data = '';
}
internal_togglePluginApiCalling(false, id, n('fetchPlugin/end') as string);
// 如果报错则结束了
if (!data) return;
await optimisticUpdateMessageContent(id, data, undefined, {
sessionId: message?.sessionId,
topicId: message?.topicId,
});
// operationId already declared above, reuse it
const context = operationId ? { operationId } : undefined;
await optimisticUpdateMessageContent(id, data, undefined, context);
return data;
},

Some files were not shown because too many files have changed in this diff Show More