diff --git a/.gitignore b/.gitignore index 0f996411d0..e278fcef10 100644 --- a/.gitignore +++ b/.gitignore @@ -146,4 +146,4 @@ apps/desktop/resources/cli-package.json # Superpowers plugin brainstorm/spec outputs (local only; do not commit) .superpowers/ -docs/superpowers/ \ No newline at end of file +.heerogeneous-tracing diff --git a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts new file mode 100644 index 0000000000..26151d461c --- /dev/null +++ b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts @@ -0,0 +1,421 @@ +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { Readable, Writable } from 'node:stream'; + +import { app as electronApp, BrowserWindow } from 'electron'; + +import { createLogger } from '@/utils/logger'; + +import { ControllerModule, IpcMethod } from './index'; + +const logger = createLogger('controllers:HeterogeneousAgentCtr'); + +/** Directory under appStoragePath for caching downloaded files */ +const FILE_CACHE_DIR = 'heteroAgent/files'; + +// ─── CLI presets per agent type ─── +// Mirrors @lobechat/heterogeneous-agents/registry but runs in main process +// (can't import from the workspace package in Electron main directly) + +interface CLIPreset { + baseArgs: string[]; + promptMode: 'positional' | 'stdin'; + resumeArgs?: (sessionId: string) => string[]; +} + +const CLI_PRESETS: Record = { + 'claude-code': { + baseArgs: [ + '-p', + '--output-format', + 'stream-json', + '--verbose', + '--permission-mode', + 'bypassPermissions', + ], + promptMode: 'positional', + resumeArgs: (sid) => ['--resume', sid], + }, + // Future presets: + // 'codex': { baseArgs: [...], promptMode: 'positional' }, + // 'kimi-cli': { baseArgs: [...], promptMode: 'positional' }, +}; + +// ─── IPC types ─── + +interface StartSessionParams { + /** Agent type key (e.g., 'claude-code'). Defaults to 'claude-code'. */ + agentType?: string; + /** Additional CLI arguments */ + args?: string[]; + /** Command to execute */ + command: string; + /** Working directory */ + cwd?: string; + /** Environment variables */ + env?: Record; + /** Session ID to resume (for multi-turn) */ + resumeSessionId?: string; +} + +interface StartSessionResult { + sessionId: string; +} + +interface ImageAttachment { + id: string; + url: string; +} + +interface SendPromptParams { + /** Image attachments to include in the prompt (downloaded from url, cached by id) */ + imageList?: ImageAttachment[]; + prompt: string; + sessionId: string; +} + +interface CancelSessionParams { + sessionId: string; +} + +interface StopSessionParams { + sessionId: string; +} + +interface GetSessionInfoParams { + sessionId: string; +} + +interface SessionInfo { + agentSessionId?: string; +} + +// ─── Internal session tracking ─── + +interface AgentSession { + agentSessionId?: string; + agentType: string; + args: string[]; + command: string; + cwd?: string; + env?: Record; + process?: ChildProcess; + sessionId: string; +} + +/** + * External Agent Controller — manages external agent CLI processes via Electron IPC. + * + * Agent-agnostic: uses CLI presets from a registry to support Claude Code, + * Codex, Kimi CLI, etc. Only handles process lifecycle and raw stdout line + * broadcasting. All event parsing and DB persistence happens on the Renderer side. + * + * Lifecycle: startSession → sendPrompt → (heteroAgentRawLine broadcasts) → stopSession + */ +export default class HeterogeneousAgentCtr extends ControllerModule { + static override readonly groupName = 'heterogeneousAgent'; + + private sessions = new Map(); + + // ─── Broadcast ─── + + private broadcast(channel: string, data: T) { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send(channel, data); + } + } + } + + // ─── File cache ─── + + private get fileCacheDir(): string { + return join(this.app.appStoragePath, FILE_CACHE_DIR); + } + + /** + * Download an image by URL, with local disk cache keyed by id. + */ + private async resolveImage( + image: ImageAttachment, + ): Promise<{ buffer: Buffer; mimeType: string }> { + const cacheDir = this.fileCacheDir; + const metaPath = join(cacheDir, `${image.id}.meta`); + const dataPath = join(cacheDir, image.id); + + // Check cache first + try { + const metaRaw = await readFile(metaPath, 'utf8'); + const meta = JSON.parse(metaRaw); + const buffer = await readFile(dataPath); + logger.debug('Image cache hit:', image.id); + return { buffer, mimeType: meta.mimeType || 'image/png' }; + } catch { + // Cache miss — download + } + + logger.info('Downloading image:', image.id); + + const res = await fetch(image.url); + if (!res.ok) + throw new Error(`Failed to download image ${image.id}: ${res.status} ${res.statusText}`); + + const arrayBuffer = await res.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const mimeType = res.headers.get('content-type') || 'image/png'; + + // Write to cache + await mkdir(cacheDir, { recursive: true }); + await writeFile(dataPath, buffer); + await writeFile(metaPath, JSON.stringify({ id: image.id, mimeType })); + logger.debug('Image cached:', image.id, `${buffer.length} bytes`); + + return { buffer, mimeType }; + } + + /** + * Build a stream-json user message with text + image content blocks. + */ + private async buildStreamJsonInput( + prompt: string, + imageList: ImageAttachment[], + ): Promise { + const content: any[] = [{ text: prompt, type: 'text' }]; + + for (const image of imageList) { + try { + const { buffer, mimeType } = await this.resolveImage(image); + content.push({ + source: { + data: buffer.toString('base64'), + media_type: mimeType, + type: 'base64', + }, + type: 'image', + }); + } catch (err) { + logger.error(`Failed to resolve image ${image.id}:`, err); + } + } + + return JSON.stringify({ + message: { content, role: 'user' }, + type: 'user', + }); + } + + // ─── IPC methods ─── + + /** + * Create a session (stores config, process spawned on sendPrompt). + */ + @IpcMethod() + async startSession(params: StartSessionParams): Promise { + const sessionId = randomUUID(); + const agentType = params.agentType || 'claude-code'; + + this.sessions.set(sessionId, { + // If resuming, pre-set the agent session ID so sendPrompt adds --resume + agentSessionId: params.resumeSessionId, + agentType, + args: params.args || [], + command: params.command, + cwd: params.cwd, + env: params.env, + sessionId, + }); + + logger.info('Session created:', { agentType, sessionId }); + return { sessionId }; + } + + /** + * Send a prompt to an agent session. + * + * Spawns the CLI process with preset flags. Broadcasts each stdout line + * as an `heteroAgentRawLine` event — Renderer side parses and adapts. + */ + @IpcMethod() + async sendPrompt(params: SendPromptParams): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) throw new Error(`Session not found: ${params.sessionId}`); + + const preset = CLI_PRESETS[session.agentType]; + if (!preset) throw new Error(`Unknown agent type: ${session.agentType}`); + + const hasImages = params.imageList && params.imageList.length > 0; + + // If images are attached, prepare the stream-json input BEFORE spawning + // so any download errors are caught early. + let stdinPayload: string | undefined; + if (hasImages) { + stdinPayload = await this.buildStreamJsonInput(params.prompt, params.imageList!); + } + + return new Promise((resolve, reject) => { + // Build CLI args: base preset + resume + user args + const cliArgs = [ + ...preset.baseArgs, + ...(session.agentSessionId && preset.resumeArgs + ? preset.resumeArgs(session.agentSessionId) + : []), + ...session.args, + ]; + + if (hasImages) { + // With files: use stdin stream-json mode + cliArgs.push('--input-format', 'stream-json'); + } else { + // Without files: use positional prompt (simple mode) + if (preset.promptMode === 'positional') { + cliArgs.push(params.prompt); + } + } + + logger.info('Spawning agent:', session.command, cliArgs.join(' ')); + + const proc = spawn(session.command, cliArgs, { + cwd: session.cwd, + env: { ...process.env, ...session.env }, + stdio: [hasImages ? 'pipe' : 'ignore', 'pipe', 'pipe'], + }); + + // If using stdin mode, write the stream-json message and close stdin + if (hasImages && stdinPayload && proc.stdin) { + const stdin = proc.stdin as Writable; + stdin.write(stdinPayload + '\n', () => { + stdin.end(); + }); + } + + session.process = proc; + let buffer = ''; + + // Stream stdout lines as raw events to Renderer + const stdout = proc.stdout as Readable; + stdout.on('data', (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const parsed = JSON.parse(trimmed); + + // Extract agent session ID from init event (for multi-turn) + if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.session_id) { + session.agentSessionId = parsed.session_id; + } + + // Broadcast raw parsed JSON — Renderer handles all adaptation + this.broadcast('heteroAgentRawLine', { + line: parsed, + sessionId: session.sessionId, + }); + } catch { + // Not valid JSON, skip + } + } + }); + + // Capture stderr + const stderrChunks: string[] = []; + const stderr = proc.stderr as Readable; + stderr.on('data', (chunk: Buffer) => { + stderrChunks.push(chunk.toString('utf8')); + }); + + proc.on('error', (err) => { + logger.error('Agent process error:', err); + this.broadcast('heteroAgentSessionError', { + error: err.message, + sessionId: session.sessionId, + }); + reject(err); + }); + + proc.on('exit', (code) => { + logger.info('Agent process exited:', { code, sessionId: session.sessionId }); + session.process = undefined; + + if (code === 0) { + this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId }); + resolve(); + } else { + const stderrOutput = stderrChunks.join('').trim(); + const errorMsg = stderrOutput || `Agent exited with code ${code}`; + this.broadcast('heteroAgentSessionError', { + error: errorMsg, + sessionId: session.sessionId, + }); + reject(new Error(errorMsg)); + } + }); + }); + } + + /** + * Get session info (agent's internal session ID for multi-turn resume). + */ + @IpcMethod() + async getSessionInfo(params: GetSessionInfoParams): Promise { + const session = this.sessions.get(params.sessionId); + return { agentSessionId: session?.agentSessionId }; + } + + /** + * Cancel an ongoing session. + */ + @IpcMethod() + async cancelSession(params: CancelSessionParams): Promise { + const session = this.sessions.get(params.sessionId); + if (session?.process) { + session.process.kill('SIGINT'); + } + } + + /** + * Stop and clean up a session. + */ + @IpcMethod() + async stopSession(params: StopSessionParams): Promise { + const session = this.sessions.get(params.sessionId); + if (!session) return; + + if (session.process && !session.process.killed) { + session.process.kill('SIGTERM'); + setTimeout(() => { + if (session.process && !session.process.killed) { + session.process.kill('SIGKILL'); + } + }, 3000); + } + + this.sessions.delete(params.sessionId); + } + + @IpcMethod() + async respondPermission(): Promise { + // No-op for CLI mode (permissions handled by --permission-mode flag) + } + + /** + * Cleanup on app quit. + */ + afterAppReady() { + electronApp.on('before-quit', () => { + for (const [, session] of this.sessions) { + if (session.process && !session.process.killed) { + session.process.kill('SIGTERM'); + } + } + this.sessions.clear(); + }); + } +} diff --git a/apps/desktop/src/main/controllers/registry.ts b/apps/desktop/src/main/controllers/registry.ts index 11690efc74..32eb70f785 100644 --- a/apps/desktop/src/main/controllers/registry.ts +++ b/apps/desktop/src/main/controllers/registry.ts @@ -5,6 +5,7 @@ import BrowserWindowsCtr from './BrowserWindowsCtr'; import CliCtr from './CliCtr'; import DevtoolsCtr from './DevtoolsCtr'; import GatewayConnectionCtr from './GatewayConnectionCtr'; +import HeterogeneousAgentCtr from './HeterogeneousAgentCtr'; import LocalFileCtr from './LocalFileCtr'; import McpCtr from './McpCtr'; import McpInstallCtr from './McpInstallCtr'; @@ -22,6 +23,7 @@ import UpdaterCtr from './UpdaterCtr'; import UploadFileCtr from './UploadFileCtr'; export const controllerIpcConstructors = [ + HeterogeneousAgentCtr, AuthCtr, BrowserWindowsCtr, CliCtr, diff --git a/apps/desktop/src/main/core/App.ts b/apps/desktop/src/main/core/App.ts index e52634d10b..23159e2759 100644 --- a/apps/desktop/src/main/core/App.ts +++ b/apps/desktop/src/main/core/App.ts @@ -17,6 +17,7 @@ import { generateCliWrapper, getCliWrapperDir } from '@/modules/cliEmbedding'; import { astSearchDetectors, browserAutomationDetectors, + cliAgentDetectors, contentSearchDetectors, fileSearchDetectors, type IToolDetector, @@ -190,6 +191,7 @@ export class App { const detectorCategories: Partial> = { 'runtime-environment': runtimeEnvironmentDetectors, + 'cli-agents': cliAgentDetectors, 'ast-search': astSearchDetectors, 'browser-automation': browserAutomationDetectors, 'content-search': contentSearchDetectors, diff --git a/apps/desktop/src/main/core/infrastructure/ToolDetectorManager.ts b/apps/desktop/src/main/core/infrastructure/ToolDetectorManager.ts index 3ff7aa1de6..5306846246 100644 --- a/apps/desktop/src/main/core/infrastructure/ToolDetectorManager.ts +++ b/apps/desktop/src/main/core/infrastructure/ToolDetectorManager.ts @@ -41,6 +41,7 @@ export type ToolCategory = | 'file-search' | 'browser-automation' | 'runtime-environment' + | 'cli-agents' | 'system' | 'custom'; diff --git a/apps/desktop/src/main/libs/acp/client.ts b/apps/desktop/src/main/libs/acp/client.ts new file mode 100644 index 0000000000..39866d1700 --- /dev/null +++ b/apps/desktop/src/main/libs/acp/client.ts @@ -0,0 +1,435 @@ +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import type { Readable } from 'node:stream'; + +import { createLogger } from '@/utils/logger'; + +import type { + ACPInitializeParams, + ACPPermissionRequest, + ACPPermissionResponse, + ACPServerCapabilities, + ACPSessionCancelParams, + ACPSessionInfo, + ACPSessionNewParams, + ACPSessionPromptParams, + ACPSessionUpdate, + FSReadTextFileParams, + FSReadTextFileResult, + FSWriteTextFileParams, + JsonRpcError, + JsonRpcNotification, + JsonRpcRequest, + JsonRpcResponse, + TerminalCreateParams, + TerminalCreateResult, + TerminalKillParams, + TerminalOutputParams, + TerminalOutputResult, + TerminalReleaseParams, + TerminalWaitForExitParams, + TerminalWaitForExitResult, +} from './types'; + +const logger = createLogger('libs:acp:client'); + +type PendingRequest = { + reject: (error: Error) => void; + resolve: (result: unknown) => void; +}; + +export interface ACPClientParams { + args?: string[]; + command: string; + cwd?: string; + env?: Record; +} + +export interface ACPClientCallbacks { + onPermissionRequest?: (request: ACPPermissionRequest) => Promise; + onSessionComplete?: (sessionId: string) => void; + onSessionUpdate?: (update: ACPSessionUpdate) => void; +} + +/** + * ACP Client that communicates with an ACP agent (e.g. Claude Code) over stdio JSON-RPC 2.0. + * + * Bidirectional: sends requests to agent AND handles incoming requests from agent + * (fs/read_text_file, fs/write_text_file, terminal/*, session/request_permission). + */ +export class ACPClient { + private buffer = ''; + private callbacks: ACPClientCallbacks = {}; + private nextId = 1; + private pendingRequests = new Map(); + private process: ChildProcess | null = null; + private stderrLogs: string[] = []; + + // Client-side method handlers (agent calls these) + private clientMethodHandlers = new Map Promise>(); + + constructor(private readonly params: ACPClientParams) {} + + /** + * Register handlers for client-side methods that the agent can call back. + */ + registerClientMethods(handlers: { + 'fs/read_text_file'?: (params: FSReadTextFileParams) => Promise; + 'fs/write_text_file'?: (params: FSWriteTextFileParams) => Promise; + 'terminal/create'?: (params: TerminalCreateParams) => Promise; + 'terminal/kill'?: (params: TerminalKillParams) => Promise; + 'terminal/output'?: (params: TerminalOutputParams) => Promise; + 'terminal/release'?: (params: TerminalReleaseParams) => Promise; + 'terminal/wait_for_exit'?: ( + params: TerminalWaitForExitParams, + ) => Promise; + }) { + for (const [method, handler] of Object.entries(handlers)) { + if (handler) { + this.clientMethodHandlers.set(method, handler); + } + } + } + + setCallbacks(callbacks: ACPClientCallbacks) { + this.callbacks = callbacks; + } + + /** + * Spawn the agent process and initialize the ACP connection. + */ + async connect(): Promise { + const { command, args = [], env, cwd } = this.params; + + this.process = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // Capture stderr + const stderr = this.process.stderr as Readable | null; + if (stderr) { + stderr.on('data', (chunk: Buffer) => { + const lines = chunk + .toString('utf8') + .split('\n') + .filter((l) => l.trim()); + this.stderrLogs.push(...lines); + }); + } + + // Listen for stdout (JSON-RPC messages) + const stdout = this.process.stdout as Readable | null; + if (stdout) { + stdout.on('data', (chunk: Buffer) => { + this.handleData(chunk.toString('utf8')); + }); + } + + this.process.on('error', (err) => { + logger.error('ACP process error:', err); + }); + + this.process.on('exit', (code, signal) => { + logger.info('ACP process exited:', { code, signal }); + // Reject all pending requests + for (const [id, pending] of this.pendingRequests) { + pending.reject(new Error(`ACP process exited (code=${code}, signal=${signal})`)); + this.pendingRequests.delete(id); + } + }); + + // Initialize + const capabilities = await this.initialize(); + return capabilities; + } + + /** + * Send initialize request to the agent. + */ + private async initialize(): Promise { + const params: ACPInitializeParams = { + capabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + }, + clientInfo: { name: 'lobehub-desktop', version: '1.0.0' }, + protocolVersion: '0.1', + }; + + return this.sendRequest('initialize', params); + } + + /** + * Create a new session. + */ + async createSession(params?: ACPSessionNewParams): Promise { + return this.sendRequest('session/new', params); + } + + /** + * Send a prompt to an existing session. + */ + async sendPrompt(params: ACPSessionPromptParams): Promise { + return this.sendRequest('session/prompt', params); + } + + /** + * Cancel an ongoing session operation. + */ + async cancelSession(params: ACPSessionCancelParams): Promise { + return this.sendRequest('session/cancel', params); + } + + /** + * Respond to a permission request from the agent. + */ + respondToPermission(requestId: string, response: ACPPermissionResponse): void { + this.sendResponse(requestId, response); + } + + /** + * Disconnect from the agent and kill the process. + */ + async disconnect(): Promise { + if (this.process) { + this.process.stdin?.end(); + this.process.kill('SIGTERM'); + + // Force kill after timeout + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (this.process && !this.process.killed) { + this.process.kill('SIGKILL'); + } + resolve(); + }, 5000); + + this.process?.on('exit', () => { + clearTimeout(timeout); + resolve(); + }); + }); + + this.process = null; + } + } + + getStderrLogs(): string[] { + return this.stderrLogs; + } + + // ============================================================ + // JSON-RPC transport layer + // ============================================================ + + private sendRequest(method: string, params?: object): Promise { + return new Promise((resolve, reject) => { + const id = this.nextId++; + const request: JsonRpcRequest = { + id, + jsonrpc: '2.0', + method, + params, + }; + + this.pendingRequests.set(id, { + reject, + resolve: resolve as (result: unknown) => void, + }); + + this.writeMessage(request); + }); + } + + private sendResponse(id: number | string, result: unknown): void { + const response: JsonRpcResponse = { + id, + jsonrpc: '2.0', + result, + }; + this.writeMessage(response); + } + + private sendErrorResponse(id: number | string, error: JsonRpcError): void { + const response: JsonRpcResponse = { + error, + id, + jsonrpc: '2.0', + }; + this.writeMessage(response); + } + + private writeMessage(message: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification): void { + if (!this.process?.stdin?.writable) { + logger.error('Cannot write to ACP process: stdin not writable'); + return; + } + + const json = JSON.stringify(message); + const content = `Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`; + this.process.stdin.write(content); + } + + /** + * Handle incoming data from stdout, parsing JSON-RPC messages. + * Uses Content-Length header framing (LSP-style). + */ + private handleData(data: string): void { + this.buffer += data; + + while (true) { + // Try to parse a complete message from the buffer + const headerEnd = this.buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) break; + + const header = this.buffer.slice(0, headerEnd); + const contentLengthMatch = header.match(/Content-Length:\s*(\d+)/i); + + if (!contentLengthMatch) { + // Try parsing as raw JSON (some agents don't use Content-Length headers) + const newlineIdx = this.buffer.indexOf('\n'); + if (newlineIdx === -1) break; + + const line = this.buffer.slice(0, newlineIdx).trim(); + this.buffer = this.buffer.slice(newlineIdx + 1); + + if (line) { + try { + const message = JSON.parse(line); + this.handleMessage(message); + } catch { + // Not valid JSON, skip + } + } + continue; + } + + const contentLength = Number.parseInt(contentLengthMatch[1], 10); + const messageStart = headerEnd + 4; // after \r\n\r\n + const messageEnd = messageStart + contentLength; + + if (Buffer.byteLength(this.buffer.slice(messageStart)) < contentLength) { + // Not enough data yet + break; + } + + const messageStr = this.buffer.slice(messageStart, messageEnd); + this.buffer = this.buffer.slice(messageEnd); + + try { + const message = JSON.parse(messageStr); + this.handleMessage(message); + } catch (err) { + logger.error('Failed to parse ACP JSON-RPC message:', err); + } + } + } + + /** + * Route incoming JSON-RPC messages. + */ + private handleMessage(message: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification): void { + // Response to our request + if ('id' in message && message.id !== null && !('method' in message)) { + const response = message as JsonRpcResponse; + const pending = this.pendingRequests.get(response.id!); + if (pending) { + this.pendingRequests.delete(response.id!); + if (response.error) { + pending.reject( + new Error(`ACP error [${response.error.code}]: ${response.error.message}`), + ); + } else { + pending.resolve(response.result); + } + } + return; + } + + // Incoming request or notification from agent + if ('method' in message) { + const method = message.method; + const params = message.params || {}; + + // Notification (no id) — e.g., session/update + if (!('id' in message) || message.id === undefined || message.id === null) { + this.handleNotification(method, params); + return; + } + + // Request (has id) — agent calling client methods + this.handleIncomingRequest(message as JsonRpcRequest); + } + } + + /** + * Handle notifications from the agent (no response expected). + */ + private handleNotification(method: string, params: Record | object): void { + switch (method) { + case 'session/update': { + if (this.callbacks.onSessionUpdate) { + this.callbacks.onSessionUpdate(params as unknown as ACPSessionUpdate); + } + break; + } + default: { + logger.warn('Unhandled ACP notification:', method); + } + } + } + + /** + * Handle incoming requests from the agent (response required). + */ + private async handleIncomingRequest(request: JsonRpcRequest): Promise { + const { id, method, params } = request; + + // Special handling for permission requests + if (method === 'session/request_permission') { + if (this.callbacks.onPermissionRequest) { + try { + const response = await this.callbacks.onPermissionRequest( + params as unknown as ACPPermissionRequest, + ); + this.sendResponse(id, response); + } catch (err) { + this.sendErrorResponse(id, { + code: -32000, + message: err instanceof Error ? err.message : 'Permission request failed', + }); + } + } else { + // Auto-allow if no handler + const permReq = params as unknown as ACPPermissionRequest; + const allowOption = permReq.options?.find((o) => o.kind === 'allow_once'); + this.sendResponse(id, { + kind: 'selected', + optionId: allowOption?.optionId || permReq.options?.[0]?.optionId, + }); + } + return; + } + + // Delegate to registered client method handlers + const handler = this.clientMethodHandlers.get(method); + if (handler) { + try { + const result = await handler(params); + this.sendResponse(id, result ?? null); + } catch (err) { + this.sendErrorResponse(id, { + code: -32000, + message: err instanceof Error ? err.message : 'Client method failed', + }); + } + } else { + this.sendErrorResponse(id, { + code: -32601, + message: `Method not found: ${method}`, + }); + } + } +} diff --git a/apps/desktop/src/main/libs/acp/index.ts b/apps/desktop/src/main/libs/acp/index.ts new file mode 100644 index 0000000000..4b8488dc8c --- /dev/null +++ b/apps/desktop/src/main/libs/acp/index.ts @@ -0,0 +1,3 @@ +export type { ACPClientCallbacks, ACPClientParams } from './client'; +export { ACPClient } from './client'; +export type * from './types'; diff --git a/apps/desktop/src/main/libs/acp/types.ts b/apps/desktop/src/main/libs/acp/types.ts new file mode 100644 index 0000000000..5993415908 --- /dev/null +++ b/apps/desktop/src/main/libs/acp/types.ts @@ -0,0 +1,326 @@ +/** + * ACP (Agent Client Protocol) type definitions + * Based on: https://agentclientprotocol.com/protocol/schema + */ + +// ============================================================ +// JSON-RPC 2.0 base types +// ============================================================ + +export interface JsonRpcRequest { + id: number | string; + jsonrpc: '2.0'; + method: string; + params?: Record | object; +} + +export interface JsonRpcResponse { + error?: JsonRpcError; + id: number | string | null; + jsonrpc: '2.0'; + result?: unknown; +} + +export interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params?: Record; +} + +export interface JsonRpcError { + code: number; + data?: unknown; + message: string; +} + +// ============================================================ +// ACP Capabilities +// ============================================================ + +export interface ACPCapabilities { + audio?: boolean; + embeddedContext?: boolean; + fs?: { + readTextFile?: boolean; + writeTextFile?: boolean; + }; + image?: boolean; + terminal?: boolean; +} + +export interface ACPServerCapabilities { + modes?: ACPMode[]; + name: string; + protocolVersion: string; + version?: string; +} + +export interface ACPMode { + description?: string; + id: string; + name: string; +} + +// ============================================================ +// Session types +// ============================================================ + +export interface ACPSessionInfo { + createdAt?: string; + id: string; + title?: string; +} + +// ============================================================ +// Content block types (used in session/update) +// ============================================================ + +export type ACPContentBlock = + | ACPTextContent + | ACPImageContent + | ACPAudioContent + | ACPResourceContent + | ACPResourceLinkContent; + +export interface ACPTextContent { + annotations?: Record; + text: string; + type: 'text'; +} + +export interface ACPImageContent { + annotations?: Record; + data: string; + mimeType: string; + type: 'image'; + uri?: string; +} + +export interface ACPAudioContent { + annotations?: Record; + data: string; + mimeType: string; + type: 'audio'; +} + +export interface ACPResourceContent { + annotations?: Record; + resource: { + blob?: string; + mimeType?: string; + text?: string; + uri: string; + }; + type: 'resource'; +} + +export interface ACPResourceLinkContent { + annotations?: Record; + description?: string; + mimeType?: string; + name: string; + size?: number; + title?: string; + type: 'resource_link'; + uri: string; +} + +// ============================================================ +// Tool call types +// ============================================================ + +export type ACPToolCallKind = + | 'read' + | 'edit' + | 'delete' + | 'move' + | 'search' + | 'execute' + | 'think' + | 'fetch' + | 'other'; + +export type ACPToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; + +export interface ACPToolCallDiffContent { + newText: string; + oldText: string; + path: string; + type: 'diff'; +} + +export interface ACPToolCallTerminalContent { + command?: string; + exitCode?: number; + output: string; + type: 'terminal'; +} + +export type ACPToolCallContent = + | ACPTextContent + | ACPImageContent + | ACPToolCallDiffContent + | ACPToolCallTerminalContent; + +export interface ACPToolCallLocation { + endLine?: number; + path: string; + startLine?: number; +} + +export interface ACPToolCallUpdate { + content?: ACPToolCallContent[]; + kind?: ACPToolCallKind; + locations?: ACPToolCallLocation[]; + rawInput?: string; + rawOutput?: string; + status?: ACPToolCallStatus; + title: string; + toolCallId: string; +} + +// ============================================================ +// Session update notification +// ============================================================ + +export type ACPMessageRole = 'assistant' | 'user' | 'thought'; + +export interface ACPMessageChunk { + content: ACPContentBlock[]; + role: ACPMessageRole; +} + +export interface ACPSessionUpdate { + messageChunks?: ACPMessageChunk[]; + sessionId: string; + toolCalls?: ACPToolCallUpdate[]; +} + +// ============================================================ +// Permission request types +// ============================================================ + +export interface ACPPermissionOption { + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; + name: string; + optionId: string; +} + +export interface ACPPermissionRequest { + message?: string; + options: ACPPermissionOption[]; + sessionId: string; + toolCall?: ACPToolCallUpdate; +} + +export interface ACPPermissionResponse { + kind: 'selected' | 'cancelled'; + optionId?: string; +} + +// ============================================================ +// Client method params (agent → client) +// ============================================================ + +export interface FSReadTextFileParams { + path: string; +} + +export interface FSReadTextFileResult { + text: string; +} + +export interface FSWriteTextFileParams { + path: string; + text: string; +} + +export interface TerminalCreateParams { + command: string; + cwd?: string; + env?: Record; +} + +export interface TerminalCreateResult { + terminalId: string; +} + +export interface TerminalOutputParams { + terminalId: string; +} + +export interface TerminalOutputResult { + exitCode?: number; + isRunning: boolean; + output: string; +} + +export interface TerminalWaitForExitParams { + terminalId: string; + timeout?: number; +} + +export interface TerminalWaitForExitResult { + exitCode: number; + output: string; +} + +export interface TerminalKillParams { + terminalId: string; +} + +export interface TerminalReleaseParams { + terminalId: string; +} + +// ============================================================ +// Agent method params (client → agent) +// ============================================================ + +export interface ACPInitializeParams { + capabilities?: ACPCapabilities; + clientInfo?: { + name: string; + version: string; + }; + protocolVersion: string; +} + +export interface ACPSessionNewParams { + title?: string; +} + +export interface ACPSessionPromptParams { + content: ACPContentBlock[]; + sessionId: string; +} + +export interface ACPSessionCancelParams { + sessionId: string; +} + +// ============================================================ +// Broadcast event types (main → renderer) +// ============================================================ + +export interface ACPSessionUpdateEvent { + sessionId: string; + update: ACPSessionUpdate; +} + +export interface ACPPermissionRequestEvent { + message?: string; + options: ACPPermissionOption[]; + requestId: string; + sessionId: string; + toolCall?: ACPToolCallUpdate; +} + +export interface ACPSessionErrorEvent { + error: string; + sessionId: string; +} + +export interface ACPSessionCompleteEvent { + sessionId: string; +} diff --git a/apps/desktop/src/main/modules/toolDetectors/cliAgentDetectors.ts b/apps/desktop/src/main/modules/toolDetectors/cliAgentDetectors.ts new file mode 100644 index 0000000000..be848dfb84 --- /dev/null +++ b/apps/desktop/src/main/modules/toolDetectors/cliAgentDetectors.ts @@ -0,0 +1,145 @@ +import { exec } from 'node:child_process'; +import { platform } from 'node:os'; +import { promisify } from 'node:util'; + +import type { IToolDetector, ToolStatus } from '@/core/infrastructure/ToolDetectorManager'; +import { createCommandDetector } from '@/core/infrastructure/ToolDetectorManager'; + +const execPromise = promisify(exec); + +/** + * Detector that resolves a command path via which/where, then validates + * the binary by matching `--version` (or `--help`) output against a keyword + * to avoid collisions with unrelated executables of the same name. + */ +const createValidatedDetector = (options: { + candidates: string[]; + description: string; + name: string; + priority: number; + validateFlag?: string; + validateKeywords: string[]; +}): IToolDetector => { + const { + name, + description, + priority, + candidates, + validateFlag = '--version', + validateKeywords, + } = options; + + return { + description, + async detect(): Promise { + const whichCmd = platform() === 'win32' ? 'where' : 'which'; + + for (const cmd of candidates) { + try { + const { stdout: pathOut } = await execPromise(`${whichCmd} ${cmd}`, { timeout: 3000 }); + const toolPath = pathOut.trim().split('\n')[0]; + if (!toolPath) continue; + + const { stdout: out } = await execPromise(`${cmd} ${validateFlag}`, { timeout: 5000 }); + const output = out.trim(); + const lowered = output.toLowerCase(); + if (!validateKeywords.some((kw) => lowered.includes(kw.toLowerCase()))) continue; + + return { + available: true, + path: toolPath, + version: output.split('\n')[0], + }; + } catch { + continue; + } + } + + return { available: false }; + }, + name, + priority, + }; +}; + +/** + * Claude Code CLI + * @see https://docs.claude.com/en/docs/claude-code + */ +export const claudeCodeDetector: IToolDetector = createValidatedDetector({ + candidates: ['claude'], + description: 'Claude Code - Anthropic official agentic coding CLI', + name: 'claude', + priority: 1, + validateKeywords: ['claude code'], +}); + +/** + * OpenAI Codex CLI + * @see https://github.com/openai/codex + */ +export const codexDetector: IToolDetector = createValidatedDetector({ + candidates: ['codex'], + description: 'Codex - OpenAI agentic coding CLI', + name: 'codex', + priority: 2, + validateKeywords: ['codex'], +}); + +/** + * Google Gemini CLI + * @see https://github.com/google-gemini/gemini-cli + */ +export const geminiCliDetector: IToolDetector = createValidatedDetector({ + candidates: ['gemini'], + description: 'Gemini CLI - Google agentic coding CLI', + name: 'gemini', + priority: 3, + validateKeywords: ['gemini'], +}); + +/** + * Qwen Code CLI + * @see https://github.com/QwenLM/qwen-code + */ +export const qwenCodeDetector: IToolDetector = createValidatedDetector({ + candidates: ['qwen'], + description: 'Qwen Code - Alibaba Qwen agentic coding CLI', + name: 'qwen', + priority: 4, + validateKeywords: ['qwen'], +}); + +/** + * Kimi CLI (Moonshot) + * @see https://github.com/MoonshotAI/kimi-cli + */ +export const kimiCliDetector: IToolDetector = createValidatedDetector({ + candidates: ['kimi'], + description: 'Kimi CLI - Moonshot AI agentic coding CLI', + name: 'kimi', + priority: 5, + validateKeywords: ['kimi'], +}); + +/** + * Aider - AI pair programming CLI + * Generic command detector; name collision is unlikely. + * @see https://github.com/Aider-AI/aider + */ +export const aiderDetector: IToolDetector = createCommandDetector('aider', { + description: 'Aider - AI pair programming in your terminal', + priority: 6, +}); + +/** + * All CLI agent detectors + */ +export const cliAgentDetectors: IToolDetector[] = [ + claudeCodeDetector, + codexDetector, + geminiCliDetector, + qwenCodeDetector, + kimiCliDetector, + aiderDetector, +]; diff --git a/apps/desktop/src/main/modules/toolDetectors/index.ts b/apps/desktop/src/main/modules/toolDetectors/index.ts index a661d3be01..5313ecd787 100644 --- a/apps/desktop/src/main/modules/toolDetectors/index.ts +++ b/apps/desktop/src/main/modules/toolDetectors/index.ts @@ -6,6 +6,7 @@ */ export { browserAutomationDetectors } from './agentBrowserDetectors'; +export { cliAgentDetectors } from './cliAgentDetectors'; export { astSearchDetectors, contentSearchDetectors } from './contentSearchDetectors'; export { fileSearchDetectors } from './fileSearchDetectors'; export { runtimeEnvironmentDetectors } from './runtimeEnvironmentDetectors'; diff --git a/locales/en-US/chat.json b/locales/en-US/chat.json index 1e9da4d3b8..9a0e6f6a08 100644 --- a/locales/en-US/chat.json +++ b/locales/en-US/chat.json @@ -18,6 +18,7 @@ "agentDefaultMessage": "Hi, I’m **{{name}}**. One sentence is enough.\n\nWant me to match your workflow better? Go to [Agent Settings]({{url}}) and fill in the Agent Profile (you can edit it anytime).", "agentDefaultMessageWithSystemRole": "Hi, I’m **{{name}}**. One sentence is enough—you're in control.", "agentDefaultMessageWithoutEdit": "Hi, I’m **{{name}}**. One sentence is enough—you're in control.", + "agentSidebar.externalTag": "External", "agents": "Agents", "artifact.generating": "Generating", "artifact.inThread": "Cannot view in subtopic, please switch to the main conversation area to open", @@ -223,6 +224,7 @@ "minimap.senderAssistant": "Agent", "minimap.senderUser": "You", "newAgent": "Create Agent", + "newClaudeCodeAgent": "Claude Code Agent", "newGroupChat": "Create Group", "newPage": "Create Page", "noAgentsYet": "This group has no members yet. Click the + button to invite agents.", @@ -234,6 +236,7 @@ "operation.contextCompression": "Context too long, compressing history...", "operation.execAgentRuntime": "Preparing response", "operation.execClientTask": "Executing task", + "operation.execHeterogeneousAgent": "Running agent", "operation.execServerAgentRuntime": "Task is running in the server. You are safe to leave this page", "operation.sendMessage": "Sending message", "owner": "Group owner", diff --git a/locales/en-US/setting.json b/locales/en-US/setting.json index a7827cbc11..16fb634a4b 100644 --- a/locales/en-US/setting.json +++ b/locales/en-US/setting.json @@ -659,6 +659,8 @@ "settingSystemTools.appEnvironment.title": "Built-in App Tools", "settingSystemTools.category.browserAutomation": "Browser Automation", "settingSystemTools.category.browserAutomation.desc": "Tools for headless browser automation and web interaction", + "settingSystemTools.category.cliAgents": "CLI Agents", + "settingSystemTools.category.cliAgents.desc": "Agentic coding CLIs detected on your system, such as Claude Code, Codex, and Kimi", "settingSystemTools.category.contentSearch": "Content Search", "settingSystemTools.category.contentSearch.desc": "Tools for searching text content within files", "settingSystemTools.category.fileSearch": "File Search", @@ -673,17 +675,23 @@ "settingSystemTools.title": "System Tools", "settingSystemTools.tools.ag.desc": "The Silver Searcher - fast code searching tool", "settingSystemTools.tools.agentBrowser.desc": "Agent-browser - headless browser automation CLI for AI agents", + "settingSystemTools.tools.aider.desc": "Aider - AI pair programming in your terminal", "settingSystemTools.tools.bun.desc": "Bun - fast JavaScript runtime and package manager", "settingSystemTools.tools.bunx.desc": "bunx - Bun package runner for executing npm packages", + "settingSystemTools.tools.claude.desc": "Claude Code - Anthropic official agentic coding CLI", + "settingSystemTools.tools.codex.desc": "Codex - OpenAI agentic coding CLI", "settingSystemTools.tools.fd.desc": "fd - fast and user-friendly alternative to find", "settingSystemTools.tools.find.desc": "Unix find - standard file search command", + "settingSystemTools.tools.gemini.desc": "Gemini CLI - Google agentic coding CLI", "settingSystemTools.tools.grep.desc": "GNU grep - standard text search tool", + "settingSystemTools.tools.kimi.desc": "Kimi CLI - Moonshot AI agentic coding CLI", "settingSystemTools.tools.lobehub.desc": "LobeHub CLI - manage and connect to LobeHub services", "settingSystemTools.tools.mdfind.desc": "macOS Spotlight search (fast indexed search)", "settingSystemTools.tools.node.desc": "Node.js - JavaScript runtime for executing JS/TS", "settingSystemTools.tools.npm.desc": "npm - Node.js package manager for installing dependencies", "settingSystemTools.tools.pnpm.desc": "pnpm - fast, disk space efficient package manager", "settingSystemTools.tools.python.desc": "Python - programming language runtime", + "settingSystemTools.tools.qwen.desc": "Qwen Code - Alibaba Qwen agentic coding CLI", "settingSystemTools.tools.rg.desc": "ripgrep - extremely fast text search tool", "settingSystemTools.tools.uv.desc": "uv - extremely fast Python package manager", "settingTTS.openai.sttModel": "OpenAI Speech-to-Text Model", diff --git a/locales/zh-CN/chat.json b/locales/zh-CN/chat.json index 5a901b27b6..ddfb72e1e6 100644 --- a/locales/zh-CN/chat.json +++ b/locales/zh-CN/chat.json @@ -18,6 +18,7 @@ "agentDefaultMessage": "你好,我是 **{{name}}**。从一句话开始就行。\n\n想让我更贴近你的工作方式:去 [助理设置]({{url}}) 补充助理档案(随时可改)", "agentDefaultMessageWithSystemRole": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你", "agentDefaultMessageWithoutEdit": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你", + "agentSidebar.externalTag": "外部", "agents": "助理", "artifact.generating": "生成中", "artifact.inThread": "子话题中暂不支持查看。请回到主对话区打开", @@ -223,6 +224,7 @@ "minimap.senderAssistant": "助理", "minimap.senderUser": "你", "newAgent": "创建助理", + "newClaudeCodeAgent": "Claude Code 智能体", "newGroupChat": "创建群组", "newPage": "创建文稿", "noAgentsYet": "这个群组还没有成员。点击「+」邀请助理加入", @@ -234,6 +236,7 @@ "operation.contextCompression": "上下文过长,正在压缩历史记录……", "operation.execAgentRuntime": "准备响应中", "operation.execClientTask": "执行任务中", + "operation.execHeterogeneousAgent": "智能体运行中", "operation.execServerAgentRuntime": "任务正在服务器运行,您可以放心离开此页面", "operation.sendMessage": "消息发送中", "owner": "群主", diff --git a/locales/zh-CN/setting.json b/locales/zh-CN/setting.json index 97479f1119..fc5dbef3f1 100644 --- a/locales/zh-CN/setting.json +++ b/locales/zh-CN/setting.json @@ -659,6 +659,8 @@ "settingSystemTools.appEnvironment.title": "内建应用工具", "settingSystemTools.category.browserAutomation": "浏览器自动化", "settingSystemTools.category.browserAutomation.desc": "用于无头浏览器自动化和网页交互的工具", + "settingSystemTools.category.cliAgents": "CLI 智能体", + "settingSystemTools.category.cliAgents.desc": "已检测到的命令行编码智能体,如 Claude Code、Codex、Kimi 等", "settingSystemTools.category.contentSearch": "内容搜索", "settingSystemTools.category.contentSearch.desc": "用于在文件内搜索文本内容的工具", "settingSystemTools.category.fileSearch": "文件搜索", @@ -673,17 +675,23 @@ "settingSystemTools.title": "系统工具", "settingSystemTools.tools.ag.desc": "The Silver Searcher - 快速代码搜索工具", "settingSystemTools.tools.agentBrowser.desc": "Agent-browser - 面向AI代理的无头浏览器自动化命令行工具", + "settingSystemTools.tools.aider.desc": "Aider - 终端内的 AI 结对编程工具", "settingSystemTools.tools.bun.desc": "Bun - 快速的 JavaScript 运行时和包管理器", "settingSystemTools.tools.bunx.desc": "bunx - Bun 包执行器,用于运行 npm 包", + "settingSystemTools.tools.claude.desc": "Claude Code - Anthropic 官方命令行编码智能体", + "settingSystemTools.tools.codex.desc": "Codex - OpenAI 命令行编码智能体", "settingSystemTools.tools.fd.desc": "fd - 快速且用户友好的 find 替代品", "settingSystemTools.tools.find.desc": "Unix find - 标准文件搜索命令", + "settingSystemTools.tools.gemini.desc": "Gemini CLI - Google 命令行编码智能体", "settingSystemTools.tools.grep.desc": "GNU grep - 标准文本搜索工具", + "settingSystemTools.tools.kimi.desc": "Kimi CLI - 月之暗面命令行编码智能体", "settingSystemTools.tools.lobehub.desc": "LobeHub CLI - 管理和连接 LobeHub 服务", "settingSystemTools.tools.mdfind.desc": "macOS 聚焦搜索(快速索引搜索)", "settingSystemTools.tools.node.desc": "Node.js - 执行 JavaScript/TypeScript 的运行时", "settingSystemTools.tools.npm.desc": "npm - Node.js 包管理器,用于安装依赖", "settingSystemTools.tools.pnpm.desc": "pnpm - 快速、节省磁盘空间的包管理器", "settingSystemTools.tools.python.desc": "Python - 编程语言运行时", + "settingSystemTools.tools.qwen.desc": "Qwen Code - 阿里通义千问命令行编码智能体", "settingSystemTools.tools.rg.desc": "ripgrep - 极快的文本搜索工具", "settingSystemTools.tools.uv.desc": "uv - 极快的 Python 包管理器", "settingTTS.openai.sttModel": "OpenAI 语音识别模型", diff --git a/package.json b/package.json index 5f1f3948e8..e4a6065505 100644 --- a/package.json +++ b/package.json @@ -252,6 +252,7 @@ "@lobechat/eval-rubric": "workspace:*", "@lobechat/fetch-sse": "workspace:*", "@lobechat/file-loaders": "workspace:*", + "@lobechat/heterogeneous-agents": "workspace:*", "@lobechat/local-file-shell": "workspace:*", "@lobechat/memory-user-memory": "workspace:*", "@lobechat/model-runtime": "workspace:*", diff --git a/packages/builtin-tool-claude-code/package.json b/packages/builtin-tool-claude-code/package.json new file mode 100644 index 0000000000..17438c9fde --- /dev/null +++ b/packages/builtin-tool-claude-code/package.json @@ -0,0 +1,23 @@ +{ + "name": "@lobechat/builtin-tool-claude-code", + "version": "1.0.0", + "private": true, + "exports": { + ".": "./src/index.ts", + "./client": "./src/client/index.ts" + }, + "main": "./src/index.ts", + "dependencies": { + "@lobechat/shared-tool-ui": "workspace:*" + }, + "devDependencies": { + "@lobechat/types": "workspace:*" + }, + "peerDependencies": { + "@lobehub/ui": "^5", + "antd-style": "*", + "lucide-react": "*", + "path-browserify-esm": "*", + "react": "*" + } +} diff --git a/packages/builtin-tool-claude-code/src/client/Inspector.tsx b/packages/builtin-tool-claude-code/src/client/Inspector.tsx new file mode 100644 index 0000000000..0d17bbff75 --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/Inspector.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { + createEditLocalFileInspector, + createGlobLocalFilesInspector, + createGrepContentInspector, + createRunCommandInspector, +} from '@lobechat/shared-tool-ui/inspectors'; + +import { ClaudeCodeApiName } from '../types'; +import { ReadInspector } from './ReadInspector'; +import { WriteInspector } from './WriteInspector'; + +// CC's own tool names (Bash / Edit / Glob / Grep / Read / Write) are already +// the intended human-facing label, so we feed them to the shared factories as +// the "translation key" and let react-i18next's missing-key fallback echo it +// back verbatim. Keeps this package out of the plugin locale file. +// +// Bash / Edit / Glob / Grep can use the shared factories directly — Edit +// already reads `file_path`, and Glob / Grep only need `pattern`. Read and +// Write need arg mapping, so they live in their own sibling files. +export const ClaudeCodeInspectors = { + [ClaudeCodeApiName.Bash]: createRunCommandInspector(ClaudeCodeApiName.Bash), + [ClaudeCodeApiName.Edit]: createEditLocalFileInspector(ClaudeCodeApiName.Edit), + [ClaudeCodeApiName.Glob]: createGlobLocalFilesInspector(ClaudeCodeApiName.Glob), + [ClaudeCodeApiName.Grep]: createGrepContentInspector({ + noResultsKey: 'No results', + translationKey: ClaudeCodeApiName.Grep, + }), + [ClaudeCodeApiName.Read]: ReadInspector, + [ClaudeCodeApiName.Write]: WriteInspector, +}; diff --git a/packages/builtin-tool-claude-code/src/client/ReadInspector.tsx b/packages/builtin-tool-claude-code/src/client/ReadInspector.tsx new file mode 100644 index 0000000000..7e0cfd6b9d --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/ReadInspector.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { createReadLocalFileInspector } from '@lobechat/shared-tool-ui/inspectors'; +import type { BuiltinInspectorProps } from '@lobechat/types'; +import { memo } from 'react'; + +import { ClaudeCodeApiName } from '../types'; + +/** + * CC Read tool uses Anthropic-native args (`file_path`, `offset`, `limit`); + * the shared inspector reads `path` / `startLine` / `endLine`. Map between + * them so shared stays untouched. + */ + +interface CCReadArgs { + file_path?: string; + limit?: number; + offset?: number; +} + +interface SharedReadArgs { + endLine?: number; + path?: string; + startLine?: number; +} + +const mapArgs = (args?: CCReadArgs): SharedReadArgs => { + const { file_path, offset, limit } = args ?? {}; + const endLine = offset !== undefined && limit !== undefined ? offset + limit : undefined; + return { endLine, path: file_path, startLine: offset }; +}; + +const SharedInspector = createReadLocalFileInspector(ClaudeCodeApiName.Read); + +export const ReadInspector = memo>((props) => ( + +)); +ReadInspector.displayName = 'ClaudeCodeReadInspector'; diff --git a/packages/builtin-tool-claude-code/src/client/Render/Edit/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/Edit/index.tsx new file mode 100644 index 0000000000..cda1c76260 --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/Render/Edit/index.tsx @@ -0,0 +1,39 @@ +'use client'; + +import type { BuiltinRenderProps } from '@lobechat/types'; +import { CodeDiff, Flexbox, Skeleton } from '@lobehub/ui'; +import path from 'path-browserify-esm'; +import { memo } from 'react'; + +interface EditArgs { + file_path?: string; + new_string?: string; + old_string?: string; + replace_all?: boolean; +} + +const Edit = memo>(({ args }) => { + if (!args) return ; + + const filePath = args.file_path || ''; + const fileName = filePath ? path.basename(filePath) : ''; + const ext = filePath ? path.extname(filePath).slice(1).toLowerCase() : ''; + + return ( + + + + ); +}); + +Edit.displayName = 'ClaudeCodeEdit'; + +export default Edit; diff --git a/packages/builtin-tool-claude-code/src/client/Render/Glob/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/Glob/index.tsx new file mode 100644 index 0000000000..055c8c5ea3 --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/Render/Glob/index.tsx @@ -0,0 +1,88 @@ +'use client'; + +import type { BuiltinRenderProps } from '@lobechat/types'; +import { Flexbox, Highlighter, Icon, Text } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { FolderSearch } from 'lucide-react'; +import { memo, useMemo } from 'react'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + container: css` + padding: 8px; + border-radius: ${cssVar.borderRadiusLG}; + background: ${cssVar.colorFillQuaternary}; + `, + count: css` + font-size: 12px; + color: ${cssVar.colorTextTertiary}; + `, + header: css` + padding-inline: 4px; + color: ${cssVar.colorTextSecondary}; + `, + pattern: css` + font-family: ${cssVar.fontFamilyCode}; + `, + previewBox: css` + overflow: hidden; + border-radius: 8px; + background: ${cssVar.colorBgContainer}; + `, + scope: css` + font-size: 12px; + color: ${cssVar.colorTextTertiary}; + word-break: break-all; + `, +})); + +interface GlobArgs { + path?: string; + pattern?: string; +} + +const Glob = memo>(({ args, content }) => { + const pattern = args?.pattern || ''; + const scope = args?.path || ''; + + const matchCount = useMemo(() => { + if (!content) return 0; + return content.split('\n').filter((line: string) => line.trim().length > 0).length; + }, [content]); + + return ( + + + + {pattern && ( + + {pattern} + + )} + {scope && ( + + {scope} + + )} + {matchCount > 0 && {`${matchCount} matches`}} + + + {content && ( + + + {content} + + + )} + + ); +}); + +Glob.displayName = 'ClaudeCodeGlob'; + +export default Glob; diff --git a/packages/builtin-tool-claude-code/src/client/Render/Grep/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/Grep/index.tsx new file mode 100644 index 0000000000..275b305181 --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/Render/Grep/index.tsx @@ -0,0 +1,83 @@ +'use client'; + +import type { BuiltinRenderProps } from '@lobechat/types'; +import { Flexbox, Highlighter, Icon, Tag, Text } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { Search } from 'lucide-react'; +import { memo } from 'react'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + container: css` + padding: 8px; + border-radius: ${cssVar.borderRadiusLG}; + background: ${cssVar.colorFillQuaternary}; + `, + header: css` + padding-inline: 4px; + color: ${cssVar.colorTextSecondary}; + `, + pattern: css` + font-family: ${cssVar.fontFamilyCode}; + `, + previewBox: css` + overflow: hidden; + border-radius: 8px; + background: ${cssVar.colorBgContainer}; + `, + scope: css` + font-size: 12px; + color: ${cssVar.colorTextTertiary}; + word-break: break-all; + `, +})); + +interface GrepArgs { + glob?: string; + output_mode?: 'files_with_matches' | 'content' | 'count'; + path?: string; + pattern?: string; + type?: string; +} + +const Grep = memo>(({ args, content }) => { + const pattern = args?.pattern || ''; + const scope = args?.path || ''; + const glob = args?.glob || args?.type; + + return ( + + + + {pattern && ( + + {pattern} + + )} + {glob && {glob}} + {scope && ( + + {scope} + + )} + + + {content && ( + + + {content} + + + )} + + ); +}); + +Grep.displayName = 'ClaudeCodeGrep'; + +export default Grep; diff --git a/packages/builtin-tool-claude-code/src/client/Render/Read/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/Read/index.tsx new file mode 100644 index 0000000000..4bf9b6f6d3 --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/Render/Read/index.tsx @@ -0,0 +1,90 @@ +'use client'; + +import type { BuiltinRenderProps } from '@lobechat/types'; +import { Flexbox, Highlighter, Icon, Text } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { FileText } from 'lucide-react'; +import path from 'path-browserify-esm'; +import { memo, useMemo } from 'react'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + container: css` + padding: 8px; + border-radius: ${cssVar.borderRadiusLG}; + background: ${cssVar.colorFillQuaternary}; + `, + header: css` + padding-inline: 4px; + color: ${cssVar.colorTextSecondary}; + `, + path: css` + font-size: 12px; + color: ${cssVar.colorTextTertiary}; + word-break: break-all; + `, + previewBox: css` + overflow: hidden; + border-radius: 8px; + background: ${cssVar.colorBgContainer}; + `, +})); + +interface ReadArgs { + file_path?: string; + limit?: number; + offset?: number; +} + +/** + * Strip Claude Code's numbered-line prefix (e.g. `␣␣␣␣␣1\tfoo`) so the + * Highlighter can tokenize the actual source. CC always returns this `cat -n` + * style output; we keep the line numbers conceptually via Highlighter's own + * gutter when available, and otherwise just display the raw source. + */ +const stripLineNumbers = (text: string): string => { + if (!text) return ''; + return text + .split('\n') + .map((line) => line.replace(/^\s*\d+\t/, '')) + .join('\n'); +}; + +const Read = memo>(({ args, content }) => { + const filePath = args?.file_path || ''; + const fileName = filePath ? path.basename(filePath) : ''; + const ext = filePath ? path.extname(filePath).slice(1).toLowerCase() : ''; + + const source = useMemo(() => stripLineNumbers(content || ''), [content]); + + return ( + + + + {fileName || 'Read'} + {filePath && filePath !== fileName && ( + + {filePath} + + )} + + + {source && ( + + + {source} + + + )} + + ); +}); + +Read.displayName = 'ClaudeCodeRead'; + +export default Read; diff --git a/packages/builtin-tool-claude-code/src/client/Render/Write/index.tsx b/packages/builtin-tool-claude-code/src/client/Render/Write/index.tsx new file mode 100644 index 0000000000..27d74ac6fa --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/Render/Write/index.tsx @@ -0,0 +1,87 @@ +'use client'; + +import type { BuiltinRenderProps } from '@lobechat/types'; +import { Flexbox, Highlighter, Icon, Markdown, Skeleton, Text } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { FilePlus2 } from 'lucide-react'; +import path from 'path-browserify-esm'; +import { memo } from 'react'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + container: css` + padding: 8px; + border-radius: ${cssVar.borderRadiusLG}; + background: ${cssVar.colorFillQuaternary}; + `, + header: css` + padding-inline: 4px; + color: ${cssVar.colorTextSecondary}; + `, + path: css` + font-size: 12px; + color: ${cssVar.colorTextTertiary}; + word-break: break-all; + `, + previewBox: css` + overflow: hidden; + border-radius: 8px; + background: ${cssVar.colorBgContainer}; + `, +})); + +interface WriteArgs { + content?: string; + file_path?: string; +} + +const Write = memo>(({ args }) => { + if (!args) return ; + + const filePath = args.file_path || ''; + const fileName = filePath ? path.basename(filePath) : ''; + const ext = filePath ? path.extname(filePath).slice(1).toLowerCase() : ''; + + const renderContent = () => { + if (!args.content) return null; + + if (ext === 'md' || ext === 'mdx') { + return ( + + {args.content} + + ); + } + + return ( + + {args.content} + + ); + }; + + return ( + + + + {fileName || 'Write'} + {filePath && filePath !== fileName && ( + + {filePath} + + )} + + + {args.content && {renderContent()}} + + ); +}); + +Write.displayName = 'ClaudeCodeWrite'; + +export default Write; diff --git a/packages/builtin-tool-claude-code/src/client/Render/index.ts b/packages/builtin-tool-claude-code/src/client/Render/index.ts new file mode 100644 index 0000000000..3f1a8c6d9c --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/Render/index.ts @@ -0,0 +1,25 @@ +import { RunCommandRender } from '@lobechat/shared-tool-ui/renders'; + +import { ClaudeCodeApiName } from '../../types'; +import Edit from './Edit'; +import Glob from './Glob'; +import Grep from './Grep'; +import Read from './Read'; +import Write from './Write'; + +/** + * Claude Code Render Components Registry. + * + * Maps CC tool names (the `name` on Anthropic `tool_use` blocks) to dedicated + * visualizations, keyed so `getBuiltinRender('claude-code', apiName)` resolves. + */ +export const ClaudeCodeRenders = { + // RunCommand already renders `args.command` + combined output the way CC emits — + // use the shared component directly instead of wrapping it in a re-export file. + [ClaudeCodeApiName.Bash]: RunCommandRender, + [ClaudeCodeApiName.Edit]: Edit, + [ClaudeCodeApiName.Glob]: Glob, + [ClaudeCodeApiName.Grep]: Grep, + [ClaudeCodeApiName.Read]: Read, + [ClaudeCodeApiName.Write]: Write, +}; diff --git a/packages/builtin-tool-claude-code/src/client/WriteInspector.tsx b/packages/builtin-tool-claude-code/src/client/WriteInspector.tsx new file mode 100644 index 0000000000..07ee85c268 --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/WriteInspector.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { createWriteLocalFileInspector } from '@lobechat/shared-tool-ui/inspectors'; +import type { BuiltinInspectorProps } from '@lobechat/types'; +import { memo } from 'react'; + +import { ClaudeCodeApiName } from '../types'; + +/** + * CC Write tool uses `file_path`; the shared inspector reads `path`. + */ + +interface CCWriteArgs { + content?: string; + file_path?: string; +} + +interface SharedWriteArgs { + content?: string; + path?: string; +} + +const mapArgs = (args?: CCWriteArgs): SharedWriteArgs => { + const { content, file_path } = args ?? {}; + return { content, path: file_path }; +}; + +const SharedInspector = createWriteLocalFileInspector(ClaudeCodeApiName.Write); + +export const WriteInspector = memo>((props) => ( + +)); +WriteInspector.displayName = 'ClaudeCodeWriteInspector'; diff --git a/packages/builtin-tool-claude-code/src/client/index.ts b/packages/builtin-tool-claude-code/src/client/index.ts new file mode 100644 index 0000000000..a6525272ff --- /dev/null +++ b/packages/builtin-tool-claude-code/src/client/index.ts @@ -0,0 +1,3 @@ +export { ClaudeCodeApiName, ClaudeCodeIdentifier } from '../types'; +export { ClaudeCodeInspectors } from './Inspector'; +export { ClaudeCodeRenders } from './Render'; diff --git a/packages/builtin-tool-claude-code/src/index.ts b/packages/builtin-tool-claude-code/src/index.ts new file mode 100644 index 0000000000..67d81e7752 --- /dev/null +++ b/packages/builtin-tool-claude-code/src/index.ts @@ -0,0 +1 @@ +export { ClaudeCodeApiName, ClaudeCodeIdentifier } from './types'; diff --git a/packages/builtin-tool-claude-code/src/types.ts b/packages/builtin-tool-claude-code/src/types.ts new file mode 100644 index 0000000000..e08b06156a --- /dev/null +++ b/packages/builtin-tool-claude-code/src/types.ts @@ -0,0 +1,20 @@ +/** + * Claude Code agent identifier — matches the value emitted by + * `ClaudeCodeAdapter` when it converts `tool_use` blocks into + * `ToolCallPayload.identifier`. + */ +export const ClaudeCodeIdentifier = 'claude-code'; + +/** + * Canonical Claude Code tool names (the `name` field on `tool_use` blocks). + * Kept as string literals so future additions (WebSearch, Task, etc.) can be + * wired in without downstream enum migrations. + */ +export enum ClaudeCodeApiName { + Bash = 'Bash', + Edit = 'Edit', + Glob = 'Glob', + Grep = 'Grep', + Read = 'Read', + Write = 'Write', +} diff --git a/packages/builtin-tools/package.json b/packages/builtin-tools/package.json index 9162d19da7..7c09e8007b 100644 --- a/packages/builtin-tools/package.json +++ b/packages/builtin-tools/package.json @@ -20,6 +20,7 @@ "@lobechat/builtin-tool-agent-builder": "workspace:*", "@lobechat/builtin-tool-agent-documents": "workspace:*", "@lobechat/builtin-tool-brief": "workspace:*", + "@lobechat/builtin-tool-claude-code": "workspace:*", "@lobechat/builtin-tool-cloud-sandbox": "workspace:*", "@lobechat/builtin-tool-creds": "workspace:*", "@lobechat/builtin-tool-cron": "workspace:*", diff --git a/packages/builtin-tools/src/inspectors.ts b/packages/builtin-tools/src/inspectors.ts index 48a4844890..d8df6870cb 100644 --- a/packages/builtin-tools/src/inspectors.ts +++ b/packages/builtin-tools/src/inspectors.ts @@ -10,6 +10,10 @@ import { AgentManagementInspectors, AgentManagementManifest, } from '@lobechat/builtin-tool-agent-management/client'; +import { + ClaudeCodeIdentifier, + ClaudeCodeInspectors, +} from '@lobechat/builtin-tool-claude-code/client'; import { CloudSandboxIdentifier, CloudSandboxInspectors, @@ -59,6 +63,7 @@ const BuiltinToolInspectors: Record> = string, BuiltinInspector >, + [ClaudeCodeIdentifier]: ClaudeCodeInspectors as Record, [CloudSandboxIdentifier]: CloudSandboxInspectors as Record, [GroupAgentBuilderManifest.identifier]: GroupAgentBuilderInspectors as Record< string, diff --git a/packages/builtin-tools/src/renders.ts b/packages/builtin-tools/src/renders.ts index 3f9abd3a95..6d404db849 100644 --- a/packages/builtin-tools/src/renders.ts +++ b/packages/builtin-tools/src/renders.ts @@ -6,6 +6,7 @@ import { AgentBuilderManifest } from '@lobechat/builtin-tool-agent-builder'; import { AgentBuilderRenders } from '@lobechat/builtin-tool-agent-builder/client'; import { AgentManagementManifest } from '@lobechat/builtin-tool-agent-management'; import { AgentManagementRenders } from '@lobechat/builtin-tool-agent-management/client'; +import { ClaudeCodeIdentifier, ClaudeCodeRenders } from '@lobechat/builtin-tool-claude-code/client'; import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox'; import { CloudSandboxRenders } from '@lobechat/builtin-tool-cloud-sandbox/client'; import { GroupAgentBuilderManifest } from '@lobechat/builtin-tool-group-agent-builder'; @@ -39,6 +40,7 @@ import { type BuiltinRender } from '@lobechat/types'; const BuiltinToolsRenders: Record> = { [AgentBuilderManifest.identifier]: AgentBuilderRenders as Record, [AgentManagementManifest.identifier]: AgentManagementRenders as Record, + [ClaudeCodeIdentifier]: ClaudeCodeRenders as Record, [CloudSandboxManifest.identifier]: CloudSandboxRenders as Record, [GroupAgentBuilderManifest.identifier]: GroupAgentBuilderRenders as Record, [GroupManagementManifest.identifier]: GroupManagementRenders as Record, diff --git a/packages/const/src/user.ts b/packages/const/src/user.ts index 039ede3d85..3659382725 100644 --- a/packages/const/src/user.ts +++ b/packages/const/src/user.ts @@ -18,6 +18,7 @@ export const DEFAULT_PREFERENCE: UserPreference = { }, lab: { enableAgentWorkingPanel: false, + enableHeterogeneousAgent: false, enableInputMarkdown: true, }, topicDisplayMode: DEFAULT_TOPIC_DISPLAY_MODE, diff --git a/packages/conversation-flow/src/transformation/FlatListBuilder.ts b/packages/conversation-flow/src/transformation/FlatListBuilder.ts index d77b506de5..2251256e83 100644 --- a/packages/conversation-flow/src/transformation/FlatListBuilder.ts +++ b/packages/conversation-flow/src/transformation/FlatListBuilder.ts @@ -21,7 +21,7 @@ export class FlatListBuilder { private branchResolver: BranchResolver, private messageCollector: MessageCollector, private messageTransformer: MessageTransformer, - ) { } + ) {} /** * Generate flatList from messages array diff --git a/packages/database/src/repositories/home/index.ts b/packages/database/src/repositories/home/index.ts index 2436ed7d4d..693d471e2b 100644 --- a/packages/database/src/repositories/home/index.ts +++ b/packages/database/src/repositories/home/index.ts @@ -44,6 +44,7 @@ export class HomeRepository { // 1. Query all agents (non-virtual) with their session info (if exists) const agentList = await this.db .select({ + agencyConfig: agents.agencyConfig, agentSessionGroupId: agents.sessionGroupId, avatar: agents.avatar, backgroundColor: agents.backgroundColor, @@ -98,6 +99,7 @@ export class HomeRepository { private processAgentList( agentItems: Array<{ + agencyConfig: { heterogeneousProvider?: { type?: string } } | null; agentSessionGroupId: string | null; avatar: string | null; backgroundColor: string | null; @@ -136,6 +138,7 @@ export class HomeRepository { backgroundColor: a.backgroundColor, description: a.description, groupId: a.agentSessionGroupId ?? a.sessionGroupId, + heterogeneousType: a.agencyConfig?.heterogeneousProvider?.type ?? null, id: a.id, pinned: a.pinned ?? a.sessionPinned ?? false, sessionId: a.sessionId, diff --git a/packages/electron-client-ipc/src/events/acp.ts b/packages/electron-client-ipc/src/events/acp.ts new file mode 100644 index 0000000000..1b25cf9109 --- /dev/null +++ b/packages/electron-client-ipc/src/events/acp.ts @@ -0,0 +1,17 @@ +// ============================================================ +// External Agent broadcast event types (main → renderer) +// ============================================================ + +export interface ACPBroadcastEvents { + /** + * Raw JSON line from agent's stdout. + * Renderer side uses an Adapter to parse into AgentStreamEvent. + */ + acpRawLine: (data: { line: any; sessionId: string }) => void; + + /** Agent session completed successfully (process exited 0). */ + acpSessionComplete: (data: { sessionId: string }) => void; + + /** Agent session errored (process exited non-zero or threw). */ + acpSessionError: (data: { error: string; sessionId: string }) => void; +} diff --git a/packages/electron-client-ipc/src/events/index.ts b/packages/electron-client-ipc/src/events/index.ts index 48dbdf406a..d8d3045dfd 100644 --- a/packages/electron-client-ipc/src/events/index.ts +++ b/packages/electron-client-ipc/src/events/index.ts @@ -1,3 +1,4 @@ +import type { ACPBroadcastEvents } from './acp'; import type { GatewayConnectionBroadcastEvents } from './gatewayConnection'; import type { NavigationBroadcastEvents } from './navigation'; import type { ProtocolBroadcastEvents } from './protocol'; @@ -11,6 +12,7 @@ import type { AutoUpdateBroadcastEvents } from './update'; export interface MainBroadcastEvents extends + ACPBroadcastEvents, AutoUpdateBroadcastEvents, GatewayConnectionBroadcastEvents, NavigationBroadcastEvents, diff --git a/packages/heterogeneous-agents/package.json b/packages/heterogeneous-agents/package.json new file mode 100644 index 0000000000..5a79d733df --- /dev/null +++ b/packages/heterogeneous-agents/package.json @@ -0,0 +1,13 @@ +{ + "name": "@lobechat/heterogeneous-agents", + "version": "1.0.0", + "private": true, + "main": "./src/index.ts", + "scripts": { + "test": "vitest", + "test:coverage": "vitest --coverage --silent='passed-only'" + }, + "devDependencies": { + "@lobechat/types": "workspace:*" + } +} diff --git a/packages/heterogeneous-agents/src/adapters/claudeCode.e2e.test.ts b/packages/heterogeneous-agents/src/adapters/claudeCode.e2e.test.ts new file mode 100644 index 0000000000..249dff8c44 --- /dev/null +++ b/packages/heterogeneous-agents/src/adapters/claudeCode.e2e.test.ts @@ -0,0 +1,258 @@ +/** + * End-to-end integration test for ClaudeCodeAdapter. + * + * Simulates a realistic Claude Code CLI stream-json session with multiple steps: + * init → thinking → text → tool_use → tool_result → new step → text → result + * + * Verifies the complete event pipeline that the executor would consume. + */ +import { describe, expect, it } from 'vitest'; + +import { ClaudeCodeAdapter } from './claudeCode'; + +/** + * Simulate a realistic multi-step Claude Code session. + * + * Scenario: CC reads a file, then writes a fix in a second LLM turn. + */ +const simulatedStream = [ + // 1. System init + { + model: 'claude-sonnet-4-6', + session_id: 'sess_abc123', + subtype: 'init', + tools: ['Read', 'Write', 'Bash'], + type: 'system', + }, + // 2. First assistant turn — thinking + tool_use (Read) + { + message: { + content: [ + { thinking: 'Let me read the file first to understand the issue.', type: 'thinking' }, + ], + id: 'msg_01', + model: 'claude-sonnet-4-6', + role: 'assistant', + usage: { input_tokens: 500, output_tokens: 100 }, + }, + type: 'assistant', + }, + { + message: { + content: [ + { id: 'toolu_read_1', input: { file_path: '/src/app.ts' }, name: 'Read', type: 'tool_use' }, + ], + id: 'msg_01', + model: 'claude-sonnet-4-6', + role: 'assistant', + usage: { input_tokens: 500, output_tokens: 150 }, + }, + type: 'assistant', + }, + // 3. Tool result (user event) + { + message: { + content: [ + { + content: 'export function add(a: number, b: number) {\n return a - b; // BUG\n}', + tool_use_id: 'toolu_read_1', + type: 'tool_result', + }, + ], + role: 'user', + }, + type: 'user', + }, + // 4. Second assistant turn — NEW message.id = new step + { + message: { + content: [ + { thinking: 'Found the bug: subtract instead of add. Let me fix it.', type: 'thinking' }, + ], + id: 'msg_02', + model: 'claude-sonnet-4-6', + role: 'assistant', + usage: { input_tokens: 800, output_tokens: 80 }, + }, + type: 'assistant', + }, + { + message: { + content: [ + { + id: 'toolu_write_1', + input: { + content: 'export function add(a: number, b: number) {\n return a + b;\n}', + file_path: '/src/app.ts', + }, + name: 'Write', + type: 'tool_use', + }, + ], + id: 'msg_02', + model: 'claude-sonnet-4-6', + role: 'assistant', + usage: { input_tokens: 800, output_tokens: 200 }, + }, + type: 'assistant', + }, + // 5. Write tool result + { + message: { + content: [ + { + content: 'File written successfully.', + tool_use_id: 'toolu_write_1', + type: 'tool_result', + }, + ], + role: 'user', + }, + type: 'user', + }, + // 6. Third assistant turn — final text response, NEW message.id + { + message: { + content: [ + { + text: 'I fixed the bug in `/src/app.ts`. The `add` function was subtracting instead of adding.', + type: 'text', + }, + ], + id: 'msg_03', + model: 'claude-sonnet-4-6', + role: 'assistant', + usage: { input_tokens: 1000, output_tokens: 30 }, + }, + type: 'assistant', + }, + // 7. Final result + { + is_error: false, + result: 'I fixed the bug in `/src/app.ts`.', + type: 'result', + }, +]; + +describe('ClaudeCodeAdapter E2E', () => { + it('produces correct event sequence for a multi-step session', () => { + const adapter = new ClaudeCodeAdapter(); + const allEvents = simulatedStream.flatMap((line) => adapter.adapt(line)); + + // Extract event types for sequence verification + const types = allEvents.map((e) => e.type); + + // 1. Should start with stream_start (from init) + expect(types[0]).toBe('stream_start'); + + // 2. Should have content chunks (thinking, tool_use, text) + const textChunks = allEvents.filter( + (e) => e.type === 'stream_chunk' && e.data.chunkType === 'text', + ); + expect(textChunks.length).toBeGreaterThanOrEqual(1); + + const reasoningChunks = allEvents.filter( + (e) => e.type === 'stream_chunk' && e.data.chunkType === 'reasoning', + ); + expect(reasoningChunks.length).toBe(2); // Two thinking blocks + + const toolChunks = allEvents.filter( + (e) => e.type === 'stream_chunk' && e.data.chunkType === 'tools_calling', + ); + expect(toolChunks.length).toBe(2); // Read + Write + + // 3. Tool lifecycle: tool_start → tool_result → tool_end for each tool + const toolStarts = allEvents.filter((e) => e.type === 'tool_start'); + const toolResults = allEvents.filter((e) => e.type === 'tool_result'); + const toolEnds = allEvents.filter((e) => e.type === 'tool_end'); + + expect(toolStarts.length).toBe(2); + expect(toolResults.length).toBe(2); + expect(toolEnds.length).toBe(2); + + // Verify tool call IDs match + expect(toolResults[0].data.toolCallId).toBe('toolu_read_1'); + expect(toolResults[1].data.toolCallId).toBe('toolu_write_1'); + + // 4. Should have step boundaries (stream_end + stream_start with newStep) + // First assistant after init does NOT trigger newStep, only subsequent message.id changes do + const newStepStarts = allEvents.filter( + (e) => e.type === 'stream_start' && e.data?.newStep === true, + ); + // 2 boundaries: msg_01 → msg_02, msg_02 → msg_03 + expect(newStepStarts.length).toBe(2); + + // 5. Should have usage metadata events + const metaEvents = allEvents.filter( + (e) => e.type === 'step_complete' && e.data?.phase === 'turn_metadata', + ); + expect(metaEvents.length).toBeGreaterThanOrEqual(3); // At least one per assistant turn + + // 6. Should end with stream_end + agent_runtime_end (from result) + const lastTwo = types.slice(-2); + expect(lastTwo).toEqual(['stream_end', 'agent_runtime_end']); + + // 7. Session ID should be captured + expect(adapter.sessionId).toBe('sess_abc123'); + }); + + it('correctly extracts tool result content', () => { + const adapter = new ClaudeCodeAdapter(); + const allEvents = simulatedStream.flatMap((line) => adapter.adapt(line)); + + const toolResults = allEvents.filter((e) => e.type === 'tool_result'); + + // First tool result: file content from Read + expect(toolResults[0].data.content).toContain('return a - b'); + expect(toolResults[0].data.isError).toBe(false); + + // Second tool result: write confirmation + expect(toolResults[1].data.content).toBe('File written successfully.'); + expect(toolResults[1].data.isError).toBe(false); + }); + + it('tracks step boundaries via stepIndex', () => { + const adapter = new ClaudeCodeAdapter(); + const allEvents = simulatedStream.flatMap((line) => adapter.adapt(line)); + + // Collect unique stepIndex values + const stepIndices = [...new Set(allEvents.map((e) => e.stepIndex))]; + // Should have at least 3 steps (init step + msg_01 step + msg_02 step + msg_03 step) + expect(stepIndices.length).toBeGreaterThanOrEqual(3); + + // stepIndex should be monotonically non-decreasing + for (let i = 1; i < allEvents.length; i++) { + expect(allEvents[i].stepIndex).toBeGreaterThanOrEqual(allEvents[i - 1].stepIndex); + } + }); + + it('handles error result correctly in multi-step session', () => { + const adapter = new ClaudeCodeAdapter(); + + // Init + one assistant turn + error result + adapter.adapt(simulatedStream[0]); // init + adapter.adapt(simulatedStream[1]); // thinking + + const events = adapter.adapt({ + is_error: true, + result: 'Permission denied: cannot write to /etc/hosts', + type: 'result', + }); + + const error = events.find((e) => e.type === 'error'); + expect(error).toBeDefined(); + expect(error!.data.message).toBe('Permission denied: cannot write to /etc/hosts'); + + // Should also have stream_end before error + expect(events[0].type).toBe('stream_end'); + }); + + it('no pending tools after full session', () => { + const adapter = new ClaudeCodeAdapter(); + simulatedStream.forEach((line) => adapter.adapt(line)); + + // flush should return empty — all tools were resolved via tool_result + const flushEvents = adapter.flush(); + expect(flushEvents).toHaveLength(0); + }); +}); diff --git a/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts b/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts new file mode 100644 index 0000000000..d52969ab3a --- /dev/null +++ b/packages/heterogeneous-agents/src/adapters/claudeCode.test.ts @@ -0,0 +1,434 @@ +import { describe, expect, it } from 'vitest'; + +import { ClaudeCodeAdapter } from './claudeCode'; + +describe('ClaudeCodeAdapter', () => { + describe('lifecycle', () => { + it('emits stream_start on init system event', () => { + const adapter = new ClaudeCodeAdapter(); + const events = adapter.adapt({ + model: 'claude-sonnet-4-6', + session_id: 'sess_123', + subtype: 'init', + type: 'system', + }); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('stream_start'); + expect(events[0].data.model).toBe('claude-sonnet-4-6'); + expect(adapter.sessionId).toBe('sess_123'); + }); + + it('emits stream_end + agent_runtime_end on success result', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + const events = adapter.adapt({ is_error: false, result: 'done', type: 'result' }); + expect(events.map((e) => e.type)).toEqual(['stream_end', 'agent_runtime_end']); + }); + + it('emits error on failed result', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + const events = adapter.adapt({ is_error: true, result: 'boom', type: 'result' }); + expect(events.map((e) => e.type)).toEqual(['stream_end', 'error']); + expect(events[1].data.message).toBe('boom'); + }); + }); + + describe('content mapping', () => { + it('maps text to stream_chunk text', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + const events = adapter.adapt({ + message: { id: 'msg_1', content: [{ text: 'hello', type: 'text' }] }, + type: 'assistant', + }); + + const chunk = events.find((e) => e.type === 'stream_chunk' && e.data.chunkType === 'text'); + expect(chunk).toBeDefined(); + expect(chunk!.data.content).toBe('hello'); + }); + + it('maps thinking to stream_chunk reasoning', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + const events = adapter.adapt({ + message: { id: 'msg_1', content: [{ thinking: 'considering', type: 'thinking' }] }, + type: 'assistant', + }); + + const chunk = events.find( + (e) => e.type === 'stream_chunk' && e.data.chunkType === 'reasoning', + ); + expect(chunk).toBeDefined(); + expect(chunk!.data.reasoning).toBe('considering'); + }); + + it('maps tool_use to tools_calling chunk + tool_start', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + const events = adapter.adapt({ + message: { + id: 'msg_1', + content: [{ id: 't1', input: { path: '/a' }, name: 'Read', type: 'tool_use' }], + }, + type: 'assistant', + }); + + const chunk = events.find( + (e) => e.type === 'stream_chunk' && e.data.chunkType === 'tools_calling', + ); + expect(chunk!.data.toolsCalling).toEqual([ + { + apiName: 'Read', + arguments: JSON.stringify({ path: '/a' }), + id: 't1', + identifier: 'claude-code', + type: 'default', + }, + ]); + + const toolStart = events.find((e) => e.type === 'tool_start'); + expect(toolStart).toBeDefined(); + }); + }); + + describe('tool_result in user events', () => { + it('emits tool_result event with content for user tool_result block', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + adapter.adapt({ + message: { + id: 'msg_1', + content: [{ id: 't1', input: {}, name: 'Read', type: 'tool_use' }], + }, + type: 'assistant', + }); + + const events = adapter.adapt({ + message: { + content: [{ content: 'file contents here', tool_use_id: 't1', type: 'tool_result' }], + role: 'user', + }, + type: 'user', + }); + + const result = events.find((e) => e.type === 'tool_result'); + expect(result).toBeDefined(); + expect(result!.data.toolCallId).toBe('t1'); + expect(result!.data.content).toBe('file contents here'); + expect(result!.data.isError).toBe(false); + + // Should also emit tool_end + const end = events.find((e) => e.type === 'tool_end'); + expect(end).toBeDefined(); + expect(end!.data.toolCallId).toBe('t1'); + }); + + it('handles array-shaped tool_result content', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + adapter.adapt({ + message: { + id: 'msg_1', + content: [{ id: 't1', input: {}, name: 'Bash', type: 'tool_use' }], + }, + type: 'assistant', + }); + + const events = adapter.adapt({ + message: { + content: [ + { + content: [ + { text: 'line1', type: 'text' }, + { text: 'line2', type: 'text' }, + ], + tool_use_id: 't1', + type: 'tool_result', + }, + ], + role: 'user', + }, + type: 'user', + }); + + const result = events.find((e) => e.type === 'tool_result'); + expect(result!.data.content).toBe('line1\nline2'); + }); + + it('marks isError when tool_result is_error is true', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + adapter.adapt({ + message: { + id: 'msg_1', + content: [{ id: 't1', input: {}, name: 'Read', type: 'tool_use' }], + }, + type: 'assistant', + }); + + const events = adapter.adapt({ + message: { + content: [{ content: 'ENOENT', is_error: true, tool_use_id: 't1', type: 'tool_result' }], + role: 'user', + }, + type: 'user', + }); + + const result = events.find((e) => e.type === 'tool_result'); + expect(result!.data.isError).toBe(true); + }); + }); + + describe('multi-step execution (message.id boundary)', () => { + it('does NOT emit step boundary for the first assistant after init', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + // First assistant message after init — should NOT trigger newStep + const events = adapter.adapt({ + message: { id: 'msg_1', content: [{ text: 'step 1', type: 'text' }] }, + type: 'assistant', + }); + + const types = events.map((e) => e.type); + expect(types).not.toContain('stream_end'); + expect(types).not.toContain('stream_start'); + // Should still emit content + const chunk = events.find((e) => e.type === 'stream_chunk'); + expect(chunk).toBeDefined(); + }); + + it('emits stream_end + stream_start(newStep) when message.id changes after first', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + // First assistant message (no step boundary) + adapter.adapt({ + message: { id: 'msg_1', content: [{ text: 'step 1', type: 'text' }] }, + type: 'assistant', + }); + + // Second assistant message with new id → new step + const events = adapter.adapt({ + message: { id: 'msg_2', content: [{ text: 'step 2', type: 'text' }] }, + type: 'assistant', + }); + + const types = events.map((e) => e.type); + expect(types).toContain('stream_end'); + expect(types).toContain('stream_start'); + + const streamStart = events.find((e) => e.type === 'stream_start'); + expect(streamStart!.data.newStep).toBe(true); + }); + + it('increments stepIndex on each new message.id (after first)', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + const e1 = adapter.adapt({ + message: { id: 'msg_1', content: [{ text: 'a', type: 'text' }] }, + type: 'assistant', + }); + // First assistant after init stays at step 0 (no step boundary) + expect(e1[0].stepIndex).toBe(0); + + const e2 = adapter.adapt({ + message: { id: 'msg_2', content: [{ text: 'b', type: 'text' }] }, + type: 'assistant', + }); + // Second message.id → stepIndex should be 1 + const newStepEvent = e2.find((e) => e.type === 'stream_start' && e.data?.newStep); + expect(newStepEvent).toBeDefined(); + expect(newStepEvent!.stepIndex).toBe(1); + }); + + it('does NOT emit new step when message.id is the same', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + adapter.adapt({ + message: { id: 'msg_1', content: [{ text: 'a', type: 'text' }] }, + type: 'assistant', + }); + + // Same id → same step, no stream_end/stream_start + const events = adapter.adapt({ + message: { id: 'msg_1', content: [{ text: 'b', type: 'text' }] }, + type: 'assistant', + }); + + const types = events.map((e) => e.type); + expect(types).not.toContain('stream_end'); + expect(types).not.toContain('stream_start'); + }); + }); + + describe('usage and model extraction', () => { + it('emits step_complete with turn_metadata when message has model and usage', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + const events = adapter.adapt({ + message: { + id: 'msg_1', + content: [{ text: 'hello', type: 'text' }], + model: 'claude-sonnet-4-6', + usage: { input_tokens: 100, output_tokens: 50 }, + }, + type: 'assistant', + }); + + const meta = events.find( + (e) => e.type === 'step_complete' && e.data?.phase === 'turn_metadata', + ); + expect(meta).toBeDefined(); + expect(meta!.data.model).toBe('claude-sonnet-4-6'); + expect(meta!.data.usage.input_tokens).toBe(100); + expect(meta!.data.usage.output_tokens).toBe(50); + }); + + it('emits step_complete with cache token usage', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + const events = adapter.adapt({ + message: { + id: 'msg_1', + content: [{ text: 'hi', type: 'text' }], + model: 'claude-sonnet-4-6', + usage: { + cache_creation_input_tokens: 200, + cache_read_input_tokens: 300, + input_tokens: 100, + output_tokens: 50, + }, + }, + type: 'assistant', + }); + + const meta = events.find( + (e) => e.type === 'step_complete' && e.data?.phase === 'turn_metadata', + ); + expect(meta!.data.usage.cache_creation_input_tokens).toBe(200); + expect(meta!.data.usage.cache_read_input_tokens).toBe(300); + }); + }); + + describe('flush', () => { + it('emits tool_end for any pending tool calls', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + // Start a tool call without providing result + adapter.adapt({ + message: { + id: 'msg_1', + content: [{ id: 't1', input: {}, name: 'Read', type: 'tool_use' }], + }, + type: 'assistant', + }); + + const events = adapter.flush(); + expect(events).toHaveLength(1); + expect(events[0].type).toBe('tool_end'); + expect(events[0].data.toolCallId).toBe('t1'); + }); + + it('returns empty array when no pending tools', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + const events = adapter.flush(); + expect(events).toHaveLength(0); + }); + + it('clears pending tools after flush', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + adapter.adapt({ + message: { + id: 'msg_1', + content: [{ id: 't1', input: {}, name: 'Read', type: 'tool_use' }], + }, + type: 'assistant', + }); + + adapter.flush(); + // Second flush should be empty + expect(adapter.flush()).toHaveLength(0); + }); + }); + + describe('edge cases', () => { + it('returns empty array for null/undefined/non-object input', () => { + const adapter = new ClaudeCodeAdapter(); + expect(adapter.adapt(null)).toEqual([]); + expect(adapter.adapt(undefined)).toEqual([]); + expect(adapter.adapt('string')).toEqual([]); + }); + + it('returns empty array for unknown event types (rate_limit_event)', () => { + const adapter = new ClaudeCodeAdapter(); + const events = adapter.adapt({ type: 'rate_limit_event', data: {} }); + expect(events).toEqual([]); + }); + + it('handles assistant event without prior init (auto-starts)', () => { + const adapter = new ClaudeCodeAdapter(); + // No system init — adapter should auto-start + const events = adapter.adapt({ + message: { id: 'msg_1', content: [{ text: 'hello', type: 'text' }] }, + type: 'assistant', + }); + + const start = events.find((e) => e.type === 'stream_start'); + expect(start).toBeDefined(); + + const chunk = events.find((e) => e.type === 'stream_chunk'); + expect(chunk).toBeDefined(); + expect(chunk!.data.content).toBe('hello'); + }); + + it('handles assistant event with empty content array', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + const events = adapter.adapt({ + message: { id: 'msg_1', content: [] }, + type: 'assistant', + }); + // Should only have step_complete metadata if model/usage present, nothing else + const chunks = events.filter((e) => e.type === 'stream_chunk'); + expect(chunks).toHaveLength(0); + }); + + it('handles multiple tool_use blocks in a single assistant event', () => { + const adapter = new ClaudeCodeAdapter(); + adapter.adapt({ subtype: 'init', type: 'system' }); + + const events = adapter.adapt({ + message: { + id: 'msg_1', + content: [ + { id: 't1', input: { path: '/a' }, name: 'Read', type: 'tool_use' }, + { id: 't2', input: { cmd: 'ls' }, name: 'Bash', type: 'tool_use' }, + ], + }, + type: 'assistant', + }); + + const chunk = events.find( + (e) => e.type === 'stream_chunk' && e.data.chunkType === 'tools_calling', + ); + expect(chunk!.data.toolsCalling).toHaveLength(2); + + const toolStarts = events.filter((e) => e.type === 'tool_start'); + expect(toolStarts).toHaveLength(2); + }); + }); +}); diff --git a/packages/heterogeneous-agents/src/adapters/claudeCode.ts b/packages/heterogeneous-agents/src/adapters/claudeCode.ts new file mode 100644 index 0000000000..8df18e8c99 --- /dev/null +++ b/packages/heterogeneous-agents/src/adapters/claudeCode.ts @@ -0,0 +1,289 @@ +/** + * Claude Code Adapter + * + * Converts Claude Code CLI `--output-format stream-json --verbose` (ndjson) + * events into unified HeterogeneousAgentEvent[] that the executor feeds into + * LobeHub's Gateway event handler. + * + * Stream-json event shapes (from real CLI output): + * + * {type: 'system', subtype: 'init', session_id, model, ...} + * {type: 'assistant', message: {id, content: [{type: 'thinking', thinking}], ...}} + * {type: 'assistant', message: {id, content: [{type: 'tool_use', id, name, input}], ...}} + * {type: 'user', message: {content: [{type: 'tool_result', tool_use_id, content}]}} + * {type: 'assistant', message: {id: , content: [{type: 'text', text}], ...}} + * {type: 'result', is_error, result, ...} + * {type: 'rate_limit_event', ...} (ignored) + * + * Key characteristics: + * - Each content block (thinking / tool_use / text) streams in its OWN assistant event + * - Multiple events can share the same `message.id` — these are ONE LLM turn + * - When `message.id` changes, a new LLM turn has begun — new DB assistant message + * - `tool_result` blocks are in `type: 'user'` events, not assistant events + */ + +import type { + AgentCLIPreset, + AgentEventAdapter, + HeterogeneousAgentEvent, + StreamChunkData, + ToolCallPayload, + ToolResultData, +} from '../types'; + +// ─── CLI Preset ─── + +export const claudeCodePreset: AgentCLIPreset = { + baseArgs: [ + '-p', + '--output-format', + 'stream-json', + '--verbose', + '--permission-mode', + 'acceptEdits', + ], + promptMode: 'positional', + resumeArgs: (sessionId) => ['--resume', sessionId], +}; + +// ─── Adapter ─── + +export class ClaudeCodeAdapter implements AgentEventAdapter { + sessionId?: string; + + /** Pending tool_use ids awaiting their tool_result */ + private pendingToolCalls = new Set(); + private started = false; + private stepIndex = 0; + /** Track current message.id to detect step boundaries */ + private currentMessageId: string | undefined; + /** Track which message.id has already emitted usage (dedup) */ + private usageEmittedForMessageId: string | undefined; + + adapt(raw: any): HeterogeneousAgentEvent[] { + if (!raw || typeof raw !== 'object') return []; + + switch (raw.type) { + case 'system': { + return this.handleSystem(raw); + } + case 'assistant': { + return this.handleAssistant(raw); + } + case 'user': { + return this.handleUser(raw); + } + case 'result': { + return this.handleResult(raw); + } + default: { + return []; + } // rate_limit_event, etc. + } + } + + flush(): HeterogeneousAgentEvent[] { + // Close any still-open tools (shouldn't happen in normal flow, but be safe) + const events = [...this.pendingToolCalls].map((id) => + this.makeEvent('tool_end', { isSuccess: true, toolCallId: id }), + ); + this.pendingToolCalls.clear(); + return events; + } + + // ─── Private handlers ─── + + private handleSystem(raw: any): HeterogeneousAgentEvent[] { + if (raw.subtype !== 'init') return []; + this.sessionId = raw.session_id; + this.started = true; + return [ + this.makeEvent('stream_start', { + model: raw.model, + provider: 'claude-code', + }), + ]; + } + + private handleAssistant(raw: any): HeterogeneousAgentEvent[] { + const content = raw.message?.content; + if (!Array.isArray(content)) return []; + + const events: HeterogeneousAgentEvent[] = []; + const messageId = raw.message?.id; + + if (!this.started) { + this.started = true; + this.currentMessageId = messageId; + events.push( + this.makeEvent('stream_start', { + model: raw.message?.model, + provider: 'claude-code', + }), + ); + } else if (messageId && messageId !== this.currentMessageId) { + if (this.currentMessageId === undefined) { + // First assistant message after init — just record the ID, no step boundary. + // The init stream_start already primed the executor with the pre-created + // assistant message, so we don't need a new one. + this.currentMessageId = messageId; + } else { + // New message.id = new LLM turn. Emit stream_end for previous step, + // then stream_start for the new one so executor creates a new assistant message. + this.currentMessageId = messageId; + this.stepIndex++; + events.push(this.makeEvent('stream_end', {})); + events.push( + this.makeEvent('stream_start', { + model: raw.message?.model, + newStep: true, + provider: 'claude-code', + }), + ); + } + } + + // Per-turn model + usage snapshot — emitted as 'step_complete'-like + // metadata event so executor can track latest model and accumulated usage. + // DEDUP: same message.id carries identical usage on every content block + // (thinking, text, tool_use). Only emit once per message.id. + if ((raw.message?.model || raw.message?.usage) && messageId !== this.usageEmittedForMessageId) { + this.usageEmittedForMessageId = messageId; + events.push( + this.makeEvent('step_complete', { + model: raw.message?.model, + phase: 'turn_metadata', + usage: raw.message?.usage, + }), + ); + } + + // Each content array here is usually ONE block (thinking OR tool_use OR text) + // but we handle multiple defensively. + const textParts: string[] = []; + const reasoningParts: string[] = []; + const newToolCalls: ToolCallPayload[] = []; + + for (const block of content) { + switch (block.type) { + case 'text': { + if (block.text) textParts.push(block.text); + break; + } + case 'thinking': { + if (block.thinking) reasoningParts.push(block.thinking); + break; + } + case 'tool_use': { + const toolPayload: ToolCallPayload = { + apiName: block.name, + arguments: JSON.stringify(block.input || {}), + id: block.id, + identifier: 'claude-code', + type: 'default', + }; + newToolCalls.push(toolPayload); + this.pendingToolCalls.add(block.id); + break; + } + } + } + + if (textParts.length > 0) { + events.push(this.makeChunkEvent({ chunkType: 'text', content: textParts.join('') })); + } + if (reasoningParts.length > 0) { + events.push( + this.makeChunkEvent({ chunkType: 'reasoning', reasoning: reasoningParts.join('') }), + ); + } + if (newToolCalls.length > 0) { + events.push(this.makeChunkEvent({ chunkType: 'tools_calling', toolsCalling: newToolCalls })); + // Also emit tool_start for each — the handler's tool_start is a no-op + // but it's semantically correct for the lifecycle. + for (const t of newToolCalls) { + events.push(this.makeEvent('tool_start', { toolCalling: t })); + } + } + + return events; + } + + /** + * Handle user events — these contain tool_result blocks. + * NOTE: In Claude Code, tool results are emitted as `type: 'user'` events + * (representing the synthetic user turn that feeds results back to the LLM). + */ + private handleUser(raw: any): HeterogeneousAgentEvent[] { + const content = raw.message?.content; + if (!Array.isArray(content)) return []; + + const events: HeterogeneousAgentEvent[] = []; + + for (const block of content) { + if (block.type !== 'tool_result') continue; + const toolCallId: string | undefined = block.tool_use_id; + if (!toolCallId) continue; + + const resultContent = + typeof block.content === 'string' + ? block.content + : Array.isArray(block.content) + ? block.content + .map((c: any) => c.text || c.content || '') + .filter(Boolean) + .join('\n') + : JSON.stringify(block.content || ''); + + // Emit tool_result for executor to persist content to tool message + events.push( + this.makeEvent('tool_result', { + content: resultContent, + isError: !!block.is_error, + toolCallId, + } satisfies ToolResultData), + ); + + // Then emit tool_end (signals handler to refresh tool result UI) + if (this.pendingToolCalls.has(toolCallId)) { + this.pendingToolCalls.delete(toolCallId); + events.push(this.makeEvent('tool_end', { isSuccess: !block.is_error, toolCallId })); + } + } + + return events; + } + + private handleResult(raw: any): HeterogeneousAgentEvent[] { + // Emit authoritative usage from result event (overrides per-turn accumulation) + const events: HeterogeneousAgentEvent[] = []; + if (raw.usage) { + events.push( + this.makeEvent('step_complete', { + costUsd: raw.total_cost_usd, + phase: 'result_usage', + usage: raw.usage, + }), + ); + } + + const finalEvent: HeterogeneousAgentEvent = raw.is_error + ? this.makeEvent('error', { + error: raw.result || 'Agent execution failed', + message: raw.result || 'Agent execution failed', + }) + : this.makeEvent('agent_runtime_end', {}); + + return [...events, this.makeEvent('stream_end', {}), finalEvent]; + } + + // ─── Event factories ─── + + private makeEvent(type: HeterogeneousAgentEvent['type'], data: any): HeterogeneousAgentEvent { + return { data, stepIndex: this.stepIndex, timestamp: Date.now(), type }; + } + + private makeChunkEvent(data: StreamChunkData): HeterogeneousAgentEvent { + return { data, stepIndex: this.stepIndex, timestamp: Date.now(), type: 'stream_chunk' }; + } +} diff --git a/packages/heterogeneous-agents/src/adapters/index.ts b/packages/heterogeneous-agents/src/adapters/index.ts new file mode 100644 index 0000000000..94b6a4e767 --- /dev/null +++ b/packages/heterogeneous-agents/src/adapters/index.ts @@ -0,0 +1 @@ +export { ClaudeCodeAdapter, claudeCodePreset } from './claudeCode'; diff --git a/packages/heterogeneous-agents/src/index.ts b/packages/heterogeneous-agents/src/index.ts new file mode 100644 index 0000000000..3aee0a4ab6 --- /dev/null +++ b/packages/heterogeneous-agents/src/index.ts @@ -0,0 +1,15 @@ +export { ClaudeCodeAdapter, claudeCodePreset } from './adapters'; +export { createAdapter, getPreset, listAgentTypes } from './registry'; +export type { + AgentCLIPreset, + AgentEventAdapter, + AgentProcessConfig, + HeterogeneousAgentEvent, + HeterogeneousEventType, + StreamChunkData, + StreamChunkType, + StreamStartData, + ToolCallPayload, + ToolEndData, + ToolResultData, +} from './types'; diff --git a/packages/heterogeneous-agents/src/registry.test.ts b/packages/heterogeneous-agents/src/registry.test.ts new file mode 100644 index 0000000000..529c0de265 --- /dev/null +++ b/packages/heterogeneous-agents/src/registry.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { ClaudeCodeAdapter } from './adapters'; +import { createAdapter, getPreset, listAgentTypes } from './registry'; + +describe('registry', () => { + describe('createAdapter', () => { + it('creates a ClaudeCodeAdapter for "claude-code"', () => { + const adapter = createAdapter('claude-code'); + expect(adapter).toBeInstanceOf(ClaudeCodeAdapter); + }); + + it('throws for unknown agent type', () => { + expect(() => createAdapter('unknown-agent')).toThrow('Unknown agent type: "unknown-agent"'); + }); + }); + + describe('getPreset', () => { + it('returns preset with stream-json args for claude-code', () => { + const preset = getPreset('claude-code'); + expect(preset.baseArgs).toContain('--output-format'); + expect(preset.baseArgs).toContain('stream-json'); + expect(preset.baseArgs).toContain('-p'); + expect(preset.promptMode).toBe('positional'); + }); + + it('preset has resumeArgs function', () => { + const preset = getPreset('claude-code'); + expect(preset.resumeArgs).toBeDefined(); + const args = preset.resumeArgs!('sess_abc'); + expect(args).toContain('--resume'); + expect(args).toContain('sess_abc'); + }); + + it('throws for unknown agent type', () => { + expect(() => getPreset('nope')).toThrow('Unknown agent type: "nope"'); + }); + }); + + describe('listAgentTypes', () => { + it('includes claude-code', () => { + const types = listAgentTypes(); + expect(types).toContain('claude-code'); + }); + }); +}); diff --git a/packages/heterogeneous-agents/src/registry.ts b/packages/heterogeneous-agents/src/registry.ts new file mode 100644 index 0000000000..bbed35ffe3 --- /dev/null +++ b/packages/heterogeneous-agents/src/registry.ts @@ -0,0 +1,55 @@ +/** + * Agent Adapter Registry + * + * Maps agent type keys to their adapter constructors and CLI presets. + * New agents are added by registering here — no other code changes needed. + */ + +import { ClaudeCodeAdapter, claudeCodePreset } from './adapters'; +import type { AgentCLIPreset, AgentEventAdapter } from './types'; + +interface AgentRegistryEntry { + createAdapter: () => AgentEventAdapter; + preset: AgentCLIPreset; +} + +const registry: Record = { + 'claude-code': { + createAdapter: () => new ClaudeCodeAdapter(), + preset: claudeCodePreset, + }, + // Future: + // 'codex': { createAdapter: () => new CodexAdapter(), preset: codexPreset }, + // 'kimi-cli': { createAdapter: () => new KimiCLIAdapter(), preset: kimiPreset }, +}; + +/** + * Create an adapter instance for the given agent type. + */ +export const createAdapter = (agentType: string): AgentEventAdapter => { + const entry = registry[agentType]; + if (!entry) { + throw new Error( + `Unknown agent type: "${agentType}". Available: ${Object.keys(registry).join(', ')}`, + ); + } + return entry.createAdapter(); +}; + +/** + * Get the CLI preset for the given agent type. + */ +export const getPreset = (agentType: string): AgentCLIPreset => { + const entry = registry[agentType]; + if (!entry) { + throw new Error( + `Unknown agent type: "${agentType}". Available: ${Object.keys(registry).join(', ')}`, + ); + } + return entry.preset; +}; + +/** + * List all registered agent types. + */ +export const listAgentTypes = (): string[] => Object.keys(registry); diff --git a/packages/heterogeneous-agents/src/types.ts b/packages/heterogeneous-agents/src/types.ts new file mode 100644 index 0000000000..aecf94df3a --- /dev/null +++ b/packages/heterogeneous-agents/src/types.ts @@ -0,0 +1,133 @@ +/** + * Heterogeneous Agent Adapter Types + * + * Adapters convert external agent protocol events into a unified + * HeterogeneousAgentEvent format, which maps 1:1 to LobeHub's + * AgentStreamEvent and can be fed directly into createGatewayEventHandler(). + * + * Architecture: + * Claude Code stream-json ──→ ClaudeCodeAdapter ──→ HeterogeneousAgentEvent[] + * Codex CLI output ──→ CodexAdapter ──→ HeterogeneousAgentEvent[] (future) + * ACP JSON-RPC ──→ ACPAdapter ──→ HeterogeneousAgentEvent[] (future) + */ + +// ─── Unified Event Format ─── +// Mirrors AgentStreamEvent from src/libs/agent-stream/types.ts +// but defined here so the package is self-contained. + +export type HeterogeneousEventType = + | 'stream_start' + | 'stream_chunk' + | 'stream_end' + | 'tool_start' + | 'tool_end' + /** + * Tool result content arrived. ACP-specific (Gateway tools run on server, + * so server handles result persistence). Executor should update the tool + * message in DB with this content. + */ + | 'tool_result' + | 'step_complete' + | 'agent_runtime_end' + | 'error'; + +export type StreamChunkType = 'text' | 'reasoning' | 'tools_calling'; + +export interface HeterogeneousAgentEvent { + data: any; + stepIndex: number; + timestamp: number; + type: HeterogeneousEventType; +} + +/** Data shape for stream_start events */ +export interface StreamStartData { + assistantMessage?: { id: string }; + model?: string; + provider?: string; +} + +/** Data shape for stream_chunk events */ +export interface StreamChunkData { + chunkType: StreamChunkType; + content?: string; + reasoning?: string; + toolsCalling?: ToolCallPayload[]; +} + +/** Data shape for tool_end events */ +export interface ToolEndData { + isSuccess: boolean; + toolCallId: string; +} + +/** Data shape for tool_result events (ACP-specific) */ +export interface ToolResultData { + content: string; + isError?: boolean; + toolCallId: string; +} + +/** Tool call payload (matches ChatToolPayload shape) */ +export interface ToolCallPayload { + apiName: string; + arguments: string; + id: string; + identifier: string; + type: string; +} + +// ─── Adapter Interface ─── + +/** + * Stateful adapter that converts raw agent events to HeterogeneousAgentEvent[]. + * + * Adapters maintain internal state (e.g., pending tool calls) to correctly + * emit lifecycle events like tool_start / tool_end. + */ +export interface AgentEventAdapter { + /** + * Convert a single raw event into zero or more HeterogeneousAgentEvents. + */ + adapt: (raw: any) => HeterogeneousAgentEvent[]; + + /** + * Flush any buffered events (call at end of stream). + */ + flush: () => HeterogeneousAgentEvent[]; + + /** The session ID extracted from the agent's init event (for multi-turn resume). */ + sessionId?: string; +} + +// ─── Agent Process Config ─── + +/** + * Configuration for spawning an external agent CLI process. + * Agent-agnostic — works for claude, codex, kimi-cli, etc. + */ +export interface AgentProcessConfig { + /** Adapter type key (e.g., 'claude-code', 'codex', 'kimi-cli') */ + adapterType: string; + /** CLI arguments appended after built-in flags */ + args?: string[]; + /** Command to execute (e.g., 'claude', 'codex') */ + command: string; + /** Working directory */ + cwd?: string; + /** Environment variables */ + env?: Record; +} + +/** + * Registry of built-in CLI flag presets per agent type. + * The Electron controller uses this to construct the full spawn args. + */ +export interface AgentCLIPreset { + /** Base CLI arguments (e.g., ['-p', '--output-format', 'stream-json', '--verbose']) */ + baseArgs: string[]; + /** How to pass the prompt (e.g., 'positional' = last arg, 'stdin' = pipe to stdin) */ + promptMode: 'positional' | 'stdin'; + /** How to resume a session (e.g., ['--resume', '{sessionId}']) */ + resumeArgs?: (sessionId: string) => string[]; +} diff --git a/packages/heterogeneous-agents/vitest.config.ts b/packages/heterogeneous-agents/vitest.config.ts new file mode 100644 index 0000000000..7eeb3f85df --- /dev/null +++ b/packages/heterogeneous-agents/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/types/src/agent/agencyConfig.ts b/packages/types/src/agent/agencyConfig.ts index 8fb07386fc..916ca8ccd7 100644 --- a/packages/types/src/agent/agencyConfig.ts +++ b/packages/types/src/agent/agencyConfig.ts @@ -1,28 +1,24 @@ /** - * Discord Bot configuration for an agent + * Heterogeneous agent provider configuration. + * When set, the assistant delegates execution to an external agent CLI + * instead of using the built-in model runtime. */ -export interface DiscordBotConfig { - applicationId: string; - botToken: string; - enabled: boolean; - publicKey: string; +export interface HeterogeneousProviderConfig { + /** Additional CLI arguments for the agent command */ + args?: string[]; + /** Command to spawn the agent (e.g. 'claude') */ + command?: string; + /** Custom environment variables */ + env?: Record; + /** Agent runtime type */ + type: 'claudecode'; } /** - * Slack Bot configuration for an agent - */ -export interface SlackBotConfig { - botToken: string; - enabled: boolean; - signingSecret: string; -} - -/** - * Agent agency configuration for external platform bot integrations. - * Each agent can independently configure its own bot providers. + * Agent agency configuration. + * Contains settings for agent execution modes and device binding. */ export interface LobeAgentAgencyConfig { boundDeviceId?: string; - discord?: DiscordBotConfig; - slack?: SlackBotConfig; + heterogeneousProvider?: HeterogeneousProviderConfig; } diff --git a/packages/types/src/agent/item.ts b/packages/types/src/agent/item.ts index 20a209ba1a..b27bc02f30 100644 --- a/packages/types/src/agent/item.ts +++ b/packages/types/src/agent/item.ts @@ -10,9 +10,10 @@ import type { LobeAgentTTSConfig } from './tts'; export interface LobeAgentConfig { /** - * Agency configuration for external platform bot integrations (Discord, Slack, etc.) + * Agency configuration: device binding, heterogeneous agent provider, etc. */ agencyConfig?: LobeAgentAgencyConfig; + avatar?: string; backgroundColor?: string; diff --git a/packages/types/src/home.ts b/packages/types/src/home.ts index d8533df513..3cbcd8142a 100644 --- a/packages/types/src/home.ts +++ b/packages/types/src/home.ts @@ -32,6 +32,13 @@ export interface SidebarAgentItem { * Only present for chat groups (type === 'group') */ groupAvatar?: string | null; + /** + * Heterogeneous agent runtime type (e.g. `claudecode`) when the agent is + * driven by an external CLI. `null` / absent means it's a regular LobeHub + * agent. Present so sidebar / list items can render an "External" tag + * without per-item agent config lookups. + */ + heterogeneousType?: string | null; id: string; pinned: boolean; sessionId?: string | null; diff --git a/packages/types/src/message/common/metadata.ts b/packages/types/src/message/common/metadata.ts index d46bc13f49..75d483bc4f 100644 --- a/packages/types/src/message/common/metadata.ts +++ b/packages/types/src/message/common/metadata.ts @@ -102,10 +102,15 @@ export const MessageMetadataSchema = ModelUsageSchema.merge(ModelPerformanceSche isMultimodal: z.boolean().optional(), isSupervisor: z.boolean().optional(), pageSelections: z.array(PageSelectionSchema).optional(), + // Canonical nested shape — flat fields above are deprecated. Must be listed + // here so zod doesn't strip them from writes going through UpdateMessageParamsSchema + // (e.g. messageService.updateMessage, used by the heterogeneous-agent executor). + performance: ModelPerformanceSchema.optional(), reactions: z.array(EmojiReactionSchema).optional(), scope: z.string().optional(), subAgentId: z.string().optional(), toolExecutionTimeMs: z.number().optional(), + usage: ModelUsageSchema.optional(), }); export interface ModelUsage extends ModelTokensUsage { @@ -134,7 +139,15 @@ export interface ModelPerformance { ttft?: number; } -export interface MessageMetadata extends ModelUsage, ModelPerformance { +export interface MessageMetadata { + // ─────────────────────────────────────────────────────────────── + // Token usage + performance fields — DEPRECATED flat shape. + // New code must write to `metadata.usage` / `metadata.performance` (nested) + // instead. Kept here so legacy reads still type-check during migration; + // writers should stop populating them. + // ─────────────────────────────────────────────────────────────── + /** @deprecated use `metadata.usage` instead */ + acceptedPredictionTokens?: number; activeBranchIndex?: number; activeColumn?: boolean; /** @@ -143,7 +156,27 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance { */ collapsed?: boolean; compare?: boolean; + /** @deprecated use `metadata.usage` instead */ + cost?: number; + /** @deprecated use `metadata.performance` instead */ + duration?: number; finishType?: string; + /** @deprecated use `metadata.usage` instead */ + inputAudioTokens?: number; + /** @deprecated use `metadata.usage` instead */ + inputCachedTokens?: number; + /** @deprecated use `metadata.usage` instead */ + inputCacheMissTokens?: number; + /** @deprecated use `metadata.usage` instead */ + inputCitationTokens?: number; + /** @deprecated use `metadata.usage` instead */ + inputImageTokens?: number; + /** @deprecated use `metadata.usage` instead */ + inputTextTokens?: number; + /** @deprecated use `metadata.usage` instead */ + inputToolTokens?: number; + /** @deprecated use `metadata.usage` instead */ + inputWriteCacheTokens?: number; /** * Tool inspect expanded state * true: expanded, false/undefined: collapsed @@ -159,11 +192,22 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance { * Flag indicating if message content is multimodal (serialized MessageContentPart[]) */ isMultimodal?: boolean; + /** * Flag indicating if message is from the Supervisor agent in group orchestration * Used by conversation-flow to transform role to 'supervisor' for UI rendering */ isSupervisor?: boolean; + /** @deprecated use `metadata.performance` instead */ + latency?: number; + /** @deprecated use `metadata.usage` instead */ + outputAudioTokens?: number; + /** @deprecated use `metadata.usage` instead */ + outputImageTokens?: number; + /** @deprecated use `metadata.usage` instead */ + outputReasoningTokens?: number; + /** @deprecated use `metadata.usage` instead */ + outputTextTokens?: number; /** * Page selections attached to user message * Used for Ask AI functionality to persist selection context @@ -178,6 +222,8 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance { * Emoji reactions on this message */ reactions?: EmojiReaction[]; + /** @deprecated use `metadata.usage` instead */ + rejectedPredictionTokens?: number; /** * Message scope - indicates the context in which this message was created * Used by conversation-flow to determine how to handle message grouping and display @@ -198,5 +244,15 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance { * Tool execution time for tool messages (ms) */ toolExecutionTimeMs?: number; + /** @deprecated use `metadata.usage` instead */ + totalInputTokens?: number; + /** @deprecated use `metadata.usage` instead */ + totalOutputTokens?: number; + /** @deprecated use `metadata.usage` instead */ + totalTokens?: number; + /** @deprecated use `metadata.performance` instead */ + tps?: number; + /** @deprecated use `metadata.performance` instead */ + ttft?: number; usage?: ModelUsage; } diff --git a/packages/types/src/user/preference.ts b/packages/types/src/user/preference.ts index dcbcfce254..3825c8dccb 100644 --- a/packages/types/src/user/preference.ts +++ b/packages/types/src/user/preference.ts @@ -50,6 +50,10 @@ export const UserLabSchema = z.object({ * enable multi-agent group chat mode */ enableGroupChat: z.boolean().optional(), + /** + * enable heterogeneous agent execution (Claude Code, Codex CLI, etc.) + */ + enableHeterogeneousAgent: z.boolean().optional(), /** * enable markdown rendering in chat input editor */ diff --git a/src/features/AgentHome/AgentInfo.tsx b/src/features/AgentHome/AgentInfo.tsx index 15415ed5f0..8feb27fd22 100644 --- a/src/features/AgentHome/AgentInfo.tsx +++ b/src/features/AgentHome/AgentInfo.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Avatar, Flexbox, Markdown, Text } from '@lobehub/ui'; +import { Avatar, Flexbox, Markdown, Skeleton, Text } from '@lobehub/ui'; import isEqual from 'fast-deep-equal'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -13,6 +13,7 @@ import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selec const AgentInfo = memo(() => { const { t } = useTranslation(['chat', 'welcome']); + const isLoading = useAgentStore(agentSelectors.isAgentConfigLoading); const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent); const meta = useAgentStore(agentSelectors.currentAgentMeta, isEqual); const openingMessage = useAgentStore(agentSelectors.openingMessage); @@ -29,6 +30,18 @@ const AgentInfo = memo(() => { }); }, [openingMessage, displayTitle, t]); + if (isLoading) { + return ( + + + + + + + + ); + } + return ( ` (ignores `showRuntimeConfig`). + */ + runtimeConfigSlot?: ReactNode; sendAreaPrefix?: ReactNode; showFootnote?: boolean; showRuntimeConfig?: boolean; @@ -72,6 +77,7 @@ const DesktopChatInput = memo( ({ showFootnote, showRuntimeConfig = true, + runtimeConfigSlot, inputContainerProps, extentHeaderContent, actionBarStyle, @@ -164,7 +170,7 @@ const DesktopChatInput = memo( > - {showRuntimeConfig && } + {runtimeConfigSlot ?? (showRuntimeConfig && )} {showFootnote && !expand && (
diff --git a/src/features/Conversation/ChatInput/index.tsx b/src/features/Conversation/ChatInput/index.tsx index 508278952a..10c3137e21 100644 --- a/src/features/Conversation/ChatInput/index.tsx +++ b/src/features/Conversation/ChatInput/index.tsx @@ -85,6 +85,11 @@ export interface ChatInputProps { * Right action buttons configuration */ rightActions?: ActionKeys[]; + /** + * Custom node to render in place of the default RuntimeConfig bar + * (Local/Cloud/Approval). When provided, replaces the default bar. + */ + runtimeConfigSlot?: ReactNode; /** * Custom content to render before the SendArea (right side of action bar) */ @@ -123,6 +128,7 @@ const ChatInput = memo( children, extraActionItems, mentionItems, + runtimeConfigSlot, sendMenu, sendAreaPrefix, sendButtonProps: customSendButtonProps, @@ -263,6 +269,7 @@ const ChatInput = memo( borderRadius={12} extraActionItems={extraActionItems} leftContent={leftContent} + runtimeConfigSlot={runtimeConfigSlot} sendAreaPrefix={sendAreaPrefix} showRuntimeConfig={showRuntimeConfig} /> diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index 1fc33ff143..d72d1856d4 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -27,6 +27,12 @@ export default { 'agentDefaultMessageWithoutEdit': "Hi, I’m **{{name}}**. One sentence is enough—you're in control.", 'agents': 'Agents', + /** + * Sidebar tag for agents driven by an external CLI runtime (Claude Code, etc.). + * Deliberately separate from `group.profile.external` so it can evolve + * independently (e.g. swap to "Claude Code" per provider later). + */ + 'agentSidebar.externalTag': 'External', 'artifact.generating': 'Generating', 'artifact.inThread': 'Cannot view in subtopic, please switch to the main conversation area to open', @@ -242,6 +248,7 @@ export default { 'createModal.placeholder': 'Describe what your agent should do...', 'createModal.title': 'What should your agent do?', 'newAgent': 'Create Agent', + 'newClaudeCodeAgent': 'Claude Code Agent', 'newGroupChat': 'Create Group', 'newPage': 'Create Page', 'noAgentsYet': 'This group has no members yet. Click the + button to invite agents.', @@ -253,6 +260,7 @@ export default { 'operation.contextCompression': 'Context too long, compressing history...', 'operation.execAgentRuntime': 'Preparing response', 'operation.execClientTask': 'Executing task', + 'operation.execHeterogeneousAgent': 'External agent running', 'operation.execServerAgentRuntime': 'Task is running in the server. You are safe to leave this page', 'operation.sendMessage': 'Sending message', diff --git a/src/locales/default/labs.ts b/src/locales/default/labs.ts index 7291be0cc1..f4e9a5c526 100644 --- a/src/locales/default/labs.ts +++ b/src/locales/default/labs.ts @@ -10,6 +10,9 @@ export default { 'features.gatewayMode.title': 'Server-Side Agent Execution (Gateway)', 'features.groupChat.desc': 'Enable multi-agent group chat coordination.', 'features.groupChat.title': 'Group Chat (Multi-Agent)', + 'features.heterogeneousAgent.desc': + 'Enable heterogeneous agent execution with Claude Code, Codex CLI, and other external agent CLIs. Creates a "Claude Code Agent" option in the sidebar agent menu.', + 'features.heterogeneousAgent.title': 'Heterogeneous Agent (Claude Code)', 'features.inputMarkdown.desc': 'Render Markdown in the input area in real time (bold text, code blocks, tables, etc.).', 'features.inputMarkdown.title': 'Input Markdown Rendering', diff --git a/src/locales/default/setting.ts b/src/locales/default/setting.ts index 0e8c87465d..c1f46add10 100644 --- a/src/locales/default/setting.ts +++ b/src/locales/default/setting.ts @@ -742,6 +742,9 @@ export default { 'settingSystemTools.category.browserAutomation': 'Browser Automation', 'settingSystemTools.category.browserAutomation.desc': 'Tools for headless browser automation and web interaction', + 'settingSystemTools.category.cliAgents': 'CLI Agents', + 'settingSystemTools.category.cliAgents.desc': + 'Agentic coding CLIs detected on your system, such as Claude Code, Codex, and Kimi', 'settingSystemTools.category.contentSearch': 'Content Search', 'settingSystemTools.category.contentSearch.desc': 'Tools for searching text content within files', 'settingSystemTools.category.fileSearch': 'File Search', @@ -758,9 +761,14 @@ export default { 'settingSystemTools.tools.agentBrowser.desc': 'Agent-browser - headless browser automation CLI for AI agents', 'settingSystemTools.tools.ag.desc': 'The Silver Searcher - fast code searching tool', + 'settingSystemTools.tools.aider.desc': 'Aider - AI pair programming in your terminal', + 'settingSystemTools.tools.claude.desc': 'Claude Code - Anthropic official agentic coding CLI', + 'settingSystemTools.tools.codex.desc': 'Codex - OpenAI agentic coding CLI', 'settingSystemTools.tools.fd.desc': 'fd - fast and user-friendly alternative to find', 'settingSystemTools.tools.find.desc': 'Unix find - standard file search command', + 'settingSystemTools.tools.gemini.desc': 'Gemini CLI - Google agentic coding CLI', 'settingSystemTools.tools.grep.desc': 'GNU grep - standard text search tool', + 'settingSystemTools.tools.kimi.desc': 'Kimi CLI - Moonshot AI agentic coding CLI', 'settingSystemTools.tools.mdfind.desc': 'macOS Spotlight search (fast indexed search)', 'settingSystemTools.tools.lobehub.desc': 'LobeHub CLI - manage and connect to LobeHub services', 'settingSystemTools.tools.bun.desc': 'Bun - fast JavaScript runtime and package manager', @@ -769,6 +777,7 @@ export default { 'settingSystemTools.tools.npm.desc': 'npm - Node.js package manager for installing dependencies', 'settingSystemTools.tools.pnpm.desc': 'pnpm - fast, disk space efficient package manager', 'settingSystemTools.tools.python.desc': 'Python - programming language runtime', + 'settingSystemTools.tools.qwen.desc': 'Qwen Code - Alibaba Qwen agentic coding CLI', 'settingSystemTools.tools.rg.desc': 'ripgrep - extremely fast text search tool', 'settingSystemTools.tools.uv.desc': 'uv - extremely fast Python package manager', 'settingTTS.openai.sttModel': 'OpenAI Speech-to-Text Model', diff --git a/src/routes/(main)/agent/_layout/AgentIdSync.tsx b/src/routes/(main)/agent/_layout/AgentIdSync.tsx index a2fc536580..a456db5c6b 100644 --- a/src/routes/(main)/agent/_layout/AgentIdSync.tsx +++ b/src/routes/(main)/agent/_layout/AgentIdSync.tsx @@ -1,10 +1,10 @@ import { useMount, usePrevious, useUnmount } from 'ahooks'; import { useEffect, useRef } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; -import { createStoreUpdater } from 'zustand-utils'; import { useAgentStore } from '@/store/agent'; import { useChatStore } from '@/store/chat'; +import { createStoreUpdater } from '@/store/utils/createStoreUpdater'; const AgentIdSync = () => { const useStoreUpdater = createStoreUpdater(useAgentStore); @@ -16,7 +16,7 @@ const AgentIdSync = () => { const prevAgentId = usePrevious(params.aid); useStoreUpdater('activeAgentId', params.aid); - useChatStoreUpdater('activeAgentId', params.aid ?? ''); + useChatStoreUpdater('activeAgentId', params.aid); // Reset activeTopicId when switching to a different agent // This prevents messages from being saved to the wrong topic bucket diff --git a/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx b/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx index 961daa4372..34033f1260 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Header/Nav.tsx @@ -12,6 +12,8 @@ import NavItem from '@/features/NavPanel/components/NavItem'; import { useQueryRoute } from '@/hooks/useQueryRoute'; import { usePathname } from '@/libs/router/navigation'; import { useActionSWR } from '@/libs/swr'; +import { useAgentStore } from '@/store/agent'; +import { agentSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; import { useGlobalStore } from '@/store/global'; import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig'; @@ -27,7 +29,9 @@ const Nav = memo(() => { const router = useQueryRoute(); const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors); const toggleCommandMenu = useGlobalStore((s) => s.toggleCommandMenu); + const isHeterogeneousAgent = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous); const hideProfile = !isAgentEditable; + const hideChannel = hideProfile || isHeterogeneousAgent; const switchTopic = useChatStore((s) => s.switchTopic); const [openNewTopicOrSaveTopic] = useChatStore((s) => [s.openNewTopicOrSaveTopic]); @@ -58,7 +62,7 @@ const Nav = memo(() => { }} /> )} - {!hideProfile && ( + {!hideChannel && ( { // Get actionsBar config with branching support from ChatStore const actionsBarConfig = useActionsBarConfig(); + // Heterogeneous agents (Claude Code, etc.) use a simplified input — their + // toolchain/memory/model are managed by the external runtime, so LobeHub's + // model/tools/memory/KB/MCP/runtime-mode pickers don't apply. + const isHeterogeneousAgent = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous); + // Auto-reconnect to running Gateway operation on topic load useGatewayReconnect(context.topicId); @@ -74,7 +82,7 @@ const Conversation = memo(() => { } /> - + {isHeterogeneousAgent ? : } diff --git a/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx new file mode 100644 index 0000000000..47da452e31 --- /dev/null +++ b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { Github } from '@lobehub/icons'; +import { Flexbox, Icon, Popover, Skeleton, Tooltip } from '@lobehub/ui'; +import { createStaticStyles, cssVar } from 'antd-style'; +import { ChevronDownIcon, FolderIcon, GitBranchIcon, SquircleDashed } from 'lucide-react'; +import { memo, type ReactNode, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useAgentId } from '@/features/ChatInput/hooks/useAgentId'; +import { getRecentDirs } from '@/features/ChatInput/RuntimeConfig/recentDirs'; +import WorkingDirectoryContent from '@/features/ChatInput/RuntimeConfig/WorkingDirectory'; +import { useAgentStore } from '@/store/agent'; +import { agentByIdSelectors } from '@/store/agent/selectors'; +import { useChatStore } from '@/store/chat'; +import { topicSelectors } from '@/store/chat/selectors'; + +const styles = createStaticStyles(({ css }) => ({ + bar: css` + padding-block: 0; + padding-inline: 4px; + `, + button: css` + cursor: pointer; + + display: flex; + gap: 6px; + align-items: center; + + height: 28px; + padding-inline: 8px; + border-radius: 6px; + + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + + transition: all 0.2s; + + &:hover { + color: ${cssVar.colorText}; + background: ${cssVar.colorFillSecondary}; + } + `, +})); + +const WorkingDirectoryBar = memo(() => { + const { t } = useTranslation('plugin'); + const agentId = useAgentId(); + const [open, setOpen] = useState(false); + + const isLoading = useAgentStore(agentByIdSelectors.isAgentConfigLoadingById(agentId)); + const agentWorkingDirectory = useAgentStore((s) => + agentId ? agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(s) : undefined, + ); + const topicWorkingDirectory = useChatStore(topicSelectors.currentTopicWorkingDirectory); + const effectiveWorkingDirectory = topicWorkingDirectory || agentWorkingDirectory; + + const dirIconNode = useMemo((): ReactNode => { + if (!effectiveWorkingDirectory) return ; + const dirs = getRecentDirs(); + const match = dirs.find((d) => d.path === effectiveWorkingDirectory); + if (match?.repoType === 'github') return ; + if (match?.repoType === 'git') return ; + return ; + }, [effectiveWorkingDirectory]); + + if (!agentId || isLoading) { + return ( + + + + ); + } + + const displayName = effectiveWorkingDirectory + ? effectiveWorkingDirectory.split('/').findLast(Boolean) || effectiveWorkingDirectory + : t('localSystem.workingDirectory.notSet'); + + const dirButton = ( +
+ {dirIconNode} + {displayName} + +
+ ); + + return ( + + setOpen(false)} />} + open={open} + placement="bottomLeft" + styles={{ content: { padding: 4 } }} + trigger="click" + onOpenChange={setOpen} + > +
+ {open ? ( + dirButton + ) : ( + + {dirButton} + + )} +
+
+
+ ); +}); + +WorkingDirectoryBar.displayName = 'HeterogeneousWorkingDirectoryBar'; + +export default WorkingDirectoryBar; diff --git a/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/index.tsx b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/index.tsx new file mode 100644 index 0000000000..31244e1045 --- /dev/null +++ b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/index.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { memo } from 'react'; + +import { type ActionKeys } from '@/features/ChatInput'; +import { ChatInput } from '@/features/Conversation'; +import { useChatStore } from '@/store/chat'; + +import WorkingDirectoryBar from './WorkingDirectoryBar'; + +// Heterogeneous agents (e.g. Claude Code) bring their own toolchain, memory, +// and model, so LobeHub-side pickers don't apply. Only file upload is kept +// (images feed into the agent via stream-json stdin). +const leftActions: ActionKeys[] = ['fileUpload']; +const rightActions: ActionKeys[] = []; + +/** + * HeterogeneousChatInput + * + * Simplified ChatInput for heterogeneous agents (Claude Code, etc.). + * Keeps only: text input, image/file upload, send button, and a + * working-directory picker — no model/tools/memory/KB/MCP/runtime-mode. + */ +const HeterogeneousChatInput = memo(() => { + return ( + } + sendButtonProps={{ shape: 'round' }} + onEditorReady={(instance) => { + // Sync to global ChatStore for compatibility with other features + useChatStore.setState({ mainInputEditor: instance }); + }} + /> + ); +}); + +HeterogeneousChatInput.displayName = 'HeterogeneousChatInput'; + +export default HeterogeneousChatInput; diff --git a/src/routes/(main)/agent/features/Conversation/useActionsBarConfig.ts b/src/routes/(main)/agent/features/Conversation/useActionsBarConfig.ts index f8f0f49cf9..7ce7f82369 100644 --- a/src/routes/(main)/agent/features/Conversation/useActionsBarConfig.ts +++ b/src/routes/(main)/agent/features/Conversation/useActionsBarConfig.ts @@ -10,6 +10,8 @@ import { type MessageActionFactory, type MessageActionItem, } from '@/features/Conversation/types'; +import { useAgentStore } from '@/store/agent'; +import { agentSelectors } from '@/store/agent/selectors'; import { useChatStore } from '@/store/chat'; import { useUserStore } from '@/store/user'; import { userGeneralSettingsSelectors } from '@/store/user/selectors'; @@ -51,16 +53,25 @@ export const useBranchingActionFactory = (): MessageActionFactory => { export const useActionsBarConfig = (): ActionsBarConfig => { const branchingFactory = useBranchingActionFactory(); const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode); + const hasACPProvider = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous); return useMemo( () => ({ assistant: { - extraBarActions: isDevMode ? [branchingFactory] : [], + extraBarActions: isDevMode && !hasACPProvider ? [branchingFactory] : [], }, + // For ACP agents, only show copy + delete in the assistant group action bar + ...(hasACPProvider + ? { + assistantGroup: { + extraBarActions: [], + }, + } + : {}), user: { extraBarActions: isDevMode ? [branchingFactory] : [], }, }), - [branchingFactory, isDevMode], + [branchingFactory, hasACPProvider, isDevMode], ); }; diff --git a/src/routes/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx b/src/routes/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx index 039be5f0cc..04157426da 100644 --- a/src/routes/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx +++ b/src/routes/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx @@ -1,6 +1,6 @@ import { SESSION_CHAT_URL } from '@lobechat/const'; import { type SidebarAgentItem } from '@lobechat/types'; -import { ActionIcon, Icon } from '@lobehub/ui'; +import { ActionIcon, Flexbox, Icon, Tag } from '@lobehub/ui'; import { cssVar } from 'antd-style'; import { Loader2, PinIcon } from 'lucide-react'; import { type CSSProperties, type DragEvent } from 'react'; @@ -28,7 +28,7 @@ interface AgentItemProps { } const AgentItem = memo(({ item, style, className }) => { - const { id, avatar, backgroundColor, title, pinned } = item; + const { id, avatar, backgroundColor, title, pinned, heterogeneousType } = item; const { t } = useTranslation('chat'); const { openCreateGroupModal } = useAgentModal(); const [anchor, setAnchor] = useState(null); @@ -43,6 +43,21 @@ const AgentItem = memo(({ item, style, className }) => { // Get display title with fallback const displayTitle = title || t('untitledAgent'); + // Heterogeneous agents (Claude Code, etc.) get an "External" tag so they + // stand out in the sidebar — mirrors the group-member pattern. + const titleNode = heterogeneousType ? ( + + + {displayTitle} + + + {t('agentSidebar.externalTag')} + + + ) : ( + displayTitle + ); + // Get URL for this agent const agentUrl = SESSION_CHAT_URL(id, false); @@ -122,7 +137,7 @@ const AgentItem = memo(({ item, style, className }) => { key={id} loading={isLoading} style={style} - title={displayTitle} + title={titleNode} onDoubleClick={handleDoubleClick} onDragEnd={handleDragEnd} onDragStart={handleDragStart} diff --git a/src/routes/(main)/home/_layout/Body/Agent/index.tsx b/src/routes/(main)/home/_layout/Body/Agent/index.tsx index 55eff6533c..b7962197c0 100644 --- a/src/routes/(main)/home/_layout/Body/Agent/index.tsx +++ b/src/routes/(main)/home/_layout/Body/Agent/index.tsx @@ -25,12 +25,15 @@ const Agent = memo(({ itemKey }) => { const { openConfigGroupModal } = useAgentModal(); // Create menu items - const { createAgentMenuItem, createGroupChatMenuItem, isLoading } = useCreateMenuItems(); + const { createAgentMenuItem, createClaudeCodeMenuItem, createGroupChatMenuItem, isLoading } = + useCreateMenuItems(); - const addMenuItems = useMemo( - () => [createAgentMenuItem(), createGroupChatMenuItem()], - [createAgentMenuItem, createGroupChatMenuItem], - ); + const addMenuItems = useMemo(() => { + const items = [createAgentMenuItem(), createGroupChatMenuItem()]; + const ccItem = createClaudeCodeMenuItem(); + if (ccItem) items.splice(1, 0, ccItem); + return items; + }, [createAgentMenuItem, createClaudeCodeMenuItem, createGroupChatMenuItem]); const handleOpenConfigGroupModal = useCallback(() => { openConfigGroupModal(); diff --git a/src/routes/(main)/home/_layout/Header/components/AddButton.tsx b/src/routes/(main)/home/_layout/Header/components/AddButton.tsx index 2c0450ad27..0e97252c33 100644 --- a/src/routes/(main)/home/_layout/Header/components/AddButton.tsx +++ b/src/routes/(main)/home/_layout/Header/components/AddButton.tsx @@ -15,6 +15,7 @@ const AddButton = memo(() => { // Create menu items const { createAgentMenuItem, + createClaudeCodeMenuItem, createGroupChatMenuItem, createPageMenuItem, openCreateModal, @@ -32,8 +33,11 @@ const AddButton = memo(() => { ); const dropdownItems = useMemo(() => { - return [createAgentMenuItem(), createGroupChatMenuItem(), createPageMenuItem()]; - }, [createAgentMenuItem, createGroupChatMenuItem, createPageMenuItem]); + const items = [createAgentMenuItem(), createGroupChatMenuItem(), createPageMenuItem()]; + const ccItem = createClaudeCodeMenuItem(); + if (ccItem) items.splice(1, 0, ccItem); // Insert after "Create Agent" + return items; + }, [createAgentMenuItem, createClaudeCodeMenuItem, createGroupChatMenuItem, createPageMenuItem]); return ( diff --git a/src/routes/(main)/home/_layout/hooks/useCreateMenuItems.tsx b/src/routes/(main)/home/_layout/hooks/useCreateMenuItems.tsx index f321d67dc9..729801c078 100644 --- a/src/routes/(main)/home/_layout/hooks/useCreateMenuItems.tsx +++ b/src/routes/(main)/home/_layout/hooks/useCreateMenuItems.tsx @@ -1,8 +1,9 @@ +import { isDesktop } from '@lobechat/const'; import { Icon } from '@lobehub/ui'; import { GroupBotSquareIcon } from '@lobehub/ui/icons'; import { App } from 'antd'; import { type ItemType } from 'antd/es/menu/interface'; -import { BotIcon, FileTextIcon, FolderCogIcon, FolderPlus } from 'lucide-react'; +import { BotIcon, FileTextIcon, FolderCogIcon, FolderPlus, TerminalSquareIcon } from 'lucide-react'; import { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -18,6 +19,8 @@ import { useAgentStore } from '@/store/agent'; import { useAgentGroupStore } from '@/store/agentGroup'; import { useHomeStore } from '@/store/home'; import { usePageStore } from '@/store/page'; +import { useUserStore } from '@/store/user'; +import { labPreferSelectors } from '@/store/user/selectors'; interface CreateAgentOptions { groupId?: string; @@ -35,6 +38,7 @@ export const useCreateMenuItems = () => { const { message } = App.useApp(); const navigate = useNavigate(); const groupTemplates = useGroupTemplates(); + const enableHeterogeneousAgent = useUserStore(labPreferSelectors.enableHeterogeneousAgent); const [storeCreateAgent] = useAgentStore((s) => [s.createAgent]); const [addGroup, refreshAgentList, switchToGroup] = useHomeStore((s) => [ @@ -192,6 +196,37 @@ export const useCreateMenuItems = () => { [mutateGroup], ); + /** + * Create a Claude Code agent with ACP provider pre-configured. + * + * Bypasses `mutateAgent` so we skip its default /profile redirect — + * CC agents land straight on the chat page since their config is fixed. + */ + const createClaudeCodeAgent = useCallback( + async (options?: CreateAgentOptions) => { + const result = await storeCreateAgent({ + config: { + agencyConfig: { + heterogeneousProvider: { + command: 'claude', + type: 'claudecode' as const, + }, + }, + avatar: + 'https://registry.npmmirror.com/@lobehub/icons-static-avatar/latest/files/avatars/claude.webp', + systemRole: + 'You are Claude Code, an AI coding agent. Help users with code-related tasks.', + title: 'Claude Code', + }, + groupId: options?.groupId, + }); + await refreshAgentList(); + navigate(`/agent/${result.agentId}`); + options?.onSuccess?.(); + }, + [storeCreateAgent, refreshAgentList, navigate], + ); + const agentModal = useOptionalAgentModal(); const openCreateModal = agentModal?.openCreateModal; @@ -217,6 +252,25 @@ export const useCreateMenuItems = () => { [t, createAgent, openCreateModal], ); + /** + * Create Claude Code agent menu item (Desktop only) + */ + const createClaudeCodeMenuItem = useCallback( + (options?: CreateAgentOptions): ItemType | null => { + if (!isDesktop || !enableHeterogeneousAgent) return null; + return { + icon: , + key: 'newClaudeCodeAgent', + label: t('newClaudeCodeAgent'), + onClick: async (info) => { + info.domEvent?.stopPropagation(); + await createClaudeCodeAgent(options); + }, + }; + }, + [t, createClaudeCodeAgent, enableHeterogeneousAgent], + ); + /** * Create group chat menu item * Creates an empty group and navigates to its profile page @@ -308,6 +362,8 @@ export const useCreateMenuItems = () => { configMenuItem, createAgent, createAgentMenuItem, + createClaudeCodeAgent, + createClaudeCodeMenuItem, createEmptyGroup, createGroupChatMenuItem, createGroupFromTemplate, diff --git a/src/routes/(main)/settings/advanced/index.tsx b/src/routes/(main)/settings/advanced/index.tsx index b3b4d571d6..a84044469f 100644 --- a/src/routes/(main)/settings/advanced/index.tsx +++ b/src/routes/(main)/settings/advanced/index.tsx @@ -40,12 +40,14 @@ const Page = memo(() => { enableInputMarkdown, enableGatewayMode, enableAgentWorkingPanel, + enableHeterogeneousAgent, updateLab, ] = useUserStore((s) => [ preferenceSelectors.isPreferenceInit(s), labPreferSelectors.enableInputMarkdown(s), labPreferSelectors.enableGatewayMode(s), labPreferSelectors.enableAgentWorkingPanel(s), + labPreferSelectors.enableHeterogeneousAgent(s), s.updateLab, ]); @@ -136,6 +138,23 @@ const Page = memo(() => { label: tLabs('features.agentWorkingPanel.title'), minWidth: undefined, }, + ...(isDesktop + ? [ + { + children: ( + updateLab({ enableHeterogeneousAgent: checked })} + /> + ), + className: styles.labItem, + desc: tLabs('features.heterogeneousAgent.desc'), + label: tLabs('features.heterogeneousAgent.title'), + minWidth: undefined, + }, + ] + : []), ...(hasGatewayUrl ? [ { diff --git a/src/routes/(main)/settings/system-tools/features/ToolDetectorSection.tsx b/src/routes/(main)/settings/system-tools/features/ToolDetectorSection.tsx index e939e5a0b1..4248ae361e 100644 --- a/src/routes/(main)/settings/system-tools/features/ToolDetectorSection.tsx +++ b/src/routes/(main)/settings/system-tools/features/ToolDetectorSection.tsx @@ -30,6 +30,19 @@ const TOOL_CATEGORIES = { ], }, + 'cli-agents': { + descKey: 'settingSystemTools.category.cliAgents.desc', + titleKey: 'settingSystemTools.category.cliAgents', + tools: [ + { descKey: 'settingSystemTools.tools.claude.desc', name: 'claude' }, + { descKey: 'settingSystemTools.tools.codex.desc', name: 'codex' }, + { descKey: 'settingSystemTools.tools.gemini.desc', name: 'gemini' }, + { descKey: 'settingSystemTools.tools.qwen.desc', name: 'qwen' }, + { descKey: 'settingSystemTools.tools.kimi.desc', name: 'kimi' }, + { descKey: 'settingSystemTools.tools.aider.desc', name: 'aider' }, + ], + }, + 'content-search': { descKey: 'settingSystemTools.category.contentSearch.desc', titleKey: 'settingSystemTools.category.contentSearch', diff --git a/src/routes/(main)/settings/system-tools/index.tsx b/src/routes/(main)/settings/system-tools/index.tsx index 20a7f1364a..8ad3fbad22 100644 --- a/src/routes/(main)/settings/system-tools/index.tsx +++ b/src/routes/(main)/settings/system-tools/index.tsx @@ -15,8 +15,8 @@ const Page = () => { return ( <> - + {isDevMode ? : null} ); diff --git a/src/services/electron/heterogeneousAgent.ts b/src/services/electron/heterogeneousAgent.ts new file mode 100644 index 0000000000..5dd41b4350 --- /dev/null +++ b/src/services/electron/heterogeneousAgent.ts @@ -0,0 +1,43 @@ +import { ensureElectronIpc } from '@/utils/electron/ipc'; + +/** + * Renderer-side service for managing heterogeneous agent processes via Electron IPC. + */ +class HeterogeneousAgentService { + private get ipc() { + return ensureElectronIpc(); + } + + async startSession(params: { + agentType?: string; + args?: string[]; + command: string; + cwd?: string; + env?: Record; + resumeSessionId?: string; + }) { + return this.ipc.heterogeneousAgent.startSession(params); + } + + async sendPrompt( + sessionId: string, + prompt: string, + imageList?: Array<{ id: string; url: string }>, + ) { + return this.ipc.heterogeneousAgent.sendPrompt({ imageList, prompt, sessionId }); + } + + async cancelSession(sessionId: string) { + return this.ipc.heterogeneousAgent.cancelSession({ sessionId }); + } + + async stopSession(sessionId: string) { + return this.ipc.heterogeneousAgent.stopSession({ sessionId }); + } + + async getSessionInfo(sessionId: string) { + return this.ipc.heterogeneousAgent.getSessionInfo({ sessionId }); + } +} + +export const heterogeneousAgentService = new HeterogeneousAgentService(); diff --git a/src/store/agent/selectors/agentByIdSelectors.ts b/src/store/agent/selectors/agentByIdSelectors.ts index effe12b366..f07a040b57 100644 --- a/src/store/agent/selectors/agentByIdSelectors.ts +++ b/src/store/agent/selectors/agentByIdSelectors.ts @@ -1,7 +1,12 @@ import { DEFAULT_PROVIDER } from '@lobechat/business-const'; import { DEFAULT_MODEL, DEFAUTT_AGENT_TTS_CONFIG, isDesktop } from '@lobechat/const'; import { type AgentBuilderContext } from '@lobechat/context-engine'; -import { type AgentMode, type LobeAgentTTSConfig, type RuntimeEnvConfig } from '@lobechat/types'; +import { + type AgentMode, + type LobeAgentAgencyConfig, + type LobeAgentTTSConfig, + type RuntimeEnvConfig, +} from '@lobechat/types'; import { globalAgentContextManager } from '@/helpers/GlobalAgentContextManager'; @@ -123,6 +128,14 @@ const getAgentBuilderContextById = }; }; +/** + * Get agencyConfig by agentId + */ +const getAgencyConfigById = + (agentId: string) => + (s: AgentStoreState): LobeAgentAgencyConfig | undefined => + agentSelectors.getAgentConfigById(agentId)(s)?.agencyConfig; + /** * Get full agent data by agentId * Returns the complete agent object including metadata fields like updatedAt @@ -130,6 +143,7 @@ const getAgentBuilderContextById = const getAgentById = (agentId: string) => (s: AgentStoreState) => s.agentMap[agentId]; export const agentByIdSelectors = { + getAgencyConfigById, getAgentBuilderContextById, getAgentById, getAgentConfigById: agentSelectors.getAgentConfigById, diff --git a/src/store/agent/selectors/selectors.ts b/src/store/agent/selectors/selectors.ts index f73e928137..0e5decc0dd 100644 --- a/src/store/agent/selectors/selectors.ts +++ b/src/store/agent/selectors/selectors.ts @@ -283,6 +283,14 @@ const currentAgentWorkingDirectory = (s: AgentStoreState): string | undefined => const isCurrentAgentExternal = (s: AgentStoreState): boolean => !currentAgentData(s)?.virtual; +/** + * Whether current agent is driven by an external heterogeneous runtime + * (e.g. Claude Code). These agents skip LobeHub's message-channel / model + * pickers because their toolchain is owned by the external runtime. + */ +const isCurrentAgentHeterogeneous = (s: AgentStoreState): boolean => + !!currentAgentConfig(s)?.agencyConfig?.heterogeneousProvider; + const getAgentDocumentsById = (agentId: string) => (s: AgentStoreState) => s.agentDocumentsMap[agentId]; @@ -321,6 +329,7 @@ export const agentSelectors = { isAgentConfigLoading, isAgentModeEnabled, isCurrentAgentExternal, + isCurrentAgentHeterogeneous, openingMessage, openingQuestions, }; diff --git a/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts b/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts index 9bc80a1d1f..159d638539 100644 --- a/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts +++ b/src/store/chat/slices/aiChat/actions/__tests__/gatewayEventHandler.test.ts @@ -456,4 +456,47 @@ describe('createGatewayEventHandler', () => { expect(store.completeOperation).toHaveBeenCalledWith('op-1'); }); }); + + describe('step transition timing (orphan tool regression)', () => { + /** + * Verifies that after the executor fix, tools_calling events at step + * boundaries arrive AFTER stream_start (correct order). + * + * Previously, the executor forwarded stream_chunk(tools_calling) sync + * while stream_start was deferred via persistQueue — handler dispatched + * tools to the OLD assistant. The fix defers all events during step + * transition through persistQueue, guaranteeing correct ordering. + */ + it('should dispatch new-step tools to the NEW assistant when events arrive in correct order', async () => { + const store = createMockStore(); + const handler = createHandler(store, { assistantMessageId: 'ast-old' }); + + // Step 1 init + handler(makeEvent('stream_start', {})); + await flush(); + + handler(makeEvent('stream_end')); + await flush(); + vi.clearAllMocks(); + + // ── Step boundary: executor now guarantees stream_start arrives FIRST ── + handler(makeEvent('stream_start', { assistantMessage: { id: 'ast-new' } })); + await flush(); + + handler( + makeEvent('stream_chunk', { + chunkType: 'tools_calling', + toolsCalling: [{ id: 'toolu_new' }], + }), + ); + await flush(); + + // ── Assert: tools dispatched to the NEW assistant ── + const toolsDispatch = store.internal_dispatchMessage.mock.calls.find( + ([action]: any) => action.value?.tools, + ); + expect(toolsDispatch).toBeDefined(); + expect(toolsDispatch![0].id).toBe('ast-new'); + }); + }); }); diff --git a/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts b/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts new file mode 100644 index 0000000000..2c098c2ca8 --- /dev/null +++ b/src/store/chat/slices/aiChat/actions/__tests__/heterogeneousAgentExecutor.test.ts @@ -0,0 +1,1004 @@ +/** + * Tests for heterogeneousAgentExecutor DB persistence layer. + * + * Verifies the critical path: CC stream events → messageService DB writes. + * Covers: + * - Tool 3-phase persistence (pre-register → create → backfill) + * - Tool result content updates + * - Multi-step assistant message creation with correct parentId chain + * - Content/reasoning/model/usage final writes + * - Sync snapshot + reset to prevent cross-step content contamination + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { executeHeterogeneousAgent } from '../heterogeneousAgentExecutor'; + +// ─── Mocks ─── + +// messageService — the DB layer under test +const mockCreateMessage = vi.fn(); +const mockUpdateMessage = vi.fn(); +const mockUpdateToolMessage = vi.fn(); +const mockGetMessages = vi.fn(); + +vi.mock('@/services/message', () => ({ + messageService: { + createMessage: (...args: any[]) => mockCreateMessage(...args), + getMessages: (...args: any[]) => mockGetMessages(...args), + updateMessage: (...args: any[]) => mockUpdateMessage(...args), + updateToolMessage: (...args: any[]) => mockUpdateToolMessage(...args), + }, +})); + +// heterogeneousAgentService — IPC to Electron main +const mockStartSession = vi.fn(); +const mockSendPrompt = vi.fn(); +const mockStopSession = vi.fn(); +const mockGetSessionInfo = vi.fn(); + +vi.mock('@/services/electron/heterogeneousAgent', () => ({ + heterogeneousAgentService: { + getSessionInfo: (...args: any[]) => mockGetSessionInfo(...args), + sendPrompt: (...args: any[]) => mockSendPrompt(...args), + startSession: (...args: any[]) => mockStartSession(...args), + stopSession: (...args: any[]) => mockStopSession(...args), + }, +})); + +// Gateway event handler — we spy on it but let it run (it calls getMessages) +vi.mock('../gatewayEventHandler', () => ({ + createGatewayEventHandler: vi.fn(() => vi.fn()), +})); + +// ─── Helpers ─── + +function setupIpcCapture() { + // Mock window.electron.ipcRenderer + const listeners = new Map void>(); + (globalThis as any).window = { + electron: { + ipcRenderer: { + on: vi.fn((channel: string, handler: (...args: any[]) => void) => { + listeners.set(channel, handler); + }), + removeListener: vi.fn(), + }, + }, + }; + + // After subscribeBroadcasts is called, extract the callbacks + // by intercepting the IPC .on() calls + return { + getListeners: () => listeners, + /** Simulate a raw line broadcast from Electron main */ + emitRawLine: (sessionId: string, line: any) => { + const handler = listeners.get('heteroAgentRawLine'); + handler?.(null, { line, sessionId }); + }, + /** Simulate session completion */ + emitComplete: (sessionId: string) => { + const handler = listeners.get('heteroAgentSessionComplete'); + handler?.(null, { sessionId }); + }, + /** Simulate session error */ + emitError: (sessionId: string, error: string) => { + const handler = listeners.get('heteroAgentSessionError'); + handler?.(null, { error, sessionId }); + }, + }; +} + +function createMockStore() { + return { + associateMessageWithOperation: vi.fn(), + completeOperation: vi.fn(), + internal_dispatchMessage: vi.fn(), + internal_toggleToolCallingStreaming: vi.fn(), + replaceMessages: vi.fn(), + } as any; +} + +const defaultContext = { + agentId: 'agent-1', + scope: 'main' as const, + topicId: 'topic-1', +}; + +const defaultParams = { + assistantMessageId: 'ast-initial', + context: defaultContext, + heterogeneousProvider: { command: 'claude', type: 'claudecode' as const }, + message: 'test prompt', + operationId: 'op-1', +}; + +/** Flush async queues */ +const flush = async () => { + for (let i = 0; i < 10; i++) { + await new Promise((r) => setTimeout(r, 10)); + } +}; + +// ─── CC stream-json event factories ─── + +const ccInit = (sessionId = 'cc-sess-1') => ({ + model: 'claude-sonnet-4-6', + session_id: sessionId, + subtype: 'init', + type: 'system', +}); + +const ccAssistant = (msgId: string, content: any[], extra?: { model?: string; usage?: any }) => ({ + message: { + content, + id: msgId, + model: extra?.model || 'claude-sonnet-4-6', + role: 'assistant', + usage: extra?.usage, + }, + type: 'assistant', +}); + +const ccToolUse = (msgId: string, toolId: string, name: string, input: any = {}) => + ccAssistant(msgId, [{ id: toolId, input, name, type: 'tool_use' }]); + +const ccText = (msgId: string, text: string) => ccAssistant(msgId, [{ text, type: 'text' }]); + +const ccThinking = (msgId: string, thinking: string) => + ccAssistant(msgId, [{ thinking, type: 'thinking' }]); + +const ccToolResult = (toolUseId: string, content: string, isError = false) => ({ + message: { + content: [{ content, is_error: isError, tool_use_id: toolUseId, type: 'tool_result' }], + role: 'user', + }, + type: 'user', +}); + +const ccResult = (isError = false, result = 'done') => ({ + is_error: isError, + result, + type: 'result', +}); + +// ─── Tests ─── + +describe('heterogeneousAgentExecutor DB persistence', () => { + let ipc: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + ipc = setupIpcCapture(); + mockStartSession.mockResolvedValue({ sessionId: 'ipc-sess-1' }); + mockSendPrompt.mockResolvedValue(undefined); + mockStopSession.mockResolvedValue(undefined); + mockGetSessionInfo.mockResolvedValue({ agentSessionId: 'cc-sess-1' }); + mockGetMessages.mockResolvedValue([]); + mockCreateMessage.mockImplementation(async (params: any) => ({ + id: `created-${params.role}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + })); + mockUpdateMessage.mockResolvedValue(undefined); + mockUpdateToolMessage.mockResolvedValue(undefined); + }); + + afterEach(() => { + delete (globalThis as any).window; + }); + + /** + * Runs the executor in background, then feeds CC events and completes. + * Returns a promise that resolves when the executor finishes. + */ + async function runWithEvents(ccEvents: any[], opts?: { params?: Partial }) { + const store = createMockStore(); + const get = vi.fn(() => store); + + // sendPrompt will resolve after we emit all events + let resolveSendPrompt: () => void; + mockSendPrompt.mockReturnValue( + new Promise((r) => { + resolveSendPrompt = r; + }), + ); + + const executorPromise = executeHeterogeneousAgent(get, { + ...defaultParams, + ...opts?.params, + }); + + // Wait for startSession + subscribeBroadcasts to complete + await flush(); + + // Feed CC events + for (const event of ccEvents) { + ipc.emitRawLine('ipc-sess-1', event); + } + + // Signal completion + ipc.emitComplete('ipc-sess-1'); + await flush(); + + // Resolve sendPrompt to let executor continue + resolveSendPrompt!(); + await flush(); + + // Wait for executor to finish + await executorPromise; + await flush(); + + return { get, store }; + } + + // ──────────────────────────────────────────────────── + // Tool 3-phase persistence + // ──────────────────────────────────────────────────── + + describe('tool 3-phase persistence', () => { + it('should pre-register tools, create tool messages, then backfill result_msg_id', async () => { + // Track createMessage call order and IDs + let toolMsgCounter = 0; + mockCreateMessage.mockImplementation(async (params: any) => { + if (params.role === 'tool') { + toolMsgCounter++; + return { id: `tool-msg-${toolMsgCounter}` }; + } + return { id: `msg-${params.role}-${Date.now()}` }; + }); + + await runWithEvents([ + ccInit(), + ccToolUse('msg_01', 'toolu_1', 'Read', { file_path: '/a.ts' }), + ccToolResult('toolu_1', 'file content'), + ccText('msg_02', 'Done'), + ccResult(), + ]); + + // Phase 1 + Phase 3: updateMessage called with tools[] on the assistant + // Phase 1 has tools without result_msg_id, Phase 3 has tools with result_msg_id + const toolUpdateCalls = mockUpdateMessage.mock.calls.filter( + ([id, val]: any) => id === 'ast-initial' && val.tools?.length > 0, + ); + // At least 2 calls: phase 1 (pre-register) + phase 3 (backfill) + expect(toolUpdateCalls.length).toBeGreaterThanOrEqual(2); + + // Phase 2: createMessage called with role='tool' + const toolCreateCalls = mockCreateMessage.mock.calls.filter( + ([params]: any) => params.role === 'tool', + ); + expect(toolCreateCalls.length).toBe(1); + expect(toolCreateCalls[0][0]).toMatchObject({ + parentId: 'ast-initial', + role: 'tool', + tool_call_id: 'toolu_1', + plugin: expect.objectContaining({ apiName: 'Read' }), + }); + + // Phase 3: the last tools[] write should have result_msg_id backfilled + const lastToolUpdate = toolUpdateCalls.at(-1)!; + expect(lastToolUpdate[1].tools[0].result_msg_id).toBe('tool-msg-1'); + }); + + it('should deduplicate tool calls (idempotent)', async () => { + await runWithEvents([ + ccInit(), + // Same tool_use id sent twice (CC can echo tool blocks) + ccToolUse('msg_01', 'toolu_1', 'Bash', { command: 'ls' }), + ccAssistant('msg_01', [ + { id: 'toolu_1', input: { command: 'ls' }, name: 'Bash', type: 'tool_use' }, + ]), + ccToolResult('toolu_1', 'output'), + ccResult(), + ]); + + // Should only create ONE tool message despite two tool_use events with same id + const toolCreates = mockCreateMessage.mock.calls.filter(([p]: any) => p.role === 'tool'); + expect(toolCreates.length).toBe(1); + }); + }); + + // ──────────────────────────────────────────────────── + // Tool result content persistence + // ──────────────────────────────────────────────────── + + describe('tool result persistence', () => { + it('should update tool message content on tool_result', async () => { + mockCreateMessage.mockImplementation(async (params: any) => { + if (params.role === 'tool') return { id: 'tool-msg-read' }; + return { id: `msg-${Date.now()}` }; + }); + + await runWithEvents([ + ccInit(), + ccToolUse('msg_01', 'toolu_read', 'Read', { file_path: '/x.ts' }), + ccToolResult('toolu_read', 'the file content here'), + ccResult(), + ]); + + expect(mockUpdateToolMessage).toHaveBeenCalledWith( + 'tool-msg-read', + { content: 'the file content here', pluginError: undefined }, + { agentId: 'agent-1', topicId: 'topic-1' }, + ); + }); + + it('should mark error tool results with pluginError', async () => { + mockCreateMessage.mockImplementation(async (params: any) => { + if (params.role === 'tool') return { id: 'tool-msg-err' }; + return { id: `msg-${Date.now()}` }; + }); + + await runWithEvents([ + ccInit(), + ccToolUse('msg_01', 'toolu_fail', 'Read', { file_path: '/nope' }), + ccToolResult('toolu_fail', 'ENOENT: no such file', true), + ccResult(), + ]); + + expect(mockUpdateToolMessage).toHaveBeenCalledWith( + 'tool-msg-err', + { content: 'ENOENT: no such file', pluginError: { message: 'ENOENT: no such file' } }, + { agentId: 'agent-1', topicId: 'topic-1' }, + ); + }); + }); + + // ──────────────────────────────────────────────────── + // Multi-step parentId chain + // ──────────────────────────────────────────────────── + + describe('multi-step parentId chain', () => { + it('should create assistant messages chained: assistant → tool → assistant', async () => { + const createdIds: string[] = []; + mockCreateMessage.mockImplementation(async (params: any) => { + const id = + params.role === 'tool' ? `tool-${createdIds.length}` : `ast-step-${createdIds.length}`; + createdIds.push(id); + return { id }; + }); + + await runWithEvents([ + ccInit(), + // Step 1: tool_use Read + ccToolUse('msg_01', 'toolu_1', 'Read', { file_path: '/a.ts' }), + ccToolResult('toolu_1', 'content of a.ts'), + // Step 2 (new message.id): tool_use Write + ccToolUse('msg_02', 'toolu_2', 'Write', { file_path: '/b.ts', content: 'new' }), + ccToolResult('toolu_2', 'file written'), + // Step 3 (new message.id): final text + ccText('msg_03', 'All done!'), + ccResult(), + ]); + + // Collect all createMessage calls with their parentId + // Tool message for step 1 — parentId should be the initial assistant + const tool1Create = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'tool' && p.tool_call_id === 'toolu_1', + ); + expect(tool1Create?.[0].parentId).toBe('ast-initial'); + + // Assistant for step 2 — parentId should be step 1's TOOL message (not assistant) + const step2Assistant = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'assistant' && p.parentId !== undefined, + ); + expect(step2Assistant).toBeDefined(); + // The parentId should be the tool message ID from step 1 + const tool1Id = createdIds.find((id) => id.startsWith('tool-')); + expect(step2Assistant![0].parentId).toBe(tool1Id); + }); + + it('should fall back to assistant parentId when step has no tools', async () => { + const ids: string[] = []; + mockCreateMessage.mockImplementation(async (params: any) => { + const id = `${params.role}-${ids.length}`; + ids.push(id); + return { id }; + }); + + await runWithEvents([ + ccInit(), + // Step 1: just text, no tools + ccText('msg_01', 'Let me think...'), + // Step 2: more text (new message.id, no tools in step 1) + ccText('msg_02', 'Here is the answer.'), + ccResult(), + ]); + + // Step 2 assistant should have parentId = initial assistant (no tools to chain through) + const step2 = mockCreateMessage.mock.calls.find(([p]: any) => p.role === 'assistant'); + expect(step2?.[0].parentId).toBe('ast-initial'); + }); + }); + + // ──────────────────────────────────────────────────── + // Final content + usage writes + // ──────────────────────────────────────────────────── + + describe('final content writes (onComplete)', () => { + it('should write accumulated content + model to the final assistant message', async () => { + await runWithEvents([ + ccInit(), + ccAssistant('msg_01', [{ text: 'Hello ', type: 'text' }], { + model: 'claude-opus-4-6', + usage: { input_tokens: 100, output_tokens: 10 }, + }), + ccAssistant('msg_01', [{ text: 'world!', type: 'text' }], { + usage: { input_tokens: 100, output_tokens: 20 }, + }), + ccResult(), + ]); + + // Final updateMessage should include accumulated content + model + const finalWrite = mockUpdateMessage.mock.calls.find( + ([id, val]: any) => id === 'ast-initial' && val.content === 'Hello world!', + ); + expect(finalWrite).toBeDefined(); + // lastModel is set from step_complete(turn_metadata). With usage dedup, + // only the FIRST event per message.id emits turn_metadata, so model stays + // as 'claude-opus-4-6' from the first event. + expect(finalWrite![1].model).toBe('claude-opus-4-6'); + }); + + it('should write accumulated reasoning', async () => { + await runWithEvents([ + ccInit(), + ccThinking('msg_01', 'Let me think about this.'), + ccText('msg_01', 'Answer.'), + ccResult(), + ]); + + const finalWrite = mockUpdateMessage.mock.calls.find( + ([id, val]: any) => id === 'ast-initial' && val.reasoning, + ); + expect(finalWrite).toBeDefined(); + expect(finalWrite![1].reasoning.content).toBe('Let me think about this.'); + }); + + it('should accumulate usage across turns into metadata', async () => { + await runWithEvents([ + ccInit(), + ccAssistant('msg_01', [{ text: 'a', type: 'text' }], { + usage: { + cache_creation_input_tokens: 50, + cache_read_input_tokens: 200, + input_tokens: 100, + output_tokens: 50, + }, + }), + ccToolUse('msg_01', 'toolu_1', 'Bash', {}), + ccToolResult('toolu_1', 'ok'), + ccAssistant('msg_02', [{ text: 'b', type: 'text' }], { + usage: { input_tokens: 300, output_tokens: 80 }, + }), + ccResult(), + ]); + + // Find the final write that has usage metadata + const finalWrite = mockUpdateMessage.mock.calls.find( + ([, val]: any) => val.metadata?.usage?.totalTokens, + ); + expect(finalWrite).toBeDefined(); + const usage = finalWrite![1].metadata.usage; + // 100 + 300 input + 200 cache_read + 50 cache_create = 650 input total + expect(usage.totalInputTokens).toBe(650); + // 50 + 80 = 130 output + expect(usage.totalOutputTokens).toBe(130); + expect(usage.totalTokens).toBe(780); + // Breakdown for pricing UI (must match anthropic usage converter shape) + expect(usage.inputCacheMissTokens).toBe(400); + expect(usage.inputCachedTokens).toBe(200); + expect(usage.inputWriteCacheTokens).toBe(50); + }); + }); + + // ──────────────────────────────────────────────────── + // Sync snapshot prevents cross-step contamination + // ──────────────────────────────────────────────────── + + describe('sync snapshot on step boundary', () => { + it('should NOT mix new-step content into old-step DB write', async () => { + // This tests the race condition fix: when adapter produces + // [stream_end, stream_start(newStep), stream_chunk(text)] from a single raw line, + // the stream_chunk should go to the NEW step, not the old one. + + const createdIds: string[] = []; + mockCreateMessage.mockImplementation(async (params: any) => { + const id = `${params.role}-${createdIds.length}`; + createdIds.push(id); + return { id }; + }); + + await runWithEvents([ + ccInit(), + // Step 1: text + ccText('msg_01', 'Step 1 content'), + // Step 2: new message.id — adapter emits stream_end + stream_start(newStep) + chunks + // in the SAME onRawLine call + ccText('msg_02', 'Step 2 content'), + ccResult(), + ]); + + // The old step (ast-initial) should get "Step 1 content", NOT "Step 1 contentStep 2 content" + const oldStepWrite = mockUpdateMessage.mock.calls.find( + ([id, val]: any) => id === 'ast-initial' && val.content === 'Step 1 content', + ); + expect(oldStepWrite).toBeDefined(); + + // The new step's final write should have "Step 2 content" + const newStepId = createdIds.find((id) => id.startsWith('assistant-')); + if (newStepId) { + const newStepWrite = mockUpdateMessage.mock.calls.find( + ([id, val]: any) => id === newStepId && val.content === 'Step 2 content', + ); + expect(newStepWrite).toBeDefined(); + } + }); + }); + + // ──────────────────────────────────────────────────── + // Error handling + // ──────────────────────────────────────────────────── + + describe('error handling', () => { + it('should persist accumulated content on error', async () => { + const store = createMockStore(); + const get = vi.fn(() => store); + + let resolveSendPrompt: () => void; + mockSendPrompt.mockReturnValue( + new Promise((r) => { + resolveSendPrompt = r; + }), + ); + + const executorPromise = executeHeterogeneousAgent(get, defaultParams); + await flush(); + + // Feed some content, then error + ipc.emitRawLine('ipc-sess-1', ccInit()); + ipc.emitRawLine('ipc-sess-1', ccText('msg_01', 'partial content')); + ipc.emitError('ipc-sess-1', 'Connection lost'); + await flush(); + + resolveSendPrompt!(); + await executorPromise.catch(() => {}); + await flush(); + + // Should have written the partial content + const contentWrite = mockUpdateMessage.mock.calls.find( + ([id, val]: any) => id === 'ast-initial' && val.content === 'partial content', + ); + expect(contentWrite).toBeDefined(); + }); + }); + + // ──────────────────────────────────────────────────── + // Full multi-step E2E + // ──────────────────────────────────────────────────── + + // ──────────────────────────────────────────────────── + // Orphan tool regression (img.png scenario) + // ──────────────────────────────────────────────────── + + describe('orphan tool regression', () => { + /** + * Reproduces the orphan tool scenario from img.png: + * + * Turn 1 (msg_01): text + Bash(git log) → assistant1.tools should include git_log + * tool_result for git log + * Turn 2 (msg_02): Bash(git diff) → assistant2.tools should include git_diff + * tool_result for git diff + * Turn 3 (msg_03): text summary + * + * The orphan happens when assistant2.tools[] does NOT contain + * the git_diff entry, making the tool message appear orphaned in the UI. + */ + it('should register tools on the correct assistant in multi-turn tool execution', async () => { + const idCounter = { tool: 0, assistant: 0 }; + mockCreateMessage.mockImplementation(async (params: any) => { + if (params.role === 'tool') { + idCounter.tool++; + return { id: `tool-${idCounter.tool}` }; + } + idCounter.assistant++; + return { id: `ast-new-${idCounter.assistant}` }; + }); + + // Track ALL updateMessage calls to inspect tools[] writes + const toolsUpdates: Array<{ assistantId: string; tools: any[] }> = []; + mockUpdateMessage.mockImplementation(async (id: string, val: any) => { + if (val.tools) { + toolsUpdates.push({ assistantId: id, tools: val.tools }); + } + }); + + await runWithEvents([ + ccInit(), + // Turn 1: text + Bash (git log) — same message.id + ccAssistant('msg_01', [ + { text: '没有未提交的修改,看看已提交但未推送的变更:', type: 'text' }, + ]), + ccToolUse('msg_01', 'toolu_gitlog', 'Bash', { command: 'git log canary..HEAD --oneline' }), + ccToolResult('toolu_gitlog', 'abc123 feat: something\ndef456 fix: another'), + // Turn 2: Bash (git diff) — NEW message.id → step boundary + ccToolUse('msg_02', 'toolu_gitdiff', 'Bash', { command: 'git diff --stat' }), + ccToolResult('toolu_gitdiff', ' file1.ts | 10 +\n file2.ts | 5 -'), + // Turn 3: text summary — NEW message.id → step boundary + ccText('msg_03', '当前分支有2个未推送的提交,修改了2个文件。'), + ccResult(), + ]); + + // ── Verify: Turn 1 tool registered on ast-initial ── + const gitlogToolUpdates = toolsUpdates.filter( + (u) => u.assistantId === 'ast-initial' && u.tools.some((t: any) => t.id === 'toolu_gitlog'), + ); + expect(gitlogToolUpdates.length).toBeGreaterThanOrEqual(1); + + // ── Verify: Turn 2 tool registered on ast-new-1 (step 2 assistant) ── + // This is the critical assertion — if this fails, the tool becomes orphaned + const gitdiffToolUpdates = toolsUpdates.filter( + (u) => u.assistantId === 'ast-new-1' && u.tools.some((t: any) => t.id === 'toolu_gitdiff'), + ); + expect(gitdiffToolUpdates.length).toBeGreaterThanOrEqual(1); + + // ── Verify: tool messages have correct parentId ── + const gitlogToolCreate = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'tool' && p.tool_call_id === 'toolu_gitlog', + ); + expect(gitlogToolCreate![0].parentId).toBe('ast-initial'); + + const gitdiffToolCreate = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'tool' && p.tool_call_id === 'toolu_gitdiff', + ); + expect(gitdiffToolCreate![0].parentId).toBe('ast-new-1'); + }); + + it('should register tools on correct assistant when turn has ONLY tool_use (no text)', async () => { + // Edge case: turn 2 has only a tool_use, no text. The step transition creates + // a new assistant, then the tool_use must be registered on it (not the old one). + const idCounter = { tool: 0, assistant: 0 }; + mockCreateMessage.mockImplementation(async (params: any) => { + if (params.role === 'tool') { + idCounter.tool++; + return { id: `tool-${idCounter.tool}` }; + } + idCounter.assistant++; + return { id: `ast-new-${idCounter.assistant}` }; + }); + + const toolsUpdates: Array<{ assistantId: string; toolIds: string[] }> = []; + mockUpdateMessage.mockImplementation(async (id: string, val: any) => { + if (val.tools) { + toolsUpdates.push({ + assistantId: id, + toolIds: val.tools.map((t: any) => t.id), + }); + } + }); + + await runWithEvents([ + ccInit(), + // Turn 1: just text, no tools + ccText('msg_01', 'Let me check...'), + // Turn 2: only tool_use (no text in this turn) + ccToolUse('msg_02', 'toolu_bash', 'Bash', { command: 'ls -la' }), + ccToolResult('toolu_bash', 'total 100\ndrwx...'), + // Turn 3: final text + ccText('msg_03', 'Done.'), + ccResult(), + ]); + + // The tool should be registered on ast-new-1 (step 2 assistant), not ast-initial + const bashToolUpdates = toolsUpdates.filter((u) => u.toolIds.includes('toolu_bash')); + expect(bashToolUpdates.length).toBeGreaterThanOrEqual(1); + // All of them should be on ast-new-1 + for (const u of bashToolUpdates) { + expect(u.assistantId).toBe('ast-new-1'); + } + }); + }); + + // ──────────────────────────────────────────────────── + // Real trace regression: multi-tool per turn (LOBE-7240 scenario) + // ──────────────────────────────────────────────────── + + describe('multi-tool per turn (real trace regression)', () => { + /** + * Reproduces the exact CC event pattern from the LOBE-7240 orphan trace. + * Key pattern: a single turn (same message.id) has text + multiple tool_uses. + * After step transition, the new turn also has multiple tool_uses with + * out-of-order tool_results. + */ + it('should register ALL tools on correct assistant when turn has text + multiple tool_uses', async () => { + const idCounter = { tool: 0, assistant: 0 }; + mockCreateMessage.mockImplementation(async (params: any) => { + if (params.role === 'tool') { + idCounter.tool++; + return { id: `tool-${idCounter.tool}` }; + } + idCounter.assistant++; + return { id: `ast-new-${idCounter.assistant}` }; + }); + + const toolsUpdates: Array<{ assistantId: string; toolIds: string[] }> = []; + mockUpdateMessage.mockImplementation(async (id: string, val: any) => { + if (val.tools) { + toolsUpdates.push({ + assistantId: id, + toolIds: val.tools.map((t: any) => t.id), + }); + } + }); + + await runWithEvents([ + ccInit(), + // Turn 1 (msg_01): thinking + tool (Skill) + ccThinking('msg_01', 'Let me check the issue'), + ccToolUse('msg_01', 'toolu_skill', 'Skill', { skill: 'linear' }), + ccToolResult('toolu_skill', 'Launching skill: linear'), + + // Turn 2 (msg_02): tool (ToolSearch) — step boundary + ccToolUse('msg_02', 'toolu_search', 'ToolSearch', { query: 'select:get_issue' }), + ccToolResult('toolu_search', 'tool loaded'), + + // Turn 3 (msg_03): tool (get_issue) — step boundary + ccToolUse('msg_03', 'toolu_getissue', 'mcp__linear__get_issue', { id: 'LOBE-7240' }), + ccToolResult('toolu_getissue', '{"title":"i18n"}'), + + // Turn 4 (msg_04): thinking + text + Grep + Grep — step boundary + // This is the critical pattern: same message.id has text AND multiple tools + ccThinking('msg_04', 'Let me understand the issue'), + ccText('msg_04', '明白了,需要补充翻译'), + ccToolUse('msg_04', 'toolu_grep1', 'Grep', { pattern: 'newClaudeCodeAgent' }), + ccToolResult('toolu_grep1', 'found in chat.ts'), + ccToolUse('msg_04', 'toolu_grep2', 'Grep', { pattern: 'agentProvider' }), + ccToolResult('toolu_grep2', 'found in setting.ts'), + + // Turn 5 (msg_05): Grep + Glob + Glob — step boundary + // Multiple tools, results may arrive out of order + ccToolUse('msg_05', 'toolu_grep3', 'Grep', { pattern: 'agentProvider', path: 'locales' }), + ccToolResult('toolu_grep3', 'locales content'), + ccToolUse('msg_05', 'toolu_glob1', 'Glob', { pattern: 'zh-CN/chat.json' }), + ccToolUse('msg_05', 'toolu_glob2', 'Glob', { pattern: 'en-US/chat.json' }), + // Results arrive out of order: glob2 before glob1 + ccToolResult('toolu_glob2', 'locales/en-US/chat.json'), + ccToolResult('toolu_glob1', 'locales/zh-CN/chat.json'), + + // Turn 6 (msg_06): text summary — step boundary + ccText('msg_06', 'All translations updated.'), + ccResult(), + ]); + + // ── Verify Turn 1: Skill tool on ast-initial ── + const skillUpdates = toolsUpdates.filter((u) => u.toolIds.includes('toolu_skill')); + expect(skillUpdates.length).toBeGreaterThanOrEqual(1); + expect(skillUpdates.every((u) => u.assistantId === 'ast-initial')).toBe(true); + + // ── Verify Turn 4: BOTH Grep tools on same assistant (ast-new-3) ── + const grep1Updates = toolsUpdates.filter((u) => u.toolIds.includes('toolu_grep1')); + const grep2Updates = toolsUpdates.filter((u) => u.toolIds.includes('toolu_grep2')); + expect(grep1Updates.length).toBeGreaterThanOrEqual(1); + expect(grep2Updates.length).toBeGreaterThanOrEqual(1); + + // Both Grep tools must be registered on the SAME assistant + const turn4AssistantId = grep1Updates[0].assistantId; + expect(grep2Updates.some((u) => u.assistantId === turn4AssistantId)).toBe(true); + + // The final tools[] update for Turn 4's assistant should contain BOTH greps + const turn4FinalUpdate = toolsUpdates.findLast((u) => u.assistantId === turn4AssistantId); + expect(turn4FinalUpdate!.toolIds).toContain('toolu_grep1'); + expect(turn4FinalUpdate!.toolIds).toContain('toolu_grep2'); + + // ── Verify Turn 5: all 3 tools (Grep + 2 Globs) on same assistant ── + const grep3Updates = toolsUpdates.filter((u) => u.toolIds.includes('toolu_grep3')); + const glob1Updates = toolsUpdates.filter((u) => u.toolIds.includes('toolu_glob1')); + const glob2Updates = toolsUpdates.filter((u) => u.toolIds.includes('toolu_glob2')); + expect(grep3Updates.length).toBeGreaterThanOrEqual(1); + expect(glob1Updates.length).toBeGreaterThanOrEqual(1); + expect(glob2Updates.length).toBeGreaterThanOrEqual(1); + + // All three must be on the SAME assistant (Turn 5's assistant) + const turn5AssistantId = grep3Updates[0].assistantId; + expect(turn5AssistantId).not.toBe(turn4AssistantId); // Different from Turn 4 + expect(glob1Updates.some((u) => u.assistantId === turn5AssistantId)).toBe(true); + expect(glob2Updates.some((u) => u.assistantId === turn5AssistantId)).toBe(true); + + // Final tools[] for Turn 5's assistant should contain all 3 + const turn5FinalUpdate = toolsUpdates.findLast((u) => u.assistantId === turn5AssistantId); + expect(turn5FinalUpdate!.toolIds).toContain('toolu_grep3'); + expect(turn5FinalUpdate!.toolIds).toContain('toolu_glob1'); + expect(turn5FinalUpdate!.toolIds).toContain('toolu_glob2'); + + // ── Verify tool messages have correct parentId ── + // Turn 4 tools should be children of Turn 4's assistant + const grep1Create = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'tool' && p.tool_call_id === 'toolu_grep1', + ); + const grep2Create = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'tool' && p.tool_call_id === 'toolu_grep2', + ); + expect(grep1Create![0].parentId).toBe(turn4AssistantId); + expect(grep2Create![0].parentId).toBe(turn4AssistantId); + + // Turn 5 tools should be children of Turn 5's assistant + const grep3Create = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'tool' && p.tool_call_id === 'toolu_grep3', + ); + const glob1Create = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'tool' && p.tool_call_id === 'toolu_glob1', + ); + const glob2Create = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'tool' && p.tool_call_id === 'toolu_glob2', + ); + expect(grep3Create![0].parentId).toBe(turn5AssistantId); + expect(glob1Create![0].parentId).toBe(turn5AssistantId); + expect(glob2Create![0].parentId).toBe(turn5AssistantId); + }); + }); + + // ──────────────────────────────────────────────────── + // Data-driven regression from real trace (regression.json) + // ──────────────────────────────────────────────────── + + describe('data-driven regression (133 events)', () => { + it('should have no orphan tools when replaying real CC trace', async () => { + // Load real trace data + const fs = await import('node:fs'); + const path = await import('node:path'); + const tracePath = path.join(process.cwd(), 'regression.json'); + + let traceData: any[]; + try { + traceData = JSON.parse(fs.readFileSync(tracePath, 'utf8')); + } catch { + // Skip if file doesn't exist (CI) + console.log('regression.json not found, skipping data-driven test'); + return; + } + + // Track all createMessage and updateMessage calls + const idCounter = { tool: 0, assistant: 0 }; + mockCreateMessage.mockImplementation(async (params: any) => { + if (params.role === 'tool') { + idCounter.tool++; + return { id: `tool-${idCounter.tool}` }; + } + idCounter.assistant++; + return { id: `ast-${idCounter.assistant}` }; + }); + + // Collect tools[] writes per assistant + const toolsRegistry = new Map>(); + mockUpdateMessage.mockImplementation(async (id: string, val: any) => { + if (val.tools && Array.isArray(val.tools)) { + if (!toolsRegistry.has(id)) toolsRegistry.set(id, new Set()); + const set = toolsRegistry.get(id)!; + for (const t of val.tools) { + if (t.id) set.add(t.id); + } + } + }); + + // Collect tool messages: { tool_call_id → parentId (assistant) } + const toolMessages = new Map(); + const origCreate = mockCreateMessage.getMockImplementation()!; + mockCreateMessage.mockImplementation(async (params: any) => { + const result = await origCreate(params); + if (params.role === 'tool' && params.tool_call_id) { + toolMessages.set(params.tool_call_id, params.parentId); + } + return result; + }); + + // Extract raw lines from trace + const rawLines = traceData.map((entry: any) => entry.rawLine); + + await runWithEvents(rawLines); + + // ── Check for orphans ── + // An orphan is a tool message whose tool_call_id doesn't appear in ANY + // assistant's tools[] registry + const allRegisteredToolIds = new Set(); + for (const toolIds of toolsRegistry.values()) { + for (const id of toolIds) allRegisteredToolIds.add(id); + } + + const orphans: string[] = []; + for (const [toolCallId, parentId] of toolMessages) { + if (!allRegisteredToolIds.has(toolCallId)) { + orphans.push(`tool_call_id=${toolCallId} parentId=${parentId}`); + } + } + + if (orphans.length > 0) { + console.error('Orphan tools found:', orphans); + } + expect(orphans).toEqual([]); + + // ── Sanity checks ── + // Should have created many tool messages (trace has ~60 tool calls) + expect(toolMessages.size).toBeGreaterThan(20); + // Should have many assistants + expect(idCounter.assistant).toBeGreaterThan(10); + }); + }); + + // ──────────────────────────────────────────────────── + // Full multi-step E2E + // ──────────────────────────────────────────────────── + + describe('full multi-step E2E', () => { + it('should produce correct DB write sequence for Read → Write → text flow', async () => { + const idCounter = { tool: 0, assistant: 0 }; + mockCreateMessage.mockImplementation(async (params: any) => { + if (params.role === 'tool') { + idCounter.tool++; + return { id: `tool-${idCounter.tool}` }; + } + idCounter.assistant++; + return { id: `ast-new-${idCounter.assistant}` }; + }); + + await runWithEvents([ + ccInit(), + // Turn 1: Read tool + ccAssistant('msg_01', [{ thinking: 'Need to read the file', type: 'thinking' }]), + ccToolUse('msg_01', 'toolu_read', 'Read', { file_path: '/src/app.ts' }), + ccToolResult('toolu_read', 'export default function App() {}'), + // Turn 2: Write tool (new message.id) + ccToolUse('msg_02', 'toolu_write', 'Write', { file_path: '/src/app.ts', content: 'fixed' }), + ccToolResult('toolu_write', 'File written'), + // Turn 3: final summary (new message.id) + ccText('msg_03', 'Fixed the bug in app.ts.'), + ccResult(), + ]); + + // --- Verify DB write sequence --- + + // 1. Tool message created for Read (parentId = initial assistant) + const readToolCreate = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'tool' && p.tool_call_id === 'toolu_read', + ); + expect(readToolCreate![0].parentId).toBe('ast-initial'); + expect(readToolCreate![0].plugin.apiName).toBe('Read'); + + // 2. Read tool result written + expect(mockUpdateToolMessage).toHaveBeenCalledWith( + 'tool-1', + expect.objectContaining({ content: 'export default function App() {}' }), + expect.any(Object), + ); + + // 3. Step 2 assistant created with parentId = tool-1 (Read tool message) + const step2Create = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'assistant' && p.parentId === 'tool-1', + ); + expect(step2Create).toBeDefined(); + + // 4. Write tool message created (parentId = step 2 assistant) + const writeToolCreate = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'tool' && p.tool_call_id === 'toolu_write', + ); + expect(writeToolCreate).toBeDefined(); + expect(writeToolCreate![0].parentId).toBe('ast-new-1'); + + // 5. Write tool result written + expect(mockUpdateToolMessage).toHaveBeenCalledWith( + 'tool-2', + expect.objectContaining({ content: 'File written' }), + expect.any(Object), + ); + + // 6. Step 3 assistant created with parentId = tool-2 (Write tool message) + const step3Create = mockCreateMessage.mock.calls.find( + ([p]: any) => p.role === 'assistant' && p.parentId === 'tool-2', + ); + expect(step3Create).toBeDefined(); + + // 7. Final content written to the last assistant message + const finalContentWrite = mockUpdateMessage.mock.calls.find( + ([, val]: any) => val.content === 'Fixed the bug in app.ts.', + ); + expect(finalContentWrite).toBeDefined(); + }); + }); +}); diff --git a/src/store/chat/slices/aiChat/actions/conversationControl.ts b/src/store/chat/slices/aiChat/actions/conversationControl.ts index e7ed0c4547..da44a07660 100644 --- a/src/store/chat/slices/aiChat/actions/conversationControl.ts +++ b/src/store/chat/slices/aiChat/actions/conversationControl.ts @@ -4,6 +4,7 @@ import { MESSAGE_CANCEL_FLAT } from '@lobechat/const'; import { type ConversationContext } from '@lobechat/types'; import { operationSelectors } from '@/store/chat/slices/operation/selectors'; +import { AI_RUNTIME_OPERATION_TYPES } from '@/store/chat/slices/operation/types'; import { type ChatStore } from '@/store/chat/store'; import { type StoreSetter } from '@/store/types'; @@ -95,14 +96,11 @@ export class ConversationControlActionImpl { const { activeAgentId, activeTopicId, cancelOperations } = this.#get(); // Cancel running agent-runtime operations in the current context — - // both client-side (execAgentRuntime) and Gateway-mode - // (execServerAgentRuntime). For the Gateway-mode branch, a cancel - // handler registered in `executeGatewayAgent` / `reconnectToGatewayOperation` - // picks up the cancellation and forwards an `interrupt` frame over the - // Agent Gateway WebSocket so the server-side loop aborts. + // client-side (execAgentRuntime), heterogeneous agent (execHeterogeneousAgent), + // and Gateway-mode (execServerAgentRuntime). cancelOperations( { - type: ['execAgentRuntime', 'execServerAgentRuntime'], + type: AI_RUNTIME_OPERATION_TYPES, status: 'running', agentId: activeAgentId, topicId: activeTopicId, diff --git a/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts b/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts index 07ed479b9b..76d9c75853 100644 --- a/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +++ b/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts @@ -1,7 +1,7 @@ // Disable the auto sort key eslint rule to make the code more logic and readable import { createCallAgentManifest } from '@lobechat/builtin-tool-agent-management'; import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const'; -import { LOADING_FLAT } from '@lobechat/const'; +import { isDesktop, LOADING_FLAT } from '@lobechat/const'; import { formatSelectedSkillsContext, formatSelectedToolsContext } from '@lobechat/context-engine'; import { chainCompressContext } from '@lobechat/prompts'; import { @@ -23,7 +23,7 @@ import { resolveSelectedSkillsWithContent } from '@/services/chat/mecha/skillPre import { resolveSelectedToolsWithContent } from '@/services/chat/mecha/toolPreload'; import { messageService } from '@/services/message'; import { getAgentStoreState } from '@/store/agent'; -import { agentSelectors } from '@/store/agent/selectors'; +import { agentByIdSelectors, agentSelectors } from '@/store/agent/selectors'; import { agentGroupByIdSelectors, getChatGroupStoreState } from '@/store/agentGroup'; import { type ChatStore } from '@/store/chat/store'; import { @@ -340,6 +340,141 @@ export class ConversationLifecycleActionImpl { inputSendErrorMsg: undefined, }); + // ── External agent mode: delegate to heterogeneous agent CLI (desktop only) ── + // Per-agent heterogeneousProvider config takes priority over the global gateway mode. + const agentConfig = agentSelectors.getAgentConfigById(agentId)(getAgentStoreState()); + const heterogeneousProvider = agentConfig?.agencyConfig?.heterogeneousProvider; + if (isDesktop && heterogeneousProvider?.type === 'claudecode') { + // Persist messages to DB first (same as client mode) + let heteroData: SendMessageServerResponse | undefined; + try { + const { model, provider } = + agentSelectors.getAgentConfigById(agentId)(getAgentStoreState()); + heteroData = await aiChatService.sendMessageInServer( + { + agentId: operationContext.agentId, + groupId: operationContext.groupId ?? undefined, + newAssistantMessage: { model, provider: provider! }, + newTopic: !operationContext.topicId + ? { + title: message.slice(0, 20) || t('defaultTitle', { ns: 'topic' }), + topicMessageIds: messages.map((m) => m.id), + } + : undefined, + newUserMessage: { + content: message, + editorData, + files: fileIdList, + pageSelections, + parentId, + }, + threadId: operationContext.threadId ?? undefined, + topicId: operationContext.topicId ?? undefined, + }, + abortController, + ); + } catch (e) { + console.error('[HeterogeneousAgent] Failed to persist messages:', e); + this.#get().failOperation(operationId, { + message: e instanceof Error ? e.message : 'Unknown error', + type: 'HeterogeneousAgentError', + }); + return; + } + + if (!heteroData) return; + + // Update context with server-created topicId + const heteroContext = { + ...operationContext, + topicId: heteroData.topicId ?? operationContext.topicId, + }; + + // Replace optimistic messages with persisted ones + this.#get().replaceMessages(heteroData.messages, { + action: 'sendMessage/serverResponse', + context: heteroContext, + }); + + // Handle new topic creation + if (heteroData.isCreateNewTopic && heteroData.topicId) { + if (heteroData.topics) { + const pageSize = systemStatusSelectors.topicPageSize(useGlobalStore.getState()); + this.#get().internal_updateTopics(operationContext.agentId, { + groupId: operationContext.groupId, + items: heteroData.topics.items, + pageSize, + total: heteroData.topics.total, + }); + } + await this.#get().switchTopic(heteroData.topicId, { + clearNewKey: true, + skipRefreshMessage: true, + }); + } + + // Clean up temp messages + this.#get().internal_dispatchMessage( + { ids: [tempId, tempAssistantId], type: 'deleteMessages' }, + { operationId }, + ); + + // Complete sendMessage operation, start ACP execution as child operation + this.#get().completeOperation(operationId); + + if (heteroData.topicId) this.#get().internal_updateTopicLoading(heteroData.topicId, true); + + // Start heterogeneous agent execution + const { operationId: heteroOpId } = this.#get().startOperation({ + context: heteroContext, + label: 'Heterogeneous Agent Execution', + parentOperationId: operationId, + type: 'execHeterogeneousAgent', + }); + + this.#get().associateMessageWithOperation(heteroData.assistantMessageId, heteroOpId); + + try { + const { executeHeterogeneousAgent } = await import('./heterogeneousAgentExecutor'); + const workingDirectory = + agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(getAgentStoreState()); + // Extract imageList from the persisted user message (chatUploadFileList + // may already be cleared by this point, so we read from DB instead) + const userMsg = heteroData.messages.find((m: any) => m.id === heteroData.userMessageId); + const persistedImageList = userMsg?.imageList; + + // Read CC session ID from topic metadata for multi-turn resume + const topic = heteroContext.topicId + ? topicSelectors.getTopicById(heteroContext.topicId)(this.#get()) + : undefined; + const resumeSessionId = topic?.metadata?.ccSessionId; + + await executeHeterogeneousAgent(() => this.#get(), { + assistantMessageId: heteroData.assistantMessageId, + context: heteroContext, + heterogeneousProvider, + imageList: persistedImageList?.length ? persistedImageList : undefined, + message, + operationId: heteroOpId, + resumeSessionId, + workingDirectory, + }); + } catch (e) { + console.error('[HeterogeneousAgent] Execution failed:', e); + this.#get().failOperation(heteroOpId, { + message: e instanceof Error ? e.message : 'Unknown error', + type: 'HeterogeneousAgentError', + }); + } + + if (heteroData.topicId) this.#get().internal_updateTopicLoading(heteroData.topicId, false); + + return { + assistantMessageId: heteroData.assistantMessageId, + userMessageId: heteroData.userMessageId, + }; + } + // ── Gateway mode: skip sendMessageInServer, let execAgentTask handle everything ── if (this.#get().isGatewayModeEnabled()) { this.#get().completeOperation(operationId); diff --git a/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts b/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts new file mode 100644 index 0000000000..e0d3aa6c79 --- /dev/null +++ b/src/store/chat/slices/aiChat/actions/heterogeneousAgentExecutor.ts @@ -0,0 +1,649 @@ +import type { AgentStreamEvent } from '@lobechat/agent-gateway-client'; +import type { HeterogeneousAgentEvent, ToolCallPayload } from '@lobechat/heterogeneous-agents'; +import { createAdapter } from '@lobechat/heterogeneous-agents'; +import type { + ChatToolPayload, + ConversationContext, + HeterogeneousProviderConfig, +} from '@lobechat/types'; + +import { heterogeneousAgentService } from '@/services/electron/heterogeneousAgent'; +import { messageService } from '@/services/message'; +import type { ChatStore } from '@/store/chat/store'; + +import { createGatewayEventHandler } from './gatewayEventHandler'; + +export interface HeterogeneousAgentExecutorParams { + assistantMessageId: string; + context: ConversationContext; + heterogeneousProvider: HeterogeneousProviderConfig; + /** Image attachments from user message — passed to Main for vision support */ + imageList?: Array<{ id: string; url: string }>; + message: string; + operationId: string; + /** CC session ID from previous execution in this topic (for --resume) */ + resumeSessionId?: string; + workingDirectory?: string; +} + +/** + * Map heterogeneousProvider.command to adapter type key. + */ +const resolveAdapterType = (config: HeterogeneousProviderConfig): string => { + // Explicit adapterType in config takes priority + if ((config as any).adapterType) return (config as any).adapterType; + + // Infer from command name + const cmd = config.command || 'claude'; + if (cmd.includes('claude')) return 'claude-code'; + if (cmd.includes('codex')) return 'codex'; + if (cmd.includes('kimi')) return 'kimi-cli'; + + return 'claude-code'; // default +}; + +/** + * Convert HeterogeneousAgentEvent to AgentStreamEvent (add operationId). + */ +const toStreamEvent = (event: HeterogeneousAgentEvent, operationId: string): AgentStreamEvent => ({ + data: event.data, + operationId, + stepIndex: event.stepIndex, + timestamp: event.timestamp, + type: event.type as AgentStreamEvent['type'], +}); + +/** + * Subscribe to Electron IPC broadcasts for raw agent lines. + * Returns unsubscribe function. + */ +const subscribeBroadcasts = ( + sessionId: string, + callbacks: { + onComplete: () => void; + onError: (error: string) => void; + onRawLine: (line: any) => void; + }, +): (() => void) => { + if (!window.electron?.ipcRenderer) return () => {}; + + const ipc = window.electron.ipcRenderer; + + const onLine = (_e: any, data: { line: any; sessionId: string }) => { + if (data.sessionId === sessionId) callbacks.onRawLine(data.line); + }; + const onComplete = (_e: any, data: { sessionId: string }) => { + if (data.sessionId === sessionId) callbacks.onComplete(); + }; + const onError = (_e: any, data: { error: string; sessionId: string }) => { + if (data.sessionId === sessionId) callbacks.onError(data.error); + }; + + ipc.on('heteroAgentRawLine' as any, onLine); + ipc.on('heteroAgentSessionComplete' as any, onComplete); + ipc.on('heteroAgentSessionError' as any, onError); + + return () => { + ipc.removeListener('heteroAgentRawLine' as any, onLine); + ipc.removeListener('heteroAgentSessionComplete' as any, onComplete); + ipc.removeListener('heteroAgentSessionError' as any, onError); + }; +}; + +/** + * Persisted tool-call registry for a single ACP execution. + * + * Tracks which tool_use ids have been persisted to avoid duplicates, + * and holds the enriched payload (with result_msg_id) that gets written + * back to the assistant message's tools JSONB. + */ +interface ToolPersistenceState { + /** Ordered list of ChatToolPayload[] written to assistant.tools */ + payloads: ChatToolPayload[]; + /** Set of tool_use.id that have been persisted (de-dupe guard) */ + persistedIds: Set; + /** Map tool_use.id → tool message DB id (for later content update on tool_result) */ + toolMsgIdByCallId: Map; +} + +/** + * Persist any newly-seen tool calls and update the assistant message's tools JSONB. + * + * Guarantees: + * - One tool message per unique tool_use.id (idempotent against re-processing) + * - assistant.tools[].result_msg_id is set to the created tool message id, so + * the UI's parse() step can link tool messages back to the assistant turn + * (otherwise they render as orphan warnings). + */ +const persistNewToolCalls = async ( + incoming: ToolCallPayload[], + state: ToolPersistenceState, + assistantMessageId: string, + context: ConversationContext, +) => { + const freshTools = incoming.filter((t) => !state.persistedIds.has(t.id)); + if (freshTools.length === 0) return; + + // Mark all fresh tools as persisted up front, so re-entrant calls (from + // Claude Code echoing tool_use blocks) are safely deduped. + for (const tool of freshTools) state.persistedIds.add(tool.id); + + // ─── PHASE 1: Write tools[] to assistant FIRST, WITHOUT result_msg_id ─── + // + // LobeHub's conversation-flow parser filters tool messages by matching + // `tool.tool_call_id` against `assistant.tools[].id`. If a tool message + // exists in DB but no matching entry exists in assistant.tools[], the UI + // renders an "orphan" warning telling the user to delete it. + // + // By writing assistant.tools[] FIRST (with the tool ids but no result_msg_id + // yet), the match works from the moment tool messages get created in DB. + // No orphan window. + for (const tool of freshTools) state.payloads.push({ ...tool } as ChatToolPayload); + try { + await messageService.updateMessage( + assistantMessageId, + { tools: state.payloads }, + { agentId: context.agentId, topicId: context.topicId }, + ); + } catch (err) { + console.error('[HeterogeneousAgent] Failed to pre-register assistant tools:', err); + } + + // ─── PHASE 2: Create the tool messages in DB ─── + // Each tool message's tool_call_id matches an already-registered tool id + // in assistant.tools[], so UI never sees orphan state. + for (const tool of freshTools) { + try { + const result = await messageService.createMessage({ + agentId: context.agentId, + content: '', + parentId: assistantMessageId, + plugin: { + apiName: tool.apiName, + arguments: tool.arguments, + identifier: tool.identifier, + type: tool.type as ChatToolPayload['type'], + }, + role: 'tool', + tool_call_id: tool.id, + topicId: context.topicId ?? undefined, + }); + state.toolMsgIdByCallId.set(tool.id, result.id); + // Back-fill result_msg_id onto the payload we pushed in PHASE 1 + const entry = state.payloads.find((p) => p.id === tool.id); + if (entry) entry.result_msg_id = result.id; + } catch (err) { + console.error('[HeterogeneousAgent] Failed to create tool message:', err); + } + } + + // ─── PHASE 3: Re-write assistant.tools[] with the result_msg_ids ─── + // Without this, the UI can't hydrate tool results back into the inspector. + try { + await messageService.updateMessage( + assistantMessageId, + { tools: state.payloads }, + { agentId: context.agentId, topicId: context.topicId }, + ); + } catch (err) { + console.error('[HeterogeneousAgent] Failed to finalize assistant tools:', err); + } +}; + +/** + * Update a tool message's content in DB when tool_result arrives. + */ +const persistToolResult = async ( + toolCallId: string, + content: string, + isError: boolean, + state: ToolPersistenceState, + context: ConversationContext, +) => { + const toolMsgId = state.toolMsgIdByCallId.get(toolCallId); + if (!toolMsgId) { + console.warn('[HeterogeneousAgent] tool_result for unknown toolCallId:', toolCallId); + return; + } + + try { + await messageService.updateToolMessage( + toolMsgId, + { + content, + pluginError: isError ? { message: content } : undefined, + }, + { + agentId: context.agentId, + topicId: context.topicId, + }, + ); + } catch (err) { + console.error('[HeterogeneousAgent] Failed to update tool message content:', err); + } +}; + +/** + * Execute a prompt via an external agent CLI. + * + * Flow: + * 1. Subscribe to IPC broadcasts + * 2. Spawn agent process via heterogeneousAgentService + * 3. Raw stdout lines → Adapter → HeterogeneousAgentEvent → AgentStreamEvent + * 4. Feed AgentStreamEvents into createGatewayEventHandler (unified handler) + * 5. Tool messages created via messageService before emitting tool events + */ +export const executeHeterogeneousAgent = async ( + get: () => ChatStore, + params: HeterogeneousAgentExecutorParams, +): Promise => { + const { + heterogeneousProvider, + assistantMessageId, + context, + imageList, + message, + operationId, + resumeSessionId, + workingDirectory, + } = params; + + // Create adapter for this agent type + const adapterType = resolveAdapterType(heterogeneousProvider); + const adapter = createAdapter(adapterType); + + // Create the unified event handler (same one Gateway uses) + const eventHandler = createGatewayEventHandler(get, { + assistantMessageId, + context, + operationId, + }); + + let agentSessionId: string | undefined; + let unsubscribe: (() => void) | undefined; + let completed = false; + + // Track state for DB persistence + const toolState: ToolPersistenceState = { + payloads: [], + persistedIds: new Set(), + toolMsgIdByCallId: new Map(), + }; + /** Serializes async persist operations so ordering is stable. */ + let persistQueue: Promise = Promise.resolve(); + /** Tracks the current assistant message being written to (switches on new steps) */ + let currentAssistantMessageId = assistantMessageId; + /** Content accumulators — reset on each new step */ + let accumulatedContent = ''; + let accumulatedReasoning = ''; + /** Extracted model + usage from each assistant event (used for final write) */ + let lastModel: string | undefined; + const accumulatedUsage: Record = { + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + input_tokens: 0, + output_tokens: 0, + }; + /** + * Deferred terminal event (agent_runtime_end or error). We don't forward + * these to the gateway handler immediately because handler triggers + * fetchAndReplaceMessages which would clobber our in-flight content + * writes with stale DB state. onComplete forwards after persistence. + */ + let deferredTerminalEvent: HeterogeneousAgentEvent | null = null; + /** + * True while a step transition is in flight (stream_start queued but not yet + * forwarded to handler). Events that would normally be forwarded sync must + * be deferred through persistQueue so the handler receives stream_start first. + * Without this, tools_calling gets dispatched to the OLD assistant → orphan. + */ + let pendingStepTransition = false; + + // Subscribe to the operation's abort signal so we can drop late events and + // stop writing to DB the moment the user clicks Stop. If the op is gone + // (cleaned up already) or missing in a test stub, treat as not-aborted. + const abortSignal = get().operations?.[operationId]?.abortController?.signal; + const isAborted = () => !!abortSignal?.aborted; + + try { + // Start session (pass resumeSessionId for multi-turn --resume) + const result = await heterogeneousAgentService.startSession({ + agentType: adapterType, + args: heterogeneousProvider.args, + command: heterogeneousProvider.command || 'claude', + cwd: workingDirectory, + env: heterogeneousProvider.env, + resumeSessionId, + }); + agentSessionId = result.sessionId; + if (!agentSessionId) throw new Error('Agent session returned no sessionId'); + + // Register cancel hook on the operation — when the user hits Stop, the op + // framework calls this; we SIGINT the CC process via the main-process IPC + // so the CLI exits instead of running to completion off-screen. + const sidForCancel = agentSessionId; + get().onOperationCancel?.(operationId, () => { + heterogeneousAgentService.cancelSession(sidForCancel).catch(() => {}); + }); + + // ─── Debug tracing (dev only) ─── + const trace: Array<{ adaptedEvents: any[]; rawLine: any; timestamp: number }> = []; + if (typeof window !== 'undefined') { + (window as any).__HETERO_AGENT_TRACE = trace; + } + + // Subscribe to broadcasts BEFORE sending prompt + unsubscribe = subscribeBroadcasts(agentSessionId, { + onRawLine: (line) => { + // Once the user cancels, drop any trailing events the CLI emits before + // exit so they don't leak into DB writes. + if (isAborted()) return; + const events = adapter.adapt(line); + + // Record for debugging + trace.push({ + adaptedEvents: events.map((e) => ({ data: e.data, type: e.type })), + rawLine: line, + timestamp: Date.now(), + }); + + for (const event of events) { + // ─── tool_result: update tool message content in DB (ACP-only) ─── + if (event.type === 'tool_result') { + const { content, isError, toolCallId } = event.data as { + content: string; + isError?: boolean; + toolCallId: string; + }; + persistQueue = persistQueue.then(() => + persistToolResult(toolCallId, content, !!isError, toolState, context), + ); + // Don't forward — the tool_end that follows triggers fetchAndReplaceMessages + // which reads the updated content from DB. + continue; + } + + // ─── step_complete with result_usage: authoritative total from CC result event ─── + if (event.type === 'step_complete' && event.data?.phase === 'result_usage') { + if (event.data.usage) { + // Override (not accumulate) — result event has the correct totals + accumulatedUsage.input_tokens = event.data.usage.input_tokens || 0; + accumulatedUsage.output_tokens = event.data.usage.output_tokens || 0; + accumulatedUsage.cache_creation_input_tokens = + event.data.usage.cache_creation_input_tokens || 0; + accumulatedUsage.cache_read_input_tokens = + event.data.usage.cache_read_input_tokens || 0; + } + continue; + } + + // ─── step_complete with turn_metadata: capture model + usage ─── + if (event.type === 'step_complete' && event.data?.phase === 'turn_metadata') { + if (event.data.model) lastModel = event.data.model; + if (event.data.usage) { + // Accumulate token usage across turns (deduped by adapter per message.id) + accumulatedUsage.input_tokens += event.data.usage.input_tokens || 0; + accumulatedUsage.output_tokens += event.data.usage.output_tokens || 0; + accumulatedUsage.cache_creation_input_tokens += + event.data.usage.cache_creation_input_tokens || 0; + accumulatedUsage.cache_read_input_tokens += + event.data.usage.cache_read_input_tokens || 0; + } + // Don't forward turn metadata — it's internal bookkeeping + continue; + } + + // ─── stream_start with newStep: new LLM turn, create new assistant message ─── + if (event.type === 'stream_start' && event.data?.newStep) { + // ⚠️ Snapshot CONTENT accumulators synchronously — stream_chunk events for + // the new step arrive in the same onRawLine batch and would contaminate. + // Tool state (toolMsgIdByCallId) is populated ASYNC by persistQueue, so + // it must be read inside the queue where previous persists have completed. + const prevContent = accumulatedContent; + const prevReasoning = accumulatedReasoning; + const prevModel = lastModel; + + // Reset content accumulators synchronously so new-step chunks go to fresh state + accumulatedContent = ''; + accumulatedReasoning = ''; + + // Mark that we're in a step transition. Events from the same onRawLine + // batch (stream_chunk, tool_start, etc.) must be deferred through + // persistQueue so the handler receives stream_start FIRST — otherwise + // it dispatches tools to the OLD assistant (orphan tool bug). + pendingStepTransition = true; + + persistQueue = persistQueue.then(async () => { + // Persist previous step's content to its assistant message + const prevUpdate: Record = {}; + if (prevContent) prevUpdate.content = prevContent; + if (prevReasoning) prevUpdate.reasoning = { content: prevReasoning }; + if (prevModel) prevUpdate.model = prevModel; + if (Object.keys(prevUpdate).length > 0) { + await messageService + .updateMessage(currentAssistantMessageId, prevUpdate, { + agentId: context.agentId, + topicId: context.topicId, + }) + .catch(console.error); + } + + // Create new assistant message for this step. + // parentId should point to the last tool message from the previous step + // (if any), forming the chain: assistant → tool → assistant → tool → ... + // If no tool was used, fall back to the previous assistant message. + // Read toolMsgIdByCallId HERE (async) because it's populated by prior persists. + const lastToolMsgId = [...toolState.toolMsgIdByCallId.values()].pop(); + const stepParentId = lastToolMsgId || currentAssistantMessageId; + + const newMsg = await messageService.createMessage({ + agentId: context.agentId, + content: '', + model: lastModel, + parentId: stepParentId, + role: 'assistant', + topicId: context.topicId ?? undefined, + }); + currentAssistantMessageId = newMsg.id; + + // Associate the new message with the operation + get().associateMessageWithOperation(currentAssistantMessageId, operationId); + + // Reset tool state AFTER reading — new-step tool persists are queued + // AFTER this handler, so they'll write to the clean state. + toolState.payloads = []; + toolState.persistedIds.clear(); + toolState.toolMsgIdByCallId.clear(); + }); + + // Update the stream_start event to carry the new message ID + // so the gateway handler can switch to it + persistQueue = persistQueue.then(() => { + event.data.assistantMessage = { id: currentAssistantMessageId }; + eventHandler(toStreamEvent(event, operationId)); + // Step transition complete — handler has the new assistant ID now + pendingStepTransition = false; + }); + continue; + } + + // ─── Defer terminal events so content writes complete first ─── + // Gateway handler's agent_runtime_end/error triggers fetchAndReplaceMessages, + // which would read stale DB state (before we persist final content + usage). + if (event.type === 'agent_runtime_end' || event.type === 'error') { + deferredTerminalEvent = event; + continue; + } + + // ─── stream_chunk: accumulate content + persist tool_use ─── + if (event.type === 'stream_chunk') { + const chunk = event.data; + if (chunk?.chunkType === 'text' && chunk.content) { + accumulatedContent += chunk.content; + } + if (chunk?.chunkType === 'reasoning' && chunk.reasoning) { + accumulatedReasoning += chunk.reasoning; + } + if (chunk?.chunkType === 'tools_calling') { + const tools = chunk.toolsCalling as ToolCallPayload[]; + if (tools?.length) { + persistQueue = persistQueue.then(() => + persistNewToolCalls(tools, toolState, currentAssistantMessageId, context), + ); + } + } + } + + // Forward to the unified Gateway handler. + // If a step transition is pending, defer through persistQueue so the + // handler receives stream_start (with new assistant ID) FIRST. + if (pendingStepTransition) { + const snapshot = toStreamEvent(event, operationId); + persistQueue = persistQueue.then(() => { + eventHandler(snapshot); + }); + } else { + eventHandler(toStreamEvent(event, operationId)); + } + } + }, + + onComplete: async () => { + if (completed) return; + completed = true; + + // Flush remaining adapter state (e.g., still-open tool_end events — but + // NOT agent_runtime_end; that's deferred below) + const flushEvents = adapter.flush(); + for (const event of flushEvents) { + if (event.type === 'agent_runtime_end' || event.type === 'error') { + deferredTerminalEvent = event; + continue; + } + eventHandler(toStreamEvent(event, operationId)); + } + + // Wait for all tool persistence to finish before writing final state + await persistQueue.catch(console.error); + + // Persist final content + reasoning + model + usage to the assistant message + // BEFORE the terminal event triggers fetchAndReplaceMessages. + const updateValue: Record = {}; + if (accumulatedContent) updateValue.content = accumulatedContent; + if (accumulatedReasoning) updateValue.reasoning = { content: accumulatedReasoning }; + if (lastModel) updateValue.model = lastModel; + const inputCacheMiss = accumulatedUsage.input_tokens; + const inputCached = accumulatedUsage.cache_read_input_tokens; + const inputWriteCache = accumulatedUsage.cache_creation_input_tokens; + const totalInputTokens = inputCacheMiss + inputCached + inputWriteCache; + const totalOutputTokens = accumulatedUsage.output_tokens; + + if (totalInputTokens + totalOutputTokens > 0) { + updateValue.metadata = { + // Use nested `usage` — the flat fields on MessageMetadata are deprecated. + // Shape mirrors the anthropic usage converter so CC CLI and Gateway turns + // render identically in pricing/usage UI. + usage: { + inputCacheMissTokens: inputCacheMiss, + inputCachedTokens: inputCached || undefined, + inputWriteCacheTokens: inputWriteCache || undefined, + totalInputTokens, + totalOutputTokens, + totalTokens: totalInputTokens + totalOutputTokens, + }, + }; + } + + if (Object.keys(updateValue).length > 0) { + await messageService + .updateMessage(currentAssistantMessageId, updateValue, { + agentId: context.agentId, + topicId: context.topicId, + }) + .catch(console.error); + } + + // NOW forward the deferred terminal event — handler will fetchAndReplaceMessages + // and pick up the final persisted state. + const terminal = deferredTerminalEvent ?? { + data: {}, + stepIndex: 0, + timestamp: Date.now(), + type: 'agent_runtime_end' as const, + }; + eventHandler(toStreamEvent(terminal, operationId)); + }, + + onError: async (error) => { + if (completed) return; + completed = true; + + await persistQueue.catch(console.error); + + if (accumulatedContent) { + await messageService + .updateMessage( + currentAssistantMessageId, + { content: accumulatedContent }, + { + agentId: context.agentId, + topicId: context.topicId, + }, + ) + .catch(console.error); + } + + // If the error came from a user-initiated cancel (SIGINT → non-zero + // exit), don't surface it as a runtime error toast — the operation is + // already marked cancelled and the partial content is persisted above. + if (isAborted()) return; + + eventHandler( + toStreamEvent( + { + data: { error, message: error }, + stepIndex: 0, + timestamp: Date.now(), + type: 'error', + }, + operationId, + ), + ); + }, + }); + + // Send the prompt — blocks until process exits + await heterogeneousAgentService.sendPrompt(agentSessionId, message, imageList); + + // Persist CC session ID to topic metadata for multi-turn resume. + // The adapter extracts session_id from the CC init event. + if (adapter.sessionId && context.topicId) { + get().updateTopicMetadata(context.topicId, { + ccSessionId: adapter.sessionId, + }); + } + } catch (error) { + if (!completed) { + completed = true; + // `sendPrompt` rejects when the CLI exits non-zero, which is how SIGINT + // lands here too. If the user cancelled, don't surface an error. + if (isAborted()) return; + const errorMsg = error instanceof Error ? error.message : 'Agent execution failed'; + eventHandler( + toStreamEvent( + { + data: { error: errorMsg, message: errorMsg }, + stepIndex: 0, + timestamp: Date.now(), + type: 'error', + }, + operationId, + ), + ); + } + } finally { + unsubscribe?.(); + // Don't stopSession here — keep it alive for multi-turn resume. + // Session cleanup happens on topic deletion or Electron quit. + } +}; diff --git a/src/store/chat/slices/operation/actions.ts b/src/store/chat/slices/operation/actions.ts index c431599e54..e3a07e0ab2 100644 --- a/src/store/chat/slices/operation/actions.ts +++ b/src/store/chat/slices/operation/actions.ts @@ -10,6 +10,7 @@ import { setNamespace } from '@/utils/storeDebug'; import { type AfterCompletionCallback, + AI_RUNTIME_OPERATION_TYPES, type Operation, type OperationCancelContext, type OperationContext, @@ -391,11 +392,9 @@ export class OperationActionsImpl { // 2. Set isAborting flag immediately for agent-runtime operations. // This ensures UI (loading button) responds instantly to user cancellation. - // Applies to both client-side (execAgentRuntime) and Gateway-mode - // (execServerAgentRuntime) runs — the latter needs the flag so the UI - // transitions out of loading right away, without waiting for the - // round-trip WS `session_complete` after the server acknowledges interrupt. - if (operation.type === 'execAgentRuntime' || operation.type === 'execServerAgentRuntime') { + // Applies to all AI runtime operation types so the UI transitions out of + // loading right away without waiting for the process to fully terminate. + if (AI_RUNTIME_OPERATION_TYPES.includes(operation.type)) { this.#get().updateOperationMetadata(operationId, { isAborting: true }); } diff --git a/src/store/chat/slices/operation/selectors.ts b/src/store/chat/slices/operation/selectors.ts index 7b5db5482e..c2b7809f56 100644 --- a/src/store/chat/slices/operation/selectors.ts +++ b/src/store/chat/slices/operation/selectors.ts @@ -203,7 +203,7 @@ const hasRunningOperationByContext = /** * Check if agent runtime is running in a specific context - * Checks both client-side (execAgentRuntime) and server-side (execServerAgentRuntime) operations + * Checks all AI runtime operation types (see AI_RUNTIME_OPERATION_TYPES) */ const isAgentRuntimeRunningByContext = (context: { @@ -303,7 +303,7 @@ const isAgentRunning = /** * Check if agent runtime is running (including both main window and thread) - * Checks both client-side (execAgentRuntime) and server-side (execServerAgentRuntime) operations + * Checks all AI runtime operation types (see AI_RUNTIME_OPERATION_TYPES) * Excludes operations that are aborting (cleaning up after cancellation) */ const isAgentRuntimeRunning = (s: ChatStoreState): boolean => { @@ -385,7 +385,7 @@ const isMessageProcessing = /** * Check if a specific message is being generated (AI generation only) - * Checks both client-side (execAgentRuntime) and server-side (execServerAgentRuntime) operations + * Checks all AI runtime operation types (see AI_RUNTIME_OPERATION_TYPES) */ const isMessageGenerating = (messageId: string) => diff --git a/src/store/chat/slices/operation/types.ts b/src/store/chat/slices/operation/types.ts index de6f20c133..79e64f63d1 100644 --- a/src/store/chat/slices/operation/types.ts +++ b/src/store/chat/slices/operation/types.ts @@ -18,6 +18,7 @@ export type OperationType = // === AI generation === | 'execAgentRuntime' // Execute agent runtime (client-side, entire agent runtime execution) | 'execServerAgentRuntime' // Execute server agent runtime (server-side, e.g., Group Chat) + | 'execHeterogeneousAgent' | 'createAssistantMessage' // Create assistant message (sub-operation of execAgentRuntime) // === LLM execution (sub-operations) === | 'callLLM' // Call LLM streaming response (sub-operation of execAgentRuntime) @@ -315,10 +316,12 @@ export interface OperationFilter { * * Includes: * - execAgentRuntime: Client-side agent execution (single chat) + * - execHeterogeneousAgent: Heterogeneous agent execution (Claude Code CLI, etc.) * - execServerAgentRuntime: Server-side agent execution (Group Chat) */ export const AI_RUNTIME_OPERATION_TYPES: OperationType[] = [ 'execAgentRuntime', + 'execHeterogeneousAgent', 'execServerAgentRuntime', ]; diff --git a/src/store/electron/actions/sync.ts b/src/store/electron/actions/sync.ts index 4f6346736c..82c9695e3b 100644 --- a/src/store/electron/actions/sync.ts +++ b/src/store/electron/actions/sync.ts @@ -122,7 +122,13 @@ export class ElectronRemoteServerActionImpl { }, { onSuccess: (data) => { - if (!isEqual(data, this.#get().dataSyncConfig)) { + const { dataSyncConfig, isInitRemoteServerConfig } = this.#get(); + // Only refresh on genuine config changes AFTER the first hydration. + // On initial load the stores are already fresh, and `refreshUserData` + // runs `stores.reset()` which wipes chat state (notably `activeAgentId`) + // that `AgentIdSync` just set from the URL — leaving the topic list + // unable to resolve its agent scope on reload. + if (isInitRemoteServerConfig && !isEqual(data, dataSyncConfig)) { void this.#get() .refreshUserData() .catch((error) => { diff --git a/src/store/user/slices/preference/selectors/labPrefer.ts b/src/store/user/slices/preference/selectors/labPrefer.ts index 853f4de3ab..9826e2edf3 100644 --- a/src/store/user/slices/preference/selectors/labPrefer.ts +++ b/src/store/user/slices/preference/selectors/labPrefer.ts @@ -6,6 +6,8 @@ export const labPreferSelectors = { enableAgentWorkingPanel: (s: UserState): boolean => s.preference.lab?.enableAgentWorkingPanel ?? false, enableGatewayMode: (s: UserState): boolean => s.preference.lab?.enableGatewayMode ?? false, + enableHeterogeneousAgent: (s: UserState): boolean => + s.preference.lab?.enableHeterogeneousAgent ?? false, enableInputMarkdown: (s: UserState): boolean => s.preference.lab?.enableInputMarkdown ?? DEFAULT_PREFERENCE.lab!.enableInputMarkdown!, }; diff --git a/src/store/utils/createStoreUpdater.ts b/src/store/utils/createStoreUpdater.ts new file mode 100644 index 0000000000..f275442601 --- /dev/null +++ b/src/store/utils/createStoreUpdater.ts @@ -0,0 +1,30 @@ +import { type StoreApi } from 'zustand'; +import { createStoreUpdater as upstream } from 'zustand-utils'; + +/** + * Local wrapper around `zustand-utils`'s `createStoreUpdater`. + * + * The upstream signature types the `value` argument as exactly `T[Key]`, so + * passing `string | undefined` to a `string`-typed key fails the TS check — + * even though the upstream implementation already guards with + * `typeof value !== 'undefined'` and skips the `setState` call in that case. + * + * This wrapper loosens the value type to `T[Key] | null | undefined`, which + * matches the runtime behavior and lets callers feed optional sources (URL + * params, selectors that may return undefined, etc.) directly without a + * lossy `?? ''` fallback that would accidentally write a sentinel into the + * store. + */ +type LooseStoreUpdater = ( + key: Key, + value: T[Key] | null | undefined, + deps?: any[], + setStateFn?: StoreApi['setState'], +) => void; + +type StoreApiLike = { + [K in keyof StoreApi]: StoreApi[K]; +}; + +export const createStoreUpdater = (storeApi: StoreApiLike): LooseStoreUpdater => + upstream(storeApi) as unknown as LooseStoreUpdater;