mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-20 06:15:58 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72b87757c6 | |||
| b6dc3eac19 | |||
| 14d4786e2d | |||
| a7b5bc428e | |||
| 3079385980 | |||
| 3f74db2399 | |||
| 0a056f3f0b | |||
| c5d71fe165 | |||
| 741f588cae | |||
| 092506906a | |||
| e8c7d1c568 | |||
| 61bb8aeaf2 | |||
| caaa331002 | |||
| fcda0b50f1 | |||
| 53a2c30a75 | |||
| 203fdc4b22 | |||
| 25c43587de | |||
| 2cd2ca9a23 |
@@ -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">
|
||||
|
||||
[](#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">
|
||||
|
||||
[](#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">
|
||||
|
||||
[](#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])?)*$/;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+7
-2
@@ -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());
|
||||
|
||||
+15
-14
@@ -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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user