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:
Arvin Xu 2026-04-17 19:33:39 +08:00 committed by GitHub
parent 3fb6b0d8e1
commit 2298ad8ce1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 5598 additions and 61 deletions

2
.gitignore vendored
View file

@ -146,4 +146,4 @@ apps/desktop/resources/cli-package.json
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
.superpowers/
docs/superpowers/
.heerogeneous-tracing

View 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();
});
}
}

View file

@ -5,6 +5,7 @@ import BrowserWindowsCtr from './BrowserWindowsCtr';
import CliCtr from './CliCtr';
import DevtoolsCtr from './DevtoolsCtr';
import GatewayConnectionCtr from './GatewayConnectionCtr';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import LocalFileCtr from './LocalFileCtr';
import McpCtr from './McpCtr';
import McpInstallCtr from './McpInstallCtr';
@ -22,6 +23,7 @@ import UpdaterCtr from './UpdaterCtr';
import UploadFileCtr from './UploadFileCtr';
export const controllerIpcConstructors = [
HeterogeneousAgentCtr,
AuthCtr,
BrowserWindowsCtr,
CliCtr,

View file

@ -17,6 +17,7 @@ import { generateCliWrapper, getCliWrapperDir } from '@/modules/cliEmbedding';
import {
astSearchDetectors,
browserAutomationDetectors,
cliAgentDetectors,
contentSearchDetectors,
fileSearchDetectors,
type IToolDetector,
@ -190,6 +191,7 @@ export class App {
const detectorCategories: Partial<Record<ToolCategory, IToolDetector[]>> = {
'runtime-environment': runtimeEnvironmentDetectors,
'cli-agents': cliAgentDetectors,
'ast-search': astSearchDetectors,
'browser-automation': browserAutomationDetectors,
'content-search': contentSearchDetectors,

View file

@ -41,6 +41,7 @@ export type ToolCategory =
| 'file-search'
| 'browser-automation'
| 'runtime-environment'
| 'cli-agents'
| 'system'
| 'custom';

View 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}`,
});
}
}
}

View file

@ -0,0 +1,3 @@
export type { ACPClientCallbacks, ACPClientParams } from './client';
export { ACPClient } from './client';
export type * from './types';

View 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;
}

View 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,
];

View file

@ -6,6 +6,7 @@
*/
export { browserAutomationDetectors } from './agentBrowserDetectors';
export { cliAgentDetectors } from './cliAgentDetectors';
export { astSearchDetectors, contentSearchDetectors } from './contentSearchDetectors';
export { fileSearchDetectors } from './fileSearchDetectors';
export { runtimeEnvironmentDetectors } from './runtimeEnvironmentDetectors';

View file

@ -18,6 +18,7 @@
"agentDefaultMessage": "Hi, Im **{{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, Im **{{name}}**. One sentence is enough—you're in control.",
"agentDefaultMessageWithoutEdit": "Hi, Im **{{name}}**. One sentence is enough—you're in control.",
"agentSidebar.externalTag": "External",
"agents": "Agents",
"artifact.generating": "Generating",
"artifact.inThread": "Cannot view in subtopic, please switch to the main conversation area to open",
@ -223,6 +224,7 @@
"minimap.senderAssistant": "Agent",
"minimap.senderUser": "You",
"newAgent": "Create Agent",
"newClaudeCodeAgent": "Claude Code Agent",
"newGroupChat": "Create Group",
"newPage": "Create Page",
"noAgentsYet": "This group has no members yet. Click the + button to invite agents.",
@ -234,6 +236,7 @@
"operation.contextCompression": "Context too long, compressing history...",
"operation.execAgentRuntime": "Preparing response",
"operation.execClientTask": "Executing task",
"operation.execHeterogeneousAgent": "Running agent",
"operation.execServerAgentRuntime": "Task is running in the server. You are safe to leave this page",
"operation.sendMessage": "Sending message",
"owner": "Group owner",

View file

@ -659,6 +659,8 @@
"settingSystemTools.appEnvironment.title": "Built-in App Tools",
"settingSystemTools.category.browserAutomation": "Browser Automation",
"settingSystemTools.category.browserAutomation.desc": "Tools for headless browser automation and web interaction",
"settingSystemTools.category.cliAgents": "CLI Agents",
"settingSystemTools.category.cliAgents.desc": "Agentic coding CLIs detected on your system, such as Claude Code, Codex, and Kimi",
"settingSystemTools.category.contentSearch": "Content Search",
"settingSystemTools.category.contentSearch.desc": "Tools for searching text content within files",
"settingSystemTools.category.fileSearch": "File Search",
@ -673,17 +675,23 @@
"settingSystemTools.title": "System Tools",
"settingSystemTools.tools.ag.desc": "The Silver Searcher - fast code searching tool",
"settingSystemTools.tools.agentBrowser.desc": "Agent-browser - headless browser automation CLI for AI agents",
"settingSystemTools.tools.aider.desc": "Aider - AI pair programming in your terminal",
"settingSystemTools.tools.bun.desc": "Bun - fast JavaScript runtime and package manager",
"settingSystemTools.tools.bunx.desc": "bunx - Bun package runner for executing npm packages",
"settingSystemTools.tools.claude.desc": "Claude Code - Anthropic official agentic coding CLI",
"settingSystemTools.tools.codex.desc": "Codex - OpenAI agentic coding CLI",
"settingSystemTools.tools.fd.desc": "fd - fast and user-friendly alternative to find",
"settingSystemTools.tools.find.desc": "Unix find - standard file search command",
"settingSystemTools.tools.gemini.desc": "Gemini CLI - Google agentic coding CLI",
"settingSystemTools.tools.grep.desc": "GNU grep - standard text search tool",
"settingSystemTools.tools.kimi.desc": "Kimi CLI - Moonshot AI agentic coding CLI",
"settingSystemTools.tools.lobehub.desc": "LobeHub CLI - manage and connect to LobeHub services",
"settingSystemTools.tools.mdfind.desc": "macOS Spotlight search (fast indexed search)",
"settingSystemTools.tools.node.desc": "Node.js - JavaScript runtime for executing JS/TS",
"settingSystemTools.tools.npm.desc": "npm - Node.js package manager for installing dependencies",
"settingSystemTools.tools.pnpm.desc": "pnpm - fast, disk space efficient package manager",
"settingSystemTools.tools.python.desc": "Python - programming language runtime",
"settingSystemTools.tools.qwen.desc": "Qwen Code - Alibaba Qwen agentic coding CLI",
"settingSystemTools.tools.rg.desc": "ripgrep - extremely fast text search tool",
"settingSystemTools.tools.uv.desc": "uv - extremely fast Python package manager",
"settingTTS.openai.sttModel": "OpenAI Speech-to-Text Model",

View file

@ -18,6 +18,7 @@
"agentDefaultMessage": "你好,我是 **{{name}}**。从一句话开始就行。\n\n想让我更贴近你的工作方式去 [助理设置]({{url}}) 补充助理档案(随时可改)",
"agentDefaultMessageWithSystemRole": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你",
"agentDefaultMessageWithoutEdit": "你好,我是 **{{name}}**。从一句话开始就行——决定权在你",
"agentSidebar.externalTag": "外部",
"agents": "助理",
"artifact.generating": "生成中",
"artifact.inThread": "子话题中暂不支持查看。请回到主对话区打开",
@ -223,6 +224,7 @@
"minimap.senderAssistant": "助理",
"minimap.senderUser": "你",
"newAgent": "创建助理",
"newClaudeCodeAgent": "Claude Code 智能体",
"newGroupChat": "创建群组",
"newPage": "创建文稿",
"noAgentsYet": "这个群组还没有成员。点击「+」邀请助理加入",
@ -234,6 +236,7 @@
"operation.contextCompression": "上下文过长,正在压缩历史记录……",
"operation.execAgentRuntime": "准备响应中",
"operation.execClientTask": "执行任务中",
"operation.execHeterogeneousAgent": "智能体运行中",
"operation.execServerAgentRuntime": "任务正在服务器运行,您可以放心离开此页面",
"operation.sendMessage": "消息发送中",
"owner": "群主",

View file

@ -659,6 +659,8 @@
"settingSystemTools.appEnvironment.title": "内建应用工具",
"settingSystemTools.category.browserAutomation": "浏览器自动化",
"settingSystemTools.category.browserAutomation.desc": "用于无头浏览器自动化和网页交互的工具",
"settingSystemTools.category.cliAgents": "CLI 智能体",
"settingSystemTools.category.cliAgents.desc": "已检测到的命令行编码智能体,如 Claude Code、Codex、Kimi 等",
"settingSystemTools.category.contentSearch": "内容搜索",
"settingSystemTools.category.contentSearch.desc": "用于在文件内搜索文本内容的工具",
"settingSystemTools.category.fileSearch": "文件搜索",
@ -673,17 +675,23 @@
"settingSystemTools.title": "系统工具",
"settingSystemTools.tools.ag.desc": "The Silver Searcher - 快速代码搜索工具",
"settingSystemTools.tools.agentBrowser.desc": "Agent-browser - 面向AI代理的无头浏览器自动化命令行工具",
"settingSystemTools.tools.aider.desc": "Aider - 终端内的 AI 结对编程工具",
"settingSystemTools.tools.bun.desc": "Bun - 快速的 JavaScript 运行时和包管理器",
"settingSystemTools.tools.bunx.desc": "bunx - Bun 包执行器,用于运行 npm 包",
"settingSystemTools.tools.claude.desc": "Claude Code - Anthropic 官方命令行编码智能体",
"settingSystemTools.tools.codex.desc": "Codex - OpenAI 命令行编码智能体",
"settingSystemTools.tools.fd.desc": "fd - 快速且用户友好的 find 替代品",
"settingSystemTools.tools.find.desc": "Unix find - 标准文件搜索命令",
"settingSystemTools.tools.gemini.desc": "Gemini CLI - Google 命令行编码智能体",
"settingSystemTools.tools.grep.desc": "GNU grep - 标准文本搜索工具",
"settingSystemTools.tools.kimi.desc": "Kimi CLI - 月之暗面命令行编码智能体",
"settingSystemTools.tools.lobehub.desc": "LobeHub CLI - 管理和连接 LobeHub 服务",
"settingSystemTools.tools.mdfind.desc": "macOS 聚焦搜索(快速索引搜索)",
"settingSystemTools.tools.node.desc": "Node.js - 执行 JavaScript/TypeScript 的运行时",
"settingSystemTools.tools.npm.desc": "npm - Node.js 包管理器,用于安装依赖",
"settingSystemTools.tools.pnpm.desc": "pnpm - 快速、节省磁盘空间的包管理器",
"settingSystemTools.tools.python.desc": "Python - 编程语言运行时",
"settingSystemTools.tools.qwen.desc": "Qwen Code - 阿里通义千问命令行编码智能体",
"settingSystemTools.tools.rg.desc": "ripgrep - 极快的文本搜索工具",
"settingSystemTools.tools.uv.desc": "uv - 极快的 Python 包管理器",
"settingTTS.openai.sttModel": "OpenAI 语音识别模型",

View file

@ -252,6 +252,7 @@
"@lobechat/eval-rubric": "workspace:*",
"@lobechat/fetch-sse": "workspace:*",
"@lobechat/file-loaders": "workspace:*",
"@lobechat/heterogeneous-agents": "workspace:*",
"@lobechat/local-file-shell": "workspace:*",
"@lobechat/memory-user-memory": "workspace:*",
"@lobechat/model-runtime": "workspace:*",

View 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": "*"
}
}

View 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,
};

View file

@ -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';

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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,
};

View file

@ -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';

View file

@ -0,0 +1,3 @@
export { ClaudeCodeApiName, ClaudeCodeIdentifier } from '../types';
export { ClaudeCodeInspectors } from './Inspector';
export { ClaudeCodeRenders } from './Render';

View file

@ -0,0 +1 @@
export { ClaudeCodeApiName, ClaudeCodeIdentifier } from './types';

View 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',
}

View file

@ -20,6 +20,7 @@
"@lobechat/builtin-tool-agent-builder": "workspace:*",
"@lobechat/builtin-tool-agent-documents": "workspace:*",
"@lobechat/builtin-tool-brief": "workspace:*",
"@lobechat/builtin-tool-claude-code": "workspace:*",
"@lobechat/builtin-tool-cloud-sandbox": "workspace:*",
"@lobechat/builtin-tool-creds": "workspace:*",
"@lobechat/builtin-tool-cron": "workspace:*",

View file

@ -10,6 +10,10 @@ import {
AgentManagementInspectors,
AgentManagementManifest,
} from '@lobechat/builtin-tool-agent-management/client';
import {
ClaudeCodeIdentifier,
ClaudeCodeInspectors,
} from '@lobechat/builtin-tool-claude-code/client';
import {
CloudSandboxIdentifier,
CloudSandboxInspectors,
@ -59,6 +63,7 @@ const BuiltinToolInspectors: Record<string, Record<string, BuiltinInspector>> =
string,
BuiltinInspector
>,
[ClaudeCodeIdentifier]: ClaudeCodeInspectors as Record<string, BuiltinInspector>,
[CloudSandboxIdentifier]: CloudSandboxInspectors as Record<string, BuiltinInspector>,
[GroupAgentBuilderManifest.identifier]: GroupAgentBuilderInspectors as Record<
string,

View file

@ -6,6 +6,7 @@ import { AgentBuilderManifest } from '@lobechat/builtin-tool-agent-builder';
import { AgentBuilderRenders } from '@lobechat/builtin-tool-agent-builder/client';
import { AgentManagementManifest } from '@lobechat/builtin-tool-agent-management';
import { AgentManagementRenders } from '@lobechat/builtin-tool-agent-management/client';
import { ClaudeCodeIdentifier, ClaudeCodeRenders } from '@lobechat/builtin-tool-claude-code/client';
import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox';
import { CloudSandboxRenders } from '@lobechat/builtin-tool-cloud-sandbox/client';
import { GroupAgentBuilderManifest } from '@lobechat/builtin-tool-group-agent-builder';
@ -39,6 +40,7 @@ import { type BuiltinRender } from '@lobechat/types';
const BuiltinToolsRenders: Record<string, Record<string, BuiltinRender>> = {
[AgentBuilderManifest.identifier]: AgentBuilderRenders as Record<string, BuiltinRender>,
[AgentManagementManifest.identifier]: AgentManagementRenders as Record<string, BuiltinRender>,
[ClaudeCodeIdentifier]: ClaudeCodeRenders as Record<string, BuiltinRender>,
[CloudSandboxManifest.identifier]: CloudSandboxRenders as Record<string, BuiltinRender>,
[GroupAgentBuilderManifest.identifier]: GroupAgentBuilderRenders as Record<string, BuiltinRender>,
[GroupManagementManifest.identifier]: GroupManagementRenders as Record<string, BuiltinRender>,

View file

@ -18,6 +18,7 @@ export const DEFAULT_PREFERENCE: UserPreference = {
},
lab: {
enableAgentWorkingPanel: false,
enableHeterogeneousAgent: false,
enableInputMarkdown: true,
},
topicDisplayMode: DEFAULT_TOPIC_DISPLAY_MODE,

View file

@ -44,6 +44,7 @@ export class HomeRepository {
// 1. Query all agents (non-virtual) with their session info (if exists)
const agentList = await this.db
.select({
agencyConfig: agents.agencyConfig,
agentSessionGroupId: agents.sessionGroupId,
avatar: agents.avatar,
backgroundColor: agents.backgroundColor,
@ -98,6 +99,7 @@ export class HomeRepository {
private processAgentList(
agentItems: Array<{
agencyConfig: { heterogeneousProvider?: { type?: string } } | null;
agentSessionGroupId: string | null;
avatar: string | null;
backgroundColor: string | null;
@ -136,6 +138,7 @@ export class HomeRepository {
backgroundColor: a.backgroundColor,
description: a.description,
groupId: a.agentSessionGroupId ?? a.sessionGroupId,
heterogeneousType: a.agencyConfig?.heterogeneousProvider?.type ?? null,
id: a.id,
pinned: a.pinned ?? a.sessionPinned ?? false,
sessionId: a.sessionId,

View 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;
}

View file

@ -1,3 +1,4 @@
import type { ACPBroadcastEvents } from './acp';
import type { GatewayConnectionBroadcastEvents } from './gatewayConnection';
import type { NavigationBroadcastEvents } from './navigation';
import type { ProtocolBroadcastEvents } from './protocol';
@ -11,6 +12,7 @@ import type { AutoUpdateBroadcastEvents } from './update';
export interface MainBroadcastEvents
extends
ACPBroadcastEvents,
AutoUpdateBroadcastEvents,
GatewayConnectionBroadcastEvents,
NavigationBroadcastEvents,

View 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:*"
}
}

View file

@ -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);
});
});

View 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);
});
});
});

View 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' };
}
}

View file

@ -0,0 +1 @@
export { ClaudeCodeAdapter, claudeCodePreset } from './claudeCode';

View 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';

View 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');
});
});
});

View 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);

View 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[];
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
});

View file

@ -1,28 +1,24 @@
/**
* Discord Bot configuration for an agent
* Heterogeneous agent provider configuration.
* When set, the assistant delegates execution to an external agent CLI
* instead of using the built-in model runtime.
*/
export interface DiscordBotConfig {
applicationId: string;
botToken: string;
enabled: boolean;
publicKey: string;
export interface HeterogeneousProviderConfig {
/** Additional CLI arguments for the agent command */
args?: string[];
/** Command to spawn the agent (e.g. 'claude') */
command?: string;
/** Custom environment variables */
env?: Record<string, string>;
/** Agent runtime type */
type: 'claudecode';
}
/**
* Slack Bot configuration for an agent
*/
export interface SlackBotConfig {
botToken: string;
enabled: boolean;
signingSecret: string;
}
/**
* Agent agency configuration for external platform bot integrations.
* Each agent can independently configure its own bot providers.
* Agent agency configuration.
* Contains settings for agent execution modes and device binding.
*/
export interface LobeAgentAgencyConfig {
boundDeviceId?: string;
discord?: DiscordBotConfig;
slack?: SlackBotConfig;
heterogeneousProvider?: HeterogeneousProviderConfig;
}

View file

@ -10,9 +10,10 @@ import type { LobeAgentTTSConfig } from './tts';
export interface LobeAgentConfig {
/**
* Agency configuration for external platform bot integrations (Discord, Slack, etc.)
* Agency configuration: device binding, heterogeneous agent provider, etc.
*/
agencyConfig?: LobeAgentAgencyConfig;
avatar?: string;
backgroundColor?: string;

View file

@ -32,6 +32,13 @@ export interface SidebarAgentItem {
* Only present for chat groups (type === 'group')
*/
groupAvatar?: string | null;
/**
* Heterogeneous agent runtime type (e.g. `claudecode`) when the agent is
* driven by an external CLI. `null` / absent means it's a regular LobeHub
* agent. Present so sidebar / list items can render an "External" tag
* without per-item agent config lookups.
*/
heterogeneousType?: string | null;
id: string;
pinned: boolean;
sessionId?: string | null;

View file

@ -102,10 +102,15 @@ export const MessageMetadataSchema = ModelUsageSchema.merge(ModelPerformanceSche
isMultimodal: z.boolean().optional(),
isSupervisor: z.boolean().optional(),
pageSelections: z.array(PageSelectionSchema).optional(),
// Canonical nested shape — flat fields above are deprecated. Must be listed
// here so zod doesn't strip them from writes going through UpdateMessageParamsSchema
// (e.g. messageService.updateMessage, used by the heterogeneous-agent executor).
performance: ModelPerformanceSchema.optional(),
reactions: z.array(EmojiReactionSchema).optional(),
scope: z.string().optional(),
subAgentId: z.string().optional(),
toolExecutionTimeMs: z.number().optional(),
usage: ModelUsageSchema.optional(),
});
export interface ModelUsage extends ModelTokensUsage {
@ -134,7 +139,15 @@ export interface ModelPerformance {
ttft?: number;
}
export interface MessageMetadata extends ModelUsage, ModelPerformance {
export interface MessageMetadata {
// ───────────────────────────────────────────────────────────────
// Token usage + performance fields — DEPRECATED flat shape.
// New code must write to `metadata.usage` / `metadata.performance` (nested)
// instead. Kept here so legacy reads still type-check during migration;
// writers should stop populating them.
// ───────────────────────────────────────────────────────────────
/** @deprecated use `metadata.usage` instead */
acceptedPredictionTokens?: number;
activeBranchIndex?: number;
activeColumn?: boolean;
/**
@ -143,7 +156,27 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance {
*/
collapsed?: boolean;
compare?: boolean;
/** @deprecated use `metadata.usage` instead */
cost?: number;
/** @deprecated use `metadata.performance` instead */
duration?: number;
finishType?: string;
/** @deprecated use `metadata.usage` instead */
inputAudioTokens?: number;
/** @deprecated use `metadata.usage` instead */
inputCachedTokens?: number;
/** @deprecated use `metadata.usage` instead */
inputCacheMissTokens?: number;
/** @deprecated use `metadata.usage` instead */
inputCitationTokens?: number;
/** @deprecated use `metadata.usage` instead */
inputImageTokens?: number;
/** @deprecated use `metadata.usage` instead */
inputTextTokens?: number;
/** @deprecated use `metadata.usage` instead */
inputToolTokens?: number;
/** @deprecated use `metadata.usage` instead */
inputWriteCacheTokens?: number;
/**
* Tool inspect expanded state
* true: expanded, false/undefined: collapsed
@ -159,11 +192,22 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance {
* Flag indicating if message content is multimodal (serialized MessageContentPart[])
*/
isMultimodal?: boolean;
/**
* Flag indicating if message is from the Supervisor agent in group orchestration
* Used by conversation-flow to transform role to 'supervisor' for UI rendering
*/
isSupervisor?: boolean;
/** @deprecated use `metadata.performance` instead */
latency?: number;
/** @deprecated use `metadata.usage` instead */
outputAudioTokens?: number;
/** @deprecated use `metadata.usage` instead */
outputImageTokens?: number;
/** @deprecated use `metadata.usage` instead */
outputReasoningTokens?: number;
/** @deprecated use `metadata.usage` instead */
outputTextTokens?: number;
/**
* Page selections attached to user message
* Used for Ask AI functionality to persist selection context
@ -178,6 +222,8 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance {
* Emoji reactions on this message
*/
reactions?: EmojiReaction[];
/** @deprecated use `metadata.usage` instead */
rejectedPredictionTokens?: number;
/**
* Message scope - indicates the context in which this message was created
* Used by conversation-flow to determine how to handle message grouping and display
@ -198,5 +244,15 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance {
* Tool execution time for tool messages (ms)
*/
toolExecutionTimeMs?: number;
/** @deprecated use `metadata.usage` instead */
totalInputTokens?: number;
/** @deprecated use `metadata.usage` instead */
totalOutputTokens?: number;
/** @deprecated use `metadata.usage` instead */
totalTokens?: number;
/** @deprecated use `metadata.performance` instead */
tps?: number;
/** @deprecated use `metadata.performance` instead */
ttft?: number;
usage?: ModelUsage;
}

View file

@ -50,6 +50,10 @@ export const UserLabSchema = z.object({
* enable multi-agent group chat mode
*/
enableGroupChat: z.boolean().optional(),
/**
* enable heterogeneous agent execution (Claude Code, Codex CLI, etc.)
*/
enableHeterogeneousAgent: z.boolean().optional(),
/**
* enable markdown rendering in chat input editor
*/

View file

@ -1,6 +1,6 @@
'use client';
import { Avatar, Flexbox, Markdown, Text } from '@lobehub/ui';
import { Avatar, Flexbox, Markdown, Skeleton, Text } from '@lobehub/ui';
import isEqual from 'fast-deep-equal';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@ -13,6 +13,7 @@ import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selec
const AgentInfo = memo(() => {
const { t } = useTranslation(['chat', 'welcome']);
const isLoading = useAgentStore(agentSelectors.isAgentConfigLoading);
const isInbox = useAgentStore(builtinAgentSelectors.isInboxAgent);
const meta = useAgentStore(agentSelectors.currentAgentMeta, isEqual);
const openingMessage = useAgentStore(agentSelectors.openingMessage);
@ -29,6 +30,18 @@ const AgentInfo = memo(() => {
});
}, [openingMessage, displayTitle, t]);
if (isLoading) {
return (
<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 (
<Flexbox gap={12}>
<Avatar

View file

@ -63,6 +63,11 @@ interface DesktopChatInputProps extends ActionToolbarProps {
inputContainerProps?: ChatInputProps;
leftContent?: 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;
showFootnote?: boolean;
showRuntimeConfig?: boolean;
@ -72,6 +77,7 @@ const DesktopChatInput = memo<DesktopChatInputProps>(
({
showFootnote,
showRuntimeConfig = true,
runtimeConfigSlot,
inputContainerProps,
extentHeaderContent,
actionBarStyle,
@ -164,7 +170,7 @@ const DesktopChatInput = memo<DesktopChatInputProps>(
>
<InputEditor placeholder={placeholder} />
</ChatInput>
{showRuntimeConfig && <RuntimeConfig />}
{runtimeConfigSlot ?? (showRuntimeConfig && <RuntimeConfig />)}
{showFootnote && !expand && (
<Center style={{ pointerEvents: 'none', zIndex: 100 }}>
<Text className={styles.footnote} type={'secondary'}>

View file

@ -85,6 +85,11 @@ export interface ChatInputProps {
* Right action buttons configuration
*/
rightActions?: ActionKeys[];
/**
* Custom node to render in place of the default RuntimeConfig bar
* (Local/Cloud/Approval). When provided, replaces the default bar.
*/
runtimeConfigSlot?: ReactNode;
/**
* Custom content to render before the SendArea (right side of action bar)
*/
@ -123,6 +128,7 @@ const ChatInput = memo<ChatInputProps>(
children,
extraActionItems,
mentionItems,
runtimeConfigSlot,
sendMenu,
sendAreaPrefix,
sendButtonProps: customSendButtonProps,
@ -263,6 +269,7 @@ const ChatInput = memo<ChatInputProps>(
borderRadius={12}
extraActionItems={extraActionItems}
leftContent={leftContent}
runtimeConfigSlot={runtimeConfigSlot}
sendAreaPrefix={sendAreaPrefix}
showRuntimeConfig={showRuntimeConfig}
/>

View file

@ -27,6 +27,12 @@ export default {
'agentDefaultMessageWithoutEdit':
"Hi, Im **{{name}}**. One sentence is enough—you're in control.",
'agents': 'Agents',
/**
* Sidebar tag for agents driven by an external CLI runtime (Claude Code, etc.).
* Deliberately separate from `group.profile.external` so it can evolve
* independently (e.g. swap to "Claude Code" per provider later).
*/
'agentSidebar.externalTag': 'External',
'artifact.generating': 'Generating',
'artifact.inThread':
'Cannot view in subtopic, please switch to the main conversation area to open',
@ -242,6 +248,7 @@ export default {
'createModal.placeholder': 'Describe what your agent should do...',
'createModal.title': 'What should your agent do?',
'newAgent': 'Create Agent',
'newClaudeCodeAgent': 'Claude Code Agent',
'newGroupChat': 'Create Group',
'newPage': 'Create Page',
'noAgentsYet': 'This group has no members yet. Click the + button to invite agents.',
@ -253,6 +260,7 @@ export default {
'operation.contextCompression': 'Context too long, compressing history...',
'operation.execAgentRuntime': 'Preparing response',
'operation.execClientTask': 'Executing task',
'operation.execHeterogeneousAgent': 'External agent running',
'operation.execServerAgentRuntime':
'Task is running in the server. You are safe to leave this page',
'operation.sendMessage': 'Sending message',

View file

@ -10,6 +10,9 @@ export default {
'features.gatewayMode.title': 'Server-Side Agent Execution (Gateway)',
'features.groupChat.desc': 'Enable multi-agent group chat coordination.',
'features.groupChat.title': 'Group Chat (Multi-Agent)',
'features.heterogeneousAgent.desc':
'Enable heterogeneous agent execution with Claude Code, Codex CLI, and other external agent CLIs. Creates a "Claude Code Agent" option in the sidebar agent menu.',
'features.heterogeneousAgent.title': 'Heterogeneous Agent (Claude Code)',
'features.inputMarkdown.desc':
'Render Markdown in the input area in real time (bold text, code blocks, tables, etc.).',
'features.inputMarkdown.title': 'Input Markdown Rendering',

View file

@ -742,6 +742,9 @@ export default {
'settingSystemTools.category.browserAutomation': 'Browser Automation',
'settingSystemTools.category.browserAutomation.desc':
'Tools for headless browser automation and web interaction',
'settingSystemTools.category.cliAgents': 'CLI Agents',
'settingSystemTools.category.cliAgents.desc':
'Agentic coding CLIs detected on your system, such as Claude Code, Codex, and Kimi',
'settingSystemTools.category.contentSearch': 'Content Search',
'settingSystemTools.category.contentSearch.desc': 'Tools for searching text content within files',
'settingSystemTools.category.fileSearch': 'File Search',
@ -758,9 +761,14 @@ export default {
'settingSystemTools.tools.agentBrowser.desc':
'Agent-browser - headless browser automation CLI for AI agents',
'settingSystemTools.tools.ag.desc': 'The Silver Searcher - fast code searching tool',
'settingSystemTools.tools.aider.desc': 'Aider - AI pair programming in your terminal',
'settingSystemTools.tools.claude.desc': 'Claude Code - Anthropic official agentic coding CLI',
'settingSystemTools.tools.codex.desc': 'Codex - OpenAI agentic coding CLI',
'settingSystemTools.tools.fd.desc': 'fd - fast and user-friendly alternative to find',
'settingSystemTools.tools.find.desc': 'Unix find - standard file search command',
'settingSystemTools.tools.gemini.desc': 'Gemini CLI - Google agentic coding CLI',
'settingSystemTools.tools.grep.desc': 'GNU grep - standard text search tool',
'settingSystemTools.tools.kimi.desc': 'Kimi CLI - Moonshot AI agentic coding CLI',
'settingSystemTools.tools.mdfind.desc': 'macOS Spotlight search (fast indexed search)',
'settingSystemTools.tools.lobehub.desc': 'LobeHub CLI - manage and connect to LobeHub services',
'settingSystemTools.tools.bun.desc': 'Bun - fast JavaScript runtime and package manager',
@ -769,6 +777,7 @@ export default {
'settingSystemTools.tools.npm.desc': 'npm - Node.js package manager for installing dependencies',
'settingSystemTools.tools.pnpm.desc': 'pnpm - fast, disk space efficient package manager',
'settingSystemTools.tools.python.desc': 'Python - programming language runtime',
'settingSystemTools.tools.qwen.desc': 'Qwen Code - Alibaba Qwen agentic coding CLI',
'settingSystemTools.tools.rg.desc': 'ripgrep - extremely fast text search tool',
'settingSystemTools.tools.uv.desc': 'uv - extremely fast Python package manager',
'settingTTS.openai.sttModel': 'OpenAI Speech-to-Text Model',

View file

@ -1,10 +1,10 @@
import { useMount, usePrevious, useUnmount } from 'ahooks';
import { useEffect, useRef } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { createStoreUpdater } from 'zustand-utils';
import { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat';
import { createStoreUpdater } from '@/store/utils/createStoreUpdater';
const AgentIdSync = () => {
const useStoreUpdater = createStoreUpdater(useAgentStore);
@ -16,7 +16,7 @@ const AgentIdSync = () => {
const prevAgentId = usePrevious(params.aid);
useStoreUpdater('activeAgentId', params.aid);
useChatStoreUpdater('activeAgentId', params.aid ?? '');
useChatStoreUpdater('activeAgentId', params.aid);
// Reset activeTopicId when switching to a different agent
// This prevents messages from being saved to the wrong topic bucket

View file

@ -12,6 +12,8 @@ import NavItem from '@/features/NavPanel/components/NavItem';
import { useQueryRoute } from '@/hooks/useQueryRoute';
import { usePathname } from '@/libs/router/navigation';
import { useActionSWR } from '@/libs/swr';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { useGlobalStore } from '@/store/global';
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
@ -27,7 +29,9 @@ const Nav = memo(() => {
const router = useQueryRoute();
const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors);
const toggleCommandMenu = useGlobalStore((s) => s.toggleCommandMenu);
const isHeterogeneousAgent = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
const hideProfile = !isAgentEditable;
const hideChannel = hideProfile || isHeterogeneousAgent;
const switchTopic = useChatStore((s) => s.switchTopic);
const [openNewTopicOrSaveTopic] = useChatStore((s) => [s.openNewTopicOrSaveTopic]);
@ -58,7 +62,7 @@ const Nav = memo(() => {
}}
/>
)}
{!hideProfile && (
{!hideChannel && (
<NavItem
active={isChannelActive}
icon={RadioTowerIcon}

View file

@ -9,10 +9,13 @@ import ChatMiniMap from '@/features/ChatMiniMap';
import { ChatList, ConversationProvider, TodoProgress } from '@/features/Conversation';
import ZenModeToast from '@/features/ZenModeToast';
import { useOperationState } from '@/hooks/useOperationState';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { messageMapKey } from '@/store/chat/utils/messageMapKey';
import ChatHydration from './ChatHydration';
import HeterogeneousChatInput from './HeterogeneousChatInput';
import MainChatInput from './MainChatInput';
import MessageFromUrl from './MainChatInput/MessageFromUrl';
import ThreadHydration from './ThreadHydration';
@ -47,6 +50,11 @@ const Conversation = memo(() => {
// Get actionsBar config with branching support from ChatStore
const actionsBarConfig = useActionsBarConfig();
// Heterogeneous agents (Claude Code, etc.) use a simplified input — their
// toolchain/memory/model are managed by the external runtime, so LobeHub's
// model/tools/memory/KB/MCP/runtime-mode pickers don't apply.
const isHeterogeneousAgent = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
// Auto-reconnect to running Gateway operation on topic load
useGatewayReconnect(context.topicId);
@ -74,7 +82,7 @@ const Conversation = memo(() => {
<ChatList welcome={<AgentHome />} />
</Flexbox>
<TodoProgress />
<MainChatInput />
{isHeterogeneousAgent ? <HeterogeneousChatInput /> : <MainChatInput />}
<ChatHydration />
<ThreadHydration />
<ChatMiniMap />

View file

@ -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;

View file

@ -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;

View file

@ -10,6 +10,8 @@ import {
type MessageActionFactory,
type MessageActionItem,
} from '@/features/Conversation/types';
import { useAgentStore } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { useChatStore } from '@/store/chat';
import { useUserStore } from '@/store/user';
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
@ -51,16 +53,25 @@ export const useBranchingActionFactory = (): MessageActionFactory => {
export const useActionsBarConfig = (): ActionsBarConfig => {
const branchingFactory = useBranchingActionFactory();
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
const hasACPProvider = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
return useMemo(
() => ({
assistant: {
extraBarActions: isDevMode ? [branchingFactory] : [],
extraBarActions: isDevMode && !hasACPProvider ? [branchingFactory] : [],
},
// For ACP agents, only show copy + delete in the assistant group action bar
...(hasACPProvider
? {
assistantGroup: {
extraBarActions: [],
},
}
: {}),
user: {
extraBarActions: isDevMode ? [branchingFactory] : [],
},
}),
[branchingFactory, isDevMode],
[branchingFactory, hasACPProvider, isDevMode],
);
};

View file

@ -1,6 +1,6 @@
import { SESSION_CHAT_URL } from '@lobechat/const';
import { type SidebarAgentItem } from '@lobechat/types';
import { ActionIcon, Icon } from '@lobehub/ui';
import { ActionIcon, Flexbox, Icon, Tag } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { Loader2, PinIcon } from 'lucide-react';
import { type CSSProperties, type DragEvent } from 'react';
@ -28,7 +28,7 @@ interface AgentItemProps {
}
const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
const { id, avatar, backgroundColor, title, pinned } = item;
const { id, avatar, backgroundColor, title, pinned, heterogeneousType } = item;
const { t } = useTranslation('chat');
const { openCreateGroupModal } = useAgentModal();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
@ -43,6 +43,21 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
// Get display title with fallback
const displayTitle = title || t('untitledAgent');
// Heterogeneous agents (Claude Code, etc.) get an "External" tag so they
// stand out in the sidebar — mirrors the group-member pattern.
const titleNode = heterogeneousType ? (
<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
const agentUrl = SESSION_CHAT_URL(id, false);
@ -122,7 +137,7 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
key={id}
loading={isLoading}
style={style}
title={displayTitle}
title={titleNode}
onDoubleClick={handleDoubleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}

View file

@ -25,12 +25,15 @@ const Agent = memo<AgentProps>(({ itemKey }) => {
const { openConfigGroupModal } = useAgentModal();
// Create menu items
const { createAgentMenuItem, createGroupChatMenuItem, isLoading } = useCreateMenuItems();
const { createAgentMenuItem, createClaudeCodeMenuItem, createGroupChatMenuItem, isLoading } =
useCreateMenuItems();
const addMenuItems = useMemo(
() => [createAgentMenuItem(), createGroupChatMenuItem()],
[createAgentMenuItem, createGroupChatMenuItem],
);
const addMenuItems = useMemo(() => {
const items = [createAgentMenuItem(), createGroupChatMenuItem()];
const ccItem = createClaudeCodeMenuItem();
if (ccItem) items.splice(1, 0, ccItem);
return items;
}, [createAgentMenuItem, createClaudeCodeMenuItem, createGroupChatMenuItem]);
const handleOpenConfigGroupModal = useCallback(() => {
openConfigGroupModal();

View file

@ -15,6 +15,7 @@ const AddButton = memo(() => {
// Create menu items
const {
createAgentMenuItem,
createClaudeCodeMenuItem,
createGroupChatMenuItem,
createPageMenuItem,
openCreateModal,
@ -32,8 +33,11 @@ const AddButton = memo(() => {
);
const dropdownItems = useMemo(() => {
return [createAgentMenuItem(), createGroupChatMenuItem(), createPageMenuItem()];
}, [createAgentMenuItem, createGroupChatMenuItem, createPageMenuItem]);
const items = [createAgentMenuItem(), createGroupChatMenuItem(), createPageMenuItem()];
const ccItem = createClaudeCodeMenuItem();
if (ccItem) items.splice(1, 0, ccItem); // Insert after "Create Agent"
return items;
}, [createAgentMenuItem, createClaudeCodeMenuItem, createGroupChatMenuItem, createPageMenuItem]);
return (
<Flexbox horizontal>

View file

@ -1,8 +1,9 @@
import { isDesktop } from '@lobechat/const';
import { Icon } from '@lobehub/ui';
import { GroupBotSquareIcon } from '@lobehub/ui/icons';
import { App } from 'antd';
import { type ItemType } from 'antd/es/menu/interface';
import { BotIcon, FileTextIcon, FolderCogIcon, FolderPlus } from 'lucide-react';
import { BotIcon, FileTextIcon, FolderCogIcon, FolderPlus, TerminalSquareIcon } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@ -18,6 +19,8 @@ import { useAgentStore } from '@/store/agent';
import { useAgentGroupStore } from '@/store/agentGroup';
import { useHomeStore } from '@/store/home';
import { usePageStore } from '@/store/page';
import { useUserStore } from '@/store/user';
import { labPreferSelectors } from '@/store/user/selectors';
interface CreateAgentOptions {
groupId?: string;
@ -35,6 +38,7 @@ export const useCreateMenuItems = () => {
const { message } = App.useApp();
const navigate = useNavigate();
const groupTemplates = useGroupTemplates();
const enableHeterogeneousAgent = useUserStore(labPreferSelectors.enableHeterogeneousAgent);
const [storeCreateAgent] = useAgentStore((s) => [s.createAgent]);
const [addGroup, refreshAgentList, switchToGroup] = useHomeStore((s) => [
@ -192,6 +196,37 @@ export const useCreateMenuItems = () => {
[mutateGroup],
);
/**
* Create a Claude Code agent with ACP provider pre-configured.
*
* Bypasses `mutateAgent` so we skip its default /profile redirect
* CC agents land straight on the chat page since their config is fixed.
*/
const createClaudeCodeAgent = useCallback(
async (options?: CreateAgentOptions) => {
const result = await storeCreateAgent({
config: {
agencyConfig: {
heterogeneousProvider: {
command: 'claude',
type: 'claudecode' as const,
},
},
avatar:
'https://registry.npmmirror.com/@lobehub/icons-static-avatar/latest/files/avatars/claude.webp',
systemRole:
'You are Claude Code, an AI coding agent. Help users with code-related tasks.',
title: 'Claude Code',
},
groupId: options?.groupId,
});
await refreshAgentList();
navigate(`/agent/${result.agentId}`);
options?.onSuccess?.();
},
[storeCreateAgent, refreshAgentList, navigate],
);
const agentModal = useOptionalAgentModal();
const openCreateModal = agentModal?.openCreateModal;
@ -217,6 +252,25 @@ export const useCreateMenuItems = () => {
[t, createAgent, openCreateModal],
);
/**
* Create Claude Code agent menu item (Desktop only)
*/
const createClaudeCodeMenuItem = useCallback(
(options?: CreateAgentOptions): ItemType | null => {
if (!isDesktop || !enableHeterogeneousAgent) return null;
return {
icon: <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
* Creates an empty group and navigates to its profile page
@ -308,6 +362,8 @@ export const useCreateMenuItems = () => {
configMenuItem,
createAgent,
createAgentMenuItem,
createClaudeCodeAgent,
createClaudeCodeMenuItem,
createEmptyGroup,
createGroupChatMenuItem,
createGroupFromTemplate,

View file

@ -40,12 +40,14 @@ const Page = memo(() => {
enableInputMarkdown,
enableGatewayMode,
enableAgentWorkingPanel,
enableHeterogeneousAgent,
updateLab,
] = useUserStore((s) => [
preferenceSelectors.isPreferenceInit(s),
labPreferSelectors.enableInputMarkdown(s),
labPreferSelectors.enableGatewayMode(s),
labPreferSelectors.enableAgentWorkingPanel(s),
labPreferSelectors.enableHeterogeneousAgent(s),
s.updateLab,
]);
@ -136,6 +138,23 @@ const Page = memo(() => {
label: tLabs('features.agentWorkingPanel.title'),
minWidth: undefined,
},
...(isDesktop
? [
{
children: (
<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
? [
{

View file

@ -30,6 +30,19 @@ const TOOL_CATEGORIES = {
],
},
'cli-agents': {
descKey: 'settingSystemTools.category.cliAgents.desc',
titleKey: 'settingSystemTools.category.cliAgents',
tools: [
{ descKey: 'settingSystemTools.tools.claude.desc', name: 'claude' },
{ descKey: 'settingSystemTools.tools.codex.desc', name: 'codex' },
{ descKey: 'settingSystemTools.tools.gemini.desc', name: 'gemini' },
{ descKey: 'settingSystemTools.tools.qwen.desc', name: 'qwen' },
{ descKey: 'settingSystemTools.tools.kimi.desc', name: 'kimi' },
{ descKey: 'settingSystemTools.tools.aider.desc', name: 'aider' },
],
},
'content-search': {
descKey: 'settingSystemTools.category.contentSearch.desc',
titleKey: 'settingSystemTools.category.contentSearch',

View file

@ -15,8 +15,8 @@ const Page = () => {
return (
<>
<SettingHeader title={t('tab.systemTools')} />
<AppEnvironmentSection />
<ToolDetectorSection />
<AppEnvironmentSection />
{isDevMode ? <CliTestSection /> : null}
</>
);

View 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();

View file

@ -1,7 +1,12 @@
import { DEFAULT_PROVIDER } from '@lobechat/business-const';
import { DEFAULT_MODEL, DEFAUTT_AGENT_TTS_CONFIG, isDesktop } from '@lobechat/const';
import { type AgentBuilderContext } from '@lobechat/context-engine';
import { type AgentMode, type LobeAgentTTSConfig, type RuntimeEnvConfig } from '@lobechat/types';
import {
type AgentMode,
type LobeAgentAgencyConfig,
type LobeAgentTTSConfig,
type RuntimeEnvConfig,
} from '@lobechat/types';
import { globalAgentContextManager } from '@/helpers/GlobalAgentContextManager';
@ -123,6 +128,14 @@ const getAgentBuilderContextById =
};
};
/**
* Get agencyConfig by agentId
*/
const getAgencyConfigById =
(agentId: string) =>
(s: AgentStoreState): LobeAgentAgencyConfig | undefined =>
agentSelectors.getAgentConfigById(agentId)(s)?.agencyConfig;
/**
* Get full agent data by agentId
* Returns the complete agent object including metadata fields like updatedAt
@ -130,6 +143,7 @@ const getAgentBuilderContextById =
const getAgentById = (agentId: string) => (s: AgentStoreState) => s.agentMap[agentId];
export const agentByIdSelectors = {
getAgencyConfigById,
getAgentBuilderContextById,
getAgentById,
getAgentConfigById: agentSelectors.getAgentConfigById,

View file

@ -283,6 +283,14 @@ const currentAgentWorkingDirectory = (s: AgentStoreState): string | undefined =>
const isCurrentAgentExternal = (s: AgentStoreState): boolean => !currentAgentData(s)?.virtual;
/**
* Whether current agent is driven by an external heterogeneous runtime
* (e.g. Claude Code). These agents skip LobeHub's message-channel / model
* pickers because their toolchain is owned by the external runtime.
*/
const isCurrentAgentHeterogeneous = (s: AgentStoreState): boolean =>
!!currentAgentConfig(s)?.agencyConfig?.heterogeneousProvider;
const getAgentDocumentsById = (agentId: string) => (s: AgentStoreState) =>
s.agentDocumentsMap[agentId];
@ -321,6 +329,7 @@ export const agentSelectors = {
isAgentConfigLoading,
isAgentModeEnabled,
isCurrentAgentExternal,
isCurrentAgentHeterogeneous,
openingMessage,
openingQuestions,
};

View file

@ -456,4 +456,47 @@ describe('createGatewayEventHandler', () => {
expect(store.completeOperation).toHaveBeenCalledWith('op-1');
});
});
describe('step transition timing (orphan tool regression)', () => {
/**
* Verifies that after the executor fix, tools_calling events at step
* boundaries arrive AFTER stream_start (correct order).
*
* Previously, the executor forwarded stream_chunk(tools_calling) sync
* while stream_start was deferred via persistQueue handler dispatched
* tools to the OLD assistant. The fix defers all events during step
* transition through persistQueue, guaranteeing correct ordering.
*/
it('should dispatch new-step tools to the NEW assistant when events arrive in correct order', async () => {
const store = createMockStore();
const handler = createHandler(store, { assistantMessageId: 'ast-old' });
// Step 1 init
handler(makeEvent('stream_start', {}));
await flush();
handler(makeEvent('stream_end'));
await flush();
vi.clearAllMocks();
// ── Step boundary: executor now guarantees stream_start arrives FIRST ──
handler(makeEvent('stream_start', { assistantMessage: { id: 'ast-new' } }));
await flush();
handler(
makeEvent('stream_chunk', {
chunkType: 'tools_calling',
toolsCalling: [{ id: 'toolu_new' }],
}),
);
await flush();
// ── Assert: tools dispatched to the NEW assistant ──
const toolsDispatch = store.internal_dispatchMessage.mock.calls.find(
([action]: any) => action.value?.tools,
);
expect(toolsDispatch).toBeDefined();
expect(toolsDispatch![0].id).toBe('ast-new');
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ import { MESSAGE_CANCEL_FLAT } from '@lobechat/const';
import { type ConversationContext } from '@lobechat/types';
import { operationSelectors } from '@/store/chat/slices/operation/selectors';
import { AI_RUNTIME_OPERATION_TYPES } from '@/store/chat/slices/operation/types';
import { type ChatStore } from '@/store/chat/store';
import { type StoreSetter } from '@/store/types';
@ -95,14 +96,11 @@ export class ConversationControlActionImpl {
const { activeAgentId, activeTopicId, cancelOperations } = this.#get();
// Cancel running agent-runtime operations in the current context —
// both client-side (execAgentRuntime) and Gateway-mode
// (execServerAgentRuntime). For the Gateway-mode branch, a cancel
// handler registered in `executeGatewayAgent` / `reconnectToGatewayOperation`
// picks up the cancellation and forwards an `interrupt` frame over the
// Agent Gateway WebSocket so the server-side loop aborts.
// client-side (execAgentRuntime), heterogeneous agent (execHeterogeneousAgent),
// and Gateway-mode (execServerAgentRuntime).
cancelOperations(
{
type: ['execAgentRuntime', 'execServerAgentRuntime'],
type: AI_RUNTIME_OPERATION_TYPES,
status: 'running',
agentId: activeAgentId,
topicId: activeTopicId,

View file

@ -1,7 +1,7 @@
// Disable the auto sort key eslint rule to make the code more logic and readable
import { createCallAgentManifest } from '@lobechat/builtin-tool-agent-management';
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { LOADING_FLAT } from '@lobechat/const';
import { isDesktop, LOADING_FLAT } from '@lobechat/const';
import { formatSelectedSkillsContext, formatSelectedToolsContext } from '@lobechat/context-engine';
import { chainCompressContext } from '@lobechat/prompts';
import {
@ -23,7 +23,7 @@ import { resolveSelectedSkillsWithContent } from '@/services/chat/mecha/skillPre
import { resolveSelectedToolsWithContent } from '@/services/chat/mecha/toolPreload';
import { messageService } from '@/services/message';
import { getAgentStoreState } from '@/store/agent';
import { agentSelectors } from '@/store/agent/selectors';
import { agentByIdSelectors, agentSelectors } from '@/store/agent/selectors';
import { agentGroupByIdSelectors, getChatGroupStoreState } from '@/store/agentGroup';
import { type ChatStore } from '@/store/chat/store';
import {
@ -340,6 +340,141 @@ export class ConversationLifecycleActionImpl {
inputSendErrorMsg: undefined,
});
// ── External agent mode: delegate to heterogeneous agent CLI (desktop only) ──
// Per-agent heterogeneousProvider config takes priority over the global gateway mode.
const agentConfig = agentSelectors.getAgentConfigById(agentId)(getAgentStoreState());
const heterogeneousProvider = agentConfig?.agencyConfig?.heterogeneousProvider;
if (isDesktop && heterogeneousProvider?.type === 'claudecode') {
// Persist messages to DB first (same as client mode)
let heteroData: SendMessageServerResponse | undefined;
try {
const { model, provider } =
agentSelectors.getAgentConfigById(agentId)(getAgentStoreState());
heteroData = await aiChatService.sendMessageInServer(
{
agentId: operationContext.agentId,
groupId: operationContext.groupId ?? undefined,
newAssistantMessage: { model, provider: provider! },
newTopic: !operationContext.topicId
? {
title: message.slice(0, 20) || t('defaultTitle', { ns: 'topic' }),
topicMessageIds: messages.map((m) => m.id),
}
: undefined,
newUserMessage: {
content: message,
editorData,
files: fileIdList,
pageSelections,
parentId,
},
threadId: operationContext.threadId ?? undefined,
topicId: operationContext.topicId ?? undefined,
},
abortController,
);
} catch (e) {
console.error('[HeterogeneousAgent] Failed to persist messages:', e);
this.#get().failOperation(operationId, {
message: e instanceof Error ? e.message : 'Unknown error',
type: 'HeterogeneousAgentError',
});
return;
}
if (!heteroData) return;
// Update context with server-created topicId
const heteroContext = {
...operationContext,
topicId: heteroData.topicId ?? operationContext.topicId,
};
// Replace optimistic messages with persisted ones
this.#get().replaceMessages(heteroData.messages, {
action: 'sendMessage/serverResponse',
context: heteroContext,
});
// Handle new topic creation
if (heteroData.isCreateNewTopic && heteroData.topicId) {
if (heteroData.topics) {
const pageSize = systemStatusSelectors.topicPageSize(useGlobalStore.getState());
this.#get().internal_updateTopics(operationContext.agentId, {
groupId: operationContext.groupId,
items: heteroData.topics.items,
pageSize,
total: heteroData.topics.total,
});
}
await this.#get().switchTopic(heteroData.topicId, {
clearNewKey: true,
skipRefreshMessage: true,
});
}
// Clean up temp messages
this.#get().internal_dispatchMessage(
{ ids: [tempId, tempAssistantId], type: 'deleteMessages' },
{ operationId },
);
// Complete sendMessage operation, start ACP execution as child operation
this.#get().completeOperation(operationId);
if (heteroData.topicId) this.#get().internal_updateTopicLoading(heteroData.topicId, true);
// Start heterogeneous agent execution
const { operationId: heteroOpId } = this.#get().startOperation({
context: heteroContext,
label: 'Heterogeneous Agent Execution',
parentOperationId: operationId,
type: 'execHeterogeneousAgent',
});
this.#get().associateMessageWithOperation(heteroData.assistantMessageId, heteroOpId);
try {
const { executeHeterogeneousAgent } = await import('./heterogeneousAgentExecutor');
const workingDirectory =
agentByIdSelectors.getAgentWorkingDirectoryById(agentId)(getAgentStoreState());
// Extract imageList from the persisted user message (chatUploadFileList
// may already be cleared by this point, so we read from DB instead)
const userMsg = heteroData.messages.find((m: any) => m.id === heteroData.userMessageId);
const persistedImageList = userMsg?.imageList;
// Read CC session ID from topic metadata for multi-turn resume
const topic = heteroContext.topicId
? topicSelectors.getTopicById(heteroContext.topicId)(this.#get())
: undefined;
const resumeSessionId = topic?.metadata?.ccSessionId;
await executeHeterogeneousAgent(() => this.#get(), {
assistantMessageId: heteroData.assistantMessageId,
context: heteroContext,
heterogeneousProvider,
imageList: persistedImageList?.length ? persistedImageList : undefined,
message,
operationId: heteroOpId,
resumeSessionId,
workingDirectory,
});
} catch (e) {
console.error('[HeterogeneousAgent] Execution failed:', e);
this.#get().failOperation(heteroOpId, {
message: e instanceof Error ? e.message : 'Unknown error',
type: 'HeterogeneousAgentError',
});
}
if (heteroData.topicId) this.#get().internal_updateTopicLoading(heteroData.topicId, false);
return {
assistantMessageId: heteroData.assistantMessageId,
userMessageId: heteroData.userMessageId,
};
}
// ── Gateway mode: skip sendMessageInServer, let execAgentTask handle everything ──
if (this.#get().isGatewayModeEnabled()) {
this.#get().completeOperation(operationId);

View file

@ -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.
}
};

View file

@ -10,6 +10,7 @@ import { setNamespace } from '@/utils/storeDebug';
import {
type AfterCompletionCallback,
AI_RUNTIME_OPERATION_TYPES,
type Operation,
type OperationCancelContext,
type OperationContext,
@ -391,11 +392,9 @@ export class OperationActionsImpl {
// 2. Set isAborting flag immediately for agent-runtime operations.
// This ensures UI (loading button) responds instantly to user cancellation.
// Applies to both client-side (execAgentRuntime) and Gateway-mode
// (execServerAgentRuntime) runs — the latter needs the flag so the UI
// transitions out of loading right away, without waiting for the
// round-trip WS `session_complete` after the server acknowledges interrupt.
if (operation.type === 'execAgentRuntime' || operation.type === 'execServerAgentRuntime') {
// Applies to all AI runtime operation types so the UI transitions out of
// loading right away without waiting for the process to fully terminate.
if (AI_RUNTIME_OPERATION_TYPES.includes(operation.type)) {
this.#get().updateOperationMetadata(operationId, { isAborting: true });
}

View file

@ -203,7 +203,7 @@ const hasRunningOperationByContext =
/**
* Check if agent runtime is running in a specific context
* Checks both client-side (execAgentRuntime) and server-side (execServerAgentRuntime) operations
* Checks all AI runtime operation types (see AI_RUNTIME_OPERATION_TYPES)
*/
const isAgentRuntimeRunningByContext =
(context: {
@ -303,7 +303,7 @@ const isAgentRunning =
/**
* Check if agent runtime is running (including both main window and thread)
* Checks both client-side (execAgentRuntime) and server-side (execServerAgentRuntime) operations
* Checks all AI runtime operation types (see AI_RUNTIME_OPERATION_TYPES)
* Excludes operations that are aborting (cleaning up after cancellation)
*/
const isAgentRuntimeRunning = (s: ChatStoreState): boolean => {
@ -385,7 +385,7 @@ const isMessageProcessing =
/**
* Check if a specific message is being generated (AI generation only)
* Checks both client-side (execAgentRuntime) and server-side (execServerAgentRuntime) operations
* Checks all AI runtime operation types (see AI_RUNTIME_OPERATION_TYPES)
*/
const isMessageGenerating =
(messageId: string) =>

View file

@ -18,6 +18,7 @@ export type OperationType =
// === AI generation ===
| 'execAgentRuntime' // Execute agent runtime (client-side, entire agent runtime execution)
| 'execServerAgentRuntime' // Execute server agent runtime (server-side, e.g., Group Chat)
| 'execHeterogeneousAgent'
| 'createAssistantMessage' // Create assistant message (sub-operation of execAgentRuntime)
// === LLM execution (sub-operations) ===
| 'callLLM' // Call LLM streaming response (sub-operation of execAgentRuntime)
@ -315,10 +316,12 @@ export interface OperationFilter {
*
* Includes:
* - execAgentRuntime: Client-side agent execution (single chat)
* - execHeterogeneousAgent: Heterogeneous agent execution (Claude Code CLI, etc.)
* - execServerAgentRuntime: Server-side agent execution (Group Chat)
*/
export const AI_RUNTIME_OPERATION_TYPES: OperationType[] = [
'execAgentRuntime',
'execHeterogeneousAgent',
'execServerAgentRuntime',
];

View file

@ -122,7 +122,13 @@ export class ElectronRemoteServerActionImpl {
},
{
onSuccess: (data) => {
if (!isEqual(data, this.#get().dataSyncConfig)) {
const { dataSyncConfig, isInitRemoteServerConfig } = this.#get();
// Only refresh on genuine config changes AFTER the first hydration.
// On initial load the stores are already fresh, and `refreshUserData`
// runs `stores.reset()` which wipes chat state (notably `activeAgentId`)
// that `AgentIdSync` just set from the URL — leaving the topic list
// unable to resolve its agent scope on reload.
if (isInitRemoteServerConfig && !isEqual(data, dataSyncConfig)) {
void this.#get()
.refreshUserData()
.catch((error) => {

View file

@ -6,6 +6,8 @@ export const labPreferSelectors = {
enableAgentWorkingPanel: (s: UserState): boolean =>
s.preference.lab?.enableAgentWorkingPanel ?? false,
enableGatewayMode: (s: UserState): boolean => s.preference.lab?.enableGatewayMode ?? false,
enableHeterogeneousAgent: (s: UserState): boolean =>
s.preference.lab?.enableHeterogeneousAgent ?? false,
enableInputMarkdown: (s: UserState): boolean =>
s.preference.lab?.enableInputMarkdown ?? DEFAULT_PREFERENCE.lab!.enableInputMarkdown!,
};

View 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>;