mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
✨ chore(heterogeneous-agent): integrate heterogeneous agents with claude code (#13754)
* ♻️ refactor(acp): move agent provider to agencyConfig + restore creation entry - Move AgentProviderConfig from chatConfig to agencyConfig.heterogeneousProvider - Rename type from 'acp' to 'claudecode' for clarity - Restore Claude Code agent creation entry in sidebar + menu - Prioritize heterogeneousProvider check over gateway mode in execution flow - Remove ACP settings from AgentChat form (provider is set at creation time) - Add getAgencyConfigById selector for cleaner access - Use existing agent workingDirectory instead of duplicating in provider config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> ✨ feat(acp): defer terminal events + extract model/usage per turn Three improvements to ACP stream handling: 1. Defer agent_runtime_end/error: Previously the adapter emitted terminal events from result.type directly into the Gateway handler. The handler immediately fires fetchAndReplaceMessages which reads stale DB state (before we persist final content/tools). Fix: intercept terminal events in the executor's event loop and forward them only AFTER content + metadata has been written to DB. 2. Extract model/usage per assistant event: Claude Code sets model name and token usage on every assistant event. Adapter now emits a 'step_complete' event with phase='turn_metadata' carrying these. Executor accumulates input/output/cache tokens across turns and persists them onto the assistant message (model + metadata.totalTokens). 3. Missing final text fix: The accumulated assistant text was being written AFTER agent_runtime_end triggered fetchAndReplaceMessages, so the UI rendered stale (empty) content. Deferred terminals solve this. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> 🐛 fix(acp): eliminate orphan-tool warning flicker during streaming Root cause: LobeHub's conversation-flow parser (collectToolMessages) filters tool messages by matching `tool_call_id` against `assistant.tools[].id`. The previous flow created tool messages FIRST, then updated assistant.tools[], which opened a brief window where the UI saw tool messages that had no matching entry in the parent's tools array — rendering them as "orphan" with a scary "请删除" warning to the user. Fix: Reorder persistNewToolCalls into three phases: 1. Pre-register tool entries in assistant.tools[] (id only, no result_msg_id) 2. Create the tool messages in DB (tool_call_id matches pre-registered ids) 3. Back-fill result_msg_id and re-write assistant.tools[] Between phase 1 and phase 3 the UI always sees consistent state: every tool message in DB has a matching entry in the parent's tools array. Verified: orphan count stays at 0 across all sampled timepoints during streaming (vs 1+ before fix). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> 🐛 fix(acp): dedupe tool_use + capture tool_result + persist result_msg_id Three critical fixes to ACP tool-call handling, discovered via live testing: 1. **tool_use dedupe** — Claude Code stream-json previously produced 15+ duplicate tool messages per tool_call_id. The adapter now tracks emitted ids so each tool_use → exactly one tool message. 2. **tool_result content capture** — tool_result blocks live in `type: 'user'` events in Claude Code's stream-json, not in assistant events. The adapter now handles the 'user' event type and emits a new `tool_result` HeterogeneousAgentEvent which the executor consumes to call messageService.updateToolMessage() with the actual result content. Previously all tool messages had empty content. 3. **result_msg_id on assistant.tools[]** — LobeHub's parse() step links tool messages to their parent assistant turn via tools[].result_msg_id. Without it, the UI renders orphan-message warnings. The executor now captures the tool message id returned by messageService.createMessage and writes it back into the assistant.tools[] JSONB. Also adds vitest config + 9 unit tests for the adapter covering lifecycle, content mapping, and tool_result handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> ✨ feat(acp): integrate external AI agents via ACP protocol Adds support for connecting external AI agents (Claude Code and future agents like Codex, Kimi CLI) into LobeHub Desktop via a new heterogeneous agent layer that adapts agent-specific protocols to the unified Gateway event stream. Architecture: - New @lobechat/heterogeneous-agents package: pluggable adapters that convert agent-specific outputs to AgentStreamEvent - AcpCtr (Electron main): agent-agnostic process manager with CLI presets registry, broadcasts raw stdout lines to renderer - acpExecutor (renderer): subscribes to broadcasts, runs events through adapter, feeds into existing createGatewayEventHandler - Tool call persistence: creates role='tool' messages via messageService before emitting tool_start/tool_end to the handler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ♻️ refactor: rename acpExecutor to heterogeneousAgentExecutor - Rename file acpExecutor.ts → heterogeneousAgentExecutor.ts - Rename ACPExecutorParams → HeterogeneousAgentExecutorParams - Rename executeACPAgent → executeHeterogeneousAgent - Change operation type from execAgentRuntime to execHeterogeneousAgent - Change operation label to "Heterogeneous Agent Execution" - Change error type from ACPError to HeterogeneousAgentError - Rename acpData/acpContext variables to heteroData/heteroContext Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ♻️ refactor: rename AcpCtr and acp service to heterogeneousAgent Desktop side: - AcpCtr.ts → HeterogeneousAgentCtr.ts - groupName 'acp' → 'heterogeneousAgent' - IPC channels: acpRawLine → heteroAgentRawLine, etc. Renderer side: - services/electron/acp.ts → heterogeneousAgent.ts - ACPService → HeterogeneousAgentService - acpService → heterogeneousAgentService - Update all IPC channel references in executor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🔧 chore: switch CC permission mode to bypassPermissions Use bypassPermissions to allow Bash and other tool execution. Previously acceptEdits only allowed file edits, causing Bash tool calls to fail during CC execution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: don't fallback activeAgentId to empty string in AgentIdSync Empty string '' causes chat store to have a truthy but invalid activeAgentId, breaking message routing. Pass undefined instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: use AI_RUNTIME_OPERATION_TYPES for loading and cancel states stopGenerateMessage and cancelOperation were hardcoding ['execAgentRuntime', 'execServerAgentRuntime'], missing execHeterogeneousAgent. This caused: - CC execution couldn't be cancelled via stop button - isAborting flag wasn't set for heterogeneous agent operations Now uses AI_RUNTIME_OPERATION_TYPES constant everywhere to ensure all AI runtime operation types are handled consistently. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ✨ feat: split multi-step CC execution into separate assistant messages Claude Code's multi-turn execution (thinking → tool → final text) was accumulating everything onto a single assistant message, causing the final text response to appear inside the tool call message. Changes: - ClaudeCodeAdapter: detect message.id changes and emit stream_end + stream_start with newStep flag at step boundaries - heterogeneousAgentExecutor: on newStep stream_start, persist previous step's content, create a new assistant message, reset accumulators, and forward the new message ID to the gateway handler This ensures each LLM turn gets its own assistant message, matching how Gateway mode handles multi-step agent execution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: fix multi-step CC execution and add DB persistence tests Adapter fixes: - Fix false step boundary on first assistant after init (ghost empty message) Executor fixes: - Fix parentId chain: new-step assistant points to last tool message - Fix content contamination: sync snapshot of content accumulators on step boundary - Fix type errors (import path, ChatToolPayload casts, sessionId guard) Tests: - Add ClaudeCodeAdapter unit tests (multi-step, usage, flush, edge cases) - Add ClaudeCodeAdapter E2E test (full multi-step session simulation) - Add registry tests - Add executor DB persistence tests covering: - Tool 3-phase write (pre-register → create → backfill) - Tool result content + error persistence - Multi-step parentId chain (assistant → tool → assistant) - Final content/reasoning/model/usage writes - Sync snapshot preventing cross-step contamination - Error handling with partial content persistence - Full multi-step E2E (Read → Write → text) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🔧 chore: add orphan tool regression tests and debug trace - Add orphan tool regression tests for multi-turn tool execution - Add __HETERO_AGENT_TRACE debug instrumentation for event flow capture Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ✨ feat: support image attachments in CC via stream-json stdin - Main process downloads files by ID from cloud (GET {domain}/f/{fileId}) - Local disk cache at lobehub-storage/heteroAgent/files/ (by fileId) - When fileIds present, switches to --input-format stream-json + stdin pipe - Constructs user message with text + image content blocks (base64) - Pass fileIds through executor → service → IPC → controller Closes LOBE-7254 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ♻️ refactor: pass imageList instead of fileIds for CC vision support - Use imageList (with url) instead of fileIds — Main downloads from URL directly - Cache by image id at lobehub-storage/heteroAgent/files/ - Only images (not arbitrary files) are sent to CC via stream-json stdin Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: read imageList from persisted DB message instead of chatUploadFileList chatUploadFileList is cleared after sendMessageInServer, so tempImages was empty by the time the executor ran. Now reads imageList from the persisted user message in heteroData.messages instead. Also removes debug console.log/console.error statements. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * update i18n * 🐛 fix: prevent orphan tool UI by deferring handler events during step transition Root cause: when a CC step boundary occurs, the adapter produces [stream_end, stream_start(newStep), stream_chunk(tools_calling)] in one batch. The executor deferred stream_start via persistQueue but forwarded stream_chunk synchronously — handler received tools_calling BEFORE stream_start, dispatching tools to the OLD assistant message → UI showed orphan tool warning. Fix: add pendingStepTransition flag that defers ALL handler-bound events through persistQueue until stream_start is forwarded, guaranteeing correct event ordering. Also adds: - Minimal regression test in gatewayEventHandler confirming correct ordering - Multi-tool per turn regression test from real LOBE-7240 trace - Data-driven regression replaying 133 real CC events from regression.json Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ✨ feat: add lab toggle for heterogeneous agent (Claude Code) - Add enableHeterogeneousAgent to UserLabSchema + defaults (off by default) - Add selector + settings UI toggle (desktop only) - Gate "Claude Code Agent" sidebar menu item behind the lab setting - Remove regression.json (no longer needed) - Add i18n keys for the lab feature Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: gate heterogeneous agent execution behind isDesktop check Without this, web users with an agent that has heterogeneousProvider config would hit the CC execution path and fail (no Electron IPC). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ♻️ refactor: rename tool identifier from acp-agent to claude-code Also update operation label to "External agent running". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ✨ feat: add CLI agent detectors for system tools settings Detect agentic coding CLIs installed on the system: - Claude Code, Codex, Gemini CLI, Qwen Code, Kimi CLI, Aider - Uses validated detection (which + --version keyword matching) - New "CLI Agents" category in System Tools settings - i18n for en-US and zh-CN Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🐛 fix: fix token usage over-counting in CC execution Two bugs fixed: 1. Adapter: same message.id emitted duplicate step_complete(turn_metadata) for each content block (thinking/text/tool_use) — all carry identical usage. Now deduped by message.id, only emits once per turn. 2. Executor: CC result event contains authoritative session-wide usage totals but was ignored. Now adapter emits step_complete(result_usage) from the result event, executor uses it to override accumulated values. Fixes LOBE-7261 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🔧 chore: gitignore cc-stream.json and .heterogeneous-tracing/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 🔧 chore: untrack .heerogeneous-tracing/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ✨ feat: wire CC session resume for multi-turn conversations Reads `ccSessionId` from topic metadata and passes it as `resumeSessionId` into the heterogeneous-agent executor, which forwards it into the Electron main-process controller. `sendPrompt` then appends `--resume <id>` so the next turn continues the same Claude Code session instead of starting fresh. After each run, the CC init-event session_id (captured by the adapter) is persisted back onto the topic so the chain survives page reloads. Also stops killing the session in `finally` — it needs to stay alive for subsequent turns; cleanup happens on topic deletion or app quit. * 🐛 fix: record cache token breakdown in CC execution metadata The prior token-usage fix only wrote totals — `inputCachedTokens`, `inputWriteCacheTokens` and `inputCacheMissTokens` were dropped, so the pricing card rendered zero cached/write-cache tokens even though CC had reported them. Map the accumulated Anthropic-shape usage to the same breakdown the anthropic usage converter emits, so CC turns display consistently with Gateway turns. Refs LOBE-7261 * ♻️ refactor: write CC usage under metadata.usage instead of flat fields Flat `inputCachedTokens / totalInputTokens / ...` on `MessageMetadata` are the legacy shape; new code should put usage under `metadata.usage`. Move the CC executor to the nested shape so it matches the convention the rest of the runtime is migrating to. Refs LOBE-7261 * ♻️ refactor(types): mark flat usage fields on MessageMetadata as deprecated Stop extending `ModelUsage` and redeclare each token field inline with a `@deprecated` JSDoc pointing to `metadata.usage` (nested). Existing readers still type-check, but IDEs now surface the deprecation so writers migrate to the nested shape. * ♻️ refactor(types): mark flat performance fields on MessageMetadata as deprecated Stop extending `ModelPerformance` and redeclare `duration` / `latency` / `tps` / `ttft` inline with `@deprecated`, pointing at `metadata.performance`. Mirrors the same treatment just done for the token usage fields. * ✨ feat: CC agent gets claude avatar + lands on chat page directly Skip the shared createAgent hook's /profile redirect for the Claude Code variant — its config is fixed so the profile editor would be noise — and preseed the Claude avatar from @lobehub/icons-static-avatar so new CC agents aren't blank. * 🐛 fix(conversation-flow): read usage/performance from nested metadata `splitMetadata` only scraped the legacy flat token/perf fields, so messages written under the new canonical shape (`metadata.usage`, `metadata.performance`) never populated `UIChatMessage.usage` and the Extras panel rendered blank. - Prefer nested `metadata.usage` / `metadata.performance` when present; keep flat scraping as fallback for pre-migration rows. - Add `usage` / `performance` to FlatListBuilder's filter sets so the nested blobs don't leak into `otherMetadata`. - Drop the stale `usage! || metadata` fallback in the Assistant / CouncilMember Extra renders — with splitMetadata fixed, `item.usage` is always populated when usage data exists, and passing raw metadata as ModelUsage is wrong now that the flat fields are gone. * 🐛 fix: skip stores.reset on initial dataSyncConfig hydration `useDataSyncConfig`'s SWR onSuccess called `refreshUserData` (which runs `stores.reset()`) whenever the freshly-fetched config didn't deep-equal the hard-coded initial `{ storageMode: 'cloud' }` — which happens on every first load. The reset would wipe `chat.activeAgentId` just after `AgentIdSync` set it from the URL, and because `AgentIdSync`'s sync effects are keyed on `params.aid` (which hasn't changed), they never re-fire to restore it. Result: topic SWR saw `activeAgentId === ''`, treated the container as invalid, and left the sidebar stuck on the loading skeleton. Gate the reset on `isInitRemoteServerConfig` so it only runs when the user actually switches sync modes, not on the first hydration. * ✨ feat(claude-code): wire Inspector layer for CC tool calls Mirrors local-system: each CC tool now has an inspector rendered above the tool-call output instead of an opaque default row. - `Inspector.tsx` — registry that passes the CC tool name itself as the shared factories' `translationKey`. react-i18next's missing-key fallback surfaces the literal name (Bash / Edit / Glob / Grep / Read / Write), so we don't add CC-specific entries to the plugin locale. - `ReadInspector.tsx` / `WriteInspector.tsx` — thin adapters that map Anthropic-native args (`file_path` / `offset` / `limit`) onto the shared inspectors' shape (`path` / `startLine` / `endLine`), so shared stays pure. Bash / Edit / Glob / Grep reuse shared factories directly. - Register `ClaudeCodeInspectors` under `claude-code` in the builtin-tools inspector dispatch. Also drops the redundant `Render/Bash/index.tsx` wrapper and pipes the shared `RunCommandRender` straight into the registry. * ♻️ refactor: use agentSelectors.isCurrentAgentHeterogeneous Two callsites (ConversationArea / useActionsBarConfig) were reaching into `currentAgentConfig(...)?.agencyConfig?.heterogeneousProvider` inline. Switch them to the existing `isCurrentAgentHeterogeneous` selector so the predicate lives in one place. * update * ♻️ refactor: drop no-op useCallback wrapper in AgentChat form `handleFinish` just called `updateConfig(values)` with no extra logic; the zustand action is already a stable reference so the wrapper added no memoization value. Leftover from the ACP refactor (930ba41fe3) where the handler once did more work — hand the action straight to `onFinish`. * update * ⏪ revert: roll back conversation-flow nested-shape reads Unwind the `splitMetadata` nested-preference + `FlatListBuilder` filter additions from 306fd6561f. The nested `metadata.usage` / `metadata.performance` promotion now happens in `parse.ts` (and a `?? metadata?.usage` fallback at the UI callsites), so conversation-flow's transformer layer goes back to its original flat-field-only behavior. * update * 🐛 fix(cc): wire Stop to cancel the external Claude Code process Previously hitting Stop only flipped the `execHeterogeneousAgent` operation to `cancelled` in the store — the spawned `claude -p` process kept running and kept streaming/persisting output for the user. The op's abort signal had no listeners and no `onCancelHandler` was registered. - On session start, register an `onCancelHandler` that calls `heterogeneousAgentService.cancelSession(sessionId)` (SIGINT to the CLI). - Read the op's `abortController.signal` and short-circuit `onRawLine` so late events the CLI emits between SIGINT and exit don't leak into DB writes. - Skip the error-event forward in `onError` / the outer catch when the abort came from the user, so the UI doesn't surface a misleading error toast on top of the already-cancelled operation. Verified end-to-end: prompt that runs a long sequence of Reads → click Stop → `claude -p` process is gone within 2s, op status = cancelled, no error message written to the conversation. * ✨ feat(sidebar): mark heterogeneous agents with an "External" tag Pipes the agent's `agencyConfig.heterogeneousProvider.type` through the sidebar data flow and renders a `<Tag>` next to the title for any agent driven by an external CLI runtime (Claude Code today, more later). Mirrors the group-member External pattern so future provider types just need a label swap — the field is a string, not a boolean. - `SidebarAgentItem.heterogeneousType?: string | null` on the shared type - `HomeRepository.getSidebarAgentList` selects `agents.agencyConfig` and derives the field via `cleanObject` - `AgentItem` shows `<Tag>{t('group.profile.external')}</Tag>` when the field is present Verified client-side by injecting `heterogeneousType: 'claudecode'` into a sidebar item at runtime — the "外部" tag renders next to the title in the zh-CN locale. * ♻️ refactor(i18n): dedicated key for the sidebar external-agent tag Instead of reusing `group.profile.external` (which is about group members that are user-linked rather than virtual), add `agentSidebar.externalTag` specifically for the heterogeneous-runtime tag. Keeps the two concepts separate so we can swap this one to "Claude Code" / provider-specific labels later without touching the group UI copy. Remember to run `pnpm i18n` before the PR so the remaining locales pick up the new key. * 🐛 fix: clear remaining CI type errors Three small fixes so `tsgo --noEmit` exits clean: - `AgentIdSync`: `useChatStoreUpdater` is typed off the chat-store key, whose `activeAgentId` is `string` (initial ''). Coerce the optional URL param to `''` so the store key type matches; `createStoreUpdater` still skips the setState when the value is undefined-ish. - `heterogeneousAgentExecutor.test.ts`: `scope: 'session'` isn't a valid `MessageMapScope` (the union dropped that variant); switch the fixture to `'main'`, which is the correct scope for agent main conversations. - Same test file: `Array.at(-1)` is `T | undefined`; non-null assert since the preceding calls guarantee the slot is populated. * 🐛 fix: loosen createStoreUpdater signature to accept nullable values Upstream `createStoreUpdater` types `value` as exactly `T[Key]`, so any call site feeding an optional source (URL param, selector that may return undefined) fails type-check — even though the runtime already guards `typeof value !== 'undefined'` and no-ops in that case. Wrap it once in `store/utils/createStoreUpdater.ts` with a `T[Key] | null | undefined` value type so callers can pass `params.aid` directly, instead of the lossy `?? ''` fallback the previous commit used (which would have written an empty-string sentinel into the chat store). Swap the import in `AgentIdSync.tsx`. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3fb6b0d8e1
commit
2298ad8ce1
84 changed files with 5598 additions and 61 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -146,4 +146,4 @@ apps/desktop/resources/cli-package.json
|
||||||
|
|
||||||
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
|
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
|
||||||
.superpowers/
|
.superpowers/
|
||||||
docs/superpowers/
|
.heerogeneous-tracing
|
||||||
|
|
|
||||||
421
apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts
Normal file
421
apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts
Normal file
|
|
@ -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<string, CLIPreset> = {
|
||||||
|
'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<string, string>;
|
||||||
|
/** 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<string, string>;
|
||||||
|
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<string, AgentSession>();
|
||||||
|
|
||||||
|
// ─── Broadcast ───
|
||||||
|
|
||||||
|
private broadcast<T>(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<string> {
|
||||||
|
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<StartSessionResult> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((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<SessionInfo> {
|
||||||
|
const session = this.sessions.get(params.sessionId);
|
||||||
|
return { agentSessionId: session?.agentSessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel an ongoing session.
|
||||||
|
*/
|
||||||
|
@IpcMethod()
|
||||||
|
async cancelSession(params: CancelSessionParams): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import BrowserWindowsCtr from './BrowserWindowsCtr';
|
||||||
import CliCtr from './CliCtr';
|
import CliCtr from './CliCtr';
|
||||||
import DevtoolsCtr from './DevtoolsCtr';
|
import DevtoolsCtr from './DevtoolsCtr';
|
||||||
import GatewayConnectionCtr from './GatewayConnectionCtr';
|
import GatewayConnectionCtr from './GatewayConnectionCtr';
|
||||||
|
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
|
||||||
import LocalFileCtr from './LocalFileCtr';
|
import LocalFileCtr from './LocalFileCtr';
|
||||||
import McpCtr from './McpCtr';
|
import McpCtr from './McpCtr';
|
||||||
import McpInstallCtr from './McpInstallCtr';
|
import McpInstallCtr from './McpInstallCtr';
|
||||||
|
|
@ -22,6 +23,7 @@ import UpdaterCtr from './UpdaterCtr';
|
||||||
import UploadFileCtr from './UploadFileCtr';
|
import UploadFileCtr from './UploadFileCtr';
|
||||||
|
|
||||||
export const controllerIpcConstructors = [
|
export const controllerIpcConstructors = [
|
||||||
|
HeterogeneousAgentCtr,
|
||||||
AuthCtr,
|
AuthCtr,
|
||||||
BrowserWindowsCtr,
|
BrowserWindowsCtr,
|
||||||
CliCtr,
|
CliCtr,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { generateCliWrapper, getCliWrapperDir } from '@/modules/cliEmbedding';
|
||||||
import {
|
import {
|
||||||
astSearchDetectors,
|
astSearchDetectors,
|
||||||
browserAutomationDetectors,
|
browserAutomationDetectors,
|
||||||
|
cliAgentDetectors,
|
||||||
contentSearchDetectors,
|
contentSearchDetectors,
|
||||||
fileSearchDetectors,
|
fileSearchDetectors,
|
||||||
type IToolDetector,
|
type IToolDetector,
|
||||||
|
|
@ -190,6 +191,7 @@ export class App {
|
||||||
|
|
||||||
const detectorCategories: Partial<Record<ToolCategory, IToolDetector[]>> = {
|
const detectorCategories: Partial<Record<ToolCategory, IToolDetector[]>> = {
|
||||||
'runtime-environment': runtimeEnvironmentDetectors,
|
'runtime-environment': runtimeEnvironmentDetectors,
|
||||||
|
'cli-agents': cliAgentDetectors,
|
||||||
'ast-search': astSearchDetectors,
|
'ast-search': astSearchDetectors,
|
||||||
'browser-automation': browserAutomationDetectors,
|
'browser-automation': browserAutomationDetectors,
|
||||||
'content-search': contentSearchDetectors,
|
'content-search': contentSearchDetectors,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export type ToolCategory =
|
||||||
| 'file-search'
|
| 'file-search'
|
||||||
| 'browser-automation'
|
| 'browser-automation'
|
||||||
| 'runtime-environment'
|
| 'runtime-environment'
|
||||||
|
| 'cli-agents'
|
||||||
| 'system'
|
| 'system'
|
||||||
| 'custom';
|
| 'custom';
|
||||||
|
|
||||||
|
|
|
||||||
435
apps/desktop/src/main/libs/acp/client.ts
Normal file
435
apps/desktop/src/main/libs/acp/client.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ACPClientCallbacks {
|
||||||
|
onPermissionRequest?: (request: ACPPermissionRequest) => Promise<ACPPermissionResponse>;
|
||||||
|
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<number | string, PendingRequest>();
|
||||||
|
private process: ChildProcess | null = null;
|
||||||
|
private stderrLogs: string[] = [];
|
||||||
|
|
||||||
|
// Client-side method handlers (agent calls these)
|
||||||
|
private clientMethodHandlers = new Map<string, (params: any) => Promise<unknown>>();
|
||||||
|
|
||||||
|
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<FSReadTextFileResult>;
|
||||||
|
'fs/write_text_file'?: (params: FSWriteTextFileParams) => Promise<void>;
|
||||||
|
'terminal/create'?: (params: TerminalCreateParams) => Promise<TerminalCreateResult>;
|
||||||
|
'terminal/kill'?: (params: TerminalKillParams) => Promise<void>;
|
||||||
|
'terminal/output'?: (params: TerminalOutputParams) => Promise<TerminalOutputResult>;
|
||||||
|
'terminal/release'?: (params: TerminalReleaseParams) => Promise<void>;
|
||||||
|
'terminal/wait_for_exit'?: (
|
||||||
|
params: TerminalWaitForExitParams,
|
||||||
|
) => Promise<TerminalWaitForExitResult>;
|
||||||
|
}) {
|
||||||
|
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<ACPServerCapabilities> {
|
||||||
|
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<ACPServerCapabilities> {
|
||||||
|
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<ACPServerCapabilities>('initialize', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session.
|
||||||
|
*/
|
||||||
|
async createSession(params?: ACPSessionNewParams): Promise<ACPSessionInfo> {
|
||||||
|
return this.sendRequest<ACPSessionInfo>('session/new', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a prompt to an existing session.
|
||||||
|
*/
|
||||||
|
async sendPrompt(params: ACPSessionPromptParams): Promise<void> {
|
||||||
|
return this.sendRequest<void>('session/prompt', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel an ongoing session operation.
|
||||||
|
*/
|
||||||
|
async cancelSession(params: ACPSessionCancelParams): Promise<void> {
|
||||||
|
return this.sendRequest<void>('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<void> {
|
||||||
|
if (this.process) {
|
||||||
|
this.process.stdin?.end();
|
||||||
|
this.process.kill('SIGTERM');
|
||||||
|
|
||||||
|
// Force kill after timeout
|
||||||
|
await new Promise<void>((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<T>(method: string, params?: object): Promise<T> {
|
||||||
|
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<string, unknown> | 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<void> {
|
||||||
|
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}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
apps/desktop/src/main/libs/acp/index.ts
Normal file
3
apps/desktop/src/main/libs/acp/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type { ACPClientCallbacks, ACPClientParams } from './client';
|
||||||
|
export { ACPClient } from './client';
|
||||||
|
export type * from './types';
|
||||||
326
apps/desktop/src/main/libs/acp/types.ts
Normal file
326
apps/desktop/src/main/libs/acp/types.ts
Normal file
|
|
@ -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<string, unknown> | 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
text: string;
|
||||||
|
type: 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ACPImageContent {
|
||||||
|
annotations?: Record<string, unknown>;
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
type: 'image';
|
||||||
|
uri?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ACPAudioContent {
|
||||||
|
annotations?: Record<string, unknown>;
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
type: 'audio';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ACPResourceContent {
|
||||||
|
annotations?: Record<string, unknown>;
|
||||||
|
resource: {
|
||||||
|
blob?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
text?: string;
|
||||||
|
uri: string;
|
||||||
|
};
|
||||||
|
type: 'resource';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ACPResourceLinkContent {
|
||||||
|
annotations?: Record<string, unknown>;
|
||||||
|
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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
145
apps/desktop/src/main/modules/toolDetectors/cliAgentDetectors.ts
Normal file
145
apps/desktop/src/main/modules/toolDetectors/cliAgentDetectors.ts
Normal file
|
|
@ -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<ToolStatus> {
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { browserAutomationDetectors } from './agentBrowserDetectors';
|
export { browserAutomationDetectors } from './agentBrowserDetectors';
|
||||||
|
export { cliAgentDetectors } from './cliAgentDetectors';
|
||||||
export { astSearchDetectors, contentSearchDetectors } from './contentSearchDetectors';
|
export { astSearchDetectors, contentSearchDetectors } from './contentSearchDetectors';
|
||||||
export { fileSearchDetectors } from './fileSearchDetectors';
|
export { fileSearchDetectors } from './fileSearchDetectors';
|
||||||
export { runtimeEnvironmentDetectors } from './runtimeEnvironmentDetectors';
|
export { runtimeEnvironmentDetectors } from './runtimeEnvironmentDetectors';
|
||||||
|
|
|
||||||
|
|
@ -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).",
|
"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.",
|
"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.",
|
"agentDefaultMessageWithoutEdit": "Hi, I’m **{{name}}**. One sentence is enough—you're in control.",
|
||||||
|
"agentSidebar.externalTag": "External",
|
||||||
"agents": "Agents",
|
"agents": "Agents",
|
||||||
"artifact.generating": "Generating",
|
"artifact.generating": "Generating",
|
||||||
"artifact.inThread": "Cannot view in subtopic, please switch to the main conversation area to open",
|
"artifact.inThread": "Cannot view in subtopic, please switch to the main conversation area to open",
|
||||||
|
|
@ -223,6 +224,7 @@
|
||||||
"minimap.senderAssistant": "Agent",
|
"minimap.senderAssistant": "Agent",
|
||||||
"minimap.senderUser": "You",
|
"minimap.senderUser": "You",
|
||||||
"newAgent": "Create Agent",
|
"newAgent": "Create Agent",
|
||||||
|
"newClaudeCodeAgent": "Claude Code Agent",
|
||||||
"newGroupChat": "Create Group",
|
"newGroupChat": "Create Group",
|
||||||
"newPage": "Create Page",
|
"newPage": "Create Page",
|
||||||
"noAgentsYet": "This group has no members yet. Click the + button to invite agents.",
|
"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.contextCompression": "Context too long, compressing history...",
|
||||||
"operation.execAgentRuntime": "Preparing response",
|
"operation.execAgentRuntime": "Preparing response",
|
||||||
"operation.execClientTask": "Executing task",
|
"operation.execClientTask": "Executing task",
|
||||||
|
"operation.execHeterogeneousAgent": "Running agent",
|
||||||
"operation.execServerAgentRuntime": "Task is running in the server. You are safe to leave this page",
|
"operation.execServerAgentRuntime": "Task is running in the server. You are safe to leave this page",
|
||||||
"operation.sendMessage": "Sending message",
|
"operation.sendMessage": "Sending message",
|
||||||
"owner": "Group owner",
|
"owner": "Group owner",
|
||||||
|
|
|
||||||
|
|
@ -659,6 +659,8 @@
|
||||||
"settingSystemTools.appEnvironment.title": "Built-in App Tools",
|
"settingSystemTools.appEnvironment.title": "Built-in App Tools",
|
||||||
"settingSystemTools.category.browserAutomation": "Browser Automation",
|
"settingSystemTools.category.browserAutomation": "Browser Automation",
|
||||||
"settingSystemTools.category.browserAutomation.desc": "Tools for headless browser automation and web interaction",
|
"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": "Content Search",
|
||||||
"settingSystemTools.category.contentSearch.desc": "Tools for searching text content within files",
|
"settingSystemTools.category.contentSearch.desc": "Tools for searching text content within files",
|
||||||
"settingSystemTools.category.fileSearch": "File Search",
|
"settingSystemTools.category.fileSearch": "File Search",
|
||||||
|
|
@ -673,17 +675,23 @@
|
||||||
"settingSystemTools.title": "System Tools",
|
"settingSystemTools.title": "System Tools",
|
||||||
"settingSystemTools.tools.ag.desc": "The Silver Searcher - fast code searching tool",
|
"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.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.bun.desc": "Bun - fast JavaScript runtime and package manager",
|
||||||
"settingSystemTools.tools.bunx.desc": "bunx - Bun package runner for executing npm packages",
|
"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.fd.desc": "fd - fast and user-friendly alternative to find",
|
||||||
"settingSystemTools.tools.find.desc": "Unix find - standard file search command",
|
"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.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.lobehub.desc": "LobeHub CLI - manage and connect to LobeHub services",
|
||||||
"settingSystemTools.tools.mdfind.desc": "macOS Spotlight search (fast indexed search)",
|
"settingSystemTools.tools.mdfind.desc": "macOS Spotlight search (fast indexed search)",
|
||||||
"settingSystemTools.tools.node.desc": "Node.js - JavaScript runtime for executing JS/TS",
|
"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.npm.desc": "npm - Node.js package manager for installing dependencies",
|
||||||
"settingSystemTools.tools.pnpm.desc": "pnpm - fast, disk space efficient package manager",
|
"settingSystemTools.tools.pnpm.desc": "pnpm - fast, disk space efficient package manager",
|
||||||
"settingSystemTools.tools.python.desc": "Python - programming language runtime",
|
"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.rg.desc": "ripgrep - extremely fast text search tool",
|
||||||
"settingSystemTools.tools.uv.desc": "uv - extremely fast Python package manager",
|
"settingSystemTools.tools.uv.desc": "uv - extremely fast Python package manager",
|
||||||
"settingTTS.openai.sttModel": "OpenAI Speech-to-Text Model",
|
"settingTTS.openai.sttModel": "OpenAI Speech-to-Text Model",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"agentDefaultMessage": "你好,我是 **{{name}}**。从一句话开始就行。\n\n想让我更贴近你的工作方式:去 [助理设置]({{url}}) 补充助理档案(随时可改)",
|
"agentDefaultMessage": "你好,我是 **{{name}}**。从一句话开始就行。\n\n想让我更贴近你的工作方式:去 [助理设置]({{url}}) 补充助理档案(随时可改)",
|
||||||
"agentDefaultMessageWithSystemRole": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你",
|
"agentDefaultMessageWithSystemRole": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你",
|
||||||
"agentDefaultMessageWithoutEdit": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你",
|
"agentDefaultMessageWithoutEdit": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你",
|
||||||
|
"agentSidebar.externalTag": "外部",
|
||||||
"agents": "助理",
|
"agents": "助理",
|
||||||
"artifact.generating": "生成中",
|
"artifact.generating": "生成中",
|
||||||
"artifact.inThread": "子话题中暂不支持查看。请回到主对话区打开",
|
"artifact.inThread": "子话题中暂不支持查看。请回到主对话区打开",
|
||||||
|
|
@ -223,6 +224,7 @@
|
||||||
"minimap.senderAssistant": "助理",
|
"minimap.senderAssistant": "助理",
|
||||||
"minimap.senderUser": "你",
|
"minimap.senderUser": "你",
|
||||||
"newAgent": "创建助理",
|
"newAgent": "创建助理",
|
||||||
|
"newClaudeCodeAgent": "Claude Code 智能体",
|
||||||
"newGroupChat": "创建群组",
|
"newGroupChat": "创建群组",
|
||||||
"newPage": "创建文稿",
|
"newPage": "创建文稿",
|
||||||
"noAgentsYet": "这个群组还没有成员。点击「+」邀请助理加入",
|
"noAgentsYet": "这个群组还没有成员。点击「+」邀请助理加入",
|
||||||
|
|
@ -234,6 +236,7 @@
|
||||||
"operation.contextCompression": "上下文过长,正在压缩历史记录……",
|
"operation.contextCompression": "上下文过长,正在压缩历史记录……",
|
||||||
"operation.execAgentRuntime": "准备响应中",
|
"operation.execAgentRuntime": "准备响应中",
|
||||||
"operation.execClientTask": "执行任务中",
|
"operation.execClientTask": "执行任务中",
|
||||||
|
"operation.execHeterogeneousAgent": "智能体运行中",
|
||||||
"operation.execServerAgentRuntime": "任务正在服务器运行,您可以放心离开此页面",
|
"operation.execServerAgentRuntime": "任务正在服务器运行,您可以放心离开此页面",
|
||||||
"operation.sendMessage": "消息发送中",
|
"operation.sendMessage": "消息发送中",
|
||||||
"owner": "群主",
|
"owner": "群主",
|
||||||
|
|
|
||||||
|
|
@ -659,6 +659,8 @@
|
||||||
"settingSystemTools.appEnvironment.title": "内建应用工具",
|
"settingSystemTools.appEnvironment.title": "内建应用工具",
|
||||||
"settingSystemTools.category.browserAutomation": "浏览器自动化",
|
"settingSystemTools.category.browserAutomation": "浏览器自动化",
|
||||||
"settingSystemTools.category.browserAutomation.desc": "用于无头浏览器自动化和网页交互的工具",
|
"settingSystemTools.category.browserAutomation.desc": "用于无头浏览器自动化和网页交互的工具",
|
||||||
|
"settingSystemTools.category.cliAgents": "CLI 智能体",
|
||||||
|
"settingSystemTools.category.cliAgents.desc": "已检测到的命令行编码智能体,如 Claude Code、Codex、Kimi 等",
|
||||||
"settingSystemTools.category.contentSearch": "内容搜索",
|
"settingSystemTools.category.contentSearch": "内容搜索",
|
||||||
"settingSystemTools.category.contentSearch.desc": "用于在文件内搜索文本内容的工具",
|
"settingSystemTools.category.contentSearch.desc": "用于在文件内搜索文本内容的工具",
|
||||||
"settingSystemTools.category.fileSearch": "文件搜索",
|
"settingSystemTools.category.fileSearch": "文件搜索",
|
||||||
|
|
@ -673,17 +675,23 @@
|
||||||
"settingSystemTools.title": "系统工具",
|
"settingSystemTools.title": "系统工具",
|
||||||
"settingSystemTools.tools.ag.desc": "The Silver Searcher - 快速代码搜索工具",
|
"settingSystemTools.tools.ag.desc": "The Silver Searcher - 快速代码搜索工具",
|
||||||
"settingSystemTools.tools.agentBrowser.desc": "Agent-browser - 面向AI代理的无头浏览器自动化命令行工具",
|
"settingSystemTools.tools.agentBrowser.desc": "Agent-browser - 面向AI代理的无头浏览器自动化命令行工具",
|
||||||
|
"settingSystemTools.tools.aider.desc": "Aider - 终端内的 AI 结对编程工具",
|
||||||
"settingSystemTools.tools.bun.desc": "Bun - 快速的 JavaScript 运行时和包管理器",
|
"settingSystemTools.tools.bun.desc": "Bun - 快速的 JavaScript 运行时和包管理器",
|
||||||
"settingSystemTools.tools.bunx.desc": "bunx - Bun 包执行器,用于运行 npm 包",
|
"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.fd.desc": "fd - 快速且用户友好的 find 替代品",
|
||||||
"settingSystemTools.tools.find.desc": "Unix find - 标准文件搜索命令",
|
"settingSystemTools.tools.find.desc": "Unix find - 标准文件搜索命令",
|
||||||
|
"settingSystemTools.tools.gemini.desc": "Gemini CLI - Google 命令行编码智能体",
|
||||||
"settingSystemTools.tools.grep.desc": "GNU grep - 标准文本搜索工具",
|
"settingSystemTools.tools.grep.desc": "GNU grep - 标准文本搜索工具",
|
||||||
|
"settingSystemTools.tools.kimi.desc": "Kimi CLI - 月之暗面命令行编码智能体",
|
||||||
"settingSystemTools.tools.lobehub.desc": "LobeHub CLI - 管理和连接 LobeHub 服务",
|
"settingSystemTools.tools.lobehub.desc": "LobeHub CLI - 管理和连接 LobeHub 服务",
|
||||||
"settingSystemTools.tools.mdfind.desc": "macOS 聚焦搜索(快速索引搜索)",
|
"settingSystemTools.tools.mdfind.desc": "macOS 聚焦搜索(快速索引搜索)",
|
||||||
"settingSystemTools.tools.node.desc": "Node.js - 执行 JavaScript/TypeScript 的运行时",
|
"settingSystemTools.tools.node.desc": "Node.js - 执行 JavaScript/TypeScript 的运行时",
|
||||||
"settingSystemTools.tools.npm.desc": "npm - Node.js 包管理器,用于安装依赖",
|
"settingSystemTools.tools.npm.desc": "npm - Node.js 包管理器,用于安装依赖",
|
||||||
"settingSystemTools.tools.pnpm.desc": "pnpm - 快速、节省磁盘空间的包管理器",
|
"settingSystemTools.tools.pnpm.desc": "pnpm - 快速、节省磁盘空间的包管理器",
|
||||||
"settingSystemTools.tools.python.desc": "Python - 编程语言运行时",
|
"settingSystemTools.tools.python.desc": "Python - 编程语言运行时",
|
||||||
|
"settingSystemTools.tools.qwen.desc": "Qwen Code - 阿里通义千问命令行编码智能体",
|
||||||
"settingSystemTools.tools.rg.desc": "ripgrep - 极快的文本搜索工具",
|
"settingSystemTools.tools.rg.desc": "ripgrep - 极快的文本搜索工具",
|
||||||
"settingSystemTools.tools.uv.desc": "uv - 极快的 Python 包管理器",
|
"settingSystemTools.tools.uv.desc": "uv - 极快的 Python 包管理器",
|
||||||
"settingTTS.openai.sttModel": "OpenAI 语音识别模型",
|
"settingTTS.openai.sttModel": "OpenAI 语音识别模型",
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,7 @@
|
||||||
"@lobechat/eval-rubric": "workspace:*",
|
"@lobechat/eval-rubric": "workspace:*",
|
||||||
"@lobechat/fetch-sse": "workspace:*",
|
"@lobechat/fetch-sse": "workspace:*",
|
||||||
"@lobechat/file-loaders": "workspace:*",
|
"@lobechat/file-loaders": "workspace:*",
|
||||||
|
"@lobechat/heterogeneous-agents": "workspace:*",
|
||||||
"@lobechat/local-file-shell": "workspace:*",
|
"@lobechat/local-file-shell": "workspace:*",
|
||||||
"@lobechat/memory-user-memory": "workspace:*",
|
"@lobechat/memory-user-memory": "workspace:*",
|
||||||
"@lobechat/model-runtime": "workspace:*",
|
"@lobechat/model-runtime": "workspace:*",
|
||||||
|
|
|
||||||
23
packages/builtin-tool-claude-code/package.json
Normal file
23
packages/builtin-tool-claude-code/package.json
Normal file
|
|
@ -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": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
packages/builtin-tool-claude-code/src/client/Inspector.tsx
Normal file
32
packages/builtin-tool-claude-code/src/client/Inspector.tsx
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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<BuiltinInspectorProps<CCReadArgs>>((props) => (
|
||||||
|
<SharedInspector {...props} args={mapArgs(props.args)} partialArgs={mapArgs(props.partialArgs)} />
|
||||||
|
));
|
||||||
|
ReadInspector.displayName = 'ClaudeCodeReadInspector';
|
||||||
|
|
@ -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<BuiltinRenderProps<EditArgs>>(({ args }) => {
|
||||||
|
if (!args) return <Skeleton active />;
|
||||||
|
|
||||||
|
const filePath = args.file_path || '';
|
||||||
|
const fileName = filePath ? path.basename(filePath) : '';
|
||||||
|
const ext = filePath ? path.extname(filePath).slice(1).toLowerCase() : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flexbox gap={12}>
|
||||||
|
<CodeDiff
|
||||||
|
fileName={fileName || filePath}
|
||||||
|
language={ext || undefined}
|
||||||
|
newContent={args.new_string ?? ''}
|
||||||
|
oldContent={args.old_string ?? ''}
|
||||||
|
showHeader={!!fileName}
|
||||||
|
variant={'borderless'}
|
||||||
|
viewMode={'unified'}
|
||||||
|
/>
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Edit.displayName = 'ClaudeCodeEdit';
|
||||||
|
|
||||||
|
export default Edit;
|
||||||
|
|
@ -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<BuiltinRenderProps<GlobArgs>>(({ 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 (
|
||||||
|
<Flexbox className={styles.container} gap={8}>
|
||||||
|
<Flexbox horizontal align={'center'} className={styles.header} gap={8} wrap={'wrap'}>
|
||||||
|
<Icon icon={FolderSearch} size={'small'} />
|
||||||
|
{pattern && (
|
||||||
|
<Text strong className={styles.pattern}>
|
||||||
|
{pattern}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{scope && (
|
||||||
|
<Text ellipsis className={styles.scope}>
|
||||||
|
{scope}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{matchCount > 0 && <Text className={styles.count}>{`${matchCount} matches`}</Text>}
|
||||||
|
</Flexbox>
|
||||||
|
|
||||||
|
{content && (
|
||||||
|
<Flexbox className={styles.previewBox}>
|
||||||
|
<Highlighter
|
||||||
|
wrap
|
||||||
|
language={'text'}
|
||||||
|
showLanguage={false}
|
||||||
|
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||||
|
variant={'borderless'}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Highlighter>
|
||||||
|
</Flexbox>
|
||||||
|
)}
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Glob.displayName = 'ClaudeCodeGlob';
|
||||||
|
|
||||||
|
export default Glob;
|
||||||
|
|
@ -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<BuiltinRenderProps<GrepArgs>>(({ args, content }) => {
|
||||||
|
const pattern = args?.pattern || '';
|
||||||
|
const scope = args?.path || '';
|
||||||
|
const glob = args?.glob || args?.type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flexbox className={styles.container} gap={8}>
|
||||||
|
<Flexbox horizontal align={'center'} className={styles.header} gap={8} wrap={'wrap'}>
|
||||||
|
<Icon icon={Search} size={'small'} />
|
||||||
|
{pattern && (
|
||||||
|
<Text strong className={styles.pattern}>
|
||||||
|
{pattern}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{glob && <Tag>{glob}</Tag>}
|
||||||
|
{scope && (
|
||||||
|
<Text ellipsis className={styles.scope}>
|
||||||
|
{scope}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flexbox>
|
||||||
|
|
||||||
|
{content && (
|
||||||
|
<Flexbox className={styles.previewBox}>
|
||||||
|
<Highlighter
|
||||||
|
wrap
|
||||||
|
language={'text'}
|
||||||
|
showLanguage={false}
|
||||||
|
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||||
|
variant={'borderless'}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Highlighter>
|
||||||
|
</Flexbox>
|
||||||
|
)}
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Grep.displayName = 'ClaudeCodeGrep';
|
||||||
|
|
||||||
|
export default Grep;
|
||||||
|
|
@ -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<BuiltinRenderProps<ReadArgs>>(({ 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 (
|
||||||
|
<Flexbox className={styles.container} gap={8}>
|
||||||
|
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
|
||||||
|
<Icon icon={FileText} size={'small'} />
|
||||||
|
<Text strong>{fileName || 'Read'}</Text>
|
||||||
|
{filePath && filePath !== fileName && (
|
||||||
|
<Text ellipsis className={styles.path}>
|
||||||
|
{filePath}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flexbox>
|
||||||
|
|
||||||
|
{source && (
|
||||||
|
<Flexbox className={styles.previewBox}>
|
||||||
|
<Highlighter
|
||||||
|
wrap
|
||||||
|
language={ext || 'text'}
|
||||||
|
showLanguage={false}
|
||||||
|
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||||
|
variant={'borderless'}
|
||||||
|
>
|
||||||
|
{source}
|
||||||
|
</Highlighter>
|
||||||
|
</Flexbox>
|
||||||
|
)}
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Read.displayName = 'ClaudeCodeRead';
|
||||||
|
|
||||||
|
export default Read;
|
||||||
|
|
@ -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<BuiltinRenderProps<WriteArgs>>(({ args }) => {
|
||||||
|
if (!args) return <Skeleton active />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Markdown style={{ maxHeight: 240, overflow: 'auto', padding: '0 8px' }} variant={'chat'}>
|
||||||
|
{args.content}
|
||||||
|
</Markdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Highlighter
|
||||||
|
wrap
|
||||||
|
language={ext || 'text'}
|
||||||
|
showLanguage={false}
|
||||||
|
style={{ maxHeight: 240, overflow: 'auto' }}
|
||||||
|
variant={'borderless'}
|
||||||
|
>
|
||||||
|
{args.content}
|
||||||
|
</Highlighter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flexbox className={styles.container} gap={8}>
|
||||||
|
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
|
||||||
|
<Icon icon={FilePlus2} size={'small'} />
|
||||||
|
<Text strong>{fileName || 'Write'}</Text>
|
||||||
|
{filePath && filePath !== fileName && (
|
||||||
|
<Text ellipsis className={styles.path}>
|
||||||
|
{filePath}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flexbox>
|
||||||
|
|
||||||
|
{args.content && <Flexbox className={styles.previewBox}>{renderContent()}</Flexbox>}
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Write.displayName = 'ClaudeCodeWrite';
|
||||||
|
|
||||||
|
export default Write;
|
||||||
25
packages/builtin-tool-claude-code/src/client/Render/index.ts
Normal file
25
packages/builtin-tool-claude-code/src/client/Render/index.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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<BuiltinInspectorProps<CCWriteArgs>>((props) => (
|
||||||
|
<SharedInspector {...props} args={mapArgs(props.args)} partialArgs={mapArgs(props.partialArgs)} />
|
||||||
|
));
|
||||||
|
WriteInspector.displayName = 'ClaudeCodeWriteInspector';
|
||||||
3
packages/builtin-tool-claude-code/src/client/index.ts
Normal file
3
packages/builtin-tool-claude-code/src/client/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { ClaudeCodeApiName, ClaudeCodeIdentifier } from '../types';
|
||||||
|
export { ClaudeCodeInspectors } from './Inspector';
|
||||||
|
export { ClaudeCodeRenders } from './Render';
|
||||||
1
packages/builtin-tool-claude-code/src/index.ts
Normal file
1
packages/builtin-tool-claude-code/src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { ClaudeCodeApiName, ClaudeCodeIdentifier } from './types';
|
||||||
20
packages/builtin-tool-claude-code/src/types.ts
Normal file
20
packages/builtin-tool-claude-code/src/types.ts
Normal file
|
|
@ -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',
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"@lobechat/builtin-tool-agent-builder": "workspace:*",
|
"@lobechat/builtin-tool-agent-builder": "workspace:*",
|
||||||
"@lobechat/builtin-tool-agent-documents": "workspace:*",
|
"@lobechat/builtin-tool-agent-documents": "workspace:*",
|
||||||
"@lobechat/builtin-tool-brief": "workspace:*",
|
"@lobechat/builtin-tool-brief": "workspace:*",
|
||||||
|
"@lobechat/builtin-tool-claude-code": "workspace:*",
|
||||||
"@lobechat/builtin-tool-cloud-sandbox": "workspace:*",
|
"@lobechat/builtin-tool-cloud-sandbox": "workspace:*",
|
||||||
"@lobechat/builtin-tool-creds": "workspace:*",
|
"@lobechat/builtin-tool-creds": "workspace:*",
|
||||||
"@lobechat/builtin-tool-cron": "workspace:*",
|
"@lobechat/builtin-tool-cron": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ import {
|
||||||
AgentManagementInspectors,
|
AgentManagementInspectors,
|
||||||
AgentManagementManifest,
|
AgentManagementManifest,
|
||||||
} from '@lobechat/builtin-tool-agent-management/client';
|
} from '@lobechat/builtin-tool-agent-management/client';
|
||||||
|
import {
|
||||||
|
ClaudeCodeIdentifier,
|
||||||
|
ClaudeCodeInspectors,
|
||||||
|
} from '@lobechat/builtin-tool-claude-code/client';
|
||||||
import {
|
import {
|
||||||
CloudSandboxIdentifier,
|
CloudSandboxIdentifier,
|
||||||
CloudSandboxInspectors,
|
CloudSandboxInspectors,
|
||||||
|
|
@ -59,6 +63,7 @@ const BuiltinToolInspectors: Record<string, Record<string, BuiltinInspector>> =
|
||||||
string,
|
string,
|
||||||
BuiltinInspector
|
BuiltinInspector
|
||||||
>,
|
>,
|
||||||
|
[ClaudeCodeIdentifier]: ClaudeCodeInspectors as Record<string, BuiltinInspector>,
|
||||||
[CloudSandboxIdentifier]: CloudSandboxInspectors as Record<string, BuiltinInspector>,
|
[CloudSandboxIdentifier]: CloudSandboxInspectors as Record<string, BuiltinInspector>,
|
||||||
[GroupAgentBuilderManifest.identifier]: GroupAgentBuilderInspectors as Record<
|
[GroupAgentBuilderManifest.identifier]: GroupAgentBuilderInspectors as Record<
|
||||||
string,
|
string,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { AgentBuilderManifest } from '@lobechat/builtin-tool-agent-builder';
|
||||||
import { AgentBuilderRenders } from '@lobechat/builtin-tool-agent-builder/client';
|
import { AgentBuilderRenders } from '@lobechat/builtin-tool-agent-builder/client';
|
||||||
import { AgentManagementManifest } from '@lobechat/builtin-tool-agent-management';
|
import { AgentManagementManifest } from '@lobechat/builtin-tool-agent-management';
|
||||||
import { AgentManagementRenders } from '@lobechat/builtin-tool-agent-management/client';
|
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 { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox';
|
||||||
import { CloudSandboxRenders } from '@lobechat/builtin-tool-cloud-sandbox/client';
|
import { CloudSandboxRenders } from '@lobechat/builtin-tool-cloud-sandbox/client';
|
||||||
import { GroupAgentBuilderManifest } from '@lobechat/builtin-tool-group-agent-builder';
|
import { GroupAgentBuilderManifest } from '@lobechat/builtin-tool-group-agent-builder';
|
||||||
|
|
@ -39,6 +40,7 @@ import { type BuiltinRender } from '@lobechat/types';
|
||||||
const BuiltinToolsRenders: Record<string, Record<string, BuiltinRender>> = {
|
const BuiltinToolsRenders: Record<string, Record<string, BuiltinRender>> = {
|
||||||
[AgentBuilderManifest.identifier]: AgentBuilderRenders as Record<string, BuiltinRender>,
|
[AgentBuilderManifest.identifier]: AgentBuilderRenders as Record<string, BuiltinRender>,
|
||||||
[AgentManagementManifest.identifier]: AgentManagementRenders as Record<string, BuiltinRender>,
|
[AgentManagementManifest.identifier]: AgentManagementRenders as Record<string, BuiltinRender>,
|
||||||
|
[ClaudeCodeIdentifier]: ClaudeCodeRenders as Record<string, BuiltinRender>,
|
||||||
[CloudSandboxManifest.identifier]: CloudSandboxRenders as Record<string, BuiltinRender>,
|
[CloudSandboxManifest.identifier]: CloudSandboxRenders as Record<string, BuiltinRender>,
|
||||||
[GroupAgentBuilderManifest.identifier]: GroupAgentBuilderRenders as Record<string, BuiltinRender>,
|
[GroupAgentBuilderManifest.identifier]: GroupAgentBuilderRenders as Record<string, BuiltinRender>,
|
||||||
[GroupManagementManifest.identifier]: GroupManagementRenders as Record<string, BuiltinRender>,
|
[GroupManagementManifest.identifier]: GroupManagementRenders as Record<string, BuiltinRender>,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export const DEFAULT_PREFERENCE: UserPreference = {
|
||||||
},
|
},
|
||||||
lab: {
|
lab: {
|
||||||
enableAgentWorkingPanel: false,
|
enableAgentWorkingPanel: false,
|
||||||
|
enableHeterogeneousAgent: false,
|
||||||
enableInputMarkdown: true,
|
enableInputMarkdown: true,
|
||||||
},
|
},
|
||||||
topicDisplayMode: DEFAULT_TOPIC_DISPLAY_MODE,
|
topicDisplayMode: DEFAULT_TOPIC_DISPLAY_MODE,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export class FlatListBuilder {
|
||||||
private branchResolver: BranchResolver,
|
private branchResolver: BranchResolver,
|
||||||
private messageCollector: MessageCollector,
|
private messageCollector: MessageCollector,
|
||||||
private messageTransformer: MessageTransformer,
|
private messageTransformer: MessageTransformer,
|
||||||
) { }
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate flatList from messages array
|
* Generate flatList from messages array
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export class HomeRepository {
|
||||||
// 1. Query all agents (non-virtual) with their session info (if exists)
|
// 1. Query all agents (non-virtual) with their session info (if exists)
|
||||||
const agentList = await this.db
|
const agentList = await this.db
|
||||||
.select({
|
.select({
|
||||||
|
agencyConfig: agents.agencyConfig,
|
||||||
agentSessionGroupId: agents.sessionGroupId,
|
agentSessionGroupId: agents.sessionGroupId,
|
||||||
avatar: agents.avatar,
|
avatar: agents.avatar,
|
||||||
backgroundColor: agents.backgroundColor,
|
backgroundColor: agents.backgroundColor,
|
||||||
|
|
@ -98,6 +99,7 @@ export class HomeRepository {
|
||||||
|
|
||||||
private processAgentList(
|
private processAgentList(
|
||||||
agentItems: Array<{
|
agentItems: Array<{
|
||||||
|
agencyConfig: { heterogeneousProvider?: { type?: string } } | null;
|
||||||
agentSessionGroupId: string | null;
|
agentSessionGroupId: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
backgroundColor: string | null;
|
backgroundColor: string | null;
|
||||||
|
|
@ -136,6 +138,7 @@ export class HomeRepository {
|
||||||
backgroundColor: a.backgroundColor,
|
backgroundColor: a.backgroundColor,
|
||||||
description: a.description,
|
description: a.description,
|
||||||
groupId: a.agentSessionGroupId ?? a.sessionGroupId,
|
groupId: a.agentSessionGroupId ?? a.sessionGroupId,
|
||||||
|
heterogeneousType: a.agencyConfig?.heterogeneousProvider?.type ?? null,
|
||||||
id: a.id,
|
id: a.id,
|
||||||
pinned: a.pinned ?? a.sessionPinned ?? false,
|
pinned: a.pinned ?? a.sessionPinned ?? false,
|
||||||
sessionId: a.sessionId,
|
sessionId: a.sessionId,
|
||||||
|
|
|
||||||
17
packages/electron-client-ipc/src/events/acp.ts
Normal file
17
packages/electron-client-ipc/src/events/acp.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { ACPBroadcastEvents } from './acp';
|
||||||
import type { GatewayConnectionBroadcastEvents } from './gatewayConnection';
|
import type { GatewayConnectionBroadcastEvents } from './gatewayConnection';
|
||||||
import type { NavigationBroadcastEvents } from './navigation';
|
import type { NavigationBroadcastEvents } from './navigation';
|
||||||
import type { ProtocolBroadcastEvents } from './protocol';
|
import type { ProtocolBroadcastEvents } from './protocol';
|
||||||
|
|
@ -11,6 +12,7 @@ import type { AutoUpdateBroadcastEvents } from './update';
|
||||||
|
|
||||||
export interface MainBroadcastEvents
|
export interface MainBroadcastEvents
|
||||||
extends
|
extends
|
||||||
|
ACPBroadcastEvents,
|
||||||
AutoUpdateBroadcastEvents,
|
AutoUpdateBroadcastEvents,
|
||||||
GatewayConnectionBroadcastEvents,
|
GatewayConnectionBroadcastEvents,
|
||||||
NavigationBroadcastEvents,
|
NavigationBroadcastEvents,
|
||||||
|
|
|
||||||
13
packages/heterogeneous-agents/package.json
Normal file
13
packages/heterogeneous-agents/package.json
Normal file
|
|
@ -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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
434
packages/heterogeneous-agents/src/adapters/claudeCode.test.ts
Normal file
434
packages/heterogeneous-agents/src/adapters/claudeCode.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
289
packages/heterogeneous-agents/src/adapters/claudeCode.ts
Normal file
289
packages/heterogeneous-agents/src/adapters/claudeCode.ts
Normal file
|
|
@ -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: <NEW>, 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<string>();
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/heterogeneous-agents/src/adapters/index.ts
Normal file
1
packages/heterogeneous-agents/src/adapters/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { ClaudeCodeAdapter, claudeCodePreset } from './claudeCode';
|
||||||
15
packages/heterogeneous-agents/src/index.ts
Normal file
15
packages/heterogeneous-agents/src/index.ts
Normal file
|
|
@ -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';
|
||||||
46
packages/heterogeneous-agents/src/registry.test.ts
Normal file
46
packages/heterogeneous-agents/src/registry.test.ts
Normal file
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
55
packages/heterogeneous-agents/src/registry.ts
Normal file
55
packages/heterogeneous-agents/src/registry.ts
Normal file
|
|
@ -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<string, AgentRegistryEntry> = {
|
||||||
|
'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);
|
||||||
133
packages/heterogeneous-agents/src/types.ts
Normal file
133
packages/heterogeneous-agents/src/types.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[];
|
||||||
|
}
|
||||||
8
packages/heterogeneous-agents/vitest.config.ts
Normal file
8
packages/heterogeneous-agents/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.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 {
|
export interface HeterogeneousProviderConfig {
|
||||||
applicationId: string;
|
/** Additional CLI arguments for the agent command */
|
||||||
botToken: string;
|
args?: string[];
|
||||||
enabled: boolean;
|
/** Command to spawn the agent (e.g. 'claude') */
|
||||||
publicKey: string;
|
command?: string;
|
||||||
|
/** Custom environment variables */
|
||||||
|
env?: Record<string, string>;
|
||||||
|
/** Agent runtime type */
|
||||||
|
type: 'claudecode';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slack Bot configuration for an agent
|
* Agent agency configuration.
|
||||||
*/
|
* Contains settings for agent execution modes and device binding.
|
||||||
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.
|
|
||||||
*/
|
*/
|
||||||
export interface LobeAgentAgencyConfig {
|
export interface LobeAgentAgencyConfig {
|
||||||
boundDeviceId?: string;
|
boundDeviceId?: string;
|
||||||
discord?: DiscordBotConfig;
|
heterogeneousProvider?: HeterogeneousProviderConfig;
|
||||||
slack?: SlackBotConfig;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ import type { LobeAgentTTSConfig } from './tts';
|
||||||
|
|
||||||
export interface LobeAgentConfig {
|
export interface LobeAgentConfig {
|
||||||
/**
|
/**
|
||||||
* Agency configuration for external platform bot integrations (Discord, Slack, etc.)
|
* Agency configuration: device binding, heterogeneous agent provider, etc.
|
||||||
*/
|
*/
|
||||||
agencyConfig?: LobeAgentAgencyConfig;
|
agencyConfig?: LobeAgentAgencyConfig;
|
||||||
|
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,13 @@ export interface SidebarAgentItem {
|
||||||
* Only present for chat groups (type === 'group')
|
* Only present for chat groups (type === 'group')
|
||||||
*/
|
*/
|
||||||
groupAvatar?: string | null;
|
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;
|
id: string;
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,15 @@ export const MessageMetadataSchema = ModelUsageSchema.merge(ModelPerformanceSche
|
||||||
isMultimodal: z.boolean().optional(),
|
isMultimodal: z.boolean().optional(),
|
||||||
isSupervisor: z.boolean().optional(),
|
isSupervisor: z.boolean().optional(),
|
||||||
pageSelections: z.array(PageSelectionSchema).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(),
|
reactions: z.array(EmojiReactionSchema).optional(),
|
||||||
scope: z.string().optional(),
|
scope: z.string().optional(),
|
||||||
subAgentId: z.string().optional(),
|
subAgentId: z.string().optional(),
|
||||||
toolExecutionTimeMs: z.number().optional(),
|
toolExecutionTimeMs: z.number().optional(),
|
||||||
|
usage: ModelUsageSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ModelUsage extends ModelTokensUsage {
|
export interface ModelUsage extends ModelTokensUsage {
|
||||||
|
|
@ -134,7 +139,15 @@ export interface ModelPerformance {
|
||||||
ttft?: number;
|
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;
|
activeBranchIndex?: number;
|
||||||
activeColumn?: boolean;
|
activeColumn?: boolean;
|
||||||
/**
|
/**
|
||||||
|
|
@ -143,7 +156,27 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance {
|
||||||
*/
|
*/
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
compare?: boolean;
|
compare?: boolean;
|
||||||
|
/** @deprecated use `metadata.usage` instead */
|
||||||
|
cost?: number;
|
||||||
|
/** @deprecated use `metadata.performance` instead */
|
||||||
|
duration?: number;
|
||||||
finishType?: string;
|
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
|
* Tool inspect expanded state
|
||||||
* true: expanded, false/undefined: collapsed
|
* true: expanded, false/undefined: collapsed
|
||||||
|
|
@ -159,11 +192,22 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance {
|
||||||
* Flag indicating if message content is multimodal (serialized MessageContentPart[])
|
* Flag indicating if message content is multimodal (serialized MessageContentPart[])
|
||||||
*/
|
*/
|
||||||
isMultimodal?: boolean;
|
isMultimodal?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flag indicating if message is from the Supervisor agent in group orchestration
|
* Flag indicating if message is from the Supervisor agent in group orchestration
|
||||||
* Used by conversation-flow to transform role to 'supervisor' for UI rendering
|
* Used by conversation-flow to transform role to 'supervisor' for UI rendering
|
||||||
*/
|
*/
|
||||||
isSupervisor?: boolean;
|
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
|
* Page selections attached to user message
|
||||||
* Used for Ask AI functionality to persist selection context
|
* Used for Ask AI functionality to persist selection context
|
||||||
|
|
@ -178,6 +222,8 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance {
|
||||||
* Emoji reactions on this message
|
* Emoji reactions on this message
|
||||||
*/
|
*/
|
||||||
reactions?: EmojiReaction[];
|
reactions?: EmojiReaction[];
|
||||||
|
/** @deprecated use `metadata.usage` instead */
|
||||||
|
rejectedPredictionTokens?: number;
|
||||||
/**
|
/**
|
||||||
* Message scope - indicates the context in which this message was created
|
* Message scope - indicates the context in which this message was created
|
||||||
* Used by conversation-flow to determine how to handle message grouping and display
|
* 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)
|
* Tool execution time for tool messages (ms)
|
||||||
*/
|
*/
|
||||||
toolExecutionTimeMs?: number;
|
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;
|
usage?: ModelUsage;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ export const UserLabSchema = z.object({
|
||||||
* enable multi-agent group chat mode
|
* enable multi-agent group chat mode
|
||||||
*/
|
*/
|
||||||
enableGroupChat: z.boolean().optional(),
|
enableGroupChat: z.boolean().optional(),
|
||||||
|
/**
|
||||||
|
* enable heterogeneous agent execution (Claude Code, Codex CLI, etc.)
|
||||||
|
*/
|
||||||
|
enableHeterogeneousAgent: z.boolean().optional(),
|
||||||
/**
|
/**
|
||||||
* enable markdown rendering in chat input editor
|
* enable markdown rendering in chat input editor
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'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 isEqual from 'fast-deep-equal';
|
||||||
import { memo, useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
@ -13,6 +13,7 @@ import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selec
|
||||||
|
|
||||||
const AgentInfo = memo(() => {
|
const AgentInfo = memo(() => {
|
||||||
const { t } = useTranslation(['chat', 'welcome']);
|
const { t } = useTranslation(['chat', 'welcome']);
|
||||||
|
const isLoading = useAgentStore(agentSelectors.isAgentConfigLoading);
|
||||||
const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent);
|
const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent);
|
||||||
const meta = useAgentStore(agentSelectors.currentAgentMeta, isEqual);
|
const meta = useAgentStore(agentSelectors.currentAgentMeta, isEqual);
|
||||||
const openingMessage = useAgentStore(agentSelectors.openingMessage);
|
const openingMessage = useAgentStore(agentSelectors.openingMessage);
|
||||||
|
|
@ -29,6 +30,18 @@ const AgentInfo = memo(() => {
|
||||||
});
|
});
|
||||||
}, [openingMessage, displayTitle, t]);
|
}, [openingMessage, displayTitle, t]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Flexbox gap={12}>
|
||||||
|
<Skeleton.Avatar active shape={'square'} size={64} />
|
||||||
|
<Skeleton.Button active style={{ height: 32, width: 200 }} />
|
||||||
|
<Flexbox width={'min(100%, 640px)'}>
|
||||||
|
<Skeleton active paragraph={{ rows: 2 }} title={false} />
|
||||||
|
</Flexbox>
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flexbox gap={12}>
|
<Flexbox gap={12}>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ interface DesktopChatInputProps extends ActionToolbarProps {
|
||||||
inputContainerProps?: ChatInputProps;
|
inputContainerProps?: ChatInputProps;
|
||||||
leftContent?: ReactNode;
|
leftContent?: ReactNode;
|
||||||
placeholder?: ReactNode;
|
placeholder?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Custom node to render in place of the default RuntimeConfig bar.
|
||||||
|
* When provided, used instead of `<RuntimeConfig />` (ignores `showRuntimeConfig`).
|
||||||
|
*/
|
||||||
|
runtimeConfigSlot?: ReactNode;
|
||||||
sendAreaPrefix?: ReactNode;
|
sendAreaPrefix?: ReactNode;
|
||||||
showFootnote?: boolean;
|
showFootnote?: boolean;
|
||||||
showRuntimeConfig?: boolean;
|
showRuntimeConfig?: boolean;
|
||||||
|
|
@ -72,6 +77,7 @@ const DesktopChatInput = memo<DesktopChatInputProps>(
|
||||||
({
|
({
|
||||||
showFootnote,
|
showFootnote,
|
||||||
showRuntimeConfig = true,
|
showRuntimeConfig = true,
|
||||||
|
runtimeConfigSlot,
|
||||||
inputContainerProps,
|
inputContainerProps,
|
||||||
extentHeaderContent,
|
extentHeaderContent,
|
||||||
actionBarStyle,
|
actionBarStyle,
|
||||||
|
|
@ -164,7 +170,7 @@ const DesktopChatInput = memo<DesktopChatInputProps>(
|
||||||
>
|
>
|
||||||
<InputEditor placeholder={placeholder} />
|
<InputEditor placeholder={placeholder} />
|
||||||
</ChatInput>
|
</ChatInput>
|
||||||
{showRuntimeConfig && <RuntimeConfig />}
|
{runtimeConfigSlot ?? (showRuntimeConfig && <RuntimeConfig />)}
|
||||||
{showFootnote && !expand && (
|
{showFootnote && !expand && (
|
||||||
<Center style={{ pointerEvents: 'none', zIndex: 100 }}>
|
<Center style={{ pointerEvents: 'none', zIndex: 100 }}>
|
||||||
<Text className={styles.footnote} type={'secondary'}>
|
<Text className={styles.footnote} type={'secondary'}>
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,11 @@ export interface ChatInputProps {
|
||||||
* Right action buttons configuration
|
* Right action buttons configuration
|
||||||
*/
|
*/
|
||||||
rightActions?: ActionKeys[];
|
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)
|
* Custom content to render before the SendArea (right side of action bar)
|
||||||
*/
|
*/
|
||||||
|
|
@ -123,6 +128,7 @@ const ChatInput = memo<ChatInputProps>(
|
||||||
children,
|
children,
|
||||||
extraActionItems,
|
extraActionItems,
|
||||||
mentionItems,
|
mentionItems,
|
||||||
|
runtimeConfigSlot,
|
||||||
sendMenu,
|
sendMenu,
|
||||||
sendAreaPrefix,
|
sendAreaPrefix,
|
||||||
sendButtonProps: customSendButtonProps,
|
sendButtonProps: customSendButtonProps,
|
||||||
|
|
@ -263,6 +269,7 @@ const ChatInput = memo<ChatInputProps>(
|
||||||
borderRadius={12}
|
borderRadius={12}
|
||||||
extraActionItems={extraActionItems}
|
extraActionItems={extraActionItems}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
|
runtimeConfigSlot={runtimeConfigSlot}
|
||||||
sendAreaPrefix={sendAreaPrefix}
|
sendAreaPrefix={sendAreaPrefix}
|
||||||
showRuntimeConfig={showRuntimeConfig}
|
showRuntimeConfig={showRuntimeConfig}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ export default {
|
||||||
'agentDefaultMessageWithoutEdit':
|
'agentDefaultMessageWithoutEdit':
|
||||||
"Hi, I’m **{{name}}**. One sentence is enough—you're in control.",
|
"Hi, I’m **{{name}}**. One sentence is enough—you're in control.",
|
||||||
'agents': 'Agents',
|
'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.generating': 'Generating',
|
||||||
'artifact.inThread':
|
'artifact.inThread':
|
||||||
'Cannot view in subtopic, please switch to the main conversation area to open',
|
'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.placeholder': 'Describe what your agent should do...',
|
||||||
'createModal.title': 'What should your agent do?',
|
'createModal.title': 'What should your agent do?',
|
||||||
'newAgent': 'Create Agent',
|
'newAgent': 'Create Agent',
|
||||||
|
'newClaudeCodeAgent': 'Claude Code Agent',
|
||||||
'newGroupChat': 'Create Group',
|
'newGroupChat': 'Create Group',
|
||||||
'newPage': 'Create Page',
|
'newPage': 'Create Page',
|
||||||
'noAgentsYet': 'This group has no members yet. Click the + button to invite agents.',
|
'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.contextCompression': 'Context too long, compressing history...',
|
||||||
'operation.execAgentRuntime': 'Preparing response',
|
'operation.execAgentRuntime': 'Preparing response',
|
||||||
'operation.execClientTask': 'Executing task',
|
'operation.execClientTask': 'Executing task',
|
||||||
|
'operation.execHeterogeneousAgent': 'External agent running',
|
||||||
'operation.execServerAgentRuntime':
|
'operation.execServerAgentRuntime':
|
||||||
'Task is running in the server. You are safe to leave this page',
|
'Task is running in the server. You are safe to leave this page',
|
||||||
'operation.sendMessage': 'Sending message',
|
'operation.sendMessage': 'Sending message',
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ export default {
|
||||||
'features.gatewayMode.title': 'Server-Side Agent Execution (Gateway)',
|
'features.gatewayMode.title': 'Server-Side Agent Execution (Gateway)',
|
||||||
'features.groupChat.desc': 'Enable multi-agent group chat coordination.',
|
'features.groupChat.desc': 'Enable multi-agent group chat coordination.',
|
||||||
'features.groupChat.title': 'Group Chat (Multi-Agent)',
|
'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':
|
'features.inputMarkdown.desc':
|
||||||
'Render Markdown in the input area in real time (bold text, code blocks, tables, etc.).',
|
'Render Markdown in the input area in real time (bold text, code blocks, tables, etc.).',
|
||||||
'features.inputMarkdown.title': 'Input Markdown Rendering',
|
'features.inputMarkdown.title': 'Input Markdown Rendering',
|
||||||
|
|
|
||||||
|
|
@ -742,6 +742,9 @@ export default {
|
||||||
'settingSystemTools.category.browserAutomation': 'Browser Automation',
|
'settingSystemTools.category.browserAutomation': 'Browser Automation',
|
||||||
'settingSystemTools.category.browserAutomation.desc':
|
'settingSystemTools.category.browserAutomation.desc':
|
||||||
'Tools for headless browser automation and web interaction',
|
'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': 'Content Search',
|
||||||
'settingSystemTools.category.contentSearch.desc': 'Tools for searching text content within files',
|
'settingSystemTools.category.contentSearch.desc': 'Tools for searching text content within files',
|
||||||
'settingSystemTools.category.fileSearch': 'File Search',
|
'settingSystemTools.category.fileSearch': 'File Search',
|
||||||
|
|
@ -758,9 +761,14 @@ export default {
|
||||||
'settingSystemTools.tools.agentBrowser.desc':
|
'settingSystemTools.tools.agentBrowser.desc':
|
||||||
'Agent-browser - headless browser automation CLI for AI agents',
|
'Agent-browser - headless browser automation CLI for AI agents',
|
||||||
'settingSystemTools.tools.ag.desc': 'The Silver Searcher - fast code searching tool',
|
'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.fd.desc': 'fd - fast and user-friendly alternative to find',
|
||||||
'settingSystemTools.tools.find.desc': 'Unix find - standard file search command',
|
'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.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.mdfind.desc': 'macOS Spotlight search (fast indexed search)',
|
||||||
'settingSystemTools.tools.lobehub.desc': 'LobeHub CLI - manage and connect to LobeHub services',
|
'settingSystemTools.tools.lobehub.desc': 'LobeHub CLI - manage and connect to LobeHub services',
|
||||||
'settingSystemTools.tools.bun.desc': 'Bun - fast JavaScript runtime and package manager',
|
'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.npm.desc': 'npm - Node.js package manager for installing dependencies',
|
||||||
'settingSystemTools.tools.pnpm.desc': 'pnpm - fast, disk space efficient package manager',
|
'settingSystemTools.tools.pnpm.desc': 'pnpm - fast, disk space efficient package manager',
|
||||||
'settingSystemTools.tools.python.desc': 'Python - programming language runtime',
|
'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.rg.desc': 'ripgrep - extremely fast text search tool',
|
||||||
'settingSystemTools.tools.uv.desc': 'uv - extremely fast Python package manager',
|
'settingSystemTools.tools.uv.desc': 'uv - extremely fast Python package manager',
|
||||||
'settingTTS.openai.sttModel': 'OpenAI Speech-to-Text Model',
|
'settingTTS.openai.sttModel': 'OpenAI Speech-to-Text Model',
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { useMount, usePrevious, useUnmount } from 'ahooks';
|
import { useMount, usePrevious, useUnmount } from 'ahooks';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useParams, useSearchParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { createStoreUpdater } from 'zustand-utils';
|
|
||||||
|
|
||||||
import { useAgentStore } from '@/store/agent';
|
import { useAgentStore } from '@/store/agent';
|
||||||
import { useChatStore } from '@/store/chat';
|
import { useChatStore } from '@/store/chat';
|
||||||
|
import { createStoreUpdater } from '@/store/utils/createStoreUpdater';
|
||||||
|
|
||||||
const AgentIdSync = () => {
|
const AgentIdSync = () => {
|
||||||
const useStoreUpdater = createStoreUpdater(useAgentStore);
|
const useStoreUpdater = createStoreUpdater(useAgentStore);
|
||||||
|
|
@ -16,7 +16,7 @@ const AgentIdSync = () => {
|
||||||
const prevAgentId = usePrevious(params.aid);
|
const prevAgentId = usePrevious(params.aid);
|
||||||
|
|
||||||
useStoreUpdater('activeAgentId', params.aid);
|
useStoreUpdater('activeAgentId', params.aid);
|
||||||
useChatStoreUpdater('activeAgentId', params.aid ?? '');
|
useChatStoreUpdater('activeAgentId', params.aid);
|
||||||
|
|
||||||
// Reset activeTopicId when switching to a different agent
|
// Reset activeTopicId when switching to a different agent
|
||||||
// This prevents messages from being saved to the wrong topic bucket
|
// This prevents messages from being saved to the wrong topic bucket
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import NavItem from '@/features/NavPanel/components/NavItem';
|
||||||
import { useQueryRoute } from '@/hooks/useQueryRoute';
|
import { useQueryRoute } from '@/hooks/useQueryRoute';
|
||||||
import { usePathname } from '@/libs/router/navigation';
|
import { usePathname } from '@/libs/router/navigation';
|
||||||
import { useActionSWR } from '@/libs/swr';
|
import { useActionSWR } from '@/libs/swr';
|
||||||
|
import { useAgentStore } from '@/store/agent';
|
||||||
|
import { agentSelectors } from '@/store/agent/selectors';
|
||||||
import { useChatStore } from '@/store/chat';
|
import { useChatStore } from '@/store/chat';
|
||||||
import { useGlobalStore } from '@/store/global';
|
import { useGlobalStore } from '@/store/global';
|
||||||
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||||
|
|
@ -27,7 +29,9 @@ const Nav = memo(() => {
|
||||||
const router = useQueryRoute();
|
const router = useQueryRoute();
|
||||||
const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors);
|
const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors);
|
||||||
const toggleCommandMenu = useGlobalStore((s) => s.toggleCommandMenu);
|
const toggleCommandMenu = useGlobalStore((s) => s.toggleCommandMenu);
|
||||||
|
const isHeterogeneousAgent = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
|
||||||
const hideProfile = !isAgentEditable;
|
const hideProfile = !isAgentEditable;
|
||||||
|
const hideChannel = hideProfile || isHeterogeneousAgent;
|
||||||
const switchTopic = useChatStore((s) => s.switchTopic);
|
const switchTopic = useChatStore((s) => s.switchTopic);
|
||||||
const [openNewTopicOrSaveTopic] = useChatStore((s) => [s.openNewTopicOrSaveTopic]);
|
const [openNewTopicOrSaveTopic] = useChatStore((s) => [s.openNewTopicOrSaveTopic]);
|
||||||
|
|
||||||
|
|
@ -58,7 +62,7 @@ const Nav = memo(() => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!hideProfile && (
|
{!hideChannel && (
|
||||||
<NavItem
|
<NavItem
|
||||||
active={isChannelActive}
|
active={isChannelActive}
|
||||||
icon={RadioTowerIcon}
|
icon={RadioTowerIcon}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,13 @@ import ChatMiniMap from '@/features/ChatMiniMap';
|
||||||
import { ChatList, ConversationProvider, TodoProgress } from '@/features/Conversation';
|
import { ChatList, ConversationProvider, TodoProgress } from '@/features/Conversation';
|
||||||
import ZenModeToast from '@/features/ZenModeToast';
|
import ZenModeToast from '@/features/ZenModeToast';
|
||||||
import { useOperationState } from '@/hooks/useOperationState';
|
import { useOperationState } from '@/hooks/useOperationState';
|
||||||
|
import { useAgentStore } from '@/store/agent';
|
||||||
|
import { agentSelectors } from '@/store/agent/selectors';
|
||||||
import { useChatStore } from '@/store/chat';
|
import { useChatStore } from '@/store/chat';
|
||||||
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
|
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
|
||||||
|
|
||||||
import ChatHydration from './ChatHydration';
|
import ChatHydration from './ChatHydration';
|
||||||
|
import HeterogeneousChatInput from './HeterogeneousChatInput';
|
||||||
import MainChatInput from './MainChatInput';
|
import MainChatInput from './MainChatInput';
|
||||||
import MessageFromUrl from './MainChatInput/MessageFromUrl';
|
import MessageFromUrl from './MainChatInput/MessageFromUrl';
|
||||||
import ThreadHydration from './ThreadHydration';
|
import ThreadHydration from './ThreadHydration';
|
||||||
|
|
@ -47,6 +50,11 @@ const Conversation = memo(() => {
|
||||||
// Get actionsBar config with branching support from ChatStore
|
// Get actionsBar config with branching support from ChatStore
|
||||||
const actionsBarConfig = useActionsBarConfig();
|
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
|
// Auto-reconnect to running Gateway operation on topic load
|
||||||
useGatewayReconnect(context.topicId);
|
useGatewayReconnect(context.topicId);
|
||||||
|
|
||||||
|
|
@ -74,7 +82,7 @@ const Conversation = memo(() => {
|
||||||
<ChatList welcome={<AgentHome />} />
|
<ChatList welcome={<AgentHome />} />
|
||||||
</Flexbox>
|
</Flexbox>
|
||||||
<TodoProgress />
|
<TodoProgress />
|
||||||
<MainChatInput />
|
{isHeterogeneousAgent ? <HeterogeneousChatInput /> : <MainChatInput />}
|
||||||
<ChatHydration />
|
<ChatHydration />
|
||||||
<ThreadHydration />
|
<ThreadHydration />
|
||||||
<ChatMiniMap />
|
<ChatMiniMap />
|
||||||
|
|
|
||||||
|
|
@ -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 <Icon icon={SquircleDashed} size={14} />;
|
||||||
|
const dirs = getRecentDirs();
|
||||||
|
const match = dirs.find((d) => d.path === effectiveWorkingDirectory);
|
||||||
|
if (match?.repoType === 'github') return <Github size={14} />;
|
||||||
|
if (match?.repoType === 'git') return <Icon icon={GitBranchIcon} size={14} />;
|
||||||
|
return <Icon icon={FolderIcon} size={14} />;
|
||||||
|
}, [effectiveWorkingDirectory]);
|
||||||
|
|
||||||
|
if (!agentId || isLoading) {
|
||||||
|
return (
|
||||||
|
<Flexbox horizontal align={'center'} className={styles.bar} gap={4}>
|
||||||
|
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 100, width: 100 }} />
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = effectiveWorkingDirectory
|
||||||
|
? effectiveWorkingDirectory.split('/').findLast(Boolean) || effectiveWorkingDirectory
|
||||||
|
: t('localSystem.workingDirectory.notSet');
|
||||||
|
|
||||||
|
const dirButton = (
|
||||||
|
<div className={styles.button}>
|
||||||
|
{dirIconNode}
|
||||||
|
<span>{displayName}</span>
|
||||||
|
<Icon icon={ChevronDownIcon} size={12} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flexbox horizontal align={'center'} className={styles.bar}>
|
||||||
|
<Popover
|
||||||
|
content={<WorkingDirectoryContent agentId={agentId} onClose={() => setOpen(false)} />}
|
||||||
|
open={open}
|
||||||
|
placement="bottomLeft"
|
||||||
|
styles={{ content: { padding: 4 } }}
|
||||||
|
trigger="click"
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{open ? (
|
||||||
|
dirButton
|
||||||
|
) : (
|
||||||
|
<Tooltip title={effectiveWorkingDirectory || t('localSystem.workingDirectory.notSet')}>
|
||||||
|
{dirButton}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</Flexbox>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
WorkingDirectoryBar.displayName = 'HeterogeneousWorkingDirectoryBar';
|
||||||
|
|
||||||
|
export default WorkingDirectoryBar;
|
||||||
|
|
@ -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 (
|
||||||
|
<ChatInput
|
||||||
|
skipScrollMarginWithList
|
||||||
|
leftActions={leftActions}
|
||||||
|
rightActions={rightActions}
|
||||||
|
runtimeConfigSlot={<WorkingDirectoryBar />}
|
||||||
|
sendButtonProps={{ shape: 'round' }}
|
||||||
|
onEditorReady={(instance) => {
|
||||||
|
// Sync to global ChatStore for compatibility with other features
|
||||||
|
useChatStore.setState({ mainInputEditor: instance });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HeterogeneousChatInput.displayName = 'HeterogeneousChatInput';
|
||||||
|
|
||||||
|
export default HeterogeneousChatInput;
|
||||||
|
|
@ -10,6 +10,8 @@ import {
|
||||||
type MessageActionFactory,
|
type MessageActionFactory,
|
||||||
type MessageActionItem,
|
type MessageActionItem,
|
||||||
} from '@/features/Conversation/types';
|
} from '@/features/Conversation/types';
|
||||||
|
import { useAgentStore } from '@/store/agent';
|
||||||
|
import { agentSelectors } from '@/store/agent/selectors';
|
||||||
import { useChatStore } from '@/store/chat';
|
import { useChatStore } from '@/store/chat';
|
||||||
import { useUserStore } from '@/store/user';
|
import { useUserStore } from '@/store/user';
|
||||||
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
|
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
|
||||||
|
|
@ -51,16 +53,25 @@ export const useBranchingActionFactory = (): MessageActionFactory => {
|
||||||
export const useActionsBarConfig = (): ActionsBarConfig => {
|
export const useActionsBarConfig = (): ActionsBarConfig => {
|
||||||
const branchingFactory = useBranchingActionFactory();
|
const branchingFactory = useBranchingActionFactory();
|
||||||
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
||||||
|
const hasACPProvider = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
assistant: {
|
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: {
|
user: {
|
||||||
extraBarActions: isDevMode ? [branchingFactory] : [],
|
extraBarActions: isDevMode ? [branchingFactory] : [],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[branchingFactory, isDevMode],
|
[branchingFactory, hasACPProvider, isDevMode],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { SESSION_CHAT_URL } from '@lobechat/const';
|
import { SESSION_CHAT_URL } from '@lobechat/const';
|
||||||
import { type SidebarAgentItem } from '@lobechat/types';
|
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 { cssVar } from 'antd-style';
|
||||||
import { Loader2, PinIcon } from 'lucide-react';
|
import { Loader2, PinIcon } from 'lucide-react';
|
||||||
import { type CSSProperties, type DragEvent } from 'react';
|
import { type CSSProperties, type DragEvent } from 'react';
|
||||||
|
|
@ -28,7 +28,7 @@ interface AgentItemProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
|
const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
|
||||||
const { id, avatar, backgroundColor, title, pinned } = item;
|
const { id, avatar, backgroundColor, title, pinned, heterogeneousType } = item;
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const { openCreateGroupModal } = useAgentModal();
|
const { openCreateGroupModal } = useAgentModal();
|
||||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
|
@ -43,6 +43,21 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
|
||||||
// Get display title with fallback
|
// Get display title with fallback
|
||||||
const displayTitle = title || t('untitledAgent');
|
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 ? (
|
||||||
|
<Flexbox horizontal align="center" gap={4}>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{displayTitle}
|
||||||
|
</span>
|
||||||
|
<Tag size="small" style={{ flexShrink: 0 }}>
|
||||||
|
{t('agentSidebar.externalTag')}
|
||||||
|
</Tag>
|
||||||
|
</Flexbox>
|
||||||
|
) : (
|
||||||
|
displayTitle
|
||||||
|
);
|
||||||
|
|
||||||
// Get URL for this agent
|
// Get URL for this agent
|
||||||
const agentUrl = SESSION_CHAT_URL(id, false);
|
const agentUrl = SESSION_CHAT_URL(id, false);
|
||||||
|
|
||||||
|
|
@ -122,7 +137,7 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
|
||||||
key={id}
|
key={id}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
style={style}
|
style={style}
|
||||||
title={displayTitle}
|
title={titleNode}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,15 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
|
||||||
const { openConfigGroupModal } = useAgentModal();
|
const { openConfigGroupModal } = useAgentModal();
|
||||||
|
|
||||||
// Create menu items
|
// Create menu items
|
||||||
const { createAgentMenuItem, createGroupChatMenuItem, isLoading } = useCreateMenuItems();
|
const { createAgentMenuItem, createClaudeCodeMenuItem, createGroupChatMenuItem, isLoading } =
|
||||||
|
useCreateMenuItems();
|
||||||
|
|
||||||
const addMenuItems = useMemo(
|
const addMenuItems = useMemo(() => {
|
||||||
() => [createAgentMenuItem(), createGroupChatMenuItem()],
|
const items = [createAgentMenuItem(), createGroupChatMenuItem()];
|
||||||
[createAgentMenuItem, createGroupChatMenuItem],
|
const ccItem = createClaudeCodeMenuItem();
|
||||||
);
|
if (ccItem) items.splice(1, 0, ccItem);
|
||||||
|
return items;
|
||||||
|
}, [createAgentMenuItem, createClaudeCodeMenuItem, createGroupChatMenuItem]);
|
||||||
|
|
||||||
const handleOpenConfigGroupModal = useCallback(() => {
|
const handleOpenConfigGroupModal = useCallback(() => {
|
||||||
openConfigGroupModal();
|
openConfigGroupModal();
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const AddButton = memo(() => {
|
||||||
// Create menu items
|
// Create menu items
|
||||||
const {
|
const {
|
||||||
createAgentMenuItem,
|
createAgentMenuItem,
|
||||||
|
createClaudeCodeMenuItem,
|
||||||
createGroupChatMenuItem,
|
createGroupChatMenuItem,
|
||||||
createPageMenuItem,
|
createPageMenuItem,
|
||||||
openCreateModal,
|
openCreateModal,
|
||||||
|
|
@ -32,8 +33,11 @@ const AddButton = memo(() => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const dropdownItems = useMemo(() => {
|
const dropdownItems = useMemo(() => {
|
||||||
return [createAgentMenuItem(), createGroupChatMenuItem(), createPageMenuItem()];
|
const items = [createAgentMenuItem(), createGroupChatMenuItem(), createPageMenuItem()];
|
||||||
}, [createAgentMenuItem, createGroupChatMenuItem, createPageMenuItem]);
|
const ccItem = createClaudeCodeMenuItem();
|
||||||
|
if (ccItem) items.splice(1, 0, ccItem); // Insert after "Create Agent"
|
||||||
|
return items;
|
||||||
|
}, [createAgentMenuItem, createClaudeCodeMenuItem, createGroupChatMenuItem, createPageMenuItem]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flexbox horizontal>
|
<Flexbox horizontal>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
|
import { isDesktop } from '@lobechat/const';
|
||||||
import { Icon } from '@lobehub/ui';
|
import { Icon } from '@lobehub/ui';
|
||||||
import { GroupBotSquareIcon } from '@lobehub/ui/icons';
|
import { GroupBotSquareIcon } from '@lobehub/ui/icons';
|
||||||
import { App } from 'antd';
|
import { App } from 'antd';
|
||||||
import { type ItemType } from 'antd/es/menu/interface';
|
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 { useCallback, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
@ -18,6 +19,8 @@ import { useAgentStore } from '@/store/agent';
|
||||||
import { useAgentGroupStore } from '@/store/agentGroup';
|
import { useAgentGroupStore } from '@/store/agentGroup';
|
||||||
import { useHomeStore } from '@/store/home';
|
import { useHomeStore } from '@/store/home';
|
||||||
import { usePageStore } from '@/store/page';
|
import { usePageStore } from '@/store/page';
|
||||||
|
import { useUserStore } from '@/store/user';
|
||||||
|
import { labPreferSelectors } from '@/store/user/selectors';
|
||||||
|
|
||||||
interface CreateAgentOptions {
|
interface CreateAgentOptions {
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
|
|
@ -35,6 +38,7 @@ export const useCreateMenuItems = () => {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const groupTemplates = useGroupTemplates();
|
const groupTemplates = useGroupTemplates();
|
||||||
|
const enableHeterogeneousAgent = useUserStore(labPreferSelectors.enableHeterogeneousAgent);
|
||||||
|
|
||||||
const [storeCreateAgent] = useAgentStore((s) => [s.createAgent]);
|
const [storeCreateAgent] = useAgentStore((s) => [s.createAgent]);
|
||||||
const [addGroup, refreshAgentList, switchToGroup] = useHomeStore((s) => [
|
const [addGroup, refreshAgentList, switchToGroup] = useHomeStore((s) => [
|
||||||
|
|
@ -192,6 +196,37 @@ export const useCreateMenuItems = () => {
|
||||||
[mutateGroup],
|
[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 agentModal = useOptionalAgentModal();
|
||||||
const openCreateModal = agentModal?.openCreateModal;
|
const openCreateModal = agentModal?.openCreateModal;
|
||||||
|
|
||||||
|
|
@ -217,6 +252,25 @@ export const useCreateMenuItems = () => {
|
||||||
[t, createAgent, openCreateModal],
|
[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: <Icon icon={TerminalSquareIcon} />,
|
||||||
|
key: 'newClaudeCodeAgent',
|
||||||
|
label: t('newClaudeCodeAgent'),
|
||||||
|
onClick: async (info) => {
|
||||||
|
info.domEvent?.stopPropagation();
|
||||||
|
await createClaudeCodeAgent(options);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[t, createClaudeCodeAgent, enableHeterogeneousAgent],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create group chat menu item
|
* Create group chat menu item
|
||||||
* Creates an empty group and navigates to its profile page
|
* Creates an empty group and navigates to its profile page
|
||||||
|
|
@ -308,6 +362,8 @@ export const useCreateMenuItems = () => {
|
||||||
configMenuItem,
|
configMenuItem,
|
||||||
createAgent,
|
createAgent,
|
||||||
createAgentMenuItem,
|
createAgentMenuItem,
|
||||||
|
createClaudeCodeAgent,
|
||||||
|
createClaudeCodeMenuItem,
|
||||||
createEmptyGroup,
|
createEmptyGroup,
|
||||||
createGroupChatMenuItem,
|
createGroupChatMenuItem,
|
||||||
createGroupFromTemplate,
|
createGroupFromTemplate,
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,14 @@ const Page = memo(() => {
|
||||||
enableInputMarkdown,
|
enableInputMarkdown,
|
||||||
enableGatewayMode,
|
enableGatewayMode,
|
||||||
enableAgentWorkingPanel,
|
enableAgentWorkingPanel,
|
||||||
|
enableHeterogeneousAgent,
|
||||||
updateLab,
|
updateLab,
|
||||||
] = useUserStore((s) => [
|
] = useUserStore((s) => [
|
||||||
preferenceSelectors.isPreferenceInit(s),
|
preferenceSelectors.isPreferenceInit(s),
|
||||||
labPreferSelectors.enableInputMarkdown(s),
|
labPreferSelectors.enableInputMarkdown(s),
|
||||||
labPreferSelectors.enableGatewayMode(s),
|
labPreferSelectors.enableGatewayMode(s),
|
||||||
labPreferSelectors.enableAgentWorkingPanel(s),
|
labPreferSelectors.enableAgentWorkingPanel(s),
|
||||||
|
labPreferSelectors.enableHeterogeneousAgent(s),
|
||||||
s.updateLab,
|
s.updateLab,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -136,6 +138,23 @@ const Page = memo(() => {
|
||||||
label: tLabs('features.agentWorkingPanel.title'),
|
label: tLabs('features.agentWorkingPanel.title'),
|
||||||
minWidth: undefined,
|
minWidth: undefined,
|
||||||
},
|
},
|
||||||
|
...(isDesktop
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
children: (
|
||||||
|
<Switch
|
||||||
|
checked={enableHeterogeneousAgent}
|
||||||
|
loading={!isPreferenceInit}
|
||||||
|
onChange={(checked: boolean) => updateLab({ enableHeterogeneousAgent: checked })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
className: styles.labItem,
|
||||||
|
desc: tLabs('features.heterogeneousAgent.desc'),
|
||||||
|
label: tLabs('features.heterogeneousAgent.title'),
|
||||||
|
minWidth: undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(hasGatewayUrl
|
...(hasGatewayUrl
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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': {
|
'content-search': {
|
||||||
descKey: 'settingSystemTools.category.contentSearch.desc',
|
descKey: 'settingSystemTools.category.contentSearch.desc',
|
||||||
titleKey: 'settingSystemTools.category.contentSearch',
|
titleKey: 'settingSystemTools.category.contentSearch',
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ const Page = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingHeader title={t('tab.systemTools')} />
|
<SettingHeader title={t('tab.systemTools')} />
|
||||||
<AppEnvironmentSection />
|
|
||||||
<ToolDetectorSection />
|
<ToolDetectorSection />
|
||||||
|
<AppEnvironmentSection />
|
||||||
{isDevMode ? <CliTestSection /> : null}
|
{isDevMode ? <CliTestSection /> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
43
src/services/electron/heterogeneousAgent.ts
Normal file
43
src/services/electron/heterogeneousAgent.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
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();
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import { DEFAULT_PROVIDER } from '@lobechat/business-const';
|
import { DEFAULT_PROVIDER } from '@lobechat/business-const';
|
||||||
import { DEFAULT_MODEL, DEFAUTT_AGENT_TTS_CONFIG, isDesktop } from '@lobechat/const';
|
import { DEFAULT_MODEL, DEFAUTT_AGENT_TTS_CONFIG, isDesktop } from '@lobechat/const';
|
||||||
import { type AgentBuilderContext } from '@lobechat/context-engine';
|
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';
|
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
|
* Get full agent data by agentId
|
||||||
* Returns the complete agent object including metadata fields like updatedAt
|
* 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];
|
const getAgentById = (agentId: string) => (s: AgentStoreState) => s.agentMap[agentId];
|
||||||
|
|
||||||
export const agentByIdSelectors = {
|
export const agentByIdSelectors = {
|
||||||
|
getAgencyConfigById,
|
||||||
getAgentBuilderContextById,
|
getAgentBuilderContextById,
|
||||||
getAgentById,
|
getAgentById,
|
||||||
getAgentConfigById: agentSelectors.getAgentConfigById,
|
getAgentConfigById: agentSelectors.getAgentConfigById,
|
||||||
|
|
|
||||||
|
|
@ -283,6 +283,14 @@ const currentAgentWorkingDirectory = (s: AgentStoreState): string | undefined =>
|
||||||
|
|
||||||
const isCurrentAgentExternal = (s: AgentStoreState): boolean => !currentAgentData(s)?.virtual;
|
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) =>
|
const getAgentDocumentsById = (agentId: string) => (s: AgentStoreState) =>
|
||||||
s.agentDocumentsMap[agentId];
|
s.agentDocumentsMap[agentId];
|
||||||
|
|
||||||
|
|
@ -321,6 +329,7 @@ export const agentSelectors = {
|
||||||
isAgentConfigLoading,
|
isAgentConfigLoading,
|
||||||
isAgentModeEnabled,
|
isAgentModeEnabled,
|
||||||
isCurrentAgentExternal,
|
isCurrentAgentExternal,
|
||||||
|
isCurrentAgentHeterogeneous,
|
||||||
openingMessage,
|
openingMessage,
|
||||||
openingQuestions,
|
openingQuestions,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -456,4 +456,47 @@ describe('createGatewayEventHandler', () => {
|
||||||
expect(store.completeOperation).toHaveBeenCalledWith('op-1');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,7 @@ import { MESSAGE_CANCEL_FLAT } from '@lobechat/const';
|
||||||
import { type ConversationContext } from '@lobechat/types';
|
import { type ConversationContext } from '@lobechat/types';
|
||||||
|
|
||||||
import { operationSelectors } from '@/store/chat/slices/operation/selectors';
|
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 ChatStore } from '@/store/chat/store';
|
||||||
import { type StoreSetter } from '@/store/types';
|
import { type StoreSetter } from '@/store/types';
|
||||||
|
|
||||||
|
|
@ -95,14 +96,11 @@ export class ConversationControlActionImpl {
|
||||||
const { activeAgentId, activeTopicId, cancelOperations } = this.#get();
|
const { activeAgentId, activeTopicId, cancelOperations } = this.#get();
|
||||||
|
|
||||||
// Cancel running agent-runtime operations in the current context —
|
// Cancel running agent-runtime operations in the current context —
|
||||||
// both client-side (execAgentRuntime) and Gateway-mode
|
// client-side (execAgentRuntime), heterogeneous agent (execHeterogeneousAgent),
|
||||||
// (execServerAgentRuntime). For the Gateway-mode branch, a cancel
|
// and Gateway-mode (execServerAgentRuntime).
|
||||||
// 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.
|
|
||||||
cancelOperations(
|
cancelOperations(
|
||||||
{
|
{
|
||||||
type: ['execAgentRuntime', 'execServerAgentRuntime'],
|
type: AI_RUNTIME_OPERATION_TYPES,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
agentId: activeAgentId,
|
agentId: activeAgentId,
|
||||||
topicId: activeTopicId,
|
topicId: activeTopicId,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Disable the auto sort key eslint rule to make the code more logic and readable
|
// Disable the auto sort key eslint rule to make the code more logic and readable
|
||||||
import { createCallAgentManifest } from '@lobechat/builtin-tool-agent-management';
|
import { createCallAgentManifest } from '@lobechat/builtin-tool-agent-management';
|
||||||
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
|
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 { formatSelectedSkillsContext, formatSelectedToolsContext } from '@lobechat/context-engine';
|
||||||
import { chainCompressContext } from '@lobechat/prompts';
|
import { chainCompressContext } from '@lobechat/prompts';
|
||||||
import {
|
import {
|
||||||
|
|
@ -23,7 +23,7 @@ import { resolveSelectedSkillsWithContent } from '@/services/chat/mecha/skillPre
|
||||||
import { resolveSelectedToolsWithContent } from '@/services/chat/mecha/toolPreload';
|
import { resolveSelectedToolsWithContent } from '@/services/chat/mecha/toolPreload';
|
||||||
import { messageService } from '@/services/message';
|
import { messageService } from '@/services/message';
|
||||||
import { getAgentStoreState } from '@/store/agent';
|
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 { agentGroupByIdSelectors, getChatGroupStoreState } from '@/store/agentGroup';
|
||||||
import { type ChatStore } from '@/store/chat/store';
|
import { type ChatStore } from '@/store/chat/store';
|
||||||
import {
|
import {
|
||||||
|
|
@ -340,6 +340,141 @@ export class ConversationLifecycleActionImpl {
|
||||||
inputSendErrorMsg: undefined,
|
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 ──
|
// ── Gateway mode: skip sendMessageInServer, let execAgentTask handle everything ──
|
||||||
if (this.#get().isGatewayModeEnabled()) {
|
if (this.#get().isGatewayModeEnabled()) {
|
||||||
this.#get().completeOperation(operationId);
|
this.#get().completeOperation(operationId);
|
||||||
|
|
|
||||||
|
|
@ -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<string>;
|
||||||
|
/** Map tool_use.id → tool message DB id (for later content update on tool_result) */
|
||||||
|
toolMsgIdByCallId: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> => {
|
||||||
|
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<void> = 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<string, number> = {
|
||||||
|
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<string, any> = {};
|
||||||
|
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<string, any> = {};
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -10,6 +10,7 @@ import { setNamespace } from '@/utils/storeDebug';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type AfterCompletionCallback,
|
type AfterCompletionCallback,
|
||||||
|
AI_RUNTIME_OPERATION_TYPES,
|
||||||
type Operation,
|
type Operation,
|
||||||
type OperationCancelContext,
|
type OperationCancelContext,
|
||||||
type OperationContext,
|
type OperationContext,
|
||||||
|
|
@ -391,11 +392,9 @@ export class OperationActionsImpl {
|
||||||
|
|
||||||
// 2. Set isAborting flag immediately for agent-runtime operations.
|
// 2. Set isAborting flag immediately for agent-runtime operations.
|
||||||
// This ensures UI (loading button) responds instantly to user cancellation.
|
// This ensures UI (loading button) responds instantly to user cancellation.
|
||||||
// Applies to both client-side (execAgentRuntime) and Gateway-mode
|
// Applies to all AI runtime operation types so the UI transitions out of
|
||||||
// (execServerAgentRuntime) runs — the latter needs the flag so the UI
|
// loading right away without waiting for the process to fully terminate.
|
||||||
// transitions out of loading right away, without waiting for the
|
if (AI_RUNTIME_OPERATION_TYPES.includes(operation.type)) {
|
||||||
// round-trip WS `session_complete` after the server acknowledges interrupt.
|
|
||||||
if (operation.type === 'execAgentRuntime' || operation.type === 'execServerAgentRuntime') {
|
|
||||||
this.#get().updateOperationMetadata(operationId, { isAborting: true });
|
this.#get().updateOperationMetadata(operationId, { isAborting: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,7 @@ const hasRunningOperationByContext =
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if agent runtime is running in a specific context
|
* 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 =
|
const isAgentRuntimeRunningByContext =
|
||||||
(context: {
|
(context: {
|
||||||
|
|
@ -303,7 +303,7 @@ const isAgentRunning =
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if agent runtime is running (including both main window and thread)
|
* 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)
|
* Excludes operations that are aborting (cleaning up after cancellation)
|
||||||
*/
|
*/
|
||||||
const isAgentRuntimeRunning = (s: ChatStoreState): boolean => {
|
const isAgentRuntimeRunning = (s: ChatStoreState): boolean => {
|
||||||
|
|
@ -385,7 +385,7 @@ const isMessageProcessing =
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a specific message is being generated (AI generation only)
|
* 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 =
|
const isMessageGenerating =
|
||||||
(messageId: string) =>
|
(messageId: string) =>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export type OperationType =
|
||||||
// === AI generation ===
|
// === AI generation ===
|
||||||
| 'execAgentRuntime' // Execute agent runtime (client-side, entire agent runtime execution)
|
| 'execAgentRuntime' // Execute agent runtime (client-side, entire agent runtime execution)
|
||||||
| 'execServerAgentRuntime' // Execute server agent runtime (server-side, e.g., Group Chat)
|
| 'execServerAgentRuntime' // Execute server agent runtime (server-side, e.g., Group Chat)
|
||||||
|
| 'execHeterogeneousAgent'
|
||||||
| 'createAssistantMessage' // Create assistant message (sub-operation of execAgentRuntime)
|
| 'createAssistantMessage' // Create assistant message (sub-operation of execAgentRuntime)
|
||||||
// === LLM execution (sub-operations) ===
|
// === LLM execution (sub-operations) ===
|
||||||
| 'callLLM' // Call LLM streaming response (sub-operation of execAgentRuntime)
|
| 'callLLM' // Call LLM streaming response (sub-operation of execAgentRuntime)
|
||||||
|
|
@ -315,10 +316,12 @@ export interface OperationFilter {
|
||||||
*
|
*
|
||||||
* Includes:
|
* Includes:
|
||||||
* - execAgentRuntime: Client-side agent execution (single chat)
|
* - execAgentRuntime: Client-side agent execution (single chat)
|
||||||
|
* - execHeterogeneousAgent: Heterogeneous agent execution (Claude Code CLI, etc.)
|
||||||
* - execServerAgentRuntime: Server-side agent execution (Group Chat)
|
* - execServerAgentRuntime: Server-side agent execution (Group Chat)
|
||||||
*/
|
*/
|
||||||
export const AI_RUNTIME_OPERATION_TYPES: OperationType[] = [
|
export const AI_RUNTIME_OPERATION_TYPES: OperationType[] = [
|
||||||
'execAgentRuntime',
|
'execAgentRuntime',
|
||||||
|
'execHeterogeneousAgent',
|
||||||
'execServerAgentRuntime',
|
'execServerAgentRuntime',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,13 @@ export class ElectronRemoteServerActionImpl {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: (data) => {
|
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()
|
void this.#get()
|
||||||
.refreshUserData()
|
.refreshUserData()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ export const labPreferSelectors = {
|
||||||
enableAgentWorkingPanel: (s: UserState): boolean =>
|
enableAgentWorkingPanel: (s: UserState): boolean =>
|
||||||
s.preference.lab?.enableAgentWorkingPanel ?? false,
|
s.preference.lab?.enableAgentWorkingPanel ?? false,
|
||||||
enableGatewayMode: (s: UserState): boolean => s.preference.lab?.enableGatewayMode ?? false,
|
enableGatewayMode: (s: UserState): boolean => s.preference.lab?.enableGatewayMode ?? false,
|
||||||
|
enableHeterogeneousAgent: (s: UserState): boolean =>
|
||||||
|
s.preference.lab?.enableHeterogeneousAgent ?? false,
|
||||||
enableInputMarkdown: (s: UserState): boolean =>
|
enableInputMarkdown: (s: UserState): boolean =>
|
||||||
s.preference.lab?.enableInputMarkdown ?? DEFAULT_PREFERENCE.lab!.enableInputMarkdown!,
|
s.preference.lab?.enableInputMarkdown ?? DEFAULT_PREFERENCE.lab!.enableInputMarkdown!,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
30
src/store/utils/createStoreUpdater.ts
Normal file
30
src/store/utils/createStoreUpdater.ts
Normal file
|
|
@ -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<T> = <Key extends keyof T>(
|
||||||
|
key: Key,
|
||||||
|
value: T[Key] | null | undefined,
|
||||||
|
deps?: any[],
|
||||||
|
setStateFn?: StoreApi<T>['setState'],
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type StoreApiLike<T> = {
|
||||||
|
[K in keyof StoreApi<T>]: StoreApi<T>[K];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createStoreUpdater = <T>(storeApi: StoreApiLike<T>): LooseStoreUpdater<T> =>
|
||||||
|
upstream(storeApi) as unknown as LooseStoreUpdater<T>;
|
||||||
Loading…
Reference in a new issue